diff --git a/AGENTS.md b/AGENTS.md index 71388167..d4319f1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Treat missing, stale, incomplete, or superseded coverage as unknown rather than as negative evidence. Keep imported external claims distinct from validation reproduced by GitContribute. For the detailed contracts, read `docs/architecture.md`, `CONTRIBUTING.md`, and, for MCP or evidence changes, -`docs/mcp-composed-workflows.md` and `docs/external-evidence-manifests.md`. +`docs/mcp.md` and `docs/external-evidence-manifests.md`. ## Product boundaries diff --git a/CHANGELOG.md b/CHANGELOG.md index f927fb5c..065e72dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,7 +276,7 @@ * **mcp:** `github.search_threads` and `github.read_source_files` now require `repository: {owner, repo}`. Flat `owner` and `repo` request fields are no - longer accepted. See [the v2 MCP migration guide](docs/mcp-v2-migration.md). + longer accepted. ### Features diff --git a/CONTEXT.md b/CONTEXT.md index 5e9f3274..0052bbee 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,7 +19,10 @@ application services, storage, and documentation. as repository metadata, thread headers, comments, reviews, or PR details. Tool namespaces describe authority and effects: `corpus.*` reads stored local -facts or deterministic derivations; `github.*` performs explicit GitHub reads -and persists observations; `jobs.*` manages durable asynchronous work; -`workflow.*` reads or changes local investigation state. Provider-derived prose -must carry provenance and must not override GitHub facts. +facts or computes deterministic derivations, except explicitly named +`corpus.materialize_*` tools that update derived local projections and the +durable `corpus.ensure_coverage` acquisition workflow; `github.*` performs +explicit GitHub reads and persists observations; `jobs.*` manages durable +asynchronous work; `workflow.*` reads or changes local investigation state. +Provider-derived prose must carry provenance and must not override GitHub +facts. diff --git a/README.md b/README.md index 0cc4aeb6..121ab4ff 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,9 @@ To start the stdio server directly: gitcontribute mcp serve --transport=stdio ``` -Add `--read-only` to remove tools that permit local writes or execution. See -[Scalable MCP workflows](docs/mcp-scalable-workflows.md) for the tool sequence, -coverage model, partial-result recovery, and side-effect boundaries. +Add `--read-only` to remove tools that permit network access, local writes, or +execution. See [MCP catalog and composition](docs/mcp.md) for capability +boundaries, snapshot recovery, resource handoffs, and common tool sequences. GitHub users are stored as first-class actors. Live discovery records identity only; profile, social-account, organization, pinned-item, repository, and @@ -272,7 +272,7 @@ See [Architecture](docs/architecture.md) for the complete boundary definitions. ## Documentation - [Onboarding and configuration](docs/onboarding.md) -- [Scalable MCP workflows](docs/mcp-scalable-workflows.md) +- [MCP catalog and composition](docs/mcp.md) - [Actor corpus](docs/actor-corpus.md) - [Architecture and side-effect boundaries](docs/architecture.md) - [Operational runbooks](docs/runbooks.md) diff --git a/docs/agent-tool-evaluation.md b/docs/agent-tool-evaluation.md index ec553257..3f16a2a9 100644 --- a/docs/agent-tool-evaluation.md +++ b/docs/agent-tool-evaluation.md @@ -61,20 +61,6 @@ and failures. At least three repeated runs per scenario are needed before making tool-choice claims; deterministic Go tests validate contracts but never count as model runs. -## Current decisions - -The durable-job scenario requires one submission and one poll. The current -surface already polls multiple IDs through one `jobs.get` call, so current -evidence does not justify merging job submission and status reads. Job references now -carry a polling delay and a suggested `jobs.get` call; further consolidation is -deferred until agent traces show missed, redundant, or premature polling. - -There is no current evidence that an opinionated repository-search preset -improves held-out task completion. The surface therefore has validated -structured filters but no `trending`, `active`, or `contribution_friendly` -preset. A preset should be introduced only with a disclosed definition and a -measurable improvement on repeated model-backed or human-agent traces. - ## Extending the suite Keep public scenarios small and representative. Add held-out cases with @@ -85,7 +71,7 @@ contracts include: - rejecting simultaneous structured filters and `raw_query`; - preserving semantic references across concise and detailed responses; - returning stable, duplicate-free pagination; -- avoiding poll suggestions for terminal jobs. +- avoiding poll suggestions for terminal jobs; - comparing bounded atomic search/select/hydrate loops on a repository where closed PR headers have unknown merge state; score confirmed merged, closed-unmerged, superseded, open, and unknown outcomes separately. diff --git a/docs/architecture.md b/docs/architecture.md index 16f5dfbf..49b7b36f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ product-owned types, errors, or constants. | --- | --- | ---: | ---: | ---: | ---: | | Corpus read | search, health, dossier show, research brief, readiness, MCP resources | no | no | no | no | | Corpus write | investigations, start-thread, evidence, lenses, tracking, cluster governance | no | yes | no | no | -| Derived projection refresh | explicit `clusters refresh OWNER/REPO` | no | yes | no | no | +| Derived projection refresh | explicit cluster refresh and `corpus.materialize_repository_dossier` | no | yes | no | no | | 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 | @@ -64,7 +64,7 @@ product-owned types, errors, or constants. | Local merge check | compare already-fetched revisions | no | no | `git` only | no | | Validation | validation run/repeat with explicit execution | no by default | yes | yes | no | -Version 1 has no GitHub mutation path. Adding one requires a separate +GitContribute has no GitHub mutation path. Adding one requires a separate application capability and protocol annotation; it must not be hidden behind a read operation. @@ -139,9 +139,10 @@ known zero merge rate remains distinct from an unknown rate. Pull-request portfolios use the ordinary repository and thread projections. `github.sync_pull_request_portfolio` is the only public portfolio producer. Its discriminated selection is either authored discovery (optionally scoped to one -repository) or an explicit bounded set; identity lookup, authored discovery, -and scalar status refresh are -internal phases rather than separately advertised operations. +repository) or an explicit bounded set; authored discovery and scalar status +refresh are internal phases rather than separately advertised operations. +`github.get_authenticated_identity` separately exposes the authenticated account +without starting portfolio discovery or persisting actor facts. REST `pr_details` and `pr_reviews` facets are combined with typed GraphQL facets for checks, unresolved review threads, detailed merge state, merge queue, closing issues, and changed files. Each facet has independent coverage; an @@ -166,9 +167,11 @@ github.index_pull_request_feedback -> gitcontribute://pull-request-feedback/{owner}/{repo}/{number}/{channel}/{feedback_id} ``` -The index job enumerates provider pull-request pages with `state=all` and -stores its next page, request count, item bound, and completeness in a -repository-scoped discovery checkpoint. Each discovered PR is then passed +The index job enumerates provider pull-request pages with an explicit `open`, +`closed`, or `all` scope (default `all`) and stores that scope with its next +page, request count, item bound, and completeness in a repository-scoped +discovery checkpoint. State-scoped completion is never reported as historical +`all` coverage. Each discovered PR is then passed through the exact feedback adapter, so open, closed, and merged PRs retain their observed head SHA, merge state, channel coverage, and raw facet payloads. The normalized `pull_request_feedback_fts` projection is rebuildable from @@ -178,6 +181,11 @@ search reports discovery and facet coverage separately from match count and returns the exact index, feedback-sync, or PR-details recovery action needed to resolve an unknown. Search never performs network access. +Exact feedback sync accepts up to 100 already-known pull requests as one +ordered durable job. Feedback producers share one cancellable application +slot, so exact sync, repository indexing, checkpoint writes, and projection +rebuilds do not compete as independent SQLite writers inside one server. + The live MCP server exposes `workflow.get_catalog_contract` as a read-only catalog-parity contract. It reports the running version, whether the server is in `all` or `read_only` mode, a deterministic fingerprint of the registered @@ -186,9 +194,8 @@ This makes a stale registration distinguishable from an intentionally restricted catalog. Clients must create a fresh MCP connection after setup, upgrade, or registration changes before comparing the contract. -Thread resolution remains outside this read/index workflow. A future mutation -must accept exact repository, PR, and thread identifiers plus the expected head -SHA and must be separately authorized; indexing never auto-resolves a thread. +Thread resolution remains outside this read/index workflow. Indexing never +auto-resolves a thread or gains mutation authority. Portfolio relationships and derived resolution records are local product contracts. Their normalized snapshots carry rule versions and exact source @@ -380,8 +387,9 @@ manifest, derived versions, completeness, provenance, and an immutable payload digest. Resolution fails closed with `snapshot_unavailable` or `snapshot_expired`; current mutable projections are never substituted. -Read-only search, precedent, coverage, code-search, fix-pattern preview, and -research-brief responses also bind their query digest to an observation +Read-only search, precedent, coverage, code-search, fix-pattern analysis, +cross-repository candidate ranking, and research-brief responses also bind +their query digest to an observation watermark. Because these operations have no local-write capability, their `ephemeral:` identities are explicitly non-durable and report completeness, truncation, and unknown coverage inline. A caller that needs reuse across calls @@ -405,15 +413,16 @@ live GitHub request -> local resources/read ``` -`github.search_threads` accepts a required nested repository reference and 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` accepts -the same required nested repository reference, 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. +`github.search_threads` accepts a required nested repository reference and +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` accepts the same +required nested repository reference, 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 @@ -543,9 +552,8 @@ authority; duplicated query columns are checked against that manifest while decoding and discarded rather than exposed as a second source of truth. Title, labels, body, and hydrated evidence are materialized into one search -document per thread and ranked by one BM25 invocation. Ranks from the legacy -thread and facet indexes are never compared; the facet index is used only to -identify the matching evidence source and excerpt. A thread page and its exact +document per thread and ranked by one BM25 invocation. The facet index only +identifies the matching evidence source and excerpt. A thread page and its exact count share one read transaction. Counts use a lean FTS match set rather than recomputing ranking and excerpts, and the first-page no-overflow case derives its total directly from the returned rows. @@ -735,9 +743,8 @@ rejects stale inventories, unknown or duplicate unit assignments, and invalid dependency graphs. Ambiguous units remain explicit. A verified reconstruction record binds one-to-one unit coverage to the exact source patch and untracked content identities. Neither operation stages files, applies patches, creates -commits, changes refs, executes repository code, or contacts GitHub. Applying a -plan is intentionally a separate future capability with an explicit mutation -boundary. +commits, changes refs, executes repository code, or contacts GitHub. No +plan-application operation is exposed. Storage changes should include tests for upgrade behavior, rollback when supported, stale-write rejection, transaction atomicity, and deterministic @@ -749,3 +756,5 @@ query ordering. - [ADR 0002: Application and corpus boundaries](adr/0002-application-and-corpus-boundaries.md) - [ADR 0003: Explicit execution boundaries](adr/0003-execution-safety.md) - [ADR 0004: Duplicate clusters are explicit derived projections](adr/0004-derived-cluster-projections.md) +- [ADR 0005: Immutable snapshot and artifact identity](adr/0005-immutable-snapshot-and-artifact-identity.md) +- [ADR 0006: Retire the orphaned crawl frontier](adr/0006-retire-crawl-frontier.md) diff --git a/docs/external-validation-receipts.md b/docs/external-validation-receipts.md deleted file mode 100644 index 7efb4ec4..00000000 --- a/docs/external-validation-receipts.md +++ /dev/null @@ -1,24 +0,0 @@ -# External validation receipts - -`internal/evidence/testdata/flameox-profiler-receipt.json` is a checked-in -Flameox-compatible example mapped to the producer-neutral -`gitcontribute.external-validation.v1` contract. It is fixture data only: the -repository does not depend on or execute Flameox. - -The field mapping is intentionally direct: - -| Profiler handoff | GitContribute receipt field | -| --- | --- | -| producer name | `producer` | -| profiler run identity | `provider`, `external_run_id` | -| source repository and commit | `repository`, `revision` | -| primary output digest | `artifact_sha256` | -| named output digests | `artifacts` | -| invoked command and workspace label | `argv`, `working_dir` | -| start/end and process result | `started_at`, `completed_at`, `exit_code`, `classification` | -| partial profiler output | `incomplete`, `limitations`, `truncated` | - -Receipts are decoded with unknown-field rejection and validated for schema, -provenance, SHA-256 digests, timestamps, bounded output, incomplete status, and -round-trip preservation. Attaching a receipt records the supplied observation; -it never invokes the named producer. diff --git a/docs/mcp-composed-workflows.md b/docs/mcp-composed-workflows.md deleted file mode 100644 index ffe426f5..00000000 --- a/docs/mcp-composed-workflows.md +++ /dev/null @@ -1,146 +0,0 @@ -# MCP composed-workflow decisions - -GitContribute does not mirror every CLI command with one MCP tool. CLI commands -may compose application capabilities for a person, while the MCP catalog -exposes bounded operations that agents can combine without hiding coverage, -provenance, authorization, or partial failure. - -## Decision criteria - -A repeated CLI workflow warrants a new MCP operation only when controlled -evaluation shows at least one of these outcomes: - -- the same multi-call sequence recurs across real contribution workflows; -- clients lose coverage, provenance, or explicit unknowns while joining - results; -- a bounded operation materially reduces calls or payload without broadening - authority; -- independent clients repeatedly make the same semantic composition mistake. - -The evidence must include semantic task success, side-effect and uncertainty -correctness, operational calls, result payload, initial and subsequently loaded -tool context, invalid calls, and latency. Catalog size and overlap count -against a new operation. Feature-count parity with the CLI is not evidence. - -Evaluation is explicit and opt-in. Run the public scenarios under -`internal/mcpserver/testdata/agent-eval/` with frozen model, sampling, catalog, -corpus, permissions, prompt, and token budget. Keep the oracle outside the -candidate workspace and retain the tool transcript and host request or -telemetry used for token claims. GitContribute sends no background evaluation -telemetry. - -## Issue or pull-request research brief - -For a complete source audit, the canonical route is: - -```text -corpus.get_coverage -> typed exact/repository recovery -> jobs.get - -> snapshot-bound offline reread -> duplicate checks - -> explicit live verification -> jobs.get -> receipt attachment - -> evidence/draft handoff -``` - -Coverage reads are offline and missing coverage is unknown. Synchronization is -always an explicit bounded operation. If `corpus.get_coverage`, an exact thread -read, or a facet read returns incomplete coverage, follow its item-level typed -recovery action while preserving exact-thread versus repository scope. Poll the -returned job, perform the offline reread, and reuse its returned -`snapshot_token` for composed duplicate checks. Use exact resource URIs only -through MCP `resources/read` before attaching receipts or handing evidence to a -draft workflow. - -Canonical MCP composition: - -1. Use `corpus.get_threads` for supplied exact issues or pull requests and - `corpus.get_thread_facets` for selected child coverage. -2. Use `corpus.find_clusters`, `corpus.find_neighbors`, and - `corpus.find_precedents` only for the duplicate or historical evidence the - task needs. -3. Read repository guidance only when the task needs repository-wide context. - Do not treat missing coverage as a negative result. -4. Perform a bounded GitHub sync only when current live state is required or - when returned coverage recovery requests it, wait for its durable job, and - then repeat the relevant offline read. - -Decision: **compose**. The facts-first catalog removed the aggregate issue-set -operation. Exact reads and bounded duplicate primitives make coverage and -selection visible to the calling agent without granting another workflow tool -authority over the sequence. - -## Base and candidate validation comparison - -Canonical MCP composition: - -1. Use `workspace.adopt` or `workspace.create` for the authorized base and - candidate worktrees. -2. Use `validation.define` once with the exact command and both workspace IDs. -3. Use `validation.run` with `target: both`; set `run_count` above one when - repeated evidence is required. Execution remains an explicit process - capability. -4. Wait for the returned durable jobs and compare their typed validation - records. Preserve the command, commit, environment policy, timing, exit - status, bounded output, and observation contract in the evidence handoff. - -Decision: **compose**. The MCP operations already share the application -validation contracts used by the CLI. Current evidence does not show repeated -loss of comparison semantics that would justify another process-capable tool. -Reconsider only if controlled traces show recurring client mistakes or a -material call/payload reduction that preserves authorization and proof. - -For long-running remote checks, use `github.wait_pull_request_checks` with the -exact repository, pull-request number, and expected head SHA. It returns a -durable watch job; poll `jobs.get` rather than resubmitting snapshots. The -watcher coalesces unchanged states, waits through the zero-check registration -window, returns `superseded` when the head changes, and does not replace a -complete local health projection after timeout, cancellation, or incomplete -coverage. - -After a local validation run, attach a JUnit artifact with -`validation.attach_junit_report`. For source-analysis or profiling results, -use `evidence.import_manifest`; the imported claims retain their producer and -revision authority and are not treated as GitContribute execution results. - -## Contribution collision checks - -```text -github.search_threads(repository={owner,repo}, bounded current work) -github.sync_pull_request_portfolio(selection=authored, repository={owner,repo}) -> jobs.get -corpus.search_pull_requests | corpus.find_pull_request_overlaps -workspace.check_merge_conflicts (only after explicit acquisition) -``` - -Decision: **compose**. The catalog no longer exposes a preflight workflow that -decides whether an agent should start work. Agents select the current-work, -portfolio, overlap, or Git comparison facts appropriate to the task. Unknown -discovery or facet coverage prevents a “no competing work” conclusion. - -`workflow.preflight_contribution` also reports `fork_freshness` when the caller -supplies a fork or the inspected workspace provides one unambiguous contributor -remote. The read resolves both default-branch tips and compares them through -the upstream fork network, preserving merge-base evidence and the current -contribution branch when available. `current`, `behind`, and `diverged` are -verified states; an unavailable fork or incomplete comparison remains unknown. -The check never fetches local refs, synchronizes a fork, force-pushes, or -mutates GitHub. - -## Unified catalog - -The managed server advertises the unified `all` catalog. Current Codex and -Claude Code hosts can defer MCP definitions and discover them with native tool -search; GitContribute does not reproduce that host machinery. Focused catalog -projections exist only inside the evaluation harness and are not a user-facing -configuration surface. - -`workflow.get_catalog_contract` is the canonical parity check for a live -connection. It returns the running version, post-registration catalog mode, -tool count, deterministic catalog fingerprint, and explicit availability of -the repository-wide feedback index, exact-PR feedback sync, and offline -feedback search. A setup or upgrade changes the installed registration only; -the client must start a fresh MCP connection before it can observe the new -catalog. A read-only server reports that restricted mode explicitly instead of -making its missing network tools look like an installation failure. - -The v5 evaluation compares unified eager and unified host-search conditions. -Serialized definition bytes are a deterministic catalog-cost proxy, not a -model-token or selection-accuracy claim. Token claims require the exact host -request or equivalent telemetry. diff --git a/docs/mcp-scalable-workflows.md b/docs/mcp-scalable-workflows.md deleted file mode 100644 index d07bf8bb..00000000 --- a/docs/mcp-scalable-workflows.md +++ /dev/null @@ -1,169 +0,0 @@ -# Composing MCP tools at scale - -GitContribute exposes bounded primitives that agents can compose without -building scalar N+1 loops. Each tool owns one side-effect boundary: offline -corpus read, explicit GitHub acquisition, local state write, Git process, or -authorized validation process. - -## Ground rules - -- Corpus reads never contact GitHub or refresh data implicitly. -- GitHub reads are explicit, bounded, rate-limited, and may write only local - observations and projections. -- Missing, stale, paginated, or truncated coverage is unknown, not evidence of - absence. -- Durable jobs must reach a terminal state through `jobs.get` before their - observations are treated as current. -- Resource URIs are opaque. Read the exact returned URI with MCP - `resources/read` rather than reconstructing it. -- No MCP tool mutates GitHub or executes repository-controlled code. - -## Repository and thread research - -Discovery and hydration stay separate: - -```text -github.search_repositories -> corpus.get_repositories -github.search_threads -> resources/read (immutable search artifact) -github.sync_repository_context -> jobs.get -> corpus.get_repositories -github.sync_threads -> jobs.get -> corpus.get_threads -github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets -corpus.find_clusters | corpus.find_neighbors | corpus.find_precedents -``` - -`github.search_repositories` and `github.search_threads` return one bounded -live page. Search pages do not prove repository-wide absence. Repository and -thread synchronization records coverage separately from stored row counts. -Thread headers do not contain every pull-request fact; comments, reviews, -merge details, checks, files, and other children require explicit facets. - -`github.read_source_files` resolves a ref once and reads up to 20 ordered -repository-relative files with per-file and total-byte limits. Its immutable -source-bundle resource records the resolved commit and blob provenance. -Both live repository acquisitions require `repository: {owner, repo}`. See the -[v2 migration guide](mcp-v2-migration.md) for request examples; flat owner and -repo arguments are rejected. - -`corpus.search_code` accepts up to 20 queries over one repository or snapshot -scope. Every query uses the same offline corpus revision. It never falls back -to live GitHub code search. - -## Actor and contributor research - -User search deliberately stores identity stubs instead of hydrating every -result: - -```text -github.search_users - -> corpus.search_actors - -> github.sync_users (selected identities only) - -> github.sync_user_* (selected facets only) - -> corpus.get_actors | corpus.get_actor_facets -``` - -Available facet acquisitions are social accounts, organizations, pinned or -showcase items, repositories, and contribution periods. Each has independent -request, page, or item bounds and independent coverage. Repository facts retain -the explicit `owned`, `affiliated`, or `contributed` relationship. - -Contribution research composes an explicit acquisition period with an offline -query: - -```text -github.sync_user_contributions -> jobs.get - -> corpus.search_contributions -``` - -The offline query filters by actor, repository, contribution kind, source, organization scope, and -time. Its cursor binds those filters and cannot be reused for a different -query. Restricted GitHub activity remains an aggregate unless GitHub discloses -an item. See [Actor corpus](actor-corpus.md) for the SQLite mapping and -freshness semantics. - -## Pull-request feedback and CI - -Repository-wide feedback indexing is the route for questions such as “find -every comment by this reviewer”: - -```text -github.index_pull_request_feedback(state=all) -> jobs.get - -> corpus.search_pull_request_feedback(feedback_author=exact_login) - -> resources/read -``` - -The index records discovery coverage and then acquires issue comments, -submitted reviews, inline comments, and review-thread topology. Body text and -author are separate filters. An empty result is unknown until both discovery -and requested feedback-channel coverage are complete. - -Exact PR refresh uses `github.sync_pull_request_feedback`. CI uses -`github.sync_pull_request_ci`; checks and statuses are bound to the observed -head SHA. Offline authored-PR reads use `corpus.search_pull_requests`, and -overlap analysis uses `corpus.find_pull_request_overlaps`. -Use `repository: {owner, repo}` with authored portfolio synchronization or -offline portfolio reads when the portfolio must be constrained to one project. - -## Jobs, partial results, and recovery - -A durable job may succeed with a partial result. Job success means the bounded -operation ran to completion and recorded every item outcome; it does not turn -retryable or unavailable items into complete facts. Poll several IDs together -with `jobs.get`, then retry only the affected inputs. - -Per-item results distinguish: - -- `complete`: the requested bounded observation was stored; -- `retryable`: rate limiting or a transient provider failure permits retry; -- `unavailable`: the identity, authorization, or provider capability is not - available; -- `failed`: a non-retryable request or persistence error occurred. - -Coverage carries observation time, source time, authorization scope, and -completeness. The caller chooses a task-appropriate freshness threshold; the -server does not claim that one age limit suits discovery, review cleanup, and -historical research equally. - -## Canonical resources - -Detailed persisted payloads use the `gitcontribute://` resource namespace. -Examples include thread facets, pull-request feedback, immutable source and -code-index artifacts, actors, investigations, opportunities, evidence, -manifests, drafts, concerns, and workspaces. The catalog does not advertise -dossier or fix-pattern workflow resources. - -## Breaking name changes - -| Previous name | Canonical name | -| --- | --- | -| `corpus.search_code_batch` | `corpus.search_code` | -| `corpus.list_pull_requests` | `corpus.search_pull_requests` | -| `github.sync_ci_failures` | `github.sync_pull_request_ci` | - -Ranking, issue-set preparation, dossier, fix-pattern, preflight, related-work, -scalar code-search, and DeepWiki workflow tools are not advertised. Agents -should select the underlying acquisition and corpus facts that match the task. - -## Side-effect matrix - -| Tool family | Network | Local write | Process | -| --- | ---: | ---: | ---: | -| `corpus.*` | no | no | no | -| `github.search_*`, `github.sync_*`, source reads | yes | observations only | no | -| `jobs.get`, `jobs.cancel` | no | cancel only | no | -| local workflow state | no | yes | no | -| `code.index_repositories` | remote-dependent | yes | Git only | -| `workspace.*` Git operations | remote-dependent | yes | Git only | -| `validation.run` | no by default | yes | explicit command | - -## Verification - -The stdio integration tests exercise a real MCP subprocess, catalog discovery, -durable job polling, offline rereads, portfolio synchronization, and resource -handoffs: - -```sh -go test ./internal/app -run '^TestMCPStdio(ScalableResearch|PullRequestPortfolio)Flow$' -count=1 -``` - -Run `make verify` before a pull request for uncached tests, changed-code lint, -module tidiness, generated-output verification, and documentation validation. diff --git a/docs/mcp-tool-redesign.md b/docs/mcp-tool-redesign.md deleted file mode 100644 index e273067e..00000000 --- a/docs/mcp-tool-redesign.md +++ /dev/null @@ -1,96 +0,0 @@ -# MCP tool design - -GitContribute targets `github.com/modelcontextprotocol/go-sdk` `v1.7.0` and -negotiates MCP `2026-07-28`. Typed Go input and output values own protocol -semantics. The SDK performs input decoding and output-schema validation; schema -customization adds bounds, enums, defaults, and discriminated `oneOf` branches -without a parallel decoder. - -## Facts before workflows - -The public catalog exposes atomic, composable facts: - -- `corpus.*` operations are offline reads and never refresh implicitly; -- `github.*` operations are explicit, bounded network reads that may update - only the local corpus; -- `jobs.*` controls durable work; -- resource URIs are the canonical detailed read plane for persisted payloads; -- workspace and validation tools retain their separate local-write and process - boundaries. - -Tool descriptions state what a call reads or writes, its bounds, and what -unknown or incomplete output means. They do not prescribe an agent workflow. -Missing, paginated, truncated, stale, unauthorized, and unavailable facts stay -distinct from an observed empty value. - -Batch inputs remain useful when every item has the same capability boundary. -They preserve input order and isolate per-item failures. Actor results use a -small item contract rather than embedding the catalog-wide recovery union in -every output schema. - -## Actor primitives - -`github.search_users` persists one bounded page of identity observations. It -does not fan out into profile reads. Exact profile and child facts use separate -tools: - -```text -github.sync_users -github.sync_user_social_accounts -github.sync_user_organizations -github.sync_user_pinned_items -github.sync_user_repositories -github.sync_user_contributions -``` - -Selectors are a discriminated union: exactly one login or GitHub node ID. -Repository synchronization requires an explicit `owned`, `affiliated`, or -`contributed` relationship. Contribution synchronization requires an explicit -period no longer than one year. Pages, items, repositories, and total requests -are bounded in the input schema. - -Offline consumers compose `corpus.search_actors`, `corpus.get_actors`, -`corpus.get_actor_facets`, and `corpus.search_contributions`. Results expose -observation and source timestamps, authorization scope, completeness, snapshot -identity, and opaque actor resource URIs. See [Actor corpus](actor-corpus.md). - -## Resources and structured output - -Small query results remain structured tool output. Larger or durable payloads -are read with MCP `resources/read` using the exact URI returned by a tool. -Actor resources use: - -```text -gitcontribute://actor/{actor_id} -gitcontribute://actor/{actor_id}/facet/{facet} -``` - -Facet resources contain the stored typed payload and its provenance; reading a -resource never contacts GitHub. Job results expose structured progress and -artifact references rather than raw request or result blobs. - -## Breaking catalog changes - -The facts-first catalog removes overlapping workflow-shaped operations for -ranking candidates, preparing issue sets, building dossiers, mining or -previewing fix patterns, contribution preflight, related-work discovery, and -DeepWiki reads. Their component corpus, GitHub, resource, workspace, and -validation capabilities remain independently selectable where applicable. - -Canonical names changed as follows: - -| Previous name | Canonical name | -| --- | --- | -| `corpus.search_code_batch` | `corpus.search_code` | -| `corpus.list_pull_requests` | `corpus.search_pull_requests` | -| `github.sync_ci_failures` | `github.sync_pull_request_ci` | - -There is no scalar code-search compatibility alias. The canonical code search -accepts several bounded queries over one shared offline snapshot. - -## Side effects - -MCP annotations reflect the actual capability boundary. Corpus reads are -read-only and idempotent. GitHub reads are open-world operations that persist -local observations. Local workflow writes, Git acquisition, and validation -execution remain separate. GitContribute exposes no GitHub mutation tool. diff --git a/docs/mcp-v2-migration.md b/docs/mcp-v2-migration.md deleted file mode 100644 index 99bb7db3..00000000 --- a/docs/mcp-v2-migration.md +++ /dev/null @@ -1,71 +0,0 @@ -# MCP v2 migration: repository-bound live acquisition - -Version 2 removes the flat `owner` and `repo` fields from the two live -repository-acquisition tools. Both now require one nested `repository` object. -There are no compatibility aliases or mixed forms: callers must update every -request, recovery replay, and saved tool call before connecting to a v2 server. - -## `github.search_threads` - -Before (v1): - -```json -{ - "owner": "acme", - "repo": "rocket", - "query": "cache eviction", - "kind": "issue" -} -``` - -After (v2): - -```json -{ - "repository": {"owner": "acme", "repo": "rocket"}, - "query": "cache eviction", - "kind": "issue" -} -``` - -## `github.read_source_files` - -Before (v1): - -```json -{ - "owner": "acme", - "repo": "rocket", - "ref": "main", - "files": [{"path": "README.md"}] -} -``` - -After (v2): - -```json -{ - "repository": {"owner": "acme", "repo": "rocket"}, - "ref": "main", - "files": [{"path": "README.md"}] -} -``` - -The tools still return an opaque artifact URI. Follow that URI only through MCP -`resources/read`; the resource reader remains local and offline. - -## Scoped authored portfolios - -`github.sync_pull_request_portfolio` accepts the same optional `repository` -scope only with `selection: "authored"`. The returned job follow-up and a -truncated `corpus.search_pull_requests` recovery retain that scope. Explicit -pull-request selections are already exact and reject `repository`. - -```json -{ - "selection": "authored", - "repository": {"owner": "acme", "repo": "rocket"}, - "state": "open", - "limit": 20 -} -``` diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..402d344d --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,201 @@ +# MCP catalog and composition + +GitContribute exposes one MCP catalog organized by authority and side effect. +Tools return bounded facts, explicit coverage, provenance, and typed recovery; +they do not hide acquisition or decide whether an agent should contribute. + +## Capability boundaries + +| Tool family | Network | Local write | Process | +| --- | :---: | :---: | :---: | +| `corpus.*` reads and analysis | no | no | no | +| `corpus.materialize_*` | no | derived projections only | no | +| `corpus.ensure_coverage` | yes, bounded | observations and snapshot | no | +| `github.search_*`, `github.sync_*`, source reads | yes | observations only | no | +| `research.*` | yes | no | no | +| `jobs.get` | no | no | no | +| `jobs.cancel` | no | cancellation request | no | +| `workflow.*` investigation state | no | yes | no | +| `code.index_repositories` | remote-dependent | yes | Git only | +| `workspace.*` | remote-dependent | yes | Git only | +| `validation.run` | no by default | yes | approved command | + +Corpus reads never refresh source data. `corpus.ensure_coverage` is the named +exception: a durable state machine that coordinates bounded acquisition and +snapshot creation. GitHub reads are explicit, bounded, rate-limited, and may +persist only local observations and projections. DeepWiki is an optional public +external read; its prose is untrusted derived context and never overrides +GitHub facts. No MCP tool mutates GitHub or executes repository-controlled code +during acquisition, indexing, or research. + +Use `--read-only` when starting the MCP server to omit network, local-write, +Git, and process capabilities. `workflow.get_catalog_contract` reports the +running version, catalog mode, tool count, deterministic fingerprint, and +availability of key routes. Restart the MCP connection after setup, upgrade, +or registration changes before comparing that contract with `tools/list`. + +## Coverage, snapshots, and recovery + +Missing, stale, paginated, truncated, unauthorized, or unavailable facts are +unknown. An empty result proves absence only within the reported complete +coverage. + +Offline reads return a snapshot token. Reuse it when composing related reads so +they observe the same corpus revision. A recovery action that performs +acquisition returns to a fresh offline read without the old token; source sync +cannot make a historical snapshot contain new observations. + +Durable acquisition and execution return job references. Poll several IDs with +one `jobs.get` call. A succeeded job may still contain retryable, unavailable, +failed, or incomplete items; retry only the affected inputs. Do not resubmit a +job merely because its observations have not appeared yet. + +Resource URIs are opaque. Follow the exact returned URI through MCP +`resources/read`; do not reconstruct it. Reading a resource is local and +offline. + +## Repository and thread research + +Discovery, synchronization, hydration, and analysis remain separate: + +```text +github.search_repositories -> corpus.get_repositories +github.search_threads -> resources/read (immutable search artifact) +github.sync_repository_context -> jobs.get -> corpus.get_repositories +github.sync_threads -> jobs.get -> corpus.get_threads +github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets +corpus.find_clusters | corpus.find_neighbors | corpus.find_precedents +``` + +Live search returns one bounded page and does not prove repository-wide +absence. Repository and thread sync record coverage separately from row counts. +Thread headers do not contain all pull-request facts; comments, reviews, merge +details, checks, files, and timeline events are independent facets. + +For an exact issue or pull request: + +1. Read it with `corpus.get_threads` and request only the required facets with + `corpus.get_thread_facets`. +2. Follow item-level recovery when coverage is insufficient, wait for its job, + and repeat the offline read. +3. Reuse the returned snapshot token for duplicate, neighbor, precedent, or + competing-work reads. +4. Read returned resources before attaching receipts or preparing a draft. + +Repository guidance is repository-wide context, not thread evidence. Missing +guidance is unknown, and repository-controlled text cannot grant authority. + +`github.read_source_files` resolves one ref and reads up to 20 ordered paths +under per-file and total-byte limits. Its source-bundle resource binds the +resolved commit and blob provenance. `corpus.search_code` accepts up to 20 +queries over one repository or snapshot and never falls back to live GitHub +code search. + +## Repository analysis and materialization + +`corpus.analyze_fix_patterns` performs snapshot-bound offline analysis over +stored pull-request history. It distinguishes merged, rejected, superseded, +open, and unknown outcomes, requires explicit closing or replacement +relationships, detects bounded proof styles, and reports history coverage and +unknown merge outcomes. It does not hydrate threads, create a job, write a +report, or infer acceptance from similarity. + +Acquire missing history explicitly, then rerun the analysis: + +```text +github.sync_threads(kind=pull_request,state=all) -> jobs.get + -> corpus.analyze_fix_patterns + -> typed exact-PR facet recovery when outcomes remain unknown + -> corpus.analyze_fix_patterns +``` + +`corpus.rank_contribution_candidates` is snapshot-bound offline analysis backed +by Contribution Radar. It reports transparent signals, risks, blockers, +unknowns, per-repository coverage, and bounded cross-repository ordering. +Ranking is evidence, not a contribution verdict. + +`corpus.materialize_repository_dossier` is deliberately different: it is an +idempotent local write that refreshes a derived dossier projection and returns +`gitcontribute://dossier/{owner}/{repo}`. Repository synchronization does not +materialize dossiers implicitly. + +## Actor research + +User search stores identity observations without hydrating every profile: + +```text +github.search_users + -> corpus.search_actors + -> github.sync_users (selected identities) + -> github.sync_user_* (selected facets) + -> corpus.get_actors | corpus.get_actor_facets +``` + +Social accounts, organizations, pinned items, repository relationships, and +contribution periods have independent bounds and coverage. Contribution +research combines `github.sync_user_contributions`, `jobs.get`, and the offline +`corpus.search_contributions`. Restricted activity remains an aggregate unless +GitHub discloses an item. See [Actor corpus](actor-corpus.md). + +## Pull-request feedback, CI, and collisions + +Repository-wide feedback search uses one explicit index job: + +```text +github.index_pull_request_feedback(state=all) -> jobs.get + -> corpus.search_pull_request_feedback(feedback_author=exact_login) + -> resources/read +``` + +The index records discovery coverage separately from comments, reviews, inline +comments, and review-thread topology. Exact PR refresh accepts up to 100 pull +requests through `github.sync_pull_request_feedback`. CI acquisition uses +`github.sync_pull_request_ci`; checks and statuses bind to the observed head +SHA. `github.wait_pull_request_checks` watches one exact head in a durable job +and reports timeout, incomplete coverage, or supersession without replacing a +complete projection with weaker evidence. + +Contribution collision research composes factual operations: + +```text +github.get_authenticated_identity +github.search_threads(repository={owner,repo}, bounded current work) +github.sync_pull_request_portfolio(selection=authored, repository={owner,repo}) + -> jobs.get +corpus.search_pull_requests | corpus.find_pull_request_overlaps +github.compare_fork +workspace.check_merge_conflicts (after explicit acquisition) +``` + +`github.compare_fork` compares exact upstream and fork tips without inferring a +repository from host paths, fetching local refs, synchronizing a fork, pushing, +or mutating GitHub. Unknown discovery or facet coverage prevents a “no +competing work” conclusion. + +## Validation and evidence + +Base/candidate validation remains an explicit process composition: + +1. Use `workspace.adopt` or `workspace.create` for authorized worktrees. +2. Call `validation.define` with the exact command and workspace identities. +3. Call `validation.run` with the requested target and repetitions. +4. Poll the jobs and preserve command, commit, environment policy, timing, + exit status, bounded output, and observation provenance. + +Attach a producer-owned execution receipt with `validation.attach_receipt` and +structured test output with `validation.attach_junit_report`. Import broader +analysis or profiler claims with `evidence.import_manifest`. Imported claims +retain their external authority and are not represented as validation +reproduced by GitContribute. + +## When to add a composed tool + +A new MCP operation needs controlled evidence that clients repeatedly lose +coverage or provenance, make the same semantic join mistake, or incur a +material call/payload cost that a bounded operation can remove without +broadening authority. CLI parity and feature count are not sufficient. + +Keep deterministic protocol checks in Go tests. Model-backed comparisons must +freeze the model, sampling, catalog, corpus, permissions, prompt, and budget; +retain tool transcripts and host evidence for token claims. See +[Agent tool evaluation](agent-tool-evaluation.md). diff --git a/docs/runbooks.md b/docs/runbooks.md index b82a36ff..e812777d 100644 --- a/docs/runbooks.md +++ b/docs/runbooks.md @@ -1,79 +1,135 @@ -# Runbooks +# Operational runbooks -## Health Check +These procedures use supported GitContribute commands. Run commands with +`--json` when the result will be inspected by automation. + +## Diagnose a local installation ```sh -gitcontribute health +gitcontribute metadata --json +gitcontribute status --json +gitcontribute doctor --strict --json ``` -Checks SQLite database integrity, GitHub API connectivity, and local filesystem state. +`doctor` checks local configuration, the corpus, Git, and installed MCP +registrations. It does not synchronize repositories. A diagnostic timeout is a +warning unless a required check reports an error. -## Deployment Observability +After setup or upgrade changes an MCP registration, restart the affected client +and call `workflow.get_catalog_contract` in the new session. Compare the server +version, mode, and catalog fingerprint with the expected installation. -**CI Pipeline**: https://github.com/morluto/gitcontribute/actions +## Inspect repository health -Monitor the CI workflow for build status, test coverage trends, and lint results. -Coverage reports are uploaded as artifacts on each run. +Repository health is an offline computation over stored observations: -**Release Dashboard**: https://github.com/morluto/gitcontribute/releases +```sh +gitcontribute health owner/repo --json +gitcontribute coverage owner/repo --json +``` -Track version history and release notes. Each release is built via GoReleaser -with cross-platform binaries and checksums. +Incomplete or stale coverage remains unknown. Synchronize the required source +facts explicitly before rerunning health; the health command itself never +contacts GitHub. -## Database Integrity +## Recover a durable job -If SQLite corruption is detected: +List and inspect jobs without changing them: -1. Stop all running GitContribute and MCP processes. -2. Run `gitcontribute doctor --strict`. A timeout warning is not proof of - corruption; an actual SQLite quick-check error is. -3. Inspect the candidate backup independently. -4. Restore through the supported command, which first creates a safety backup: +```sh +gitcontribute jobs list --status running --json +gitcontribute jobs get JOB_ID --json +``` - ```sh - gitcontribute corpus restore /safe/path/corpus.db --yes - ``` +Request cancellation only when the operation should stop: -5. Run `gitcontribute corpus inspect` and `gitcontribute doctor --strict`. +```sh +gitcontribute jobs cancel JOB_ID --json +``` -## Rate Limiting +Cancellation is cooperative and bounded. Inspect the job again to confirm its +terminal state. There is no manual lock-release or job-reconciliation command; +restart recovery is owned by the application. -If GitHub API rate limits are hit: +## Diagnose GitHub acquisition failures -1. Check current limits: `gh api /rate_limit` -2. Wait for the reset window (shown in `X-RateLimit-Reset` header) -3. Reduce concurrent operations via `--concurrency` flag +GitHub acquisition is explicit. First inspect the returned typed error and +item-level recovery. For provider rate limits, verify the authenticated +account's live limit independently when `gh` is configured: -## Circuit Breaker +```sh +gh api /rate_limit +``` -The GitHub client uses a circuit breaker that opens after 5 consecutive failures. -When the circuit is open, all requests fail fast with `ErrCircuitOpen` rather -than retrying. After a 30-second cooldown, a single probe request is allowed. -If the probe succeeds, the circuit closes; if it fails, the circuit re-opens. +Wait for the reported reset or retry only the affected bounded inputs. Reduce a +command's documented request/page bounds rather than launching parallel manual +chunks. Repeated provider failures may open the in-process circuit breaker for +30 seconds; retries during that interval fail fast. Debug logging can show the +request and retry boundary: -To check circuit status, enable debug logging: ```sh GITCONTRIBUTE_LOG_LEVEL=debug gitcontribute archive sync owner/repo ``` -## Job Reconciliation +## Recover from database corruption -If jobs appear stuck: +Do not replace a corpus until an actual SQLite check reports corruption. A +timeout warning alone is not proof. -1. List active jobs: `gitcontribute jobs list --status running` -2. Check for lock conflicts: `gitcontribute jobs reconcile` -3. Force-release stale locks if the owning process is confirmed dead +1. Stop GitContribute CLI, TUI, and MCP processes using the corpus. +2. Run `gitcontribute doctor --strict --json` and + `gitcontribute corpus inspect --json`. +3. Inspect the proposed backup independently. +4. Restore it through the supported lifecycle; restore first creates a safety + backup of the current database: -## Migration Failures + ```sh + gitcontribute corpus restore /safe/path/corpus.db --yes + ``` -Do not run Goose directly against a user corpus. Use the product-owned lifecycle: +5. Repeat `corpus inspect` and `doctor --strict`. -1. Stop running MCP processes and inspect without mutation: - `gitcontribute corpus inspect --json`. -2. Preserve the backup path and checksum printed by the failed migration. -3. Fix the migration in a newer binary; never edit an already released - migration in place. -4. Retry with `gitcontribute corpus migrate --yes`. -5. If recovery requires returning to the pre-migration database, use - `gitcontribute corpus restore BACKUP --yes`. Reinstalling an older binary - alone cannot roll back an advanced schema. +## Recover from a schema migration failure + +Never run Goose directly against a user corpus or edit an already released +migration. + +1. Stop processes holding corpus leases. +2. Inspect without mutation: `gitcontribute corpus inspect --json`. +3. Preserve the backup path and checksum reported by the failed migration. +4. Fix the migration in a newer binary and retry: + + ```sh + gitcontribute corpus migrate --yes + ``` + +5. To return to the pre-migration database, restore the verified backup: + + ```sh + gitcontribute corpus restore BACKUP --yes + ``` + +Installing an older binary does not roll back an advanced schema. + +## Inspect or remove stored data + +Inspect scope before any deletion: + +```sh +gitcontribute corpus list --json +gitcontribute corpus inventory owner/repo --json +gitcontribute corpus prune-code owner/repo +gitcontribute corpus remove-repository owner/repo +``` + +The prune and repository-removal commands show a plan by default. Review it, +then use their documented confirmation flag when deletion is intended. Code +pruning removes derived snapshots, not GitHub observations. + +## Release failures + +Releases are tag-triggered. Inspect the GitHub Actions run and the matching +GitHub release. One tag version must agree across the Go binaries, npm package, +`server.json`, MCP Registry metadata, and GitHub release. Do not repair a +partially published release by changing an immutable tag; fix the workflow and +publish a new version. diff --git a/internal/app/app.go b/internal/app/app.go index 409f8e20..2733ec45 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -39,6 +39,7 @@ type Service struct { corpus *corpus.Corpus readCorpus *corpus.Corpus jobs *JobExecutor + feedbackWriter workflowGate ghReader github.Reader archiveFetcher discovery.ArchiveFetcher deepWikiReader deepwiki.Reader @@ -51,6 +52,10 @@ type Service struct { cancelLifecycle context.CancelFunc } +func (s *Service) acquireFeedbackWorkflow(ctx context.Context) (func(), error) { + return s.feedbackWriter.acquire(ctx) +} + // New creates a Service and resolves local configuration. GitHub credentials // are resolved lazily only when a network-reading operation is requested. func New(paths *config.Paths, version string, logger *slog.Logger) (*Service, error) { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e1524dcf..fc40fd96 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -333,7 +333,7 @@ func TestMCPReaderLocalReads(t *testing.T) { _, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"}) var dossierErr *mcpcontract.ToolError - if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type() != "get_repositories" { + if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type() != "materialize_repository_dossier" { t.Fatalf("MCP dossier before build error = %+v", err) } _, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "missing"}) @@ -341,8 +341,8 @@ func TestMCPReaderLocalReads(t *testing.T) { if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("MCP dossier for missing repository error = %+v", err) } - if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil { - t.Fatalf("build dossier: %v", err) + if _, err := reader.(*MCPReader).MaterializeRepositoryDossier(ctx, mcpcontract.MaterializeRepositoryDossierInput{Owner: "acme", Repo: "rocket"}); err != nil { + t.Fatalf("materialize dossier: %v", err) } dossier, err := reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"}) if err != nil { diff --git a/internal/app/issue_disposition_test.go b/internal/app/issue_disposition_test.go deleted file mode 100644 index a1c15bcd..00000000 --- a/internal/app/issue_disposition_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package app - -import ( - "testing" - - "github.com/morluto/gitcontribute/internal/mcpcontract" -) - -func TestIssueContributionDisposition(t *testing.T) { - merged, unmerged := true, false - complete := []mcpcontract.FacetCoverageOutput{ - {Facet: FacetIssueComments, Complete: true}, - {Facet: FacetIssueTimeline, Complete: true}, - } - cases := []struct { - name string - in mcpcontract.PreparedIssueEvidence - want string - }{ - {"merged closing work", mcpcontract.PreparedIssueEvidence{Number: 1, State: "open", RelatedWorkTotalKnown: true, Coverage: complete, RelatedWork: []mcpcontract.IssueSetRelatedWork{{Ref: "pr#2", Kind: "pull_request", State: "closed", Relation: "claims_to_close", Direction: "inbound", Merged: &merged}}}, "already_resolved_upstream"}, - {"active closing work", mcpcontract.PreparedIssueEvidence{Number: 1, State: "open", RelatedWorkTotalKnown: true, Coverage: complete, RelatedWork: []mcpcontract.IssueSetRelatedWork{{Ref: "pr#2", Kind: "pull_request", State: "open", Relation: "claims_to_close", Direction: "inbound", Merged: &unmerged}}}, "active_competing_work"}, - {"closed unmerged", mcpcontract.PreparedIssueEvidence{Number: 1, State: "open", RelatedWorkTotalKnown: true, Coverage: complete, RelatedWork: []mcpcontract.IssueSetRelatedWork{{Ref: "pr#2", Kind: "pull_request", State: "closed", Relation: "claims_to_close", Direction: "inbound", Merged: &unmerged}}}, "needs_maintainer_alignment"}, - {"not planned", mcpcontract.PreparedIssueEvidence{Number: 1, State: "closed", StateReason: "not_planned"}, "blocked_by_repository_policy"}, - {"complete no work", mcpcontract.PreparedIssueEvidence{Number: 1, State: "open", RelatedWorkTotalKnown: true, Coverage: complete}, "ready_to_investigate"}, - {"incomplete", mcpcontract.PreparedIssueEvidence{Number: 1, State: "open"}, "unknown"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := issueContributionDisposition(tc.in) - if got.Status != tc.want { - t.Fatalf("status = %q, want %q (%+v)", got.Status, tc.want, got) - } - if got.Status != "unknown" && len(got.EvidenceRefs) == 0 { - t.Fatal("non-unknown disposition lacks evidence refs") - } - }) - } -} diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 65224776..be2d7c7c 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -8,6 +8,7 @@ import ( "time" "github.com/morluto/gitcontribute/internal/clustering" + "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" @@ -148,7 +149,7 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc return mcpcontract.DossierOutput{}, mcpcontract.Unavailable( "dossier_not_persisted", fmt.Sprintf("No persisted dossier exists for %s.", ref), - mcpcontract.RecoveryAction(mcpcontract.GetRepositoriesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}}), + mcpcontract.RecoveryAction(mcpcontract.MaterializeRepositoryDossierInput{Owner: ref.Owner(), Repo: ref.Repo()}), ) } d, err := dossierFromRecord(record, sources) @@ -158,6 +159,17 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc return dossierToMCPOutput(d), nil } +// MaterializeRepositoryDossier refreshes one deterministic dossier from local +// corpus facts. It performs no network access and writes only the derived +// dossier projection. +func (r *MCPReader) MaterializeRepositoryDossier(ctx context.Context, in mcpcontract.MaterializeRepositoryDossierInput) (mcpcontract.DossierOutput, error) { + d, err := r.application().BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: in.Owner, Repo: in.Repo}) + if err != nil { + return mcpcontract.DossierOutput{}, err + } + return dossierToMCPOutput(d), nil +} + // Investigation reads a local investigation workspace from the corpus. func (r *MCPReader) Investigation(ctx context.Context, in mcpcontract.InvestigationInput) (mcpcontract.InvestigationOutput, error) { id, err := normalizeMCPID("id", in.ID) diff --git a/internal/app/mcp_code_search.go b/internal/app/mcp_code_search.go index 67edd24b..783e85df 100644 --- a/internal/app/mcp_code_search.go +++ b/internal/app/mcp_code_search.go @@ -18,28 +18,11 @@ type mcpCodeSearchRequest struct { snapshotToken string } -func parseMCPCodeSearchInput(in mcpcontract.SearchCodeInput) (mcpCodeSearchRequest, error) { - query := strings.TrimSpace(in.Query) - if query == "" { - return mcpCodeSearchRequest{}, errors.New("query is required") - } - page, err := corpus.ParseSearchPage(in.Limit, in.Cursor) - if err != nil { - return mcpCodeSearchRequest{}, err - } - repository, err := optionalRepoRef(in.Owner, in.Repo) - if err != nil { - return mcpCodeSearchRequest{}, err - } - return mcpCodeSearchRequest{query: query, repository: repository, page: page, snapshotToken: in.SnapshotToken}, nil -} - -func (r mcpCodeSearchRequest) canonical() mcpcontract.SearchCodeInput { - in := mcpcontract.SearchCodeInput{Query: r.query, Limit: r.page.Limit(), Cursor: r.page.Cursor(), SnapshotToken: r.snapshotToken} - if r.repository.IsValid() { - in.Owner, in.Repo = r.repository.Owner(), r.repository.Repo() +func (r mcpCodeSearchRequest) canonical() mcpcontract.SearchCodeBatchInput { + return mcpcontract.SearchCodeBatchInput{ + Owner: r.repository.Owner(), Repo: r.repository.Repo(), Queries: []string{r.query}, + Limit: r.page.Limit(), Cursor: r.page.Cursor(), SnapshotToken: r.snapshotToken, } - return in } type mcpCodeSearchBatchRequest struct { @@ -59,7 +42,10 @@ func parseMCPCodeSearchBatchInput(in mcpcontract.SearchCodeBatchInput) (mcpCodeS if in.Limit < 1 || in.Limit > 100 { return mcpCodeSearchBatchRequest{}, errors.New("limit must be between 1 and 100") } - page, err := corpus.ParseSearchPage(in.Limit, "") + if in.Cursor != "" && len(in.Queries) != 1 { + return mcpCodeSearchBatchRequest{}, errors.New("cursor requires exactly one query") + } + page, err := corpus.ParseSearchPage(in.Limit, in.Cursor) if err != nil { return mcpCodeSearchBatchRequest{}, err } @@ -83,44 +69,8 @@ func parseMCPCodeSearchBatchInput(in mcpcontract.SearchCodeBatchInput) (mcpCodeS func (r mcpCodeSearchBatchRequest) canonical() mcpcontract.SearchCodeBatchInput { return mcpcontract.SearchCodeBatchInput{ Owner: r.repository.Owner(), Repo: r.repository.Repo(), Queries: append([]string(nil), r.queries...), - Limit: r.page.Limit(), SnapshotToken: r.snapshotToken, - } -} - -// SearchCode searches indexed code snapshots in the local corpus. -func (r *MCPReader) SearchCode(ctx context.Context, in mcpcontract.SearchCodeInput) (mcpcontract.SearchCodeOutput, error) { - request, err := parseMCPCodeSearchInput(in) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err + Limit: r.page.Limit(), Cursor: r.page.Cursor(), SnapshotToken: r.snapshotToken, } - canonical := request.canonical() - c, err := r.openReadOnlyCorpus(ctx) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err - } - revision, err := beginCorpusRead(ctx, c, request.snapshotToken) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err - } - out, coverage, page, truncated, unknownCoverage, err := r.searchCodeAtRevision(ctx, c, request) - 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, canonical, truncated, unknownCoverage) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err - } - var recovery *mcpcontract.RecoveryPlan - if unknownCoverage || codeCoverageTruncated(coverage) { - recovery = codeSearchRecovery(canonical, coverage, truncated, unknownCoverage) - } else if page.NextCursor != "" { - recovery = codeSearchPageRecovery(canonical, page.NextCursor, request.snapshotToken) - } - provenance.Recovery = recovery - return mcpcontract.SearchCodeOutput{Query: request.query, Total: page.Total, Matches: out, Coverage: coverage, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(request.snapshotToken, revision), Recovery: recovery, Provenance: provenance}, nil } func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, request mcpCodeSearchRequest) ( @@ -171,14 +121,11 @@ func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, return matches, coverage, page, truncated, unknownCoverage, nil } -func codeSearchRecovery(in mcpcontract.SearchCodeInput, coverage []mcpcontract.CodeIndexCoverageOutput, truncated, unknown bool) *mcpcontract.RecoveryPlan { - if (in.Owner != "" && in.Repo != "") || len(coverage) > 0 { - return codeIndexRecovery(in, coverage, truncated, unknown) - } - return recoveryPlan("code_index_coverage_unknown", "Code search is limited to locally indexed snapshots. Select a repository, index it, then repeat before inferring absence.", mcpcontract.RecoveryAction(mcpcontract.SearchGitHubRepositoriesInput{Text: in.Query, Limit: searchRecoveryLimit(in.Limit)})) +func codeSearchRecovery(in mcpcontract.SearchCodeBatchInput, coverage []mcpcontract.CodeIndexCoverageOutput, truncated, unknown bool) *mcpcontract.RecoveryPlan { + return codeIndexRecovery(in, coverage, truncated, unknown) } -func codeIndexRecovery(in mcpcontract.SearchCodeInput, coverage []mcpcontract.CodeIndexCoverageOutput, truncated, unknown bool) *mcpcontract.RecoveryPlan { +func codeIndexRecovery(in mcpcontract.SearchCodeBatchInput, coverage []mcpcontract.CodeIndexCoverageOutput, truncated, unknown bool) *mcpcontract.RecoveryPlan { refs := make([]mcpcontract.IndexRepositoryInput, 0, len(coverage)) seen := make(map[string]struct{}, len(coverage)) if in.Owner != "" && in.Repo != "" { @@ -203,7 +150,7 @@ func codeIndexRecovery(in mcpcontract.SearchCodeInput, coverage []mcpcontract.Co } } if len(refs) == 0 { - return codeSearchRecovery(in, nil, truncated, unknown) + return recoveryPlan("code_index_coverage_unknown", "The selected repository has no complete local code index. Index it, then repeat this exact search before inferring absence.", mcpcontract.RecoveryAction(mcpcontract.IndexRepositoriesInput{Repositories: []mcpcontract.IndexRepositoryInput{{Owner: in.Owner, Repo: in.Repo}}})) } reason, message := "code_index_coverage_unknown", "Re-index the exact repositories, poll the job, then repeat code search before inferring absence." if truncated && !unknown { @@ -299,7 +246,7 @@ func codeCoverageTruncated(coverage []mcpcontract.CodeIndexCoverageOutput) bool return false } -func codeSearchPageRecovery(in mcpcontract.SearchCodeInput, cursor, snapshotToken string) *mcpcontract.RecoveryPlan { +func codeSearchPageRecovery(in mcpcontract.SearchCodeBatchInput, cursor, snapshotToken string) *mcpcontract.RecoveryPlan { next := in next.Cursor = cursor next.SnapshotToken = snapshotToken diff --git a/internal/app/mcp_contribution_fork.go b/internal/app/mcp_contribution_fork.go index b1e7bfa3..f8dc309d 100644 --- a/internal/app/mcp_contribution_fork.go +++ b/internal/app/mcp_contribution_fork.go @@ -2,67 +2,47 @@ package app import ( "context" + "errors" "strings" "github.com/morluto/gitcontribute/internal/github" - "github.com/morluto/gitcontribute/internal/gitremote" "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/workspace" ) const forkFreshnessRequestCost = 5 -type preflightForkContext struct { +type forkComparisonContext struct { ref mcpcontract.RepositoryRef branch string sha string } -// checkPreflightForkFreshness performs only provider reads. It never fetches -// local refs or updates the fork; the compare API supplies the merge-base -// evidence across the upstream repository network. -func checkPreflightForkFreshness( +// compareExplicitForkFreshness verifies one caller-selected fork. It performs +// only provider reads and never fetches local refs or updates the fork. +func compareExplicitForkFreshness( ctx context.Context, reader github.Reader, upstream mcpcontract.RepositoryRef, - fork *mcpcontract.RepositoryRef, - identity string, - candidate preflightCandidate, - worktrees []workspace.LocalWorktree, - existing *preflightExisting, - maxRequests int, - requests int, -) (mcpcontract.ForkFreshnessOutput, bool, error) { - forkContext, shouldCheck, reason := resolvePreflightFork(upstream, fork, identity, candidate, worktrees, existing) - if !shouldCheck { - return mcpcontract.ForkFreshnessOutput{}, false, nil - } - result := newForkFreshnessOutput(upstream, reason) - if forkContext != nil { - result.Fork = forkContext.ref - result.ContributionBranch = forkContext.branch - result.ContributionSHA = forkContext.sha - } - if reason != "" { - return result, true, nil - } - if forkContext == nil { - result.Reason = "contributor fork could not be identified from the supplied context" - return result, true, nil - } - if maxRequests-requests < forkFreshnessRequestCost { + forkContext forkComparisonContext, + availableRequests int, +) (mcpcontract.ForkFreshnessOutput, error) { + result := newForkFreshnessOutput(upstream, "") + result.Fork = forkContext.ref + result.ContributionBranch = forkContext.branch + result.ContributionSHA = forkContext.sha + if availableRequests < forkFreshnessRequestCost { result.Reason = "request budget cannot fund complete fork freshness coverage" - return result, true, nil + return result, nil } branchReader, hasBranchReader := reader.(github.BranchReader) comparisonReader, hasComparisonReader := reader.(github.CommitComparisonReader) if !hasBranchReader { result.Reason = "configured GitHub reader does not support branch-tip reads" - return result, true, nil + return result, nil } if !hasComparisonReader { result.Reason = "configured GitHub reader does not support fork ancestry comparison" - return result, true, nil + return result, nil } upstreamRepo, _, err := reader.GetRepository(ctx, upstream.Owner, upstream.Repo) @@ -75,11 +55,11 @@ func checkPreflightForkFreshness( } if !forkRepo.Fork || forkRepo.Parent == nil || !sameGitHubRepository(forkRepo.Parent.Owner, forkRepo.Parent.Name, upstream) { result.Reason = "selected repository is not a fork of the requested upstream repository" - return result, true, nil + return result, nil } if strings.TrimSpace(upstreamRepo.DefaultBranch) == "" || strings.TrimSpace(forkRepo.DefaultBranch) == "" { result.Reason = "upstream or fork default branch is unavailable" - return result, true, nil + return result, nil } result.UpstreamBranch = upstreamRepo.DefaultBranch result.ForkBranch = forkRepo.DefaultBranch @@ -96,7 +76,7 @@ func checkPreflightForkFreshness( result.ForkSHA = forkBranch.CommitSHA if result.UpstreamSHA == "" || result.ForkSHA == "" { result.Reason = "upstream or fork default branch did not include a commit SHA" - return result, true, nil + return result, nil } comparison, _, err := comparisonReader.CompareCommits(ctx, upstream.Owner, upstream.Repo, upstreamRepo.DefaultBranch, forkContext.ref.Owner+":"+forkRepo.DefaultBranch) @@ -105,80 +85,23 @@ func checkPreflightForkFreshness( } if comparison.BaseSHA != "" && !strings.EqualFold(comparison.BaseSHA, result.UpstreamSHA) { result.Reason = "comparison base SHA did not match the resolved upstream default branch" - return result, true, nil + return result, nil } if comparison.MergeBaseSHA == "" { result.Reason = "comparison did not provide merge-base evidence" - return result, true, nil + return result, nil } result.Status, result.NextAction = classifyForkFreshness(comparison.Status) if result.Status == "unavailable" { result.Reason = "GitHub returned an unsupported fork comparison status" - return result, true, nil + return result, nil } result.MergeBaseSHA = comparison.MergeBaseSHA result.AheadBy = comparison.AheadBy result.BehindBy = comparison.BehindBy result.Coverage = "verified" result.EffectiveDiffRisk = result.Status != "current" - return result, true, nil -} - -func resolvePreflightFork( - upstream mcpcontract.RepositoryRef, - explicit *mcpcontract.RepositoryRef, - identity string, - candidate preflightCandidate, - worktrees []workspace.LocalWorktree, - existing *preflightExisting, -) (*preflightForkContext, bool, string) { - if explicit != nil { - return &preflightForkContext{ref: *explicit, branch: candidate.headRef, sha: candidate.headSHA}, true, "" - } - if existing != nil && existing.details.HeadOwner != "" && existing.details.HeadRepo != "" && !sameGitHubRepository(existing.details.HeadOwner, existing.details.HeadRepo, upstream) { - return &preflightForkContext{ - ref: mcpcontract.RepositoryRef{Owner: existing.details.HeadOwner, Repo: existing.details.HeadRepo}, - branch: existing.details.HeadRef, - sha: existing.details.HeadSHA, - }, true, "" - } - if len(worktrees) == 0 { - return nil, false, "" - } - - var candidates []preflightForkContext - seen := make(map[string]struct{}) - for _, worktree := range worktrees { - for _, urls := range worktree.Remotes { - for _, remote := range urls { - identityRef, err := gitremote.ParseRepositoryIdentity(remote) - if err != nil || !strings.EqualFold(identityRef.Owner, identity) || sameGitHubRepository(identityRef.Owner, identityRef.Repo, upstream) { - continue - } - ref := mcpcontract.RepositoryRef{Owner: identityRef.Owner, Repo: identityRef.Repo} - key := strings.ToLower(ref.Owner + "/" + ref.Repo) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - branch, sha := worktree.Branch, worktree.HeadSHA - if candidate.headRef != "" { - branch = candidate.headRef - } - if candidate.headSHA != "" { - sha = candidate.headSHA - } - candidates = append(candidates, preflightForkContext{ref: ref, branch: branch, sha: sha}) - } - } - } - if len(candidates) == 1 { - return &candidates[0], true, "" - } - if len(candidates) > 1 { - return nil, true, "multiple contributor fork remotes were found; provide fork explicitly" - } - return nil, true, "no contributor fork remote was found in the supplied workspaces" + return result, nil } func newForkFreshnessOutput(upstream mcpcontract.RepositoryRef, reason string) mcpcontract.ForkFreshnessOutput { @@ -191,12 +114,12 @@ func newForkFreshnessOutput(upstream mcpcontract.RepositoryRef, reason string) m } } -func forkFreshnessUnavailable(result mcpcontract.ForkFreshnessOutput, reason string, err error) (mcpcontract.ForkFreshnessOutput, bool, error) { - if contextError(err) { - return mcpcontract.ForkFreshnessOutput{}, false, err +func forkFreshnessUnavailable(result mcpcontract.ForkFreshnessOutput, reason string, err error) (mcpcontract.ForkFreshnessOutput, error) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return mcpcontract.ForkFreshnessOutput{}, err } result.Reason = reason - return result, true, nil + return result, nil } func classifyForkFreshness(providerStatus string) (string, string) { diff --git a/internal/app/mcp_contribution_preflight.go b/internal/app/mcp_contribution_preflight.go deleted file mode 100644 index 25a43d08..00000000 --- a/internal/app/mcp_contribution_preflight.go +++ /dev/null @@ -1,414 +0,0 @@ -package app - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "unicode" - - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/github" - "github.com/morluto/gitcontribute/internal/gitremote" - "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/workspace" -) - -const ( - defaultContributionPreflightLimit = 20 - defaultContributionPreflightMaxRequests = 100 -) - -type preflightCandidate struct { - title string - query string - body string - issueNumber int - headRef string - headSHA string - changedFileCount int -} - -func parsePreflightCandidate(input mcpcontract.ContributionPreflightCandidate) (preflightCandidate, error) { - if input.IssueNumber < 0 { - return preflightCandidate{}, errors.New("candidate issue_number must be positive when provided") - } - return preflightCandidate{ - title: strings.TrimSpace(input.Title), query: strings.TrimSpace(input.Query), body: strings.TrimSpace(input.Body), - issueNumber: input.IssueNumber, headRef: strings.TrimSpace(input.HeadRef), headSHA: strings.TrimSpace(input.HeadSHA), - changedFileCount: len(input.ChangedFiles), - }, nil -} - -func (c preflightCandidate) hasInputContext(workspacePathCount int) bool { - return c.title != "" || c.query != "" || c.body != "" || c.issueNumber > 0 || - c.headRef != "" || c.headSHA != "" || c.changedFileCount > 0 || workspacePathCount > 0 -} - -func (c preflightCandidate) hasComparableIdentity(worktrees []workspace.LocalWorktree) bool { - return c.title != "" || c.query != "" || c.body != "" || c.issueNumber > 0 || - c.headRef != "" || c.headSHA != "" || len(worktrees) > 0 -} - -func (c preflightCandidate) searchQuery() string { - switch { - case c.query != "": - return c.query - case c.title != "": - return c.title - case c.headRef != "": - return c.headRef - case len(c.body) > 200: - return c.body[:200] - default: - return c.body - } -} - -// PreflightContribution performs the bounded, side-effect-free routing check -// described by workflow.preflight_contribution. Unlike portfolio sync, it does -// not write observations, create jobs, create worktrees, or adopt paths. -func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.ContributionPreflightInput) (mcpcontract.ContributionPreflightOutput, error) { - repository, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) - if err != nil { - return mcpcontract.ContributionPreflightOutput{}, err - } - in.Repository.Owner = repository.Owner() - in.Repository.Repo = repository.Repo() - if in.Fork != nil { - fork, err := domain.NewRepoRef(in.Fork.Owner, in.Fork.Repo) - if err != nil { - return mcpcontract.ContributionPreflightOutput{}, fmt.Errorf("validate fork repository: %w", err) - } - in.Fork.Owner = fork.Owner() - in.Fork.Repo = fork.Repo() - if sameGitHubRepository(in.Fork.Owner, in.Fork.Repo, in.Repository) { - return mcpcontract.ContributionPreflightOutput{}, errors.New("fork repository must differ from the upstream repository") - } - } - candidate, err := parsePreflightCandidate(in.Candidate) - if err != nil { - return mcpcontract.ContributionPreflightOutput{}, err - } - if in.Limit == 0 { - in.Limit = defaultContributionPreflightLimit - } - if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.ContributionPreflightOutput{}, errors.New("limit must be between 1 and 100") - } - if in.MaxRequests == 0 { - in.MaxRequests = defaultContributionPreflightMaxRequests - } - if in.MaxRequests < 2 || in.MaxRequests > 1000 { - return mcpcontract.ContributionPreflightOutput{}, errors.New("max_requests must be between 2 and 1000") - } - if !candidate.hasInputContext(len(in.WorkspacePaths)) { - return mcpcontract.ContributionPreflightOutput{}, errors.New("candidate or workspace_paths must provide contribution context") - } - - out := mcpcontract.ContributionPreflightOutput{ - Status: "coverage_unknown", - Repository: in.Repository, - Coverage: "coverage_unknown", - NextAction: "complete_preflight_coverage", - } - - worktrees, worktreeReasons := inspectPreflightWorktrees(ctx, in.WorkspacePaths) - out.CoverageReasons = append(out.CoverageReasons, worktreeReasons...) - - reader, readerErr := r.githubReader() //nolint:contextcheck // construction performs no request - if readerErr != nil { - out.CoverageReasons = append(out.CoverageReasons, "authenticated GitHub reader is unavailable") - return out, nil //nolint:nilerr // unavailable optional live reads are a structured unknown result - } - identityReader, ok := reader.(github.IdentityReader) - if !ok { - out.CoverageReasons = append(out.CoverageReasons, "configured GitHub reader does not support authenticated identity lookup") - return out, nil - } - identity, _, err := identityReader.GetAuthenticatedIdentity(ctx) - if err != nil { - if contextError(err) { - return mcpcontract.ContributionPreflightOutput{}, err - } - out.CoverageReasons = append(out.CoverageReasons, "authenticated GitHub identity could not be resolved") - return out, nil - } - if strings.TrimSpace(identity.Login) == "" { - out.CoverageReasons = append(out.CoverageReasons, "authenticated GitHub identity did not include a login") - return out, nil - } - out.Identity = identity.Login - if !candidate.hasComparableIdentity(worktrees) { - out.CoverageReasons = append(out.CoverageReasons, "candidate has no comparable title, branch, commit, issue, or inspected worktree identity") - } - - searcher, ok := reader.(github.AuthoredPullRequestSearcher) - if !ok { - out.CoverageReasons = append(out.CoverageReasons, "configured GitHub reader does not support authored pull-request search") - return out, nil - } - requests := 1 // identity lookup - if requests >= in.MaxRequests { - out.CoverageReasons = append(out.CoverageReasons, "request budget cannot fund authored pull-request discovery") - return out, nil - } - requests++ - authored, err := searcher.SearchAuthoredPullRequests(ctx, github.AuthoredPullRequestSearchOptions{ - Login: identity.Login, RepositoryOwner: in.Repository.Owner, RepositoryName: in.Repository.Repo, - State: "open", PageOptions: github.PageOptions{Page: 1, PerPage: in.Limit}, - }) - if err != nil { - if contextError(err) { - return mcpcontract.ContributionPreflightOutput{}, err - } - out.CoverageReasons = append(out.CoverageReasons, "live authored pull-request search failed") - return out, nil - } - if authored.Incomplete || authored.Page.HasNext || authored.Total > len(authored.Items) { - out.CoverageReasons = append(out.CoverageReasons, "authored pull-request search coverage is incomplete") - } - - threadSearcher, hasThreadSearch := reader.(github.ThreadSearcher) - query := candidate.searchQuery() - if !hasThreadSearch { - out.CoverageReasons = append(out.CoverageReasons, "configured GitHub reader does not support related-thread search") - } else if requests >= in.MaxRequests { - out.CoverageReasons = append(out.CoverageReasons, "request budget cannot fund related-thread search") - } else { - requests++ - related, searchErr := threadSearcher.SearchThreads(ctx, github.ThreadSearchOptions{ - Owner: in.Repository.Owner, Repo: in.Repository.Repo, Query: query, State: "open", - Sort: "updated", Order: "desc", PageOptions: github.PageOptions{Page: 1, PerPage: in.Limit}, - }) - if searchErr != nil { - if contextError(searchErr) { - return mcpcontract.ContributionPreflightOutput{}, searchErr - } - out.CoverageReasons = append(out.CoverageReasons, "live related-thread search failed") - } else { - out.Related = relatedThreadOutputs(related.Items) - if related.Incomplete || related.Page.HasNext || related.Total > len(related.Items) { - out.CoverageReasons = append(out.CoverageReasons, "related-thread search coverage is incomplete") - } - } - } - - detailsReader := reader - existing := make([]preflightExisting, 0) - for _, marker := range authored.Items { - if !sameRepository(marker.RepositoryOwner, marker.RepositoryName, in.Repository) || marker.Kind != domain.PullRequestKind { - continue - } - if requests >= in.MaxRequests { - out.CoverageReasons = append(out.CoverageReasons, "request budget prevented complete pull-request detail inspection") - break - } - requests++ - details, _, detailErr := detailsReader.GetPullRequestDetails(ctx, in.Repository.Owner, in.Repository.Repo, marker.Number) - if detailErr != nil { - if contextError(detailErr) { - return mcpcontract.ContributionPreflightOutput{}, detailErr - } - out.CoverageReasons = append(out.CoverageReasons, fmt.Sprintf("pull-request #%d details could not be inspected", marker.Number)) - continue - } - if preflightMatchesCandidate(candidate, marker, details, worktrees) { - existing = append(existing, preflightExisting{marker: marker, details: details}) - } - } - if len(existing) > 0 { - sort.Slice(existing, func(i, j int) bool { return existing[i].marker.Number < existing[j].marker.Number }) - match := existing[0] - issue := candidate.issueNumber - if issue == 0 { - issue = relatedIssueNumber(out.Related, candidate) - } - headOwner := match.details.HeadOwner - if headOwner == "" { - headOwner = identity.Login - } - head := strings.TrimSpace(headOwner + ":" + match.details.HeadRef) - if strings.HasSuffix(head, ":") { - head = "" - } - out.Existing = &mcpcontract.ExistingContributionOutput{Issue: issue, PullRequest: match.marker.Number, Head: head, HeadSHA: match.details.HeadSHA, URL: match.details.HTMLURL} - out.LocalMatches = localMatchOutputs(worktrees, existing) - } - - if out.Existing != nil { - out.Status = "existing_pr" - out.NextAction = "review_or_follow_through" - } - forkFreshness, forkChecked, forkErr := checkPreflightForkFreshness(ctx, reader, in.Repository, in.Fork, identity.Login, candidate, worktrees, existingMatch(existing), in.MaxRequests, requests) - if forkErr != nil { - return mcpcontract.ContributionPreflightOutput{}, forkErr - } - if forkChecked { - out.ForkFreshness = &forkFreshness - if forkFreshness.Coverage != "verified" { - out.CoverageReasons = append(out.CoverageReasons, "fork freshness coverage is unavailable: "+forkFreshness.Reason) - } - } - if len(out.CoverageReasons) == 0 { - out.Coverage = "live_verified" - if out.Existing == nil { - out.Status = "new_work" - out.NextAction = "create_local_work" - } - } - return out, nil -} - -func existingMatch(existing []preflightExisting) *preflightExisting { - if len(existing) == 0 { - return nil - } - return &existing[0] -} - -type preflightExisting struct { - marker github.Issue - details github.PullRequestDetails -} - -func contextError(err error) bool { - return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) -} - -func sameRepository(owner, repo string, ref mcpcontract.RepositoryRef) bool { - return strings.EqualFold(strings.TrimSpace(owner), strings.TrimSpace(ref.Owner)) && strings.EqualFold(strings.TrimSpace(repo), strings.TrimSpace(ref.Repo)) -} - -func inspectPreflightWorktrees(ctx context.Context, paths []string) ([]workspace.LocalWorktree, []string) { - var inspected []workspace.LocalWorktree - var reasons []string - for _, path := range paths { - path = strings.TrimSpace(path) - if path == "" { - reasons = append(reasons, "a supplied workspace path is empty") - continue - } - worktree, err := workspace.InspectPath(ctx, path, nil) - if err != nil { - reasons = append(reasons, "local workspace inspection failed for "+path) - continue - } - inspected = append(inspected, worktree) - } - return inspected, reasons -} - -func relatedThreadOutputs(items []github.Issue) []mcpcontract.RelatedContributionThread { - out := make([]mcpcontract.RelatedContributionThread, 0, len(items)) - for _, item := range items { - out = append(out, mcpcontract.RelatedContributionThread{Kind: string(item.Kind), Number: item.Number, Title: item.Title, Author: item.Author, URL: item.HTMLURL}) - } - return out -} - -func relatedIssueNumber(items []mcpcontract.RelatedContributionThread, candidate preflightCandidate) int { - for _, item := range items { - if item.Kind == string(domain.IssueKind) && textSimilarity(candidate, item.Title) >= 0.5 { - return item.Number - } - } - return 0 -} - -func preflightMatchesCandidate(candidate preflightCandidate, marker github.Issue, details github.PullRequestDetails, worktrees []workspace.LocalWorktree) bool { - if candidate.issueNumber > 0 && marker.Number == candidate.issueNumber { - return true - } - if candidate.headSHA != "" && strings.EqualFold(candidate.headSHA, strings.TrimSpace(details.HeadSHA)) { - return true - } - if candidate.headRef != "" && strings.EqualFold(candidate.headRef, strings.TrimSpace(details.HeadRef)) { - return true - } - for _, worktree := range worktrees { - if worktree.HeadSHA != "" && strings.EqualFold(worktree.HeadSHA, details.HeadSHA) { - return true - } - if worktree.Branch != "" && strings.EqualFold(worktree.Branch, details.HeadRef) { - return true - } - for _, urls := range worktree.Remotes { - for _, remote := range urls { - identity, err := gitremote.ParseRepositoryIdentity(remote) - if err == nil && strings.EqualFold(identity.Owner, details.HeadOwner) && strings.EqualFold(identity.Repo, details.HeadRepo) && strings.EqualFold(worktree.Branch, details.HeadRef) { - return true - } - } - } - } - return textSimilarity(candidate, marker.Title) >= 0.5 -} - -func localMatchOutputs(worktrees []workspace.LocalWorktree, existing []preflightExisting) []mcpcontract.LocalContributionMatch { - var out []mcpcontract.LocalContributionMatch - for _, worktree := range worktrees { - for _, match := range existing { - if (worktree.HeadSHA != "" && strings.EqualFold(worktree.HeadSHA, match.details.HeadSHA)) || (worktree.Branch != "" && strings.EqualFold(worktree.Branch, match.details.HeadRef)) { - remote := "" - if values := worktree.Remotes["origin"]; len(values) > 0 { - remote = values[0] - } - out = append(out, mcpcontract.LocalContributionMatch{Path: worktree.Path, Branch: worktree.Branch, Remote: remote, PullRequest: match.marker.Number}) - break - } - } - } - return out -} - -func textSimilarity(candidate preflightCandidate, other string) float64 { - left := candidate.query - if left == "" { - left = candidate.title - } - if left == "" { - left = candidate.body - } - leftTokens := preflightTokens(left) - rightTokens := preflightTokens(other) - if len(leftTokens) == 0 || len(rightTokens) == 0 { - return 0 - } - shared := 0 - for token := range leftTokens { - if _, ok := rightTokens[token]; ok { - shared++ - } - } - denominator := len(leftTokens) - if len(rightTokens) > denominator { - denominator = len(rightTokens) - } - return float64(shared) / float64(denominator) -} - -func preflightTokens(value string) map[string]struct{} { - value = strings.ToLower(value) - var builder strings.Builder - for _, r := range value { - if unicode.IsLetter(r) || unicode.IsNumber(r) { - builder.WriteRune(r) - } else { - builder.WriteByte(' ') - } - } - tokens := make(map[string]struct{}) - for _, token := range strings.Fields(builder.String()) { - if len([]rune(token)) < 3 { - continue - } - switch token { - case "the", "and", "for", "with", "from", "this", "that", "fix", "add", "issue": - continue - } - tokens[token] = struct{}{} - } - return tokens -} diff --git a/internal/app/mcp_contribution_preflight_test.go b/internal/app/mcp_contribution_preflight_test.go deleted file mode 100644 index 58efe3dc..00000000 --- a/internal/app/mcp_contribution_preflight_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package app - -import ( - "context" - "errors" - "path/filepath" - "strings" - "testing" - - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/github" - "github.com/morluto/gitcontribute/internal/mcpcontract" -) - -type preflightReader struct { - github.Reader - identityErr error - forkStatus string - forkErr error - authored github.AuthoredPullRequestSearchResult - related github.ThreadSearchResult - details map[int]github.PullRequestDetails - searchOptions github.AuthoredPullRequestSearchOptions - threadSearch github.ThreadSearchOptions -} - -func (r *preflightReader) GetRepository(_ context.Context, owner, repo string) (github.Repository, github.RateInfo, error) { - if r.forkErr != nil { - return github.Repository{}, github.RateInfo{}, r.forkErr - } - result := github.Repository{Owner: owner, Name: repo, DefaultBranch: "main"} - if !strings.EqualFold(owner, "fla-org") { - result.Fork = true - result.Parent = &github.RepositoryParent{Owner: "fla-org", Name: "flash-linear-attention", FullName: "fla-org/flash-linear-attention"} - } - return result, github.RateInfo{}, nil -} - -func (r *preflightReader) GetBranch(_ context.Context, owner, repo, branch string) (github.Branch, github.RateInfo, error) { - sha := "upstream-sha" - if !strings.EqualFold(owner, "fla-org") || !strings.EqualFold(repo, "flash-linear-attention") { - sha = "fork-sha" - } - return github.Branch{Name: branch, CommitSHA: sha}, github.RateInfo{}, nil -} - -func (r *preflightReader) CompareCommits(_ context.Context, _, _, _, _ string) (github.CommitComparison, github.RateInfo, error) { - if r.forkErr != nil { - return github.CommitComparison{}, github.RateInfo{}, r.forkErr - } - status := r.forkStatus - if status == "" { - status = "identical" - } - return github.CommitComparison{Status: status, BaseSHA: "upstream-sha", MergeBaseSHA: "merge-base", AheadBy: 0, BehindBy: 0}, github.RateInfo{}, nil -} - -func (r *preflightReader) GetAuthenticatedIdentity(context.Context) (github.Identity, github.RateInfo, error) { - if r.identityErr != nil { - return github.Identity{}, github.RateInfo{}, r.identityErr - } - return github.Identity{Login: "morluto", ID: 1}, github.RateInfo{}, nil -} - -func (r *preflightReader) SearchAuthoredPullRequests(_ context.Context, opts github.AuthoredPullRequestSearchOptions) (github.AuthoredPullRequestSearchResult, error) { - r.searchOptions = opts - return r.authored, nil -} - -func (r *preflightReader) SearchThreads(_ context.Context, opts github.ThreadSearchOptions) (github.ThreadSearchResult, error) { - r.threadSearch = opts - return r.related, nil -} - -func (r *preflightReader) GetPullRequestDetails(_ context.Context, _, _ string, number int) (github.PullRequestDetails, github.RateInfo, error) { - details, ok := r.details[number] - if !ok { - return github.PullRequestDetails{}, github.RateInfo{}, errors.New("missing details") - } - return details, github.RateInfo{}, nil -} - -func TestPreflightContributionRoutesExistingAuthoredPRAndLocalWorktree(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - - worktree := t.TempDir() - runGitApp(t, worktree, "init", "-b", "main") - runGitApp(t, worktree, "config", "user.email", "test@example.com") - runGitApp(t, worktree, "config", "user.name", "Test") - writeAppFile(t, filepath.Join(worktree, "README.md"), "base\n") - runGitApp(t, worktree, "add", ".") - runGitApp(t, worktree, "commit", "-m", "base") - runGitApp(t, worktree, "checkout", "-b", "fix/n01-mask-cu-seqlens-v2") - runGitApp(t, worktree, "remote", "add", "origin", "https://github.com/morluto/flash-linear-attention.git") - headSHA := runGitApp(t, worktree, "rev-parse", "HEAD") - - reader := &preflightReader{ - authored: github.AuthoredPullRequestSearchResult{Items: []github.Issue{{ - RepositoryOwner: "fla-org", RepositoryName: "flash-linear-attention", Kind: domain.PullRequestKind, - Number: 1088, Title: "Fix n01 mask cu seqlens v2", Author: "morluto", - }}, Total: 1}, - related: github.ThreadSearchResult{Items: []github.Issue{ - {RepositoryOwner: "fla-org", RepositoryName: "flash-linear-attention", Kind: domain.IssueKind, Number: 1086, Title: "Fix n01 mask cu seqlens v2"}, - {RepositoryOwner: "fla-org", RepositoryName: "flash-linear-attention", Kind: domain.PullRequestKind, Number: 1088, Title: "Fix n01 mask cu seqlens v2"}, - }, Total: 2}, - details: map[int]github.PullRequestDetails{1088: { - Number: 1088, HeadRef: "fix/n01-mask-cu-seqlens-v2", HeadSHA: headSHA, - HeadOwner: "morluto", HeadRepo: "flash-linear-attention", HTMLURL: "https://github.com/fla-org/flash-linear-attention/pull/1088", - }}, - } - svc.SetGitHubReader(reader) - beforeCorpus, err := svc.corpus.Status(ctx) - if err != nil { - t.Fatal(err) - } - - out, err := (&MCPReader{svc}).PreflightContribution(ctx, mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{Title: "Fix n01 mask cu seqlens v2"}, WorkspacePaths: []string{worktree}, - }) - if err != nil { - t.Fatal(err) - } - if out.Status != "existing_pr" || out.Coverage != "live_verified" || out.Existing == nil { - t.Fatalf("preflight = %+v", out) - } - if out.ForkFreshness == nil || out.ForkFreshness.Fork.Owner != "morluto" || out.ForkFreshness.Status != "current" { - t.Fatalf("inferred fork freshness = %+v", out.ForkFreshness) - } - if out.Existing.Issue != 1086 || out.Existing.PullRequest != 1088 || out.Existing.Head != "morluto:fix/n01-mask-cu-seqlens-v2" { - t.Fatalf("existing = %+v", out.Existing) - } - expectedWorktree, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatal(err) - } - if len(out.LocalMatches) != 1 || out.LocalMatches[0].Path != expectedWorktree || out.LocalMatches[0].Branch != "fix/n01-mask-cu-seqlens-v2" { - t.Fatalf("local matches = %+v", out.LocalMatches) - } - if reader.searchOptions.RepositoryOwner != "fla-org" || reader.searchOptions.RepositoryName != "flash-linear-attention" { - t.Fatalf("authored search was not repository scoped: %+v", reader.searchOptions) - } - if out.NextAction != "review_or_follow_through" { - t.Fatalf("next action = %q", out.NextAction) - } - afterCorpus, err := svc.corpus.Status(ctx) - if err != nil { - t.Fatal(err) - } - if beforeCorpus != afterCorpus { - t.Fatalf("preflight mutated corpus status: before=%+v after=%+v", beforeCorpus, afterCorpus) - } -} - -func TestPreflightContributionNeverClaimsNewWorkWithIncompleteCoverage(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - reader := &preflightReader{identityErr: errors.New("token unavailable")} - svc.SetGitHubReader(reader) - out, err := (&MCPReader{svc}).PreflightContribution(ctx, mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{Title: "Unrelated change"}, - }) - if err != nil { - t.Fatal(err) - } - if out.Status != "coverage_unknown" || out.Coverage != "coverage_unknown" || out.NextAction != "complete_preflight_coverage" { - t.Fatalf("incomplete preflight = %+v", out) - } - if len(out.CoverageReasons) == 0 { - t.Fatal("missing coverage reason") - } -} - -func TestPreflightContributionReturnsNewWorkOnlyAfterLiveNegativeChecks(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - reader := &preflightReader{ - authored: github.AuthoredPullRequestSearchResult{Items: nil, Total: 0}, - related: github.ThreadSearchResult{Items: nil, Total: 0}, - } - svc.SetGitHubReader(reader) - out, err := (&MCPReader{svc}).PreflightContribution(context.Background(), mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{Title: "Unrelated change"}, - }) - if err != nil { - t.Fatal(err) - } - if out.Status != "new_work" || out.Coverage != "live_verified" || out.NextAction != "create_local_work" { - t.Fatalf("negative preflight = %+v", out) - } -} - -func TestPreflightContributionCanonicalizesCandidateBeforeLiveReads(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - reader := &preflightReader{} - svc.SetGitHubReader(reader) - out, err := (&MCPReader{svc}).PreflightContribution(context.Background(), mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Fork: &mcpcontract.RepositoryRef{Owner: "morluto", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{ - Query: " focused duplicate search ", HeadRef: " fix/candidate ", HeadSHA: " abc123 ", - }, - }) - if err != nil { - t.Fatal(err) - } - if reader.threadSearch.Query != "focused duplicate search" { - t.Fatalf("related-thread query = %q", reader.threadSearch.Query) - } - if out.ForkFreshness == nil || out.ForkFreshness.ContributionBranch != "fix/candidate" || out.ForkFreshness.ContributionSHA != "abc123" { - t.Fatalf("fork contribution identity = %+v", out.ForkFreshness) - } -} - -func TestPreflightContributionReportsForkFreshness(t *testing.T) { - t.Parallel() - for _, test := range []struct { - providerStatus string - wantStatus string - wantCoverage string - wantRisk bool - }{ - {providerStatus: "identical", wantStatus: "current", wantCoverage: "verified", wantRisk: false}, - {providerStatus: "behind", wantStatus: "behind", wantCoverage: "verified", wantRisk: true}, - {providerStatus: "diverged", wantStatus: "diverged", wantCoverage: "verified", wantRisk: true}, - {providerStatus: "ahead", wantStatus: "diverged", wantCoverage: "verified", wantRisk: true}, - {providerStatus: "unknown", wantStatus: "unavailable", wantCoverage: "unavailable", wantRisk: false}, - } { - t.Run(test.providerStatus, func(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - reader := &preflightReader{forkStatus: test.providerStatus} - svc.SetGitHubReader(reader) - out, err := (&MCPReader{svc}).PreflightContribution(context.Background(), mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Fork: &mcpcontract.RepositoryRef{Owner: "morluto", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{Title: "Fix contribution"}, - }) - if err != nil { - t.Fatal(err) - } - if out.ForkFreshness == nil { - t.Fatalf("missing fork freshness: %+v", out) - } - freshness := out.ForkFreshness - if freshness.Status != test.wantStatus || freshness.Coverage != test.wantCoverage || freshness.EffectiveDiffRisk != test.wantRisk { - t.Fatalf("fork freshness = %+v", freshness) - } - if freshness.Coverage == "verified" && (freshness.UpstreamSHA != "upstream-sha" || freshness.ForkSHA != "fork-sha" || freshness.MergeBaseSHA != "merge-base") { - t.Fatalf("fork ancestry = %+v", freshness) - } - }) - } -} - -func TestPreflightContributionReportsUnavailableForkFreshness(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - reader := &preflightReader{forkErr: errors.New("fork unavailable")} - svc.SetGitHubReader(reader) - out, err := (&MCPReader{svc}).PreflightContribution(context.Background(), mcpcontract.ContributionPreflightInput{ - Repository: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, - Fork: &mcpcontract.RepositoryRef{Owner: "morluto", Repo: "flash-linear-attention"}, - Candidate: mcpcontract.ContributionPreflightCandidate{Title: "Fix contribution"}, - }) - if err != nil { - t.Fatal(err) - } - if out.Coverage != "coverage_unknown" || out.ForkFreshness == nil || out.ForkFreshness.Status != "unavailable" { - t.Fatalf("unavailable fork freshness = %+v", out) - } - if len(out.CoverageReasons) == 0 { - t.Fatalf("missing coverage reason: %+v", out) - } -} diff --git a/internal/app/mcp_fix_pattern_analysis.go b/internal/app/mcp_fix_pattern_analysis.go new file mode 100644 index 00000000..e139d9c8 --- /dev/null +++ b/internal/app/mcp_fix_pattern_analysis.go @@ -0,0 +1,263 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/fixpatterns" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type fixPatternAnalysisRequest struct { + canonical mcpcontract.AnalyzeFixPatternsInput + repository domain.RepoRef + after time.Time + before time.Time + wantedOutcomes map[mcpcontract.FixPatternOutcome]struct{} + candidatePage corpus.SearchPage + representativeLimit int +} + +func (r *MCPReader) AnalyzeFixPatterns(ctx context.Context, in mcpcontract.AnalyzeFixPatternsInput) (mcpcontract.AnalyzeFixPatternsOutput, error) { + request, err := parseFixPatternAnalysisInput(in) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, request.canonical.SnapshotToken) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + repository, err := c.GetRepository(ctx, request.repository.Owner(), request.repository.Repo()) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + if repository == nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, mcpcontract.Unavailable( + "repository_not_indexed", fmt.Sprintf("Repository %s is not present in the local corpus.", request.repository), + syncRepositoryContextCall(request.repository.Owner(), request.repository.Repo()), + ) + } + scope, err := corpus.NewThreadRepositoryScope(request.repository, repository.ID) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + out := mcpcontract.AnalyzeFixPatternsOutput{ + Status: "complete", Repository: request.canonical.Repository, TimeWindow: request.canonical.TimeWindow, + Clusters: make([]mcpcontract.FixPatternCluster, len(request.canonical.SymptomTaxonomy)), + Limitations: []string{ + "Similarity-only examples are candidates, not proof that a pull request fixed a related issue.", + "Accepted fixes require observed merged state and an explicit closing relationship in stored pull-request text.", + }, + SnapshotToken: snapshotIdentity(request.canonical.SnapshotToken, revision), + } + unique := make(map[int64]struct{}) + unknown := make(map[int64]struct{}) + for index, symptom := range request.canonical.SymptomTaxonomy { + if err := ctx.Err(); err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + page, err := c.SearchThreadsPage(ctx, strings.Join(symptom.Terms, " "), corpus.SearchFilter{ + Repository: scope, Kind: corpus.PullRequestThreadKind(), UpdatedAfter: request.after, UpdatedBefore: request.before, + Page: request.candidatePage, TermMatch: corpus.MatchAnyTerm(), + }) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, fmt.Errorf("search symptom %q: %w", symptom.Name, err) + } + cluster := mcpcontract.FixPatternCluster{Name: symptom.Name, Terms: append([]string(nil), symptom.Terms...), CandidateCount: mcpcontract.NonNegativeInt(page.Total)} + out.Coverage.CandidateMatches += mcpcontract.NonNegativeInt(page.Total) + out.Coverage.CandidateTruncated = out.Coverage.CandidateTruncated || page.Total > len(page.Threads) + for _, thread := range page.Threads { + unique[thread.ID] = struct{}{} + classification := fixpatterns.Classify(request.repository, fixpatterns.PullRequest{State: thread.State, Body: thread.Body, Merge: thread.Merge}) + incrementFixPatternOutcome(&cluster.Outcomes, mcpcontract.FixPatternOutcome(classification.Outcome)) + if classification.Outcome == fixpatterns.Unknown { + if _, exists := unknown[thread.ID]; !exists { + unknown[thread.ID] = struct{}{} + if len(out.Coverage.UnknownPullRequests) < 100 { + out.Coverage.UnknownPullRequests = append(out.Coverage.UnknownPullRequests, mcpcontract.ThreadRef{Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: string(domain.PullRequestKind), Number: thread.Number}) + } + } + } + if _, wanted := request.wantedOutcomes[mcpcontract.FixPatternOutcome(classification.Outcome)]; !wanted { + continue + } + if len(cluster.Examples) == request.representativeLimit { + cluster.ExamplesTruncated = true + continue + } + cluster.Examples = append(cluster.Examples, fixPatternExample(request.repository, thread, classification)) + } + out.Clusters[index] = cluster + } + out.Coverage.UniqueCandidates = mcpcontract.NonNegativeInt(len(unique)) + out.Coverage.UnknownOutcomes = mcpcontract.NonNegativeInt(len(unknown)) + out.Coverage.UnknownRefsTruncated = len(unknown) > len(out.Coverage.UnknownPullRequests) + history, err := c.GetCoverage(ctx, repository.ID, nil, "threads") + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + switch { + case history == nil: + out.Coverage.HistoryStatus = "unknown" + case history.Complete: + out.Coverage.HistoryStatus = "complete" + default: + out.Coverage.HistoryStatus = "partial" + } + out.Truncated = out.Coverage.CandidateTruncated + out.UnknownCoverage = out.Coverage.HistoryStatus != "complete" || out.Coverage.UnknownOutcomes > 0 + if out.Truncated || out.UnknownCoverage { + out.Status = "partial" + out.Recovery = fixPatternRecovery(request, out) + } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + out.Provenance, err = offlineReadProvenance("fix_pattern_analysis", revision, request.canonical, out.Truncated, out.UnknownCoverage) + if err != nil { + return mcpcontract.AnalyzeFixPatternsOutput{}, err + } + out.Provenance.Recovery = out.Recovery + return out, nil +} + +func parseFixPatternAnalysisInput(in mcpcontract.AnalyzeFixPatternsInput) (fixPatternAnalysisRequest, error) { + repository, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { + return fixPatternAnalysisRequest{}, err + } + in.Repository = mcpcontract.RepositoryRef{Owner: repository.Owner(), Repo: repository.Repo()} + after, err := time.Parse(time.RFC3339, in.TimeWindow.UpdatedAfter) + if err != nil { + return fixPatternAnalysisRequest{}, errors.New("time_window.updated_after must be RFC 3339") + } + var before time.Time + if in.TimeWindow.UpdatedBefore != "" { + before, err = time.Parse(time.RFC3339, in.TimeWindow.UpdatedBefore) + if err != nil { + return fixPatternAnalysisRequest{}, errors.New("time_window.updated_before must be RFC 3339") + } + if before.Before(after) { + return fixPatternAnalysisRequest{}, errors.New("time_window.updated_before must not be earlier than updated_after") + } + } + if len(in.SymptomTaxonomy) < 1 || len(in.SymptomTaxonomy) > 12 { + return fixPatternAnalysisRequest{}, errors.New("symptom_taxonomy must contain 1 to 12 categories") + } + seenNames := make(map[string]struct{}, len(in.SymptomTaxonomy)) + for i := range in.SymptomTaxonomy { + in.SymptomTaxonomy[i].Name = strings.TrimSpace(in.SymptomTaxonomy[i].Name) + if in.SymptomTaxonomy[i].Name == "" || len(in.SymptomTaxonomy[i].Terms) < 1 || len(in.SymptomTaxonomy[i].Terms) > 12 { + return fixPatternAnalysisRequest{}, fmt.Errorf("symptom_taxonomy[%d] requires a name and 1 to 12 terms", i) + } + nameKey := strings.ToLower(in.SymptomTaxonomy[i].Name) + if _, duplicate := seenNames[nameKey]; duplicate { + return fixPatternAnalysisRequest{}, fmt.Errorf("symptom_taxonomy[%d].name duplicates %q", i, in.SymptomTaxonomy[i].Name) + } + seenNames[nameKey] = struct{}{} + seenTerms := make(map[string]struct{}, len(in.SymptomTaxonomy[i].Terms)) + for j := range in.SymptomTaxonomy[i].Terms { + term := strings.TrimSpace(in.SymptomTaxonomy[i].Terms[j]) + if term == "" { + return fixPatternAnalysisRequest{}, fmt.Errorf("symptom_taxonomy[%d].terms[%d] is required", i, j) + } + key := strings.ToLower(term) + if _, duplicate := seenTerms[key]; duplicate { + return fixPatternAnalysisRequest{}, fmt.Errorf("symptom_taxonomy[%d].terms contains duplicate %q", i, term) + } + seenTerms[key] = struct{}{} + in.SymptomTaxonomy[i].Terms[j] = term + } + } + if in.CandidateLimit == 0 { + in.CandidateLimit = mcpcontract.DefaultFixPatternCandidateLimit + } + if in.CandidateLimit < 1 || in.CandidateLimit > 100 { + return fixPatternAnalysisRequest{}, errors.New("candidate_limit must be between 1 and 100") + } + if in.RepresentativeLimit == 0 { + in.RepresentativeLimit = mcpcontract.DefaultFixPatternRepresentativeLimit + } + if in.RepresentativeLimit < 1 || in.RepresentativeLimit > 20 { + return fixPatternAnalysisRequest{}, errors.New("representative_limit must be between 1 and 20") + } + if len(in.MergeOutcomes) == 0 { + in.MergeOutcomes = []mcpcontract.FixPatternOutcome{mcpcontract.FixPatternMerged} + } + wanted := make(map[mcpcontract.FixPatternOutcome]struct{}, len(in.MergeOutcomes)) + for _, outcome := range in.MergeOutcomes { + switch outcome { + case mcpcontract.FixPatternMerged, mcpcontract.FixPatternClosedUnmerged, mcpcontract.FixPatternSuperseded, mcpcontract.FixPatternOpen, mcpcontract.FixPatternUnknown: + default: + return fixPatternAnalysisRequest{}, fmt.Errorf("unsupported merge outcome %q", outcome) + } + if _, duplicate := wanted[outcome]; duplicate { + return fixPatternAnalysisRequest{}, fmt.Errorf("duplicate merge outcome %q", outcome) + } + wanted[outcome] = struct{}{} + } + page, err := corpus.ParseSearchPage(in.CandidateLimit, "") + if err != nil { + return fixPatternAnalysisRequest{}, err + } + return fixPatternAnalysisRequest{canonical: in, repository: repository, after: after, before: before, wantedOutcomes: wanted, candidatePage: page, representativeLimit: in.RepresentativeLimit}, nil +} + +func fixPatternExample(repository domain.RepoRef, thread corpus.Thread, classification fixpatterns.Classification) mcpcontract.FixPatternExample { + out := mcpcontract.FixPatternExample{ + PullRequest: mcpcontract.ThreadRef{Owner: repository.Owner(), Repo: repository.Repo(), Kind: string(domain.PullRequestKind), Number: thread.Number}, + Title: thread.Title, Outcome: mcpcontract.FixPatternOutcome(classification.Outcome), Relationship: mcpcontract.FixPatternRelationship(classification.Relationship), + RelationshipEvidence: classification.RelationshipEvidence, AcceptedFix: classification.Outcome == fixpatterns.Merged && classification.Relationship == fixpatterns.Closes, + UpdatedAt: formatTime(thread.SourceUpdatedAt), + } + for _, style := range classification.ProofStyles { + out.ProofStyles = append(out.ProofStyles, mcpcontract.FixPatternProofStyle(style)) + } + if classification.RelatedRepository.IsValid() { + out.RelatedThread = &mcpcontract.ThreadRef{Owner: classification.RelatedRepository.Owner(), Repo: classification.RelatedRepository.Repo(), Kind: string(classification.RelatedKind), Number: classification.RelatedNumber} + } + return out +} + +func incrementFixPatternOutcome(counts *mcpcontract.FixPatternOutcomeCounts, outcome mcpcontract.FixPatternOutcome) { + switch outcome { + case mcpcontract.FixPatternMerged: + counts.Merged++ + case mcpcontract.FixPatternClosedUnmerged: + counts.ClosedUnmerged++ + case mcpcontract.FixPatternSuperseded: + counts.Superseded++ + case mcpcontract.FixPatternOpen: + counts.Open++ + case mcpcontract.FixPatternUnknown: + counts.Unknown++ + } +} + +func fixPatternRecovery(request fixPatternAnalysisRequest, out mcpcontract.AnalyzeFixPatternsOutput) *mcpcontract.RecoveryPlan { + next := request.canonical + next.SnapshotToken = "" + if out.Truncated { + next.CandidateLimit = min(100, max(next.CandidateLimit*2, next.CandidateLimit+1)) + } + var actions []mcpcontract.ToolCall + if out.Coverage.HistoryStatus != "complete" { + actions = append(actions, mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ + Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{next.Repository}, Kind: "pull_request", State: "all", UpdatedAfter: next.TimeWindow.UpdatedAfter, + })) + } + if out.Coverage.UnknownOutcomes > 0 { + actions = append(actions, mcpcontract.RecoveryAction(mcpcontract.HydrateThreadsInput{Threads: append([]mcpcontract.ThreadRef(nil), out.Coverage.UnknownPullRequests...), Facets: []string{FacetPRDetails}, MaxPages: 1})) + } + actions = append(actions, mcpcontract.RecoveryAction(next)) + return recoveryPlan("fix_pattern_coverage_incomplete", "Acquire the explicitly listed missing history or pull-request details, then repeat the same offline analysis before treating the result as exhaustive.", actions...) +} diff --git a/internal/app/mcp_fix_pattern_analysis_test.go b/internal/app/mcp_fix_pattern_analysis_test.go new file mode 100644 index 00000000..0e9a26e1 --- /dev/null +++ b/internal/app/mcp_fix_pattern_analysis_test.go @@ -0,0 +1,104 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestAnalyzeFixPatternsIsOfflineAndPreservesUnknownOutcomes(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + repository, err := svc.corpus.ApplyRepositoryObservation(ctx, "owner", "repo", "R_1", now, `{}`) + if err != nil { + t.Fatal(err) + } + for _, thread := range []corpus.Thread{ + {RepositoryID: repository.ID, Kind: domain.PullRequestKind, Number: 2, State: domain.ClosedState, Title: "Fix numeric drift", Body: "Fixes #1. Regression test covers numeric drift.", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: now.Add(time.Hour)}, + {RepositoryID: repository.ID, Kind: domain.PullRequestKind, Number: 3, State: domain.ClosedState, Title: "Investigate numeric drift", Body: "Numeric drift reproduction.", Merge: domain.UnknownMergeStatus(), SourceUpdatedAt: now.Add(2 * time.Hour)}, + } { + if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { + t.Fatal(err) + } + } + if err := svc.corpus.ApplyFacetObservationSet(ctx, repository.ID, nil, "threads", now.Add(2*time.Hour), nil, true, 0); err != nil { + t.Fatal(err) + } + beforeRevision, err := svc.corpus.CorpusRevision(ctx) + if err != nil { + t.Fatal(err) + } + report, err := (&MCPReader{Service: svc}).AnalyzeFixPatterns(ctx, mcpcontract.AnalyzeFixPatternsInput{ + Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, + TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: now.Format(time.RFC3339)}, + SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "numeric drift", Terms: []string{"numeric", "drift"}}}, + MergeOutcomes: []mcpcontract.FixPatternOutcome{mcpcontract.FixPatternMerged, mcpcontract.FixPatternUnknown}, + }) + if err != nil { + t.Fatal(err) + } + afterRevision, err := svc.corpus.CorpusRevision(ctx) + if err != nil { + t.Fatal(err) + } + if afterRevision != beforeRevision { + t.Fatalf("offline analysis mutated corpus revision: %d -> %d", beforeRevision, afterRevision) + } + if report.Status != "partial" || report.Coverage.UniqueCandidates != 2 || report.Coverage.UnknownOutcomes != 1 || report.Coverage.HistoryStatus != "complete" { + t.Fatalf("coverage = %+v status=%q", report.Coverage, report.Status) + } + if report.Recovery == nil || len(report.Recovery.Then) != 2 || report.Recovery.Then[0].Type() != "hydrate_threads" || report.Recovery.Then[1].Type() != "analyze_fix_patterns" { + t.Fatalf("recovery = %+v", report.Recovery) + } + if len(report.Clusters) != 1 || len(report.Clusters[0].Examples) != 2 { + t.Fatalf("clusters = %+v", report.Clusters) + } + examples := make(map[int]mcpcontract.FixPatternExample) + for _, example := range report.Clusters[0].Examples { + examples[example.PullRequest.Number] = example + } + if accepted := examples[2]; !accepted.AcceptedFix || accepted.Relationship != mcpcontract.FixPatternCloses || len(accepted.ProofStyles) != 1 { + t.Fatalf("accepted example = %+v", accepted) + } + if unknown := examples[3]; unknown.Outcome != mcpcontract.FixPatternUnknown || unknown.AcceptedFix { + t.Fatalf("unknown example = %+v", unknown) + } +} + +func TestAnalyzeFixPatternsReportsMissingHistoryRecovery(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + if _, err := svc.corpus.ApplyRepositoryObservation(ctx, "owner", "repo", "R_1", now, `{}`); err != nil { + t.Fatal(err) + } + report, err := (&MCPReader{Service: svc}).AnalyzeFixPatterns(ctx, mcpcontract.AnalyzeFixPatternsInput{ + Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, + TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: now.Format(time.RFC3339)}, + SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "panic", Terms: []string{"panic"}}}, + }) + if err != nil { + t.Fatal(err) + } + if !report.UnknownCoverage || report.Coverage.HistoryStatus != "unknown" || report.Recovery == nil || report.Recovery.Then[0].Type() != "sync_threads" { + t.Fatalf("report = %+v", report) + } +} + +func TestFixPatternRecoveryRerunsAfterAcquisitionWithoutStaleSnapshot(t *testing.T) { + request := fixPatternAnalysisRequest{canonical: mcpcontract.AnalyzeFixPatternsInput{ + Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, SnapshotToken: "snapshot:stale", CandidateLimit: 10, + }} + plan := fixPatternRecovery(request, mcpcontract.AnalyzeFixPatternsOutput{Coverage: mcpcontract.FixPatternCoverage{HistoryStatus: "unknown"}}) + next, ok := mcpcontract.RecoveryInput[mcpcontract.AnalyzeFixPatternsInput](plan.Then[len(plan.Then)-1]) + if !ok || next.SnapshotToken != "" { + t.Fatalf("analysis recovery retained stale snapshot: %+v", plan) + } +} diff --git a/internal/app/mcp_fix_patterns.go b/internal/app/mcp_fix_patterns.go deleted file mode 100644 index 901ac435..00000000 --- a/internal/app/mcp_fix_patterns.go +++ /dev/null @@ -1,648 +0,0 @@ -package app - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "time" - - "github.com/morluto/gitcontribute/internal/corpus" - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/failure" - "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/relatedwork" -) - -type fixPatternCandidate struct { - thread corpus.Thread - unknownBefore bool - symptoms map[int]struct{} -} - -type fixPatternAnalysis struct { - clusters [][]int64 - candidates map[int64]*fixPatternCandidate - orderedIDs []int64 - candidateMatches int - candidateTruncated bool -} - -type fixPatternClassification struct { - relationship mcpcontract.FixPatternRelationship - related *mcpcontract.ThreadRef - evidence string - superseded bool -} - -type fixPatternSymptom struct { - name string - terms []string -} - -type fixPatternWindow struct { - after time.Time - before *time.Time -} - -func (w fixPatternWindow) beforeTime() time.Time { - if w.before == nil { - return time.Time{} - } - return *w.before -} - -type fixPatternRequest struct { - canonical mcpcontract.MineRepositoryFixPatternsInput - repository domain.RepoRef - window fixPatternWindow - symptoms []fixPatternSymptom - wantedOutcomes map[mcpcontract.FixPatternOutcome]struct{} - candidatePage corpus.SearchPage - hydrationLimit int - representativeLimit int -} - -func (r fixPatternRequest) withHydrationLimit(limit int) fixPatternRequest { - r.hydrationLimit = limit - r.canonical.HydrationLimit = new(int) - *r.canonical.HydrationLimit = limit - return r -} - -type fixPatternOperation uint8 - -const ( - fixPatternPreview fixPatternOperation = iota - fixPatternWorkflow -) - -type fixPatternSnapshotSource struct { - ObservationWatermark int64 `json:"observation_watermark"` - QueryDigest string `json:"query_digest"` -} - -type fixPatternSnapshotVersions struct { - FixPatterns string `json:"fix_patterns"` -} - -type fixPatternSnapshotCompleteness struct { - Complete bool `json:"complete"` - Truncated bool `json:"truncated"` - UnknownCoverage bool `json:"unknown_coverage"` -} - -type fixPatternSnapshotProvenance struct { - Producer string `json:"producer"` - Operation string `json:"operation"` -} - -// MineRepositoryFixPatterns submits one bounded GitHub-read/local-write -// workflow. It searches stored candidates first and hydrates only finalists -// whose merge outcome is unknown. -func (r *MCPReader) MineRepositoryFixPatterns(ctx context.Context, in mcpcontract.MineRepositoryFixPatternsInput) (mcpcontract.JobReference, error) { - request, normalized, err := parseFixPatternInput(in) - if err != nil { - return mcpcontract.JobReference{}, err - } - id, err := r.submitJob(ctx, "mine_repository_fix_patterns", normalized, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.runFixPatternOperation(ctx, request, report, fixPatternWorkflow) - }) - if err != nil { - return mcpcontract.JobReference{}, err - } - return queuedJobReference(id, "mine_repository_fix_patterns", "repository fix-pattern mining job started"), nil -} - -// PreviewRepositoryFixPatterns performs the same bounded corpus analysis as -// the durable workflow while keeping the entire operation read-only. Preview -// deliberately disables hydration: it is an offline planning read, not a -// hidden synchronization request. -func (r *MCPReader) PreviewRepositoryFixPatterns(ctx context.Context, in mcpcontract.PreviewRepositoryFixPatternsInput) (mcpcontract.FixPatternReport, error) { - request, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput(in)) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - return r.runFixPatternOperation(ctx, request.withHydrationLimit(0), nil, fixPatternPreview) -} - -// GetFixPatternReport reads the typed terminal result of a pattern-mining job. -func (r *MCPReader) GetFixPatternReport(ctx context.Context, id string) (mcpcontract.FixPatternReport, error) { - job, err := r.Service.GetJob(ctx, id) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - if job.Kind != "mine_repository_fix_patterns" { - return mcpcontract.FixPatternReport{}, failure.NotFound(fmt.Errorf("job %s is not a fix-pattern report", id)) - } - if job.Status != corpus.JobStatusSucceeded.String() { - return mcpcontract.FixPatternReport{}, errors.New("fix-pattern report is not available until the job succeeds") - } - var report mcpcontract.FixPatternReport - if err := json.Unmarshal([]byte(job.Result), &report); err != nil { - return mcpcontract.FixPatternReport{}, fmt.Errorf("decode fix-pattern report: %w", err) - } - var identity struct { - SnapshotToken string `json:"snapshot_token"` - } - if err := json.Unmarshal([]byte(job.Result), &identity); err != nil { - return mcpcontract.FixPatternReport{}, fmt.Errorf("decode fix-pattern report identity: %w", err) - } - if identity.SnapshotToken == "" { - var request mcpcontract.MineRepositoryFixPatternsInput - var actions []mcpcontract.ToolCall - if err := json.Unmarshal([]byte(job.Request), &request); err == nil { - if _, canonical, parseErr := parseFixPatternInput(request); parseErr == nil { - actions = append(actions, mcpcontract.RecoveryAction(canonical)) - } - } - return mcpcontract.FixPatternReport{}, mcpcontract.Unavailable( - "legacy_artifact", - "this persisted fix-pattern report predates immutable snapshot binding; rerun the fix-pattern workflow to regenerate it", - actions..., - ) - } - if err := report.Validate(); err != nil { - return mcpcontract.FixPatternReport{}, fmt.Errorf("parse fix-pattern report: %w", err) - } - report.Persisted = true - return report, nil -} - -func parseFixPatternInput(in mcpcontract.MineRepositoryFixPatternsInput) (fixPatternRequest, mcpcontract.MineRepositoryFixPatternsInput, error) { - ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) - if err != nil { - return fixPatternRequest{}, in, err - } - in.Repository.Owner = ref.Owner() - in.Repository.Repo = ref.Repo() - after, err := time.Parse(time.RFC3339, in.TimeWindow.UpdatedAfter) - if err != nil { - return fixPatternRequest{}, in, errors.New("time_window.updated_after must be RFC 3339") - } - var before *time.Time - if in.TimeWindow.UpdatedBefore != "" { - parsed, err := time.Parse(time.RFC3339, in.TimeWindow.UpdatedBefore) - if err != nil { - return fixPatternRequest{}, in, errors.New("time_window.updated_before must be RFC 3339") - } - if parsed.Before(after) { - return fixPatternRequest{}, in, errors.New("time_window.updated_before must not be earlier than updated_after") - } - before = &parsed - } - if len(in.SymptomTaxonomy) < 1 || len(in.SymptomTaxonomy) > 12 { - return fixPatternRequest{}, in, errors.New("symptom_taxonomy must contain 1 to 12 categories") - } - canonicalSymptoms := make([]mcpcontract.FixPatternSymptom, len(in.SymptomTaxonomy)) - parsedSymptoms := make([]fixPatternSymptom, len(in.SymptomTaxonomy)) - seenNames := make(map[string]struct{}, len(in.SymptomTaxonomy)) - for i := range in.SymptomTaxonomy { - symptom := in.SymptomTaxonomy[i] - symptom.Name = strings.TrimSpace(symptom.Name) - if symptom.Name == "" { - return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].name is required", i) - } - key := strings.ToLower(symptom.Name) - if _, exists := seenNames[key]; exists { - return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].name duplicates %q", i, symptom.Name) - } - seenNames[key] = struct{}{} - if len(symptom.Terms) < 1 || len(symptom.Terms) > 12 { - return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].terms must contain 1 to 12 values", i) - } - terms := make([]string, len(symptom.Terms)) - seenTerms := make(map[string]struct{}, len(symptom.Terms)) - for j, value := range symptom.Terms { - terms[j] = strings.TrimSpace(value) - if terms[j] == "" { - return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].terms[%d] must not be empty", i, j) - } - key := strings.ToLower(terms[j]) - if _, exists := seenTerms[key]; exists { - return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].terms contains duplicate %q", i, terms[j]) - } - seenTerms[key] = struct{}{} - } - canonicalSymptoms[i] = mcpcontract.FixPatternSymptom{Name: symptom.Name, Terms: append([]string(nil), terms...)} - parsedSymptoms[i] = fixPatternSymptom{name: symptom.Name, terms: terms} - } - in.SymptomTaxonomy = canonicalSymptoms - if in.CandidateLimit == 0 { - in.CandidateLimit = mcpcontract.DefaultFixPatternCandidateLimit - } - if in.CandidateLimit < 1 || in.CandidateLimit > 100 { - return fixPatternRequest{}, in, errors.New("candidate_limit must be between 1 and 100") - } - hydrationLimit := mcpcontract.DefaultFixPatternHydrationLimit - if in.HydrationLimit != nil { - hydrationLimit = *in.HydrationLimit - } - if hydrationLimit < 0 || hydrationLimit > 100 { - return fixPatternRequest{}, in, errors.New("hydration_limit must be between 0 and 100") - } - in.HydrationLimit = &hydrationLimit - if in.RepresentativeLimit == 0 { - in.RepresentativeLimit = mcpcontract.DefaultFixPatternRepresentativeLimit - } - if in.RepresentativeLimit < 1 || in.RepresentativeLimit > 20 { - return fixPatternRequest{}, in, errors.New("representative_limit must be between 1 and 20") - } - if len(in.MergeOutcomes) == 0 { - in.MergeOutcomes = []mcpcontract.FixPatternOutcome{mcpcontract.FixPatternMerged} - } - seenOutcomes := make(map[mcpcontract.FixPatternOutcome]struct{}, len(in.MergeOutcomes)) - for _, outcome := range in.MergeOutcomes { - switch outcome { - case mcpcontract.FixPatternMerged, mcpcontract.FixPatternClosedUnmerged, mcpcontract.FixPatternSuperseded, mcpcontract.FixPatternOpen, mcpcontract.FixPatternUnknown: - default: - return fixPatternRequest{}, in, fmt.Errorf("unsupported merge outcome %q", outcome) - } - if _, exists := seenOutcomes[outcome]; exists { - return fixPatternRequest{}, in, fmt.Errorf("duplicate merge outcome %q", outcome) - } - seenOutcomes[outcome] = struct{}{} - } - in.MergeOutcomes = append([]mcpcontract.FixPatternOutcome(nil), in.MergeOutcomes...) - page, err := corpus.ParseSearchPage(in.CandidateLimit, "") - if err != nil { - return fixPatternRequest{}, in, err - } - return fixPatternRequest{ - canonical: in, repository: ref, window: fixPatternWindow{after: after, before: before}, symptoms: parsedSymptoms, - wantedOutcomes: seenOutcomes, candidatePage: page, hydrationLimit: hydrationLimit, representativeLimit: in.RepresentativeLimit, - }, in, nil -} - -func collectFixPatternCandidates(ctx context.Context, c *corpus.Corpus, repo *corpus.Repository, request fixPatternRequest, progress func(string, string) error) (fixPatternAnalysis, error) { - a := fixPatternAnalysis{ - clusters: make([][]int64, len(request.symptoms)), - candidates: make(map[int64]*fixPatternCandidate), - orderedIDs: make([]int64, 0), - } - ref, err := domain.NewRepoRef(repo.Owner, repo.Name) - if err != nil { - return fixPatternAnalysis{}, fmt.Errorf("parse stored repository: %w", err) - } - repositoryScope, err := corpus.NewThreadRepositoryScope(ref, repo.ID) - if err != nil { - return fixPatternAnalysis{}, err - } - if err := progress("candidate_search", jobProgressCounts(0, len(request.symptoms))); err != nil { - return fixPatternAnalysis{}, err - } - for symptomIndex, symptom := range request.symptoms { - page, err := c.SearchThreadsPage(ctx, strings.Join(symptom.terms, " "), corpus.SearchFilter{ - Repository: repositoryScope, - Kind: corpus.PullRequestThreadKind(), UpdatedAfter: request.window.after, UpdatedBefore: request.window.beforeTime(), - Page: request.candidatePage, TermMatch: corpus.MatchAnyTerm(), - }) - if err != nil { - return fixPatternAnalysis{}, fmt.Errorf("search symptom %q: %w", symptom.name, err) - } - a.candidateMatches += page.Total - a.candidateTruncated = a.candidateTruncated || page.Total > len(page.Threads) - for _, thread := range page.Threads { - candidate := a.candidates[thread.ID] - if candidate == nil { - candidate = &fixPatternCandidate{thread: thread, unknownBefore: needsMergeHydration(thread), symptoms: make(map[int]struct{})} - a.candidates[thread.ID] = candidate - a.orderedIDs = append(a.orderedIDs, thread.ID) - } - if _, exists := candidate.symptoms[symptomIndex]; !exists { - candidate.symptoms[symptomIndex] = struct{}{} - a.clusters[symptomIndex] = append(a.clusters[symptomIndex], thread.ID) - } - } - if err := progress("candidate_search", jobProgressCounts(symptomIndex+1, len(request.symptoms))); err != nil { - return fixPatternAnalysis{}, err - } - } - return a, nil -} - -func selectFixPatternHydration(a fixPatternAnalysis, request fixPatternRequest) []mcpcontract.ThreadRef { - unknown := countUnknownCandidates(a.candidates) - refs := make([]mcpcontract.ThreadRef, 0, min(request.hydrationLimit, unknown)) - for _, id := range a.orderedIDs { - candidate := a.candidates[id] - if !needsMergeHydration(candidate.thread) || len(refs) >= request.hydrationLimit { - continue - } - refs = append(refs, mcpcontract.ThreadRef{ - Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: "pull_request", Number: candidate.thread.Number, - }) - } - return refs -} - -func (r *MCPReader) runFixPatternOperation(ctx context.Context, request fixPatternRequest, progress func(string, string) error, operation fixPatternOperation) (mcpcontract.FixPatternReport, error) { - in := request.canonical - repoRef := request.repository - var err error - var c *corpus.Corpus - if operation == fixPatternPreview { - c, err = r.openReadOnlyCorpus(ctx) - } else { - c, err = r.openCorpus(ctx) - } - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - if progress == nil { - progress = func(string, string) error { return nil } - } - repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - if repo == nil { - return mcpcontract.FixPatternReport{}, fmt.Errorf("repository %s has not been synced", repoRef) - } - analysis, err := collectFixPatternCandidates(ctx, c, repo, request, progress) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - unknownBefore := countUnknownCandidates(analysis.candidates) - unknownBeforeByID := make(map[int64]bool, len(analysis.candidates)) - for id, candidate := range analysis.candidates { - unknownBeforeByID[id] = candidate.unknownBefore - } - hydrationRefs := selectFixPatternHydration(analysis, request) - - hydrated, failures := 0, make([]mcpcontract.FixPatternHydrationFailure, 0) - if operation == fixPatternWorkflow && len(hydrationRefs) > 0 { - // Validate the candidate read before making the workflow's deliberate - // corpus mutation. The report is then assembled again from the - // post-hydration state, rather than comparing the current revision with - // itself after the write. - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.FixPatternReport{}, err - } - raw, err := r.hydrateThreadsBatch(ctx, mcpcontract.HydrateThreadsInput{ - Threads: hydrationRefs, Facets: []string{FacetPRDetails}, MaxPages: 1, - }, progress) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - items := raw.Items - for i, ref := range hydrationRefs { - if i < len(items) && items[i].Status() == mcpcontract.BatchItemComplete { - hydrated++ - } else { - reason, message := "hydration_failed", "pull-request details were not refreshed" - retryable := false - if i < len(items) { - reason = items[i].Reason() - message = items[i].Message() - retryable = items[i].Status() == mcpcontract.BatchItemRetryable - } - failures = append(failures, mcpcontract.FixPatternHydrationFailure{ - PullRequest: ref, Reason: reason, Message: message, Retryable: retryable, - }) - } - } - revision, err = beginCorpusRead(ctx, c, "") - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - analysis, err = collectFixPatternCandidates(ctx, c, repo, request, progress) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - for id, candidate := range analysis.candidates { - if unknown, ok := unknownBeforeByID[id]; ok { - candidate.unknownBefore = unknown - } - } - } - candidates := analysis.candidates - - reportClusters := make([]mcpcontract.FixPatternCluster, len(request.symptoms)) - for i, symptom := range request.symptoms { - cluster := mcpcontract.FixPatternCluster{ - Name: symptom.name, Terms: append([]string(nil), symptom.terms...), - CandidateCount: mcpcontract.NonNegativeInt(len(analysis.clusters[i])), - } - for _, id := range analysis.clusters[i] { - thread := candidates[id].thread - if candidates[id].unknownBefore { - cluster.UnknownBefore++ - } - classification := classifyFixPattern(thread, repoRef) - outcome := fixPatternOutcome(thread, classification.superseded) - incrementFixPatternOutcome(&cluster.Outcomes, outcome) - if outcome == "unknown" { - cluster.UnknownAfter++ - } - if _, wanted := request.wantedOutcomes[outcome]; !wanted { - continue - } - if len(cluster.Examples) >= request.representativeLimit { - cluster.ExamplesTruncated = true - continue - } - cluster.Examples = append(cluster.Examples, buildFixPatternExample(ctx, c, repo.ID, in.Repository, thread, outcome, classification)) - } - reportClusters[i] = cluster - } - - status := mcpcontract.FixPatternReportComplete - if len(failures) > 0 || analysis.candidateTruncated || countUnknownCandidates(candidates) > 0 { - status = mcpcontract.FixPatternReportPartial - } - limitations := []string{ - "Similarity-only examples are candidates, not proof that a pull request fixed a related issue.", - "Accepted fixes require refreshed merged state and an explicit closing relationship in stored pull-request text.", - } - if analysis.candidateTruncated { - limitations = append(limitations, "At least one symptom category exceeded candidate_limit; coverage is truncated.") - } - var recovery *mcpcontract.RecoveryPlan - if analysis.candidateTruncated || countUnknownCandidates(candidates) > 0 || len(failures) > 0 { - next := in - if analysis.candidateTruncated { - next.CandidateLimit = min(100, max(in.CandidateLimit*2, in.CandidateLimit+1)) - } - if next.HydrationLimit != nil && (*next.HydrationLimit > 0 || countUnknownCandidates(candidates) > 0) { - value := min(100, max(*next.HydrationLimit*2, *next.HydrationLimit+1)) - next.HydrationLimit = &value - } - if operation == fixPatternPreview && countUnknownCandidates(candidates) == 0 && len(failures) == 0 { - recovery = recoveryPlan("fix_pattern_candidates_truncated", "The candidate population exceeded candidate_limit. Rerun the read-only preview with a larger candidate_limit.", mcpcontract.RecoveryAction(mcpcontract.PreviewRepositoryFixPatternsInput(next))) - } else { - recovery = recoveryPlan("fix_pattern_coverage_incomplete", "The report retained unknown or bounded candidate outcomes. Rerun the workflow with the returned larger bounds to hydrate and recompute the report.", mcpcontract.RecoveryAction(next)) - } - } - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.FixPatternReport{}, err - } - inputBytes, _ := json.Marshal(in) - queryHash := sha256.Sum256(inputBytes) - report := mcpcontract.FixPatternReport{ - Status: status, Repository: in.Repository, TimeWindow: in.TimeWindow, - GeneratedAt: r.now().Format(time.RFC3339), - Coverage: mcpcontract.FixPatternCoverage{ - CandidateMatches: mcpcontract.NonNegativeInt(analysis.candidateMatches), UniqueCandidates: mcpcontract.NonNegativeInt(len(candidates)), - UnknownBefore: mcpcontract.NonNegativeInt(unknownBefore), SelectedForHydration: mcpcontract.NonNegativeInt(len(hydrationRefs)), - Hydrated: mcpcontract.NonNegativeInt(hydrated), HydrationFailed: mcpcontract.NonNegativeInt(len(failures)), - UnknownAfter: mcpcontract.NonNegativeInt(countUnknownCandidates(candidates)), CandidateTruncated: analysis.candidateTruncated, - }, - Clusters: reportClusters, Failures: failures, Limitations: limitations, Persisted: operation == fixPatternWorkflow, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), - ObservationWatermark: revision, QueryDigestSHA256: hex.EncodeToString(queryHash[:]), - Complete: status == mcpcontract.FixPatternReportComplete, Truncated: analysis.candidateTruncated, UnknownCoverage: countUnknownCandidates(candidates) > 0, Recovery: recovery, - } - if err := report.Validate(); err != nil { - return mcpcontract.FixPatternReport{}, err - } - if operation == fixPatternWorkflow { - materialization, err := corpus.NewSnapshotMaterialization( - "fix_pattern_report", in.Repository, - fixPatternSnapshotSource{ObservationWatermark: revision, QueryDigest: report.QueryDigestSHA256}, - fixPatternSnapshotVersions{FixPatterns: "v1"}, - fixPatternSnapshotCompleteness{Complete: report.Complete, Truncated: report.Truncated, UnknownCoverage: report.UnknownCoverage}, - fixPatternSnapshotProvenance{Producer: "gitcontribute", Operation: "workflow.mine_repository_fix_patterns"}, report, - ) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - snapshot, err := c.MaterializeReadSnapshot(ctx, materialization) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } - report.SnapshotToken, report.ArtifactDigest = snapshot.Token, snapshot.ArtifactDigest - } else { - tokenHash := sha256.Sum256([]byte(fmt.Sprintf("offline-fix-pattern\x00%d\x00%s", revision, report.QueryDigestSHA256))) - report.SnapshotToken = "ephemeral:" + hex.EncodeToString(tokenHash[:]) - report.Limitations = append(report.Limitations, "Preview snapshot identity is transaction-bound and is not a durable resource token because preview performs no local writes.") - } - return report, nil -} - -func countUnknownCandidates(candidates map[int64]*fixPatternCandidate) int { - count := 0 - for _, candidate := range candidates { - if needsMergeHydration(candidate.thread) { - count++ - } - } - return count -} - -func needsMergeHydration(thread corpus.Thread) bool { - return thread.State == "closed" && !thread.Merge.Known() -} - -func fixPatternOutcome(thread corpus.Thread, superseded bool) mcpcontract.FixPatternOutcome { - switch { - case thread.Merge.IsMerged(): - return mcpcontract.FixPatternMerged - case thread.State != "closed": - return mcpcontract.FixPatternOpen - case !thread.Merge.Known(): - return mcpcontract.FixPatternUnknown - case superseded: - return mcpcontract.FixPatternSuperseded - default: - return mcpcontract.FixPatternClosedUnmerged - } -} - -func incrementFixPatternOutcome(counts *mcpcontract.FixPatternOutcomeCounts, outcome mcpcontract.FixPatternOutcome) { - switch outcome { - case mcpcontract.FixPatternMerged: - counts.Merged++ - case mcpcontract.FixPatternClosedUnmerged: - counts.ClosedUnmerged++ - case mcpcontract.FixPatternSuperseded: - counts.Superseded++ - case mcpcontract.FixPatternOpen: - counts.Open++ - case mcpcontract.FixPatternUnknown: - counts.Unknown++ - } -} - -func buildFixPatternExample(ctx context.Context, c *corpus.Corpus, repoID int64, repository mcpcontract.RepositoryRef, thread corpus.Thread, outcome mcpcontract.FixPatternOutcome, classification fixPatternClassification) mcpcontract.FixPatternExample { - example := mcpcontract.FixPatternExample{ - PullRequest: mcpcontract.ThreadRef{Owner: repository.Owner, Repo: repository.Repo, Kind: "pull_request", Number: thread.Number}, - Title: thread.Title, Outcome: outcome, Relationship: classification.relationship, RelationshipEvidence: classification.evidence, - AcceptedFix: outcome == mcpcontract.FixPatternMerged && classification.relationship == mcpcontract.FixPatternCloses, - ProofStyles: detectProofStyles(thread.Body), UpdatedAt: thread.SourceUpdatedAt.Format(time.RFC3339), - } - if classification.related != nil { - example.RelatedThread = classification.related - example.RelatedKind = mcpcontract.FixPatternRelatedKind(classification.related.Kind) - relatedRepoID := repoID - if classification.related.Owner != repository.Owner || classification.related.Repo != repository.Repo { - relatedRepo, err := c.GetRepository(ctx, classification.related.Owner, classification.related.Repo) - if err != nil || relatedRepo == nil { - return example - } - relatedRepoID = relatedRepo.ID - } - related, err := c.GetThreadByNumber(ctx, relatedRepoID, classification.related.Number) - if err == nil && related != nil { - example.RelatedKind = mcpcontract.FixPatternRelatedKind(related.Kind) - example.RelatedThread.Kind = string(related.Kind) - } - } - return example -} - -func classifyFixPattern(thread corpus.Thread, repository domain.RepoRef) fixPatternClassification { - refs := relatedwork.Extract(thread.Body, repository) - classification := fixPatternClassification{relationship: mcpcontract.FixPatternSimilarityOnly} - bestPriority := 0 - for _, ref := range refs { - if ref.Relation == relatedwork.RelationSupersededBy { - classification.superseded = true - } - priority := relatedwork.Priority(ref.Relation) - if priority <= bestPriority { - continue - } - bestPriority = priority - classification.related = &mcpcontract.ThreadRef{ - Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: string(ref.Kind), Number: ref.Number, - } - classification.evidence = ref.Evidence - switch ref.Relation { - case relatedwork.RelationClaimsToClose: - classification.relationship = mcpcontract.FixPatternCloses - case relatedwork.RelationReplaces, relatedwork.RelationSupersededBy: - classification.relationship = mcpcontract.FixPatternExplicitReplacement - default: - classification.relationship = mcpcontract.FixPatternReferences - } - } - return classification -} - -func detectProofStyles(body string) []mcpcontract.FixPatternProofStyle { - lower := strings.ToLower(body) - var styles []mcpcontract.FixPatternProofStyle - for _, candidate := range []struct { - name mcpcontract.FixPatternProofStyle - terms []string - }{ - {name: mcpcontract.FixPatternRegressionTest, terms: []string{"regression test", "unit test", "test coverage"}}, - {name: mcpcontract.FixPatternReproduction, terms: []string{"reproducer", "reproduction", "repro case"}}, - {name: mcpcontract.FixPatternBenchmark, terms: []string{"benchmark", "throughput", "latency"}}, - {name: mcpcontract.FixPatternBeforeAfter, terms: []string{"before and after", "before/after"}}, - {name: mcpcontract.FixPatternScreenshot, terms: []string{"screenshot"}}, - } { - if slices.ContainsFunc(candidate.terms, func(term string) bool { return strings.Contains(lower, term) }) { - styles = append(styles, candidate.name) - } - } - return styles -} diff --git a/internal/app/mcp_fix_patterns_test.go b/internal/app/mcp_fix_patterns_test.go deleted file mode 100644 index ce36bf07..00000000 --- a/internal/app/mcp_fix_patterns_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "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" -) - -func TestMineRepositoryFixPatternsSeparatesAcceptedFixesFromSimilarity(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - repo, err := svc.corpus.ApplyRepositoryObservation(ctx, "owner", "repo", "R_1", now, `{}`) - if err != nil { - t.Fatal(err) - } - for _, thread := range []corpus.Thread{ - {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Numeric drift on RDNA", Body: "split cumsum produces the wrong result", SourceUpdatedAt: now}, - {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "Restrict barrier conversion to CDNA", Body: "Fixes #1.\n\nRegression test covers numeric drift.", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: now.Add(time.Hour)}, - {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "Try a different barrier lowering", Body: "Similar numeric drift was observed, with a reproduction.", Merge: domain.UnmergedStatus(), SourceUpdatedAt: now.Add(2 * time.Hour)}, - {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 4, State: "closed", Title: "Investigate numeric drift", Body: "Numeric drift investigation.", SourceUpdatedAt: now.Add(3 * time.Hour)}, - {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 5, State: "closed", Title: "Earlier numeric drift attempt", Body: "Numeric drift attempt. Superseded by #2.", Merge: domain.UnmergedStatus(), SourceUpdatedAt: now.Add(3 * time.Hour)}, - {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 6, State: "open", Title: "New numeric drift approach", Body: "Numeric drift work remains open.", SourceUpdatedAt: now.Add(3 * time.Hour)}, - } { - if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { - t.Fatal(err) - } - } - zero := 0 - request, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ - Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, - TimeWindow: mcpcontract.FixPatternTimeWindow{ - UpdatedAfter: now.Add(-time.Hour).Format(time.RFC3339), - UpdatedBefore: now.Add(4 * time.Hour).Format(time.RFC3339), - }, - SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "numeric drift", Terms: []string{"numeric", "drift"}}}, - MergeOutcomes: []mcpcontract.FixPatternOutcome{"merged", "closed_unmerged", "superseded", "open", "unknown"}, - HydrationLimit: &zero, - }) - if err != nil { - t.Fatal(err) - } - - report, err := (&MCPReader{Service: svc}).runFixPatternOperation(ctx, request, func(string, string) error { return nil }, fixPatternWorkflow) - if err != nil { - t.Fatal(err) - } - if report.Status != "partial" || report.Coverage.UniqueCandidates != 5 || report.Coverage.UnknownBefore != 1 || report.Coverage.UnknownAfter != 1 { - t.Fatalf("coverage = %+v, status = %q", report.Coverage, report.Status) - } - if report.Recovery == nil || len(report.Recovery.Then) != 1 || report.Recovery.Then[0].Type() != "mine_repository_fix_patterns" { - t.Fatalf("fix-pattern recovery = %+v", report.Recovery) - } - if len(report.Clusters) != 1 || len(report.Clusters[0].Examples) != 5 { - t.Fatalf("clusters = %+v", report.Clusters) - } - examples := make(map[int]mcpcontract.FixPatternExample) - for _, example := range report.Clusters[0].Examples { - examples[example.PullRequest.Number] = example - } - if got := examples[2]; got.PullRequest.Kind != "pull_request" || !got.AcceptedFix || got.Outcome != "merged" || got.Relationship != "closes" || got.RelatedThread == nil || got.RelatedThread.Number != 1 { - t.Fatalf("accepted example = %+v", got) - } - if got := examples[3]; got.AcceptedFix || got.Outcome != "closed_unmerged" || got.Relationship != "similarity_only" { - t.Fatalf("closed similarity example = %+v", got) - } - if got := examples[4]; got.Outcome != "unknown" || got.AcceptedFix { - t.Fatalf("unknown example = %+v", got) - } - if got := examples[5]; got.Outcome != "superseded" || got.Relationship != "explicit_replacement" || got.RelatedThread == nil || got.RelatedThread.Number != 2 { - t.Fatalf("superseded example = %+v", got) - } - if got := examples[6]; got.Outcome != "open" || got.AcceptedFix { - t.Fatalf("open example = %+v", got) - } -} - -func TestMineRepositoryFixPatternsHydratesOnlyUnknownFinalists(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - repo, err := svc.corpus.ApplyRepositoryObservation(ctx, "owner", "repo", "R_1", now, `{}`) - if err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", - Title: "Fix numeric drift", Body: "Fixes #1 with a regression test.", SourceUpdatedAt: now, - }, `{}`); err != nil { - t.Fatal(err) - } - mergedAt := now.Add(time.Hour) - reader := &exactHydrationReader{ - fakeHydrationReader: &fakeHydrationReader{prDetails: github.PullRequestDetails{ - Number: 2, State: "closed", Title: "Fix numeric drift", Body: "Fixes #1 with a regression test.", - Merged: true, MergedAt: &mergedAt, UpdatedAt: mergedAt, - }}, - header: github.Issue{ - RepositoryOwner: "owner", RepositoryName: "repo", Number: 2, Kind: domain.PullRequestKind, - State: "closed", Title: "Fix numeric drift", Body: "Fixes #1 with a regression test.", - UpdatedAt: mergedAt, - }, - } - svc.SetGitHubReader(reader) - one := 1 - request, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ - Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, - TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: now.Add(-time.Hour).Format(time.RFC3339)}, - SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "numeric drift", Terms: []string{"numeric drift"}}}, - HydrationLimit: &one, - }) - if err != nil { - t.Fatal(err) - } - - report, err := (&MCPReader{Service: svc}).runFixPatternOperation(ctx, request, func(string, string) error { return nil }, fixPatternWorkflow) - if err != nil { - t.Fatal(err) - } - if report.Coverage.UnknownBefore != 1 || report.Coverage.SelectedForHydration != 1 || report.Coverage.Hydrated != 1 || report.Coverage.UnknownAfter != 0 { - t.Fatalf("coverage = %+v, failures = %+v", report.Coverage, report.Failures) - } - if report.Status != "complete" || len(report.Clusters) != 1 || len(report.Clusters[0].Examples) != 1 || !report.Clusters[0].Examples[0].AcceptedFix { - t.Fatalf("report = %+v", report) - } -} - -func TestPreviewRepositoryFixPatternsIsReadOnlyAndNeverHydrates(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - repo, err := svc.corpus.ApplyRepositoryObservation(ctx, "owner", "repo", "R_1", now, `{}`) - if err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", - Title: "Fix numeric drift", Body: "Numeric drift reproduction", SourceUpdatedAt: now, - }, `{}`); err != nil { - t.Fatal(err) - } - beforeRevision, err := svc.corpus.CorpusRevision(ctx) - if err != nil { - t.Fatal(err) - } - beforeJobs, err := svc.corpus.ListJobs(ctx, corpus.JobStatus(""), 100) - if err != nil { - t.Fatal(err) - } - report, err := (&MCPReader{Service: svc}).PreviewRepositoryFixPatterns(ctx, mcpcontract.PreviewRepositoryFixPatternsInput{ - Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, - TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: now.Add(-time.Hour).Format(time.RFC3339)}, - SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "drift", Terms: []string{"drift"}}}, - }) - if err != nil { - t.Fatal(err) - } - if report.Persisted || !strings.HasPrefix(report.SnapshotToken, "ephemeral:") || report.Coverage.SelectedForHydration != 0 || report.Coverage.Hydrated != 0 { - t.Fatalf("preview report = %+v", report) - } - afterRevision, err := svc.corpus.CorpusRevision(ctx) - if err != nil { - t.Fatal(err) - } - afterJobs, err := svc.corpus.ListJobs(ctx, corpus.JobStatus(""), 100) - if err != nil { - t.Fatal(err) - } - if afterRevision != beforeRevision || len(afterJobs) != len(beforeJobs) { - t.Fatalf("preview mutated corpus: revision %d -> %d, jobs %d -> %d", beforeRevision, afterRevision, len(beforeJobs), len(afterJobs)) - } -} - -func TestGetFixPatternReportRejectsLegacyUnboundArtifact(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - request := mcpcontract.MineRepositoryFixPatternsInput{ - Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, - TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: "2026-07-01T00:00:00Z"}, - SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "drift", Terms: []string{"drift"}}}, - } - requestJSON, err := json.Marshal(request) - if err != nil { - t.Fatal(err) - } - job, err := svc.corpus.CreateJob(ctx, "mine_repository_fix_patterns", string(requestJSON)) - if err != nil { - t.Fatal(err) - } - if err := svc.corpus.StartJob(ctx, job.ID); err != nil { - t.Fatal(err) - } - if err := svc.corpus.TransitionJob(ctx, job.ID, corpus.JobRunningToSucceeded, `{"status":"complete","persisted":true}`, ""); err != nil { - t.Fatal(err) - } - _, err = (&MCPReader{svc}).GetFixPatternReport(ctx, job.ID) - var toolErr *mcpcontract.ToolError - if !errors.As(err, &toolErr) || toolErr.Code != "legacy_artifact" { - t.Fatalf("legacy report error = %v", err) - } - if toolErr.Recovery == nil || len(toolErr.Recovery.Then) != 1 { - t.Fatalf("legacy report recovery = %+v", toolErr.Recovery) - } - next, ok := mcpcontract.RecoveryInput[mcpcontract.MineRepositoryFixPatternsInput](toolErr.Recovery.Then[0]) - if !ok || next.Repository != request.Repository { - t.Fatalf("legacy report recovery = %+v", toolErr.Recovery) - } -} - -func TestNormalizeFixPatternInputRejectsInvalidWindow(t *testing.T) { - t.Parallel() - for _, window := range []mcpcontract.FixPatternTimeWindow{ - {UpdatedAfter: "not-a-date"}, - {UpdatedAfter: "2026-07-02T00:00:00Z", UpdatedBefore: "2026-07-01T00:00:00Z"}, - } { - _, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ - Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, - TimeWindow: window, - SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "drift", Terms: []string{"drift"}}}, - }) - if err == nil { - t.Fatalf("invalid time window was accepted: %+v", window) - } - } -} diff --git a/internal/app/mcp_github_identity.go b/internal/app/mcp_github_identity.go new file mode 100644 index 00000000..15a5a204 --- /dev/null +++ b/internal/app/mcp_github_identity.go @@ -0,0 +1,59 @@ +package app + +import ( + "context" + "errors" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +// GetAuthenticatedIdentity resolves the configured GitHub account without +// persisting an actor or starting broader discovery. +func (r *MCPReader) GetAuthenticatedIdentity(ctx context.Context, _ mcpcontract.GetAuthenticatedIdentityInput) (mcpcontract.AuthenticatedIdentityOutput, error) { + reader, err := r.githubReader() //nolint:contextcheck // construction performs no request + if err != nil { + return mcpcontract.AuthenticatedIdentityOutput{}, err + } + identityReader, ok := reader.(github.IdentityReader) + if !ok { + return mcpcontract.AuthenticatedIdentityOutput{}, errors.New("GitHub reader does not support authenticated identity lookup") + } + identity, rate, err := identityReader.GetAuthenticatedIdentity(ctx) + if err != nil { + return mcpcontract.AuthenticatedIdentityOutput{}, err + } + if identity.Login == "" { + return mcpcontract.AuthenticatedIdentityOutput{}, errors.New("authenticated GitHub identity has no login") + } + return mcpcontract.AuthenticatedIdentityOutput{ + Login: identity.Login, DatabaseID: identity.ID, NodeID: identity.NodeID, + ObservedAt: formatTime(r.now()), Rate: githubRateOutput(rate), + }, nil +} + +// CompareFork verifies one explicit fork relationship and default-branch +// ancestry. It performs only bounded provider reads and never updates the fork. +func (r *MCPReader) CompareFork(ctx context.Context, in mcpcontract.CompareForkInput) (mcpcontract.ForkFreshnessOutput, error) { + upstream, err := domain.NewRepoRef(in.Upstream.Owner, in.Upstream.Repo) + if err != nil { + return mcpcontract.ForkFreshnessOutput{}, err + } + fork, err := domain.NewRepoRef(in.Fork.Owner, in.Fork.Repo) + if err != nil { + return mcpcontract.ForkFreshnessOutput{}, err + } + in.Upstream = mcpcontract.RepositoryRef{Owner: upstream.Owner(), Repo: upstream.Repo()} + in.Fork = mcpcontract.RepositoryRef{Owner: fork.Owner(), Repo: fork.Repo()} + if sameGitHubRepository(in.Fork.Owner, in.Fork.Repo, in.Upstream) { + return mcpcontract.ForkFreshnessOutput{}, errors.New("fork repository must differ from upstream") + } + reader, err := r.githubReader() //nolint:contextcheck // construction performs no request + if err != nil { + return mcpcontract.ForkFreshnessOutput{}, err + } + return compareExplicitForkFreshness(ctx, reader, in.Upstream, forkComparisonContext{ + ref: in.Fork, branch: in.ContributionBranch, sha: in.ContributionSHA, + }, forkFreshnessRequestCost) +} diff --git a/internal/app/mcp_github_identity_test.go b/internal/app/mcp_github_identity_test.go new file mode 100644 index 00000000..9119880d --- /dev/null +++ b/internal/app/mcp_github_identity_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + "strings" + "testing" + + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type githubFactsReader struct { + github.Reader + forkStatus string +} + +func (*githubFactsReader) GetAuthenticatedIdentity(context.Context) (github.Identity, github.RateInfo, error) { + return github.Identity{Login: "morluto", ID: 1}, github.RateInfo{}, nil +} + +func (*githubFactsReader) GetRepository(_ context.Context, owner, repo string) (github.Repository, github.RateInfo, error) { + result := github.Repository{Owner: owner, Name: repo, DefaultBranch: "main"} + if !strings.EqualFold(owner, "fla-org") { + result.Fork = true + result.Parent = &github.RepositoryParent{Owner: "fla-org", Name: "flash-linear-attention"} + } + return result, github.RateInfo{}, nil +} + +func (*githubFactsReader) GetBranch(_ context.Context, owner, _, branch string) (github.Branch, github.RateInfo, error) { + sha := "upstream-sha" + if !strings.EqualFold(owner, "fla-org") { + sha = "fork-sha" + } + return github.Branch{Name: branch, CommitSHA: sha}, github.RateInfo{}, nil +} + +func (r *githubFactsReader) CompareCommits(context.Context, string, string, string, string) (github.CommitComparison, github.RateInfo, error) { + status := r.forkStatus + if status == "" { + status = "identical" + } + return github.CommitComparison{Status: status, BaseSHA: "upstream-sha", MergeBaseSHA: "merge-base"}, github.RateInfo{}, nil +} + +func TestGetAuthenticatedIdentityReturnsOnlyCredentialIdentity(t *testing.T) { + t.Parallel() + svc := newSearchTestService(t) + svc.SetGitHubReader(&githubFactsReader{}) + + out, err := (&MCPReader{svc}).GetAuthenticatedIdentity(context.Background(), mcpcontract.GetAuthenticatedIdentityInput{}) + if err != nil { + t.Fatal(err) + } + if out.Login != "morluto" || out.DatabaseID != 1 || out.ObservedAt == "" { + t.Fatalf("identity = %+v", out) + } +} + +func TestCompareForkUsesExplicitRepositories(t *testing.T) { + t.Parallel() + svc := newSearchTestService(t) + svc.SetGitHubReader(&githubFactsReader{forkStatus: "behind"}) + + out, err := (&MCPReader{svc}).CompareFork(context.Background(), mcpcontract.CompareForkInput{ + Upstream: mcpcontract.RepositoryRef{Owner: "fla-org", Repo: "flash-linear-attention"}, + Fork: mcpcontract.RepositoryRef{Owner: "morluto", Repo: "flash-linear-attention"}, + ContributionBranch: "fix/attention", + ContributionSHA: "candidate-sha", + }) + if err != nil { + t.Fatal(err) + } + if out.Status != "behind" || out.Coverage != "verified" || !out.EffectiveDiffRisk { + t.Fatalf("fork comparison = %+v", out) + } + if out.Fork.Owner != "morluto" || out.ContributionBranch != "fix/attention" || out.ContributionSHA != "candidate-sha" { + t.Fatalf("fork provenance = %+v", out) + } +} diff --git a/internal/app/mcp_issue_set.go b/internal/app/mcp_issue_set.go deleted file mode 100644 index d8073bc2..00000000 --- a/internal/app/mcp_issue_set.go +++ /dev/null @@ -1,549 +0,0 @@ -package app - -import ( - "context" - "errors" - "fmt" - "slices" - "strings" - "time" - - "github.com/morluto/gitcontribute/internal/corpus" - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/radar" - "github.com/morluto/gitcontribute/internal/relatedwork" -) - -const ( - defaultIssueSetPrecedentLimit = 3 - conciseIssueSetRelatedLimit = 5 -) - -type prepareIssueSetRequest struct { - repository domain.RepoRef - issueNumbers []int - precedentLimit int - format responseFormat - snapshotToken string -} - -// PrepareIssueSet composes contribution-facing evidence for exact stored -// issues. It opens only the read-only corpus and never creates workflow state. -func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareIssueSetInput) (mcpcontract.PrepareIssueSetOutput, error) { - request, err := parsePrepareIssueSetInput(in) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - ref := request.repository - c, err := r.openReadOnlyCorpus(ctx) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - revision, err := beginCorpusRead(ctx, c, request.snapshotToken) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - out := mcpcontract.PrepareIssueSetOutput{ - Status: "complete", Owner: ref.Owner(), Repo: ref.Repo(), ResponseFormat: request.format.String(), - Items: make([]mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence], len(request.issueNumbers)), - Coverage: []mcpcontract.FacetCoverageOutput{}, - SnapshotToken: snapshotIdentity(request.snapshotToken, revision), - } - stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if stored == nil { - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - return unavailableIssueSet(request, out), nil - } - threadsCoverage, err := c.GetCoverage(ctx, stored.ID, nil, "threads") - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if threadsCoverage == nil { - action := repositoryPullRequestSyncAction(ref) - out.Coverage = []mcpcontract.FacetCoverageOutput{{Facet: "threads", Status: "unknown"}} - out.Gaps = append(out.Gaps, mcpcontract.IssueSetGap{ - Code: "relationship_population_unknown", Facet: "threads", Status: "unknown", - Message: "repository thread coverage is not recorded, so the stored pull-request population may be incomplete", Recovery: recoveryPlan("coverage_stale", "Complete the stored pull-request population used for related-work analysis.", action), - }) - out.RecoveryPlans = append(out.RecoveryPlans, *recoveryPlan("coverage_stale", "Complete the stored pull-request population used for related-work analysis.", action)) - out.Status = "partial" - } else { - status := "complete" - if !threadsCoverage.Complete { - status = "partial" - action := repositoryPullRequestSyncAction(ref) - out.Gaps = append(out.Gaps, mcpcontract.IssueSetGap{ - Code: "relationship_population_incomplete", Facet: "threads", Status: "partial", - Message: "repository thread coverage is incomplete, so the stored pull-request population is not exhaustive", Recovery: recoveryPlan("facet_incomplete", "Complete the stored pull-request population used for related-work analysis.", action), - }) - out.RecoveryPlans = append(out.RecoveryPlans, *recoveryPlan("facet_incomplete", "Complete the stored pull-request population used for related-work analysis.", action)) - out.Status = "partial" - } - out.Coverage = []mcpcontract.FacetCoverageOutput{{ - Facet: "threads", Complete: threadsCoverage.Complete, Status: status, UpdatedAt: formatTime(threadsCoverage.SourceUpdatedAt), - }} - out.SourceAsOf = formatTime(threadsCoverage.SourceUpdatedAt) - } - - issues := make([]corpus.Thread, 0, len(request.issueNumbers)) - issuesByNumber := make(map[int]corpus.Thread, len(request.issueNumbers)) - for _, number := range request.issueNumbers { - issue, err := c.GetThread(ctx, stored.ID, domain.IssueKind, number) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if issue != nil { - issues = append(issues, *issue) - issuesByNumber[number] = *issue - } - } - - pullRequests, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState(), radarPullRequestPopulation) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - pullRequestTotal, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - out.RelationshipPopulation = pullRequestTotal - out.RelationshipConsidered = len(pullRequests) - relationshipScanCapped := pullRequestTotal > len(pullRequests) - out.Truncated = relationshipScanCapped - if out.Truncated { - out.Status = "partial" - } - relatedByIssue, projectedCapped, err := radarPullRequestRelatedWork(ctx, c, stored, ref, issues, pullRequests, corpus.AnyThreadState()) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if projectedCapped { - relationshipScanCapped = true - out.Status, out.Truncated = "partial", true - } - duplicatesByIssue, duplicateCapped, err := radarDuplicateClusters(ctx, c, ref) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if duplicateCapped { - out.Status, out.Truncated = "partial", true - } - - precedentInput := issueSetPrecedentInput(request) - // Reuse only a caller-supplied durable token. The response token for an - // unpinned read is an ephemeral result identity and cannot be resolved by a - // nested read. - precedentInput.SnapshotToken = request.snapshotToken - precedents, err := r.FindPrecedents(ctx, precedentInput) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - pullRequestsByNumber := make(map[int]corpus.Thread, len(pullRequests)) - pullRequestBodiesAvailable := true - for _, pullRequest := range pullRequests { - pullRequestsByNumber[pullRequest.Number] = pullRequest - if pullRequest.Body == "" { - pullRequestBodiesAvailable = false - } - } - - evaluatedAt := r.now() - for i, number := range request.issueNumbers { - key := threadRefKey(mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: string(domain.IssueKind), Number: number}) - item := mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence]{Key: key, Status: "complete"} - issue, ok := issuesByNumber[number] - if !ok { - item.Status, item.Reason = "unavailable", "thread_not_indexed" - item.Message = "issue is not present in the local corpus" - item.Recovery = recoveryPlan("thread_not_indexed", item.Message, issueSyncAction(ref, number)) - out.Items[i] = item - out.Status = "partial" - out.RecoveryPlans = append(out.RecoveryPlans, *recoveryPlan("thread_not_indexed", item.Message, issueSyncAction(ref, number))) - continue - } - value, actions, partial, err := prepareOneIssue( - ctx, c, stored, ref, issue, relatedByIssue[number], pullRequestsByNumber, duplicatesByIssue[number], - precedents.Items[i], request.format, request.precedentLimit, - threadsCoverage != nil && threadsCoverage.Complete && !relationshipScanCapped && pullRequestBodiesAvailable, - threadsCoverage == nil || !threadsCoverage.SourceUpdatedAt.Before(issue.SourceUpdatedAt), evaluatedAt, - ) - if err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - if partial { - out.Status = "partial" - } - if sourceAsOf := preparedIssueSourceAsOf(value); sourceAsOf > out.SourceAsOf { - out.SourceAsOf = sourceAsOf - } - item.Value = &value - out.Items[i] = item - out.RecoveryPlans = append(out.RecoveryPlans, actions...) - if value.RelatedWorkTruncated { - out.Truncated = true - } - if item := precedents.Items[i].Value; item != nil && item.Truncated { - out.Status, out.Truncated = "partial", true - } - } - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - return out, nil -} - -func parsePrepareIssueSetInput(in mcpcontract.PrepareIssueSetInput) (prepareIssueSetRequest, error) { - if len(in.IssueNumbers) < 1 || len(in.IssueNumbers) > 20 { - return prepareIssueSetRequest{}, errors.New("issue_numbers must contain 1 to 20 items") - } - seen := make(map[int]struct{}, len(in.IssueNumbers)) - for _, number := range in.IssueNumbers { - if number < 1 { - return prepareIssueSetRequest{}, errors.New("issue_numbers must contain only positive numbers") - } - if _, ok := seen[number]; ok { - return prepareIssueSetRequest{}, fmt.Errorf("issue_numbers contains duplicate #%d", number) - } - seen[number] = struct{}{} - } - if in.PrecedentLimit == 0 { - in.PrecedentLimit = defaultIssueSetPrecedentLimit - } - if in.PrecedentLimit < 1 || in.PrecedentLimit > 10 { - return prepareIssueSetRequest{}, errors.New("precedent_limit must be between 1 and 10") - } - format, err := parseResponseFormat(in.ResponseFormat) - if err != nil { - return prepareIssueSetRequest{}, err - } - repository, err := domain.NewRepoRef(in.Owner, in.Repo) - if err != nil { - return prepareIssueSetRequest{}, err - } - return prepareIssueSetRequest{ - repository: repository, issueNumbers: append([]int(nil), in.IssueNumbers...), - precedentLimit: in.PrecedentLimit, format: format, snapshotToken: in.SnapshotToken, - }, nil -} - -func unavailableIssueSet(request prepareIssueSetRequest, out mcpcontract.PrepareIssueSetOutput) mcpcontract.PrepareIssueSetOutput { - out.Status = "partial" - for i, number := range request.issueNumbers { - out.Items[i] = mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence]{ - Key: threadRefKey(mcpcontract.ThreadRef{Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: string(domain.IssueKind), Number: number}), Status: "unavailable", - Reason: "repository_not_indexed", Message: "repository is not present in the local corpus", - Recovery: recoveryPlan("repository_not_indexed", "Synchronize the repository, then retry this exact issue.", syncRepositoryContextCall(request.repository.Owner(), request.repository.Repo()), issueSyncAction(request.repository, number)), - } - out.RecoveryPlans = append(out.RecoveryPlans, *recoveryPlan("repository_not_indexed", "Synchronize the repository, then retry this exact issue.", syncRepositoryContextCall(request.repository.Owner(), request.repository.Repo()), issueSyncAction(request.repository, number))) - } - return out -} - -func issueSetPrecedentInput(request prepareIssueSetRequest) mcpcontract.FindPrecedentsInput { - threads := make([]mcpcontract.ThreadRef, len(request.issueNumbers)) - for i, number := range request.issueNumbers { - threads[i] = mcpcontract.ThreadRef{Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: string(domain.IssueKind), Number: number} - } - return mcpcontract.FindPrecedentsInput{Threads: threads, Limit: 100, SnapshotToken: request.snapshotToken} -} - -func prepareOneIssue( - ctx context.Context, - c *corpus.Corpus, - stored *corpus.Repository, - ref domain.RepoRef, - issue corpus.Thread, - inbound []radar.RelatedWork, - pullRequests map[int]corpus.Thread, - duplicate *radar.DuplicateCluster, - precedents mcpcontract.BatchItem[mcpcontract.PrecedentSet], - format responseFormat, - precedentLimit int, - relationshipPopulationComplete bool, - relationshipPopulationFresh bool, - evaluatedAt time.Time, -) (mcpcontract.PreparedIssueEvidence, []mcpcontract.RecoveryPlan, bool, error) { - value := mcpcontract.PreparedIssueEvidence{ - Number: issue.Number, Title: issue.Title, State: string(issue.State), StateReason: issue.StateReason, - Labels: append([]string(nil), issue.Labels...), BodyStatus: "unknown", - SourceUpdatedAt: formatTime(issue.SourceUpdatedAt), Coverage: []mcpcontract.FacetCoverageOutput{}, - RelatedWork: []mcpcontract.IssueSetRelatedWork{}, AcceptedExamples: []mcpcontract.PrecedentOutput{}, - Linkage: mcpcontract.IssueSetLinkageCandidate{ - IssueNumber: issue.Number, Relation: "related", AllowedRelations: []string{"closes", "advances", "related"}, - RequiresConfirmation: true, Basis: "The caller explicitly included this issue; the implementation relationship has not been validated.", - }, - } - actions := []mcpcontract.RecoveryPlan{} - partial := false - if !relationshipPopulationFresh { - action := repositoryPullRequestSyncAction(ref) - value.Gaps = append(value.Gaps, mcpcontract.IssueSetGap{ - Code: "relationship_coverage_stale", Facet: "threads", Status: "unknown", - Message: "repository relationship coverage predates the issue observation", Recovery: recoveryPlan("coverage_stale", "Repository relationship coverage predates the issue observation.", action), - }) - actions, partial = append(actions, *recoveryPlan("coverage_stale", "Repository relationship coverage predates the issue observation.", action)), true - relationshipPopulationComplete = false - } - relationshipEvidenceComplete := issue.Body != "" - if issue.Body != "" { - value.BodyStatus = "available" - if format.includesDetails() { - value.Body = issue.Body - } - } else { - action := issueSyncAction(ref, issue.Number) - value.Gaps = append(value.Gaps, mcpcontract.IssueSetGap{ - Code: "body_unknown", Facet: "body", Status: "unknown", - Message: "the corpus does not distinguish a known-empty body from a body that was not captured", Recovery: recoveryPlan("facet_not_observed", "Fetch the exact issue header and body into the local corpus.", action), - }) - actions, partial = append(actions, *recoveryPlan("facet_not_observed", "Fetch the exact issue header and body into the local corpus.", action)), true - } - - for _, facet := range []string{FacetIssueComments, FacetIssueTimeline} { - coverage, err := c.GetCoverage(ctx, stored.ID, &issue.ID, facet) - if err != nil { - return mcpcontract.PreparedIssueEvidence{}, nil, false, err - } - if coverage == nil { - relationshipEvidenceComplete = false - value.Coverage = append(value.Coverage, mcpcontract.FacetCoverageOutput{Facet: facet, Status: "unknown"}) - action := issueHydrateAction(ref, issue.Number, facet) - value.Gaps = append(value.Gaps, mcpcontract.IssueSetGap{ - Code: "facet_missing", Facet: facet, Status: "unknown", - Message: "no coverage observation is stored for this facet", Recovery: recoveryPlan("facet_not_observed", "Complete the missing issue evidence facet.", action), - }) - actions, partial = append(actions, *recoveryPlan("facet_not_observed", "Complete the missing issue evidence facet.", action)), true - continue - } - status := "complete" - if !coverage.Complete { - status = "partial" - } - value.Coverage = append(value.Coverage, mcpcontract.FacetCoverageOutput{ - Facet: facet, Complete: coverage.Complete, Status: status, UpdatedAt: formatTime(coverage.SourceUpdatedAt), - }) - if !coverage.Complete { - relationshipEvidenceComplete = false - action := issueHydrateAction(ref, issue.Number, facet) - value.Gaps = append(value.Gaps, mcpcontract.IssueSetGap{ - Code: "facet_incomplete", Facet: facet, Status: "partial", - Message: "stored facet coverage is incomplete", Recovery: recoveryPlan("facet_incomplete", "Complete the missing issue evidence facet.", action), - }) - actions, partial = append(actions, *recoveryPlan("facet_incomplete", "Complete the missing issue evidence facet.", action)), true - } - } - - _, outbound, relatedCapped, err := radarIssueDiscussionAndRelatedWork(ctx, c, stored, issue, ref, evaluatedAt) - if err != nil { - return mcpcontract.PreparedIssueEvidence{}, nil, false, err - } - combined, relatedTotal, recordsCapped, evidenceCapped := normalizeRadarRelatedWorkDetails( - append(append([]radar.RelatedWork(nil), inbound...), outbound...), maxRadarRelatedWork, - ) - value.RelatedWorkTotal = relatedTotal - value.RelatedWorkTotalKnown = relationshipPopulationComplete && relationshipEvidenceComplete && !relatedCapped - limit := len(combined) - if !format.includesDetails() && limit > conciseIssueSetRelatedLimit { - limit = conciseIssueSetRelatedLimit - } - conciseEvidenceOmitted := false - for _, work := range combined[:limit] { - if !format.includesDetails() && len(work.Evidence) > 0 { - conciseEvidenceOmitted = true - } - value.RelatedWork = append(value.RelatedWork, issueSetRelatedWork(work, ref, pullRequests, format)) - } - value.RelatedWorkTruncated = relatedCapped || recordsCapped || evidenceCapped || conciseEvidenceOmitted || limit < len(combined) - if relatedCapped || recordsCapped || evidenceCapped { - partial = true - } - if duplicate != nil { - value.DuplicateCluster = &mcpcontract.IssueSetDuplicateCluster{ - StableID: duplicate.StableID, CanonicalRef: duplicate.CanonicalRef, CandidateCount: duplicate.CandidateCount, - } - } - if precedents.Status != "complete" || precedents.Value == nil { - action := repositoryHistorySyncAction(ref) - value.Gaps = append(value.Gaps, mcpcontract.IssueSetGap{ - Code: "precedent_evidence_unavailable", Facet: "precedents", Status: "unknown", - Message: "historical precedent evidence is unavailable for this issue", Recovery: recoveryPlan("coverage_stale", "Fetch closed issue and pull-request headers used for historical precedent analysis.", action), - }) - actions, partial = append(actions, *recoveryPlan("coverage_stale", "Fetch closed issue and pull-request headers used for historical precedent analysis.", action)), true - } else { - for _, match := range precedents.Value.Matches { - if match.Kind == string(domain.PullRequestKind) && match.MergedAt != "" { - value.AcceptedExamples = append(value.AcceptedExamples, match) - if len(value.AcceptedExamples) == precedentLimit { - break - } - } - } - } - value.ContributionDisposition = issueContributionDisposition(value) - return value, actions, partial, nil -} - -func issueContributionDisposition(issue mcpcontract.PreparedIssueEvidence) mcpcontract.ContributionDisposition { - unknown := func(reasons ...string) mcpcontract.ContributionDisposition { - return mcpcontract.ContributionDisposition{ - Status: "unknown", Confidence: "low", Unknowns: reasons, - Recovery: recoveryPlan("blocked", "Complete the listed evidence gaps, then prepare this exact issue set again."), - } - } - issueRef := fmt.Sprintf("issue:#%d", issue.Number) - if strings.EqualFold(issue.StateReason, "not_planned") || slices.ContainsFunc(issue.Labels, func(label string) bool { - label = strings.ToLower(strings.TrimSpace(label)) - return label == "duplicate" || label == "wontfix" || label == "wont-fix" - }) { - return mcpcontract.ContributionDisposition{ - Status: "blocked_by_repository_policy", Confidence: "high", EvidenceRefs: []string{issueRef}, - Recovery: recoveryPlan("blocked", "Do not create an implementation workspace unless a maintainer reopens or redirects the issue."), - } - } - var mergedClosing, openClosing, closedUnmerged []mcpcontract.IssueSetRelatedWork - var missingMerge []string - for _, work := range issue.RelatedWork { - if work.Kind != string(domain.PullRequestKind) || work.Relation != string(relatedwork.RelationClaimsToClose) || work.Direction != string(radar.RelatedWorkInbound) { - continue - } - switch { - case work.Merged != nil && *work.Merged: - mergedClosing = append(mergedClosing, work) - case strings.EqualFold(work.State, "open"): - openClosing = append(openClosing, work) - case work.Merged != nil && !*work.Merged: - closedUnmerged = append(closedUnmerged, work) - default: - missingMerge = append(missingMerge, work.Ref) - } - } - if len(mergedClosing) > 0 { - return mcpcontract.ContributionDisposition{ - Status: "already_resolved_upstream", Confidence: "high", EvidenceRefs: relatedWorkRefs(mergedClosing), - Recovery: recoveryPlan("blocked", "Verify the released behavior before considering any follow-up contribution."), - } - } - if len(missingMerge) > 0 { - return unknown("the merge outcome is unknown for " + strings.Join(missingMerge, ", ")) - } - if !issue.RelatedWorkTotalKnown { - return unknown("related-work population or relationship evidence is incomplete") - } - if len(openClosing) > 0 { - return mcpcontract.ContributionDisposition{ - Status: "active_competing_work", Confidence: "high", EvidenceRefs: relatedWorkRefs(openClosing), - Recovery: recoveryPlan("blocked", "Coordinate with the active pull request before creating another implementation workspace."), - } - } - if len(closedUnmerged) > 0 { - if !hasCompleteIssueFacet(issue.Coverage, FacetIssueComments) { - return unknown("a closing pull request was closed unmerged, but maintainer discussion is incomplete") - } - return mcpcontract.ContributionDisposition{ - Status: "needs_maintainer_alignment", Confidence: "medium", EvidenceRefs: relatedWorkRefs(closedUnmerged), - Recovery: recoveryPlan("blocked", "Confirm the desired semantics and acceptable approach with maintainers before coding."), - } - } - if !strings.EqualFold(issue.State, "open") { - return unknown("the issue is not open and no deterministic resolution or policy reason is stored") - } - return mcpcontract.ContributionDisposition{ - Status: "ready_to_investigate", Confidence: "medium", EvidenceRefs: []string{issueRef}, - Recovery: recoveryPlan("blocked", "Investigate the current behavior and contribution fit before creating an implementation workspace."), - } -} - -func relatedWorkRefs(values []mcpcontract.IssueSetRelatedWork) []string { - refs := make([]string, 0, len(values)) - for _, value := range values { - refs = append(refs, value.Ref) - } - slices.Sort(refs) - return slices.Compact(refs) -} - -func hasCompleteIssueFacet(values []mcpcontract.FacetCoverageOutput, facet string) bool { - return slices.ContainsFunc(values, func(value mcpcontract.FacetCoverageOutput) bool { - return value.Facet == facet && value.Complete - }) -} - -func issueSetRelatedWork(work radar.RelatedWork, ref domain.RepoRef, pullRequests map[int]corpus.Thread, format responseFormat) mcpcontract.IssueSetRelatedWork { - out := mcpcontract.IssueSetRelatedWork{ - Ref: work.Ref, Kind: work.Kind, Number: work.Number, Title: work.Title, State: work.State, - Relation: string(work.Relation), Direction: string(work.Direction), URL: work.URL, - SourceUpdatedAt: formatTime(work.SourceUpdatedAt), - } - localPullRequestRef := fmt.Sprintf("pull_request:%s#%d", ref, work.Number) - if pullRequest, ok := pullRequests[work.Number]; ok && work.Kind == string(domain.PullRequestKind) && work.Ref == localPullRequestRef { - if pullRequest.Merge.Known() { - merged := pullRequest.Merge.IsMerged() - out.Merged = &merged - } - out.MergedAt = formatTime(pullRequest.Merge.MergedAt()) - } - if format.includesDetails() { - seen := map[string]struct{}{} - for _, evidence := range work.Evidence { - if _, ok := seen[evidence.Kind]; ok { - continue - } - seen[evidence.Kind] = struct{}{} - out.EvidenceKinds = append(out.EvidenceKinds, evidence.Kind) - } - } - return out -} - -func preparedIssueSourceAsOf(value mcpcontract.PreparedIssueEvidence) string { - latest := value.SourceUpdatedAt - for _, coverage := range value.Coverage { - if coverage.UpdatedAt > latest { - latest = coverage.UpdatedAt - } - } - for _, work := range value.RelatedWork { - if work.SourceUpdatedAt > latest { - latest = work.SourceUpdatedAt - } - } - return latest -} - -func issueSyncAction(ref domain.RepoRef, number int) mcpcontract.ToolCall { - return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ - Selection: "threads", - Threads: []mcpcontract.ThreadRef{{Owner: ref.Owner(), Repo: ref.Repo(), Kind: string(domain.IssueKind), Number: number}}, - }) -} - -func issueHydrateAction(ref domain.RepoRef, number int, facet string) mcpcontract.ToolCall { - return mcpcontract.RecoveryAction(mcpcontract.HydrateThreadsInput{ - Threads: []mcpcontract.ThreadRef{{Owner: ref.Owner(), Repo: ref.Repo(), Kind: string(domain.IssueKind), Number: number}}, - Facets: []string{facet}, - }) -} - -func repositoryPullRequestSyncAction(ref domain.RepoRef) mcpcontract.ToolCall { - return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ - Selection: "repositories", - Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, - Kind: string(domain.PullRequestKind), - State: "all", - }) -} - -func repositoryHistorySyncAction(ref domain.RepoRef) mcpcontract.ToolCall { - return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ - Selection: "repositories", - Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, - Kind: "both", - State: "closed", - }) -} diff --git a/internal/app/mcp_issue_set_test.go b/internal/app/mcp_issue_set_test.go deleted file mode 100644 index 8e02fe9a..00000000 --- a/internal/app/mcp_issue_set_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package app - -import ( - "context" - "testing" - "time" - - "github.com/morluto/gitcontribute/internal/corpus" - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/radar" -) - -func TestPrepareIssueSetComposesStoredEvidenceWithoutClaimingClosure(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - svc.SetGitHubReader(panicRadarReader{}) - now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) - - repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: "acme", Name: "rocket", SourceUpdatedAt: now, - }, `{}`) - if err != nil { - t.Fatal(err) - } - issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 7, State: "open", - Title: "Avoid duplicate cache work", Body: "Cache identical requests once.", Labels: []string{"performance"}, - SourceUpdatedAt: now.Add(-2 * time.Hour), - }, `{}`) - if err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 21, State: "closed", - Title: "Avoid duplicate cache work in readers", Body: "This advances #7 by caching repository reads.", - Merge: domain.MergedStatus(now.Add(-time.Hour)), SourceUpdatedAt: now.Add(-time.Hour), - }, `{}`); err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 22, State: "closed", - Title: "Avoid duplicate cache work", Body: "Cache identical requests once.", - Merge: domain.UnmergedStatus(), SourceUpdatedAt: now.Add(-30 * time.Minute), - }, `{}`); err != nil { - t.Fatal(err) - } - if err := svc.corpus.AdvanceFacet(ctx, repo.ID, &issue.ID, FacetIssueComments, now, true, 0); err != nil { - t.Fatal(err) - } - if err := svc.corpus.AdvanceFacet(ctx, repo.ID, nil, "threads", now, true, 0); err != nil { - t.Fatal(err) - } - - out, err := (&MCPReader{svc}).PrepareIssueSet(ctx, mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "rocket", IssueNumbers: []int{7}, PrecedentLimit: 1, - }) - if err != nil { - t.Fatal(err) - } - if out.Status != "partial" || out.ResponseFormat != "concise" || len(out.Items) != 1 { - t.Fatalf("result = %+v", out) - } - item := out.Items[0] - if item.Status != "complete" || item.Value == nil { - t.Fatalf("item = %+v", item) - } - value := item.Value - if value.Title != "Avoid duplicate cache work" || value.BodyStatus != "available" || value.Body != "" { - t.Fatalf("issue facts = %+v", value) - } - if len(value.RelatedWork) != 1 || value.RelatedWork[0].Number != 21 || value.RelatedWork[0].Merged == nil || !*value.RelatedWork[0].Merged { - t.Fatalf("related work = %+v", value.RelatedWork) - } - if !value.RelatedWorkTruncated { - t.Fatal("concise response did not report omitted relationship evidence") - } - if value.RelatedWorkTotalKnown { - t.Fatal("related-work total reported known with missing timeline coverage") - } - if len(value.AcceptedExamples) != 1 || value.AcceptedExamples[0].Ref != "acme/rocket#21" { - t.Fatalf("accepted examples = %+v", value.AcceptedExamples) - } - if value.Linkage.Relation != "related" || !value.Linkage.RequiresConfirmation { - t.Fatalf("linkage = %+v", value.Linkage) - } - if len(value.Gaps) != 1 || value.Gaps[0].Facet != FacetIssueTimeline || value.Gaps[0].Recovery == nil || len(value.Gaps[0].Recovery.Then) != 1 || value.Gaps[0].Recovery.Then[0].Type() != "hydrate_threads" { - t.Fatalf("gaps = %+v", value.Gaps) - } - detailed, err := (&MCPReader{svc}).PrepareIssueSet(ctx, mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "rocket", IssueNumbers: []int{7}, ResponseFormat: "detailed", - }) - if err != nil { - t.Fatal(err) - } - if got := detailed.Items[0].Value; got == nil || got.Body != "Cache identical requests once." || len(got.RelatedWork[0].EvidenceKinds) == 0 || got.RelatedWorkTruncated { - t.Fatalf("detailed issue = %+v", got) - } -} - -func TestPrepareIssueSetPreservesUnknownAndExactRecovery(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: "acme", Name: "rocket"}, `{}`) - if err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Body not captured", - }, `{}`); err != nil { - t.Fatal(err) - } - - out, err := (&MCPReader{svc}).PrepareIssueSet(ctx, mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "rocket", IssueNumbers: []int{1, 2}, ResponseFormat: "detailed", - }) - if err != nil { - t.Fatal(err) - } - if out.Status != "partial" || len(out.Items) != 2 { - t.Fatalf("result = %+v", out) - } - if len(out.Gaps) != 1 || out.Gaps[0].Code != "relationship_population_unknown" || out.Gaps[0].Recovery == nil || len(out.Gaps[0].Recovery.Then) != 1 || out.Gaps[0].Recovery.Then[0].Type() != "sync_threads" { - t.Fatalf("relationship gaps = %+v", out.Gaps) - } - if got := out.Items[0].Value; got == nil || got.BodyStatus != "unknown" || len(got.Gaps) != 3 { - t.Fatalf("known issue = %+v", got) - } - missing := out.Items[1] - if missing.Status != "unavailable" || missing.Reason != "thread_not_indexed" || missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type() != "sync_threads" { - t.Fatalf("missing issue = %+v", missing) - } - if len(out.RecoveryPlans) == 0 || len(out.RecoveryPlans[0].Then) != 1 || out.RecoveryPlans[0].Then[0].Type() != "sync_threads" { - t.Fatalf("recovery plans = %+v", out.RecoveryPlans) - } -} - -func TestPrepareIssueSetRejectsDuplicateIssueNumbers(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - _, err := (&MCPReader{svc}).PrepareIssueSet(context.Background(), mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "rocket", IssueNumbers: []int{7, 7}, - }) - if err == nil { - t.Fatal("expected duplicate issue number error") - } -} - -func TestPrepareIssueSetReturnsArrayCoverageForUnknownRepository(t *testing.T) { - t.Parallel() - svc := newSearchTestService(t) - out, err := (&MCPReader{svc}).PrepareIssueSet(context.Background(), mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "missing", IssueNumbers: []int{7}, - }) - if err != nil { - t.Fatal(err) - } - if out.Coverage == nil || len(out.Coverage) != 0 { - t.Fatalf("coverage = %#v, want empty array", out.Coverage) - } -} - -func TestPrepareIssueSetKeepsRelatedTotalUnknownForAmbiguousPullRequestBody(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) - repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: "acme", Name: "rocket"}, `{}`) - if err != nil { - t.Fatal(err) - } - issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 7, State: "open", - Title: "Exact issue", Body: "Known issue body.", SourceUpdatedAt: now, - }, `{}`) - if err != nil { - t.Fatal(err) - } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 21, State: "open", - Title: "Body may not have been captured", SourceUpdatedAt: now, - }, `{}`); err != nil { - t.Fatal(err) - } - for _, target := range []struct { - threadID *int64 - facet string - }{ - {facet: "threads"}, - {threadID: &issue.ID, facet: FacetIssueComments}, - {threadID: &issue.ID, facet: FacetIssueTimeline}, - } { - if err := svc.corpus.AdvanceFacet(ctx, repo.ID, target.threadID, target.facet, now, true, 0); err != nil { - t.Fatal(err) - } - } - - out, err := (&MCPReader{svc}).PrepareIssueSet(ctx, mcpcontract.PrepareIssueSetInput{ - Owner: "acme", Repo: "rocket", IssueNumbers: []int{7}, - }) - if err != nil { - t.Fatal(err) - } - if got := out.Items[0].Value; got == nil || got.RelatedWorkTotalKnown { - t.Fatalf("prepared issue = %+v", got) - } -} - -func TestIssueSetRelatedWorkDoesNotBorrowMergeStateAcrossRepositories(t *testing.T) { - t.Parallel() - out := issueSetRelatedWork( - radar.RelatedWork{Ref: "pull_request:other/repo#21", Kind: string(domain.PullRequestKind), Number: 21}, - domain.MustRepoRef("acme", "rocket"), - map[int]corpus.Thread{21: {Number: 21, Merge: domain.MergedStatus(time.Time{})}}, - conciseResponse, - ) - if out.Merged != nil || out.MergedAt != "" { - t.Fatalf("external related work borrowed local merge state: %+v", out) - } -} diff --git a/internal/app/mcp_job_artifacts.go b/internal/app/mcp_job_artifacts.go index 1d6fe5bd..9bd437f3 100644 --- a/internal/app/mcp_job_artifacts.go +++ b/internal/app/mcp_job_artifacts.go @@ -15,10 +15,6 @@ const maxJobArtifactItems = 100 func jobArtifactsAndFollowUp(job *contracts.JobResult, total int) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { switch job.Kind { - case "mine_repository_fix_patterns": - return fixPatternJobArtifact(job) - case "build_repository_dossier": - return dossierJobArtifact(job) case "create_workspace": return workspaceJobArtifact(job) case "run_validation": @@ -59,25 +55,6 @@ func resourceFollowUp(uri, reason string) *mcpcontract.JobFollowUp { return &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.ResourceReadAction{URI: uri}), Reason: reason} } -func fixPatternJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { - uri := "gitcontribute://fix-pattern-report/" + job.ID - return []mcpcontract.JobArtifactReference{{Kind: "fix_pattern_report", ID: job.ID, URI: uri}}, - resourceFollowUp(uri, "Read the persisted typed fix-pattern report.") -} - -func dossierJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { - var request struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - } - if json.Unmarshal([]byte(job.Request), &request) != nil || request.Owner == "" || request.Repo == "" { - return nil, nil - } - uri := fmt.Sprintf("gitcontribute://dossier/%s/%s", request.Owner, request.Repo) - return []mcpcontract.JobArtifactReference{{Kind: "dossier", ID: request.Owner + "/" + request.Repo, URI: uri}}, - resourceFollowUp(uri, "Read the persisted typed dossier resource.") -} - func workspaceJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { var result struct { ID string `json:"id"` diff --git a/internal/app/mcp_pr_workflows.go b/internal/app/mcp_pr_workflows.go index 0f8d6c6b..b1563852 100644 --- a/internal/app/mcp_pr_workflows.go +++ b/internal/app/mcp_pr_workflows.go @@ -14,8 +14,9 @@ import ( ) const ( - facetPRCIReport = "pr_ci_report" - maxFeedbackItemsPerChannel = 1000 + facetPRCIReport = "pr_ci_report" + maxFeedbackItemsPerChannel = 1000 + maxExactFeedbackPullRequests = 100 ) var ( @@ -46,8 +47,8 @@ func (r *MCPReader) SyncPullRequestFeedback(ctx context.Context, in mcpcontract. return mcpcontract.JobReference{}, err } in.PullRequests = refs - if len(in.PullRequests) < 1 || len(in.PullRequests) > 50 { - return mcpcontract.JobReference{}, errors.New("pull_requests must contain 1 to 50 items") + if len(in.PullRequests) < 1 || len(in.PullRequests) > maxExactFeedbackPullRequests { + return mcpcontract.JobReference{}, errors.New("pull_requests must contain 1 to 100 items") } if in.ThreadState == "" { in.ThreadState = "unresolved" @@ -88,6 +89,11 @@ func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract. if !ok { return pullRequestWorkflowResult{}, errors.New("GitHub reader does not support pull-request feedback") } + release, err := r.acquireFeedbackWorkflow(ctx) + if err != nil { + return pullRequestWorkflowResult{}, err + } + defer release() budget := github.NewRequestBudget(in.MaxRequests) channels, providerChannels, threadState := selection.ChannelValues(), selection.Channels(), selection.ThreadState() out := pullRequestWorkflowResult{BatchStatus: batchOperationComplete, Items: make([]pullRequestWorkflowItem, len(in.PullRequests))} diff --git a/internal/app/mcp_pull_request_feedback_index.go b/internal/app/mcp_pull_request_feedback_index.go index 074751ee..03f7b3f5 100644 --- a/internal/app/mcp_pull_request_feedback_index.go +++ b/internal/app/mcp_pull_request_feedback_index.go @@ -32,6 +32,7 @@ type pullRequestFeedbackIndexItem struct { type pullRequestFeedbackIndexResult struct { Status batchOperationStatus `json:"status"` DiscoveryStatus batchOperationStatus `json:"discovery_status"` + State string `json:"state"` NextPage int `json:"next_page,omitempty"` PullRequests int `json:"pull_requests"` FeedbackItems int `json:"feedback_items"` @@ -50,6 +51,11 @@ func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract } in.Repository.Owner = ref.Owner() in.Repository.Repo = ref.Repo() + pullRequestState, err := parsePullRequestFeedbackIndexState(in.State) + if err != nil { + return mcpcontract.JobReference{}, err + } + in.State = feedbackIndexStateValue(pullRequestState) if len(in.Channels) == 0 { in.Channels = corpus.AllFeedbackSelection().Channels() } @@ -96,6 +102,11 @@ func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract.IndexPullRequestFeedbackInput, selection corpus.FeedbackSelection, report func(string, string) error) (pullRequestFeedbackIndexResult, error) { in.Channels, in.ThreadState = selection.Channels(), selection.ThreadState() + pullRequestState, err := parsePullRequestFeedbackIndexState(in.State) + if err != nil { + return pullRequestFeedbackIndexResult{}, err + } + in.State = feedbackIndexStateValue(pullRequestState) reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return pullRequestFeedbackIndexResult{}, err @@ -108,6 +119,11 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract if !ok { return pullRequestFeedbackIndexResult{}, errors.New("GitHub reader does not support pull-request feedback") } + release, err := r.acquireFeedbackWorkflow(ctx) + if err != nil { + return pullRequestFeedbackIndexResult{}, err + } + defer release() c, err := r.openCorpus(ctx) if err != nil { return pullRequestFeedbackIndexResult{}, err @@ -130,20 +146,21 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract if err != nil { return pullRequestFeedbackIndexResult{}, err } - if discovery == nil || discovery.IsComplete() || !discovery.Selection.Equal(selection) { + if discovery == nil || discovery.IsComplete() || !discovery.Selection.Equal(selection) || discovery.PullRequestState.String() != pullRequestState.String() { generation := int64(1) if discovery != nil { generation = discovery.Generation + 1 } - discovery = &corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: generation, NextPage: 1, Selection: selection} + discovery = &corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: generation, NextPage: 1, Selection: selection, PullRequestState: pullRequestState} } else { discovery.Selection = selection + discovery.PullRequestState = pullRequestState } if discovery.NextPage < 1 { discovery.NextPage = 1 } budget := github.NewRequestBudget(in.MaxRequests) - result := pullRequestFeedbackIndexResult{Status: batchOperationComplete, DiscoveryStatus: batchOperationComplete, Items: make([]pullRequestFeedbackIndexItem, 0, in.MaxPullRequests)} + result := pullRequestFeedbackIndexResult{Status: batchOperationComplete, DiscoveryStatus: batchOperationComplete, State: in.State, Items: make([]pullRequestFeedbackIndexItem, 0, in.MaxPullRequests)} page := discovery.NextPage initialRequests := discovery.Requests pages := 0 @@ -154,7 +171,7 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract stopReason = "request_budget_exhausted" break } - listed, listErr := indexer.ListPullRequests(ctx, in.Repository.Owner, in.Repository.Repo, github.PullRequestListOptions{State: "all", Sort: "updated", Direction: "desc", PageOptions: github.PageOptions{Page: page, PerPage: feedbackDiscoveryPageSize}}) + listed, listErr := indexer.ListPullRequests(ctx, in.Repository.Owner, in.Repository.Repo, github.PullRequestListOptions{State: in.State, Sort: "updated", Direction: "desc", PageOptions: github.PageOptions{Page: page, PerPage: feedbackDiscoveryPageSize}}) if listErr != nil { stopReason = classifyIndexError(listErr) break @@ -198,7 +215,7 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract stopReason = "discovery_page_bound" break } - discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) + discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), pullRequestState) if err != nil { return pullRequestFeedbackIndexResult{}, err } @@ -227,7 +244,7 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract } } } - discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) + discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), pullRequestState) if err != nil { return pullRequestFeedbackIndexResult{}, err } @@ -247,6 +264,24 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract return result, nil } +func parsePullRequestFeedbackIndexState(value string) (corpus.ThreadStateFilter, error) { + if value == "" || value == "all" { + return corpus.AnyThreadState(), nil + } + state, err := corpus.ParseThreadStateFilter(value) + if err != nil { + return corpus.ThreadStateFilter{}, fmt.Errorf("feedback index state: %w", err) + } + return state, nil +} + +func feedbackIndexStateValue(state corpus.ThreadStateFilter) string { + if state.IsAny() { + return "all" + } + return state.String() +} + func (r *MCPReader) indexOnePullRequestFeedback(ctx context.Context, feedbackReader github.PullRequestFeedbackReader, ref mcpcontract.ThreadRef, in mcpcontract.IndexPullRequestFeedbackInput, selection corpus.FeedbackSelection, budget *github.RequestBudget) pullRequestFeedbackIndexItem { item := pullRequestFeedbackIndexItem{Key: pullRequestKey(ref), Status: mcpcontract.BatchItemComplete} channels := selection.ChannelValues() diff --git a/internal/app/mcp_pull_request_feedback_index_test.go b/internal/app/mcp_pull_request_feedback_index_test.go index 2180716e..bc22d65b 100644 --- a/internal/app/mcp_pull_request_feedback_index_test.go +++ b/internal/app/mcp_pull_request_feedback_index_test.go @@ -16,6 +16,7 @@ type feedbackIndexTestReader struct { panicRadarReader pages map[int]github.ListResult[github.Issue] perPage []int + states []string withThread bool } @@ -33,6 +34,7 @@ func (r *cancelledPartialFeedbackReader) GetPullRequestFeedback(_ context.Contex func (r *feedbackIndexTestReader) ListPullRequests(_ context.Context, _, _ string, opts github.PullRequestListOptions) (github.ListResult[github.Issue], error) { r.perPage = append(r.perPage, opts.PerPage) + r.states = append(r.states, opts.State) return r.pages[opts.Page], nil } @@ -105,6 +107,9 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t if len(githubReader.perPage) != 2 || githubReader.perPage[0] != feedbackDiscoveryPageSize || githubReader.perPage[1] != feedbackDiscoveryPageSize { t.Fatalf("discovery page sizes = %v, want stable size %d", githubReader.perPage, feedbackDiscoveryPageSize) } + if len(githubReader.states) != 2 || githubReader.states[0] != "all" || githubReader.states[1] != "all" { + t.Fatalf("discovery states = %v, want all", githubReader.states) + } discovery, err = svc.corpus.GetFeedbackDiscovery(ctx, 1) if err != nil { t.Fatal(err) @@ -124,6 +129,88 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t } } +func TestPullRequestFeedbackIndexPreservesOpenDiscoveryScope(t *testing.T) { + ctx := context.Background() + svc := newLocalService(t) + t.Cleanup(func() { _ = svc.Close() }) + githubReader := &feedbackIndexTestReader{pages: map[int]github.ListResult[github.Issue]{ + 1: {Items: []github.Issue{{Number: 1, Kind: domain.PullRequestKind}}, Page: github.PageInfo{Page: 1, HasNext: false}}, + }} + svc.SetGitHubReader(githubReader) + reader := &MCPReader{Service: svc} + in := mcpcontract.IndexPullRequestFeedbackInput{ + Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, State: "open", + Channels: []string{"issue_comments"}, ThreadState: "all", MaxPullRequests: 10, MaxItemsPerChannel: 10, MaxPages: 10, MaxRequests: 20, + } + result, err := reader.indexPullRequestFeedback(ctx, in, mustFeedbackSelection(t, in.Channels, in.ThreadState), func(string, string) error { return nil }) + if err != nil { + t.Fatal(err) + } + if result.State != "open" || len(githubReader.states) != 1 || githubReader.states[0] != "open" { + t.Fatalf("state-scoped provider request/result = states %v result %+v", githubReader.states, result) + } + discovery, err := svc.corpus.GetFeedbackDiscovery(ctx, 1) + if err != nil { + t.Fatal(err) + } + if discovery == nil || !discovery.IsComplete() || !discovery.PullRequestState.IsOpen() { + t.Fatalf("open discovery checkpoint = %+v", discovery) + } + openResult, err := reader.SearchPullRequestFeedback(ctx, mcpcontract.SearchPullRequestFeedbackInput{Repository: in.Repository, State: "open", Channel: "issue_comments", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if openResult.Coverage != "complete" { + t.Fatalf("open search coverage = %+v", openResult) + } + allResult, err := reader.SearchPullRequestFeedback(ctx, mcpcontract.SearchPullRequestFeedbackInput{Repository: in.Repository, State: "all", Channel: "issue_comments", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if allResult.Coverage == "complete" || allResult.Recovery == nil { + t.Fatalf("open-only discovery was presented as historical coverage: %+v", allResult) + } +} + +func TestExactFeedbackSyncAcceptsSixtyTwoPullRequestsAsOneJob(t *testing.T) { + ctx := context.Background() + svc := newLocalService(t) + t.Cleanup(func() { _ = svc.Close() }) + svc.SetGitHubReader(&feedbackIndexTestReader{}) + refs := make([]mcpcontract.ThreadRef, 62) + for i := range refs { + refs[i] = mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: i + 1} + } + job, err := (&MCPReader{Service: svc}).SyncPullRequestFeedback(ctx, mcpcontract.SyncPullRequestFeedbackInput{ + PullRequests: refs, Channels: []string{"issue_comments"}, ThreadState: "all", MaxItemsPerChannel: 10, MaxRequests: 100, + }) + if err != nil { + t.Fatal(err) + } + if job.ID == "" || job.Kind != "sync_pull_request_feedback" { + t.Fatalf("62-PR exact feedback job = %+v", job) + } +} + +func TestFeedbackWorkflowSlotWaitIsCancellable(t *testing.T) { + svc := &Service{} + release, err := svc.acquireFeedbackWorkflow(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := svc.acquireFeedbackWorkflow(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("waiting feedback workflow error = %v, want cancellation", err) + } + release() + secondRelease, err := svc.acquireFeedbackWorkflow(context.Background()) + if err != nil { + t.Fatal(err) + } + secondRelease() +} + func TestPullRequestFeedbackSearchKeepsThreadResourceReadable(t *testing.T) { ctx := context.Background() svc := newLocalService(t) diff --git a/internal/app/mcp_pull_request_feedback_search.go b/internal/app/mcp_pull_request_feedback_search.go index ed1d9b01..6d9a5f81 100644 --- a/internal/app/mcp_pull_request_feedback_search.go +++ b/internal/app/mcp_pull_request_feedback_search.go @@ -54,7 +54,7 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac page, err := c.SearchPullRequestFeedback(ctx, request) if err != nil { if errors.Is(err, corpus.ErrProjectionStale) { - return mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.Unavailable("feedback_projection_stale", "The normalized feedback projection is missing or stale. Continue the repository feedback index job, then retry this offline search.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) + return mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.Unavailable("feedback_projection_stale", "The normalized feedback projection is missing or stale. Continue the repository feedback index job, then retry this offline search.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, State: feedbackSearchIndexState(in.State), Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) } return mcpcontract.SearchPullRequestFeedbackOutput{}, err } @@ -127,7 +127,7 @@ func compactFeedbackBody(value string) string { func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID int64, ref domain.RepoRef, in mcpcontract.SearchPullRequestFeedbackInput, page corpus.FeedbackSearchPage) *mcpcontract.RecoveryPlan { if !page.Coverage.DiscoveryComplete() { - return recoveryPlan("feedback_discovery_incomplete", "Discovery is incomplete; continue the repository feedback index job before treating an empty result as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) + return recoveryPlan("feedback_discovery_incomplete", "Discovery is incomplete for the selected pull-request state; continue that scoped repository feedback index before treating an empty result as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, State: feedbackSearchIndexState(in.State), Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) } channels := []string{in.Channel} if in.Channel == "" { @@ -138,7 +138,7 @@ func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID if threadState == "" { threadState = "all" } - threads, err := c.ListPullRequestsWithIncompleteFeedback(ctx, repositoryID, channels, threadState, 50) + threads, err := c.ListPullRequestsWithIncompleteFeedback(ctx, repositoryID, channels, threadState, maxExactFeedbackPullRequests) if err == nil && len(threads) > 0 { refs := make([]mcpcontract.ThreadRef, 0, len(threads)) for _, thread := range threads { @@ -164,7 +164,14 @@ func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID } return recoveryPlan("merge_state_unknown", "Some matching pull requests have no observed merge state; refresh the exact PR-details facet before filtering on merge state.", mcpcontract.RecoveryAction(mcpcontract.HydrateThreadsInput{Threads: threads, Facets: []string{facets.PRDetails}, MaxPages: 1})) } - return recoveryPlan("feedback_coverage_partial", "Feedback coverage is partial; continue indexing or retry the returned exact synchronization before treating missing feedback as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}})) + return recoveryPlan("feedback_coverage_partial", "Feedback coverage is partial; continue indexing or retry the returned exact synchronization before treating missing feedback as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, State: feedbackSearchIndexState(in.State)})) +} + +func feedbackSearchIndexState(state string) string { + if state == "open" || state == "closed" { + return state + } + return "all" } func uniqueThreadRefs(values []mcpcontract.ThreadRef) []mcpcontract.ThreadRef { diff --git a/internal/app/mcp_radar.go b/internal/app/mcp_radar.go new file mode 100644 index 00000000..5c329d3d --- /dev/null +++ b/internal/app/mcp_radar.go @@ -0,0 +1,140 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" + "github.com/morluto/gitcontribute/internal/radar" +) + +// RankContributionCandidates projects the application-owned cross-repository +// Radar use case as one snapshot-bound offline MCP read. +func (r *MCPReader) RankContributionCandidates(ctx context.Context, in mcpcontract.RankContributionCandidatesInput) (mcpcontract.RankContributionCandidatesOutput, error) { + if len(in.Repositories) < 1 || len(in.Repositories) > 50 { + return mcpcontract.RankContributionCandidatesOutput{}, errors.New("repositories must contain 1 to 50 items") + } + if in.Limit == 0 { + in.Limit = 20 + } + if in.MaxResultsPerRepository == 0 { + in.MaxResultsPerRepository = 10 + } + if in.Limit < 1 || in.Limit > 100 { + return mcpcontract.RankContributionCandidatesOutput{}, errors.New("limit must be between 1 and 100") + } + if in.MaxResultsPerRepository < 1 || in.MaxResultsPerRepository > 100 { + return mcpcontract.RankContributionCandidatesOutput{}, errors.New("max_results_per_repository must be between 1 and 100") + } + repositories := make([]contracts.RepoRef, len(in.Repositories)) + seen := make(map[string]struct{}, len(in.Repositories)) + for index, input := range in.Repositories { + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, fmt.Errorf("repositories[%d]: %w", index, err) + } + key := ref.String() + if _, duplicate := seen[key]; duplicate { + return mcpcontract.RankContributionCandidatesOutput{}, fmt.Errorf("repositories contains duplicate %s", key) + } + seen[key] = struct{}{} + in.Repositories[index] = mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()} + repositories[index] = contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()} + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, err + } + result, err := r.application().RankContributionCandidates(ctx, contracts.RadarBatchOptions{Repositories: repositories, Limit: in.Limit, MaxResultsPerRepository: in.MaxResultsPerRepository}) + if err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, err + } + out := mcpcontract.RankContributionCandidatesOutput{ + Status: "complete", GeneratedAt: formatTime(result.GeneratedAt), Total: result.Ranking.Total, Truncated: result.Ranking.Truncated, + SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), + Repositories: make([]mcpcontract.BatchItem[mcpcontract.RepositoryCandidateRankingOutput], len(result.Repositories)), + Candidates: make([]mcpcontract.ContributionCandidateOutput, len(result.Ranking.Candidates)), + } + for index, item := range result.Repositories { + key := item.Repository.Owner + "/" + item.Repository.Repo + projected := mcpcontract.BatchItem[mcpcontract.RepositoryCandidateRankingOutput]{Key: key, Status: "complete"} + if item.Err != nil { + if !errors.Is(item.Err, errRepositoryNotFound) { + return mcpcontract.RankContributionCandidatesOutput{}, item.Err + } + projected.Status, projected.Reason, projected.Message = "unavailable", "repository_not_indexed", item.Err.Error() + projected.Recovery = recoveryPlan(projected.Reason, projected.Message, syncRepositoryContextCall(item.Repository.Owner, item.Repository.Repo), mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: item.Repository.Owner, Repo: item.Repository.Repo}}, Kind: "issue", State: "open"})) + out.Repositories[index] = projected + out.Status = "partial" + continue + } + report := item.Report + summary := mcpcontract.RepositoryCandidateRankingOutput{ + Repo: report.Repo, TotalOpenIssues: report.TotalOpenIssues, Considered: report.CandidatePopulation, + Returned: len(report.Candidates), Truncated: len(report.Candidates) < report.CandidatePopulation, PopulationCapped: report.PopulationCapped, + } + if summary.Truncated || summary.PopulationCapped { + projected.Status, projected.Reason, projected.Message = "partial", "ranking_population_truncated", "the repository ranking population exceeded the requested bound" + out.Status = "partial" + next := freshCandidateRankingInput(in) + next.Repositories = []mcpcontract.RepositoryRef{{Owner: item.Repository.Owner, Repo: item.Repository.Repo}} + next.MaxResultsPerRepository = min(100, max(in.MaxResultsPerRepository*2, in.MaxResultsPerRepository+1)) + summary.Recovery = recoveryPlan(projected.Reason, "Refresh issue headers, then rerun with a larger per-repository bound before treating the ranking as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: next.Repositories, Kind: "issue", State: "open"}), mcpcontract.RecoveryAction(next)) + } + projected.Value = &summary + out.Repositories[index] = projected + } + for index, candidate := range result.Ranking.Candidates { + out.Candidates[index] = radarCandidateToMCP(candidate) + } + if out.Truncated { + out.Status = "partial" + next := freshCandidateRankingInput(in) + next.Limit = min(100, max(in.Limit*2, in.Limit+1)) + out.Recovery = recoveryPlan("ranking_truncated", "The cross-repository ranking is bounded. Refresh issue headers, then rerun with a larger result limit before treating the returned candidates as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: append([]mcpcontract.RepositoryRef(nil), in.Repositories...), Kind: "issue", State: "open"}), mcpcontract.RecoveryAction(next)) + } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, err + } + out.Provenance, err = offlineReadProvenance("contribution_candidate_ranking", revision, in, out.Truncated, out.Status != "complete") + if err != nil { + return mcpcontract.RankContributionCandidatesOutput{}, err + } + out.Provenance.Recovery = out.Recovery + return out, nil +} + +func freshCandidateRankingInput(in mcpcontract.RankContributionCandidatesInput) mcpcontract.RankContributionCandidatesInput { + in.SnapshotToken = "" + return in +} + +func radarCandidateToMCP(candidate radar.Candidate) mcpcontract.ContributionCandidateOutput { + out := mcpcontract.ContributionCandidateOutput{Rank: candidate.Rank, Ref: candidate.Ref, Repo: candidate.Repo, Number: candidate.Number, Title: candidate.Title, URL: candidate.URL, Score: mcpcontract.RadarScore(candidate.Score), Eligibility: string(candidate.Eligibility), Confidence: candidate.Confidence, SourceUpdatedAt: formatTime(candidate.SourceUpdatedAt)} + for _, signal := range candidate.PositiveSignals { + out.PositiveSignals = append(out.PositiveSignals, signal.Summary) + } + for _, signal := range candidate.Risks { + out.Risks = append(out.Risks, signal.Summary) + } + for _, signal := range candidate.Blockers { + out.Blockers = append(out.Blockers, signal.Summary) + } + for _, unknown := range candidate.Unknowns { + out.Unknowns = append(out.Unknowns, unknown.Summary) + } + for _, linked := range candidate.LinkedPullRequests { + out.LinkedPullRequests = append(out.LinkedPullRequests, linked.Number) + } + for _, work := range candidate.RelatedWork { + out.RelatedWork = append(out.RelatedWork, mcpcontract.OpportunityRelatedWorkOutput{Ref: work.Ref, Relation: string(work.Relation), Direction: string(work.Direction), State: work.State}) + } + return out +} diff --git a/internal/app/mcp_radar_eligibility_test.go b/internal/app/mcp_radar_eligibility_test.go deleted file mode 100644 index 0b63e5a2..00000000 --- a/internal/app/mcp_radar_eligibility_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package app - -import ( - "testing" - - "github.com/morluto/gitcontribute/internal/radar" -) - -func TestMCPCrossRepositoryEligibilityOrderMatchesRadar(t *testing.T) { - t.Parallel() - ordered := []radar.Eligibility{ - radar.EligibilityReadyToCode, - radar.EligibilityNeedsDiagnosis, - radar.EligibilityNeedsCoordination, - radar.EligibilityBlocked, - } - for i, eligibility := range ordered { - if got := eligibilityRank(eligibility); got != i { - t.Fatalf("eligibilityRank(%q) = %d, want %d", eligibility, got, i) - } - } -} diff --git a/internal/app/mcp_radar_test.go b/internal/app/mcp_radar_test.go new file mode 100644 index 00000000..39119c4f --- /dev/null +++ b/internal/app/mcp_radar_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestRankContributionCandidatesPreservesBoundsAndStableRanks(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + svc.SetClock(func() time.Time { return now }) + svc.SetGitHubReader(panicRadarReader{}) + seedCandidateRankingRepository(ctx, t, svc, "rocket", 5, now) + + reader := &MCPReader{svc} + bounded, err := reader.RankContributionCandidates(ctx, mcpcontract.RankContributionCandidatesInput{ + Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 2, MaxResultsPerRepository: 5, + }) + if err != nil { + t.Fatal(err) + } + if bounded.Status != "partial" || bounded.Total != 5 || len(bounded.Candidates) != 2 || !bounded.Truncated || bounded.SnapshotToken == "" { + t.Fatalf("bounded ranking = %+v", bounded) + } + if bounded.Recovery == nil || len(bounded.Recovery.Then) != 2 || bounded.Recovery.Then[0].Type() != "sync_threads" || bounded.Recovery.Then[1].Type() != "rank_contribution_candidates" { + t.Fatalf("bounded recovery = %+v", bounded.Recovery) + } + if summary := bounded.Repositories[0].Value; summary == nil || summary.Considered != 5 || summary.Returned != 5 || summary.Truncated { + t.Fatalf("repository summary = %+v", summary) + } + for index, candidate := range bounded.Candidates { + if candidate.Rank != index+1 { + t.Fatalf("candidate rank = %+v", candidate) + } + } + if bounded.Provenance.QueryDigestSHA256 == "" || bounded.Provenance.Complete() { + t.Fatalf("provenance = %+v", bounded.Provenance) + } +} + +func TestRankContributionCandidatesUsesOneEvaluationTime(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + clockCalls := 0 + svc.SetClock(func() time.Time { + clockCalls++ + return now.Add(time.Duration(clockCalls) * time.Hour) + }) + for _, name := range []string{"one", "two"} { + seedCandidateRankingRepository(ctx, t, svc, name, 1, now) + } + out, err := (&MCPReader{svc}).RankContributionCandidates(ctx, mcpcontract.RankContributionCandidatesInput{ + Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "one"}, {Owner: "acme", Repo: "two"}}, Limit: 2, MaxResultsPerRepository: 1, + }) + if err != nil { + t.Fatal(err) + } + if clockCalls != 1 || out.GeneratedAt != now.Add(time.Hour).Format(time.RFC3339) || len(out.Candidates) != 2 { + t.Fatalf("evaluation = calls:%d output:%+v", clockCalls, out) + } +} + +func TestCandidateRankingRecoveryUsesFreshSnapshot(t *testing.T) { + in := mcpcontract.RankContributionCandidatesInput{SnapshotToken: "snapshot:stale"} + if next := freshCandidateRankingInput(in); next.SnapshotToken != "" { + t.Fatalf("ranking recovery retained stale snapshot: %+v", next) + } +} + +func seedCandidateRankingRepository(ctx context.Context, t *testing.T, svc *Service, name string, candidates int, now time.Time) { + t.Helper() + repository, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: "acme", Name: name, SourceUpdatedAt: now}, `{}`) + if err != nil { + t.Fatal(err) + } + for number := 1; number <= candidates; number++ { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repository.ID, Kind: domain.IssueKind, Number: number, State: domain.OpenState, Title: "same-score candidate", SourceUpdatedAt: now.Add(-time.Hour)}, `{}`); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/app/mcp_recovery_test.go b/internal/app/mcp_recovery_test.go index 37fd1fab..dd3a5724 100644 --- a/internal/app/mcp_recovery_test.go +++ b/internal/app/mcp_recovery_test.go @@ -27,14 +27,6 @@ func TestMCPThreadAndRepositorySearchExposeCoverageRecovery(t *testing.T) { t.Fatalf("thread recovery target = %+v", got) } - code, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Query: "missing", Limit: 10}) - if err != nil { - t.Fatalf("unscoped code search: %v", err) - } - if !code.Provenance.UnknownCoverage() || code.Recovery == nil || len(code.Recovery.Then) != 1 || code.Recovery.Then[0].Type() != "search_github_repositories" { - t.Fatalf("unscoped code recovery = %+v", code) - } - repositories, err := reader.SearchRepositories(ctx, mcpcontract.SearchRepositoriesInput{Owner: "owner", Repo: "repo", Query: "missing", Limit: 10}) if err != nil { t.Fatalf("repository search: %v", err) @@ -43,7 +35,6 @@ func TestMCPThreadAndRepositorySearchExposeCoverageRecovery(t *testing.T) { t.Fatalf("repository search recovery = %+v", repositories) } } - func TestMCPRelatedWorkDoesNotTreatAbsentRepositoryAsNoFindings(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/app/mcp_related_work.go b/internal/app/mcp_related_work.go index 0e0aa7b9..93b462ad 100644 --- a/internal/app/mcp_related_work.go +++ b/internal/app/mcp_related_work.go @@ -35,13 +35,6 @@ func (k relatedWorkCheckKind) resultName() string { return "duplicate" } -func (k relatedWorkCheckKind) recoveryKind() string { - if k == competingPullRequests { - return "competing_pull_requests" - } - return "duplicates" -} - func (r *MCPReader) checkRelatedWorkInput(ctx context.Context, rawTarget, rawID string, limit int, check relatedWorkCheckKind) (mcpcontract.CheckOutput, error) { target, err := parseRelatedWorkSubjectKind(rawTarget) if err != nil { @@ -139,7 +132,12 @@ func collisionCheckResultToMCP(subject relatedWorkSubject, result *contracts.Col func relatedWorkLimitRecovery(subject relatedWorkSubject, limit int, check relatedWorkCheckKind) *mcpcontract.RecoveryPlan { nextLimit := min(100, max(limit*2, limit+1)) - return recoveryPlan("related_work_truncated", "The related-work result reached its bound. Rerun workflow.find_related_work with a larger limit before treating the returned findings as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.FindRelatedWorkInput{Target: subject.kind.String(), ID: subject.id, Kinds: []string{check.recoveryKind()}, Limit: nextLimit})) + message := "The related-work result reached its bound. Rerun the same exact check with a larger limit before treating the findings as exhaustive." + input := mcpcontract.CheckDuplicatesInput{Target: subject.kind.String(), ID: subject.id, Limit: nextLimit} + if check == competingPullRequests { + return recoveryPlan("related_work_truncated", message, mcpcontract.RecoveryAction(mcpcontract.CheckCollisionsInput(input))) + } + return recoveryPlan("related_work_truncated", message, mcpcontract.RecoveryAction(input)) } func (r *MCPReader) relatedWorkRepositoryIndexed(ctx context.Context, repo domain.RepoRef) (bool, error) { diff --git a/internal/app/mcp_scalable_inputs_test.go b/internal/app/mcp_scalable_inputs_test.go index faea4f7f..bed3657e 100644 --- a/internal/app/mcp_scalable_inputs_test.go +++ b/internal/app/mcp_scalable_inputs_test.go @@ -169,9 +169,6 @@ func TestScalableRuntimeRejectsPageBoundsBeforeSubmittingJob(t *testing.T) { func TestScalableRuntimeBoundsMatchSchemas(t *testing.T) { t.Parallel() reader := &MCPReader{newSearchTestService(t)} - if _, err := reader.RankOpportunities(context.Background(), mcpcontract.RankOpportunitiesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 101}); err == nil { - t.Fatal("rank opportunities accepted limit above schema maximum") - } if _, err := reader.FindPrecedents(context.Background(), mcpcontract.FindPrecedentsInput{Threads: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Number: 1}}, Limit: 101}); err == nil { t.Fatal("find precedents accepted limit above schema maximum") } diff --git a/internal/app/mcp_scalable_reads.go b/internal/app/mcp_scalable_reads.go index 77e3474e..e14acfbd 100644 --- a/internal/app/mcp_scalable_reads.go +++ b/internal/app/mcp_scalable_reads.go @@ -5,16 +5,13 @@ import ( "encoding/json" "errors" "fmt" - "sort" "strings" "time" - "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/radar" ) // GetRepositories performs an offline, input-ordered corpus read and clears @@ -586,127 +583,3 @@ func classifyChecks(checks []github.PullRequestCheck) string { } return status } - -// RankOpportunities performs deterministic offline Radar ranking across stored repositories. -func (r *MCPReader) RankOpportunities(ctx context.Context, in mcpcontract.RankOpportunitiesInput) (mcpcontract.RankOpportunitiesOutput, error) { - if len(in.Repositories) < 1 || len(in.Repositories) > 50 { - return mcpcontract.RankOpportunitiesOutput{}, errors.New("repositories must contain 1 to 50 items") - } - if in.Limit == 0 { - in.Limit = 20 - } - if in.MaxResultsPerRepository == 0 { - in.MaxResultsPerRepository = 10 - } - if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.RankOpportunitiesOutput{}, errors.New("limit must be between 1 and 100") - } - if in.MaxResultsPerRepository < 1 || in.MaxResultsPerRepository > 100 { - return mcpcontract.RankOpportunitiesOutput{}, errors.New("max_results_per_repository must be between 1 and 100") - } - evaluationTime := r.now().UTC() - c, err := r.openReadOnlyCorpus(ctx) - if err != nil { - return mcpcontract.RankOpportunitiesOutput{}, err - } - revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) - if err != nil { - return mcpcontract.RankOpportunitiesOutput{}, err - } - out := mcpcontract.RankOpportunitiesOutput{ - Status: "complete", GeneratedAt: formatTime(evaluationTime), - Candidates: make([]mcpcontract.OpportunityCandidateOutput, 0, in.Limit), - Repositories: make([]mcpcontract.BatchItem[mcpcontract.RepositoryOpportunitySummaryOutput], len(in.Repositories)), - SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), - } - var candidates []radar.Candidate - for i, input := range in.Repositories { - key := input.Owner + "/" + input.Repo - item := mcpcontract.BatchItem[mcpcontract.RepositoryOpportunitySummaryOutput]{Key: key, Status: "complete"} - report, err := r.contributionRadarAt(ctx, contracts.RadarOptions{Repo: contracts.RepoRef{Owner: input.Owner, Repo: input.Repo}, Limit: in.MaxResultsPerRepository}, evaluationTime) - if err != nil { - item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", err.Error() - item.Recovery = recoveryPlan(item.Reason, item.Message, syncRepositoryContextCall(input.Owner, input.Repo), mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: input.Owner, Repo: input.Repo}}, Kind: "issue", State: "open"})) - out.Repositories[i] = item - out.Status = "partial" - continue - } - summary := mcpcontract.RepositoryOpportunitySummaryOutput{ - Repo: report.Repo, TotalOpenIssues: report.TotalOpenIssues, Considered: report.CandidatePopulation, - Returned: len(report.Candidates), Truncated: len(report.Candidates) < report.CandidatePopulation, - PopulationCapped: report.PopulationCapped, - } - if summary.Truncated || summary.PopulationCapped { - item.Status, item.Reason, item.Message = "partial", "ranking_population_truncated", "the repository ranking population exceeded the requested bound" - out.Status = "partial" - nextLimit := min(100, max(in.MaxResultsPerRepository*2, in.MaxResultsPerRepository+1)) - summary.Recovery = recoveryPlan("ranking_population_truncated", "The repository ranking population was bounded. Refresh issue headers, then rerun with a larger per-repository result bound before treating the ranking as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: input.Owner, Repo: input.Repo}}, Kind: "issue", State: "open"}), mcpcontract.RecoveryAction(mcpcontract.RankOpportunitiesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: input.Owner, Repo: input.Repo}}, Limit: in.Limit, MaxResultsPerRepository: nextLimit})) - } - out.Total += report.CandidatePopulation - out.Truncated = out.Truncated || summary.Truncated || summary.PopulationCapped - item.Value = &summary - out.Repositories[i] = item - candidates = append(candidates, report.Candidates...) - } - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Eligibility != candidates[j].Eligibility { - return eligibilityRank(candidates[i].Eligibility) < eligibilityRank(candidates[j].Eligibility) - } - if candidates[i].Score != candidates[j].Score { - return candidates[i].Score > candidates[j].Score - } - return candidates[i].Ref < candidates[j].Ref - }) - out.Truncated = out.Truncated || len(candidates) > in.Limit - end := min(in.Limit, len(candidates)) - for i, candidate := range candidates[:end] { - mapped := radarCandidateToMCP(candidate) - mapped.Rank = i + 1 - out.Candidates = append(out.Candidates, mapped) - } - if out.Truncated { - nextLimit := min(100, max(in.Limit*2, in.Limit+1)) - out.Recovery = recoveryPlan("ranking_truncated", "The cross-repository ranking is bounded. Refresh issue headers, then rerun with a larger result limit before treating the returned candidates as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: append([]mcpcontract.RepositoryRef(nil), in.Repositories...), Kind: "issue", State: "open"}), mcpcontract.RecoveryAction(mcpcontract.RankOpportunitiesInput{Repositories: append([]mcpcontract.RepositoryRef(nil), in.Repositories...), Limit: nextLimit, MaxResultsPerRepository: in.MaxResultsPerRepository})) - } - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.RankOpportunitiesOutput{}, err - } - return out, nil -} - -func radarCandidateToMCP(c radar.Candidate) mcpcontract.OpportunityCandidateOutput { - out := mcpcontract.OpportunityCandidateOutput{Ref: c.Ref, Repo: c.Repo, Number: c.Number, Title: c.Title, URL: c.URL, Score: mcpcontract.RadarScore(c.Score), Eligibility: string(c.Eligibility), Confidence: c.Confidence, SourceUpdatedAt: formatTime(c.SourceUpdatedAt)} - for _, signal := range c.PositiveSignals { - out.PositiveSignals = append(out.PositiveSignals, signal.Summary) - } - for _, signal := range c.Risks { - out.Risks = append(out.Risks, signal.Summary) - } - for _, signal := range c.Blockers { - out.Blockers = append(out.Blockers, signal.Summary) - } - for _, unknown := range c.Unknowns { - out.Unknowns = append(out.Unknowns, unknown.Summary) - } - for _, linked := range c.LinkedPullRequests { - out.LinkedPullRequests = append(out.LinkedPullRequests, linked.Number) - } - for _, work := range c.RelatedWork { - out.RelatedWork = append(out.RelatedWork, mcpcontract.OpportunityRelatedWorkOutput{ - Ref: work.Ref, Relation: string(work.Relation), Direction: string(work.Direction), State: work.State, - }) - } - return out -} -func eligibilityRank(v radar.Eligibility) int { - switch v { - case radar.EligibilityReadyToCode: - return 0 - case radar.EligibilityNeedsDiagnosis: - return 1 - case radar.EligibilityNeedsCoordination: - return 2 - default: - return 3 - } -} diff --git a/internal/app/mcp_scalable_test.go b/internal/app/mcp_scalable_test.go index 4f5c57b2..89cf443a 100644 --- a/internal/app/mcp_scalable_test.go +++ b/internal/app/mcp_scalable_test.go @@ -13,19 +13,8 @@ import ( "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" - "github.com/morluto/gitcontribute/internal/radar" ) -func TestRadarCandidateToMCPPreservesRelatedWorkSemantics(t *testing.T) { - t.Parallel() - out := radarCandidateToMCP(radar.Candidate{RelatedWork: []radar.RelatedWork{{ - Ref: "pull_request:owner/repo#9", Relation: "depends_on", Direction: "outbound", State: "open", - }}}) - if len(out.RelatedWork) != 1 || out.RelatedWork[0].Ref != "pull_request:owner/repo#9" || out.RelatedWork[0].Relation != "depends_on" || out.RelatedWork[0].Direction != "outbound" || out.RelatedWork[0].State != "open" { - t.Fatalf("related work = %+v", out.RelatedWork) - } -} - func TestTypedBatchResultsPreserveDurableJSONShapes(t *testing.T) { t.Parallel() threadSync := threadSyncBatchResult{ @@ -155,114 +144,6 @@ func assertJSONDocumentEqual(t *testing.T, value any, expected string) { } } -func TestRankOpportunitiesReportsBoundedNonPaginatedTruncation(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) - svc.SetClock(func() time.Time { return now }) - svc.SetGitHubReader(panicRadarReader{}) - seedRadarRepository(ctx, t, svc, "rocket", 5, now) - - reader := &MCPReader{svc} - bounded, err := reader.RankOpportunities(ctx, mcpcontract.RankOpportunitiesInput{ - Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, - Limit: 2, MaxResultsPerRepository: 5, - }) - if err != nil { - t.Fatal(err) - } - if bounded.Total != 5 || len(bounded.Candidates) != 2 || !bounded.Truncated { - t.Fatalf("bounded radar result = %+v", bounded) - } - if bounded.Recovery == nil || len(bounded.Recovery.Then) != 2 || bounded.Recovery.Then[0].Type() != "sync_threads" || bounded.Recovery.Then[1].Type() != "rank_opportunities" { - t.Fatalf("bounded radar recovery = %+v", bounded.Recovery) - } - if summary := bounded.Repositories[0].Value; summary == nil || summary.Considered != 5 || summary.Returned != 5 || summary.Truncated || summary.PopulationCapped { - t.Fatalf("bounded repository summary = %+v", summary) - } - perRepositoryBound, err := reader.RankOpportunities(ctx, mcpcontract.RankOpportunitiesInput{ - Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 100, MaxResultsPerRepository: 3, - }) - if err != nil { - t.Fatal(err) - } - if perRepositoryBound.Total != 5 || len(perRepositoryBound.Candidates) != 3 || !perRepositoryBound.Truncated { - t.Fatalf("per-repository bounded result = %+v", perRepositoryBound) - } - if summary := perRepositoryBound.Repositories[0].Value; summary == nil || summary.Considered != 5 || summary.Returned != 3 || !summary.Truncated { - t.Fatalf("per-repository bounded summary = %+v", summary) - } - if summary := perRepositoryBound.Repositories[0].Value; summary == nil || summary.Recovery == nil || len(summary.Recovery.Then) != 2 { - t.Fatalf("per-repository recovery = %+v", summary) - } - full, err := reader.RankOpportunities(ctx, mcpcontract.RankOpportunitiesInput{ - Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 100, MaxResultsPerRepository: 5, - }) - if err != nil { - t.Fatal(err) - } - if full.Total != 5 || len(full.Candidates) != 5 || full.Truncated { - t.Fatalf("full radar result = %+v", full) - } - assertRadarCandidateRanks(t, full.Candidates) - for i := range bounded.Candidates { - if bounded.Candidates[i].Ref != full.Candidates[i].Ref { - t.Fatalf("bounded order = %+v, full order = %+v", bounded.Candidates, full.Candidates) - } - } -} - -func seedRadarRepository(ctx context.Context, t *testing.T, svc *Service, name string, candidates int, now time.Time) { - t.Helper() - repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: "acme", Name: name, SourceUpdatedAt: now}, `{}`) - if err != nil { - t.Fatal(err) - } - for number := 1; number <= candidates; number++ { - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: domain.IssueKind, Number: number, State: "open", - Title: "same-score candidate", SourceUpdatedAt: now.Add(-time.Hour), - }, `{}`); err != nil { - t.Fatal(err) - } - } -} - -func assertRadarCandidateRanks(t *testing.T, candidates []mcpcontract.OpportunityCandidateOutput) { - t.Helper() - for index, candidate := range candidates { - if candidate.Rank != index+1 { - t.Fatalf("candidate %s rank = %d, want %d", candidate.Ref, candidate.Rank, index+1) - } - } -} - -func TestRankOpportunitiesUsesOneEvaluationTimeAcrossRepositories(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) - clockCalls := 0 - svc.SetClock(func() time.Time { - clockCalls++ - return now.Add(time.Duration(clockCalls) * time.Hour) - }) - for _, name := range []string{"one", "two"} { - seedRadarRepository(ctx, t, svc, name, 1, now) - } - out, err := (&MCPReader{svc}).RankOpportunities(ctx, mcpcontract.RankOpportunitiesInput{ - Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "one"}, {Owner: "acme", Repo: "two"}}, - Limit: 2, MaxResultsPerRepository: 1, - }) - if err != nil { - t.Fatal(err) - } - if clockCalls != 1 || out.GeneratedAt != now.Add(time.Hour).Format(time.RFC3339) || len(out.Candidates) != 2 { - t.Fatalf("cross-repository evaluation = calls:%d output:%+v", clockCalls, out) - } -} - func TestGetRepositoriesPreservesUnknownMetadataAndInputOrder(t *testing.T) { t.Parallel() ctx := context.Background() @@ -548,64 +429,6 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { partial.Artifacts[0].Recovery == nil || len(partial.Artifacts[0].Recovery.Then) != 1 || partial.Artifacts[0].Recovery.Then[0].Type() != "sync_portfolio" { t.Fatalf("partial portfolio outcome = %+v", partial) } - - fixPatterns := jobResultToMCP(&contracts.JobResult{ - ID: "job-patterns", Kind: "mine_repository_fix_patterns", Status: "succeeded", - Result: `{"status":"complete","coverage":{"unique_candidates":21}}`, CreatedAt: "2026-07-19T00:00:00Z", - }, detailedResponse) - if fixPatterns.ExecutionState != "terminal" || fixPatterns.Outcome != "succeeded" || - len(fixPatterns.Artifacts) != 1 || fixPatterns.Artifacts[0].Kind != "fix_pattern_report" || - fixPatterns.Artifacts[0].URI != "gitcontribute://fix-pattern-report/job-patterns" { - t.Fatalf("fix-pattern job outcome = %+v", fixPatterns) - } - - runningPatterns := jobResultToMCP(&contracts.JobResult{ - ID: "job-running-patterns", Kind: "mine_repository_fix_patterns", Status: "running", - }, detailedResponse) - if len(runningPatterns.Artifacts) != 0 || runningPatterns.FollowUp == nil || - runningPatterns.FollowUp.Action.Type() != "poll_job" { - t.Fatalf("running fix-pattern job advertised unavailable artifacts: %+v", runningPatterns) - } -} - -func TestGetJobsDetailedReturnsTypedArtifactsWithoutStoredPayloads(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - job, err := svc.corpus.CreateJob(ctx, "build_repository_dossier", `{"owner":"acme","repo":"rocket"}`) - if err != nil { - t.Fatal(err) - } - if err := svc.corpus.StartJob(ctx, job.ID); err != nil { - t.Fatal(err) - } - if err := svc.corpus.TransitionJob(ctx, job.ID, corpus.JobRunningToSucceeded, `{"status":"complete"}`, ""); err != nil { - t.Fatal(err) - } - reader := &MCPReader{svc} - concise, err := reader.GetJobs(ctx, mcpcontract.GetJobsInput{IDs: []string{job.ID}}) - if err != nil { - t.Fatal(err) - } - if len(concise.Items) != 1 || concise.Items[0].Value == nil || len(concise.Items[0].Value.Artifacts) != 0 { - t.Fatalf("concise jobs output should remain a bounded state summary: %+v", concise) - } - if concise.Items[0].Recovery == nil || len(concise.Items[0].Recovery.Then) != 1 { - t.Fatalf("default concise terminal result lacks detailed recovery hint: %+v", concise) - } - detailed, err := reader.GetJobs(ctx, mcpcontract.GetJobsInput{IDs: []string{job.ID}, ResponseFormat: "detailed"}) - if err != nil { - t.Fatal(err) - } - value := detailed.Items[0].Value - if value == nil || len(value.Artifacts) != 1 || value.Artifacts[0].Kind != "dossier" || - value.Artifacts[0].URI != "gitcontribute://dossier/acme/rocket" || value.FollowUp == nil { - t.Fatalf("detailed jobs output lost typed artifact reference: %+v", detailed) - } - read, ok := mcpcontract.RecoveryInput[mcpcontract.ResourceReadAction](value.FollowUp.Action) - if value.FollowUp.Action.Type() != "read_resource" || !ok || read.URI != "gitcontribute://dossier/acme/rocket" { - t.Fatalf("detailed jobs output lost typed follow-up: %+v", detailed) - } } func TestFindPrecedentsUsesClosedAndMergedHistory(t *testing.T) { diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index 31c0f5d7..db93fd65 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -34,13 +34,21 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { } reader := svc.MCPReader() - if _, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Owner: "owner", Repo: "repo", Query: " \t "}); err == nil { + if _, err := reader.SearchCodeBatch(ctx, mcpcontract.SearchCodeBatchInput{Owner: "owner", Repo: "repo", Queries: []string{" \t "}}); err == nil { t.Fatal("whitespace-only code query was accepted") } - out, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Query: "searchableParser", Limit: 10}) - if err != nil { - t.Fatalf("search code: %v", err) + search := func(repo, query string) mcpcontract.SearchCodeOutput { + t.Helper() + batch, err := reader.SearchCodeBatch(ctx, mcpcontract.SearchCodeBatchInput{Owner: "owner", Repo: repo, Queries: []string{query}, Limit: 10}) + if err != nil { + t.Fatalf("search code: %v", err) + } + if len(batch.Items) != 1 || batch.Items[0].Value == nil { + t.Fatalf("search code batch: %+v", batch) + } + return *batch.Items[0].Value } + out := search("repo", "searchableParser") if out.Total != 1 || len(out.Matches) != 1 { t.Fatalf("unexpected output: %+v", out) } @@ -57,20 +65,14 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { if !out.Provenance.Truncated() || out.Provenance.Complete() || out.Provenance.QueryDigestSHA256 == "" { t.Fatalf("unexpected code-search provenance: %+v", out.Provenance) } - missing, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Owner: "owner", Repo: "repo", Query: "doesNotExist", Limit: 10}) - if err != nil { - t.Fatal(err) - } + missing := search("repo", "doesNotExist") if len(missing.Matches) != 0 || len(missing.Coverage) != 1 || missing.Coverage[0].Status != "indexed" || !missing.Coverage[0].Truncated { t.Fatalf("zero-match search lost index coverage: %+v", missing) } if missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type() != "index_repositories" || missing.Coverage[0].Recovery == nil { t.Fatalf("truncated code search recovery = %+v", missing) } - unindexed, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Owner: "owner", Repo: "unindexed", Query: "anything", Limit: 10}) - if err != nil { - t.Fatal(err) - } + unindexed := search("unindexed", "anything") if len(unindexed.Coverage) != 1 || unindexed.Coverage[0].Status != "missing" { t.Fatalf("unindexed repository coverage = %+v", unindexed.Coverage) } @@ -82,10 +84,7 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { }); err != nil { t.Fatal(err) } - legacy, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Owner: "owner", Repo: "legacy", Query: "anything", Limit: 10}) - if err != nil { - t.Fatal(err) - } + legacy := search("legacy", "anything") if len(legacy.Coverage) != 1 || legacy.Coverage[0].Status != "indexed_coverage_unknown" { t.Fatalf("legacy snapshot coverage = %+v", legacy.Coverage) } @@ -320,13 +319,16 @@ func TestMCPReaderExplainCodeExactPathNotOnFirstSearchPage(t *testing.T) { } // Confirm the target is not on the first search page. - searchOut, err := svc.MCPReader().SearchCode(ctx, mcpcontract.SearchCodeInput{ - Owner: ref.Owner(), Repo: ref.Repo(), Query: "searchableParser", Limit: 20, + searchBatch, err := svc.MCPReader().SearchCodeBatch(ctx, mcpcontract.SearchCodeBatchInput{ + Owner: ref.Owner(), Repo: ref.Repo(), Queries: []string{"searchableParser"}, Limit: 20, }) if err != nil { t.Fatalf("search code: %v", err) } - for _, m := range searchOut.Matches { + if len(searchBatch.Items) != 1 || searchBatch.Items[0].Value == nil { + t.Fatalf("search code batch = %+v", searchBatch) + } + for _, m := range searchBatch.Items[0].Value.Matches { if m.Path == "target.go" { t.Fatal("target.go unexpectedly on first search page") } diff --git a/internal/app/mcp_v1.go b/internal/app/mcp_v1.go index 41ad4f03..0644f7bb 100644 --- a/internal/app/mcp_v1.go +++ b/internal/app/mcp_v1.go @@ -228,28 +228,6 @@ func (r *MCPReader) ExplainMatch(ctx context.Context, in mcpcontract.ExplainMatc return out, nil } -// BuildRepositoryDossier submits a durable job that builds a repository dossier. -func (r *MCPReader) BuildRepositoryDossier(ctx context.Context, in mcpcontract.BuildRepositoryDossierInput) (mcpcontract.JobReference, error) { - repo := contracts.RepoRef{Owner: in.Owner, Repo: in.Repo} - id, err := r.submitJob(ctx, "build_repository_dossier", in, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { - if err := report("repository_dossier", jobProgressCounts(0, 1)); err != nil { - return nil, err - } - res, err := r.application().BuildRepositoryDossier(ctx, repo) - if err != nil { - return nil, err - } - if err := report("repository_dossier", jobProgressCounts(1, 1)); err != nil { - return nil, err - } - return res, nil - }) - if err != nil { - return mcpcontract.JobReference{}, err - } - return queuedJobReference(id, "build_repository_dossier", "dossier build job started"), nil -} - // CreateWorkspace submits a durable job that clones a remote and creates a worktree. func (r *MCPReader) CreateWorkspace(ctx context.Context, in mcpcontract.CreateWorkspaceInput) (mcpcontract.JobReference, error) { opts := contracts.WorkspaceCreateOptions{ diff --git a/internal/app/radar.go b/internal/app/radar.go index 2e34e757..6d56aa74 100644 --- a/internal/app/radar.go +++ b/internal/app/radar.go @@ -27,6 +27,38 @@ func (s *Service) ContributionRadar(ctx context.Context, opts contracts.RadarOpt return s.contributionRadarAt(ctx, opts, s.now()) } +// RankContributionCandidates evaluates each repository at one instant and +// delegates cross-repository ordering to the storage-free Radar domain. +func (s *Service) RankContributionCandidates(ctx context.Context, opts contracts.RadarBatchOptions) (*contracts.RadarBatchResult, error) { + if len(opts.Repositories) < 1 || len(opts.Repositories) > 50 { + return nil, fmt.Errorf("repositories must contain 1 to 50 items") + } + if opts.Limit < 1 || opts.Limit > 100 { + return nil, fmt.Errorf("limit must be between 1 and 100") + } + if opts.MaxResultsPerRepository < 1 || opts.MaxResultsPerRepository > 100 { + return nil, fmt.Errorf("max results per repository must be between 1 and 100") + } + evaluationTime := s.now().UTC() + result := &contracts.RadarBatchResult{ + GeneratedAt: evaluationTime, + Repositories: make([]contracts.RadarBatchRepositoryResult, len(opts.Repositories)), + } + reports := make([]*radar.Report, 0, len(opts.Repositories)) + for index, repository := range opts.Repositories { + if err := ctx.Err(); err != nil { + return nil, err + } + report, err := s.contributionRadarAt(ctx, contracts.RadarOptions{Repo: repository, Limit: opts.MaxResultsPerRepository}, evaluationTime) + result.Repositories[index] = contracts.RadarBatchRepositoryResult{Repository: repository, Report: report, Err: err} + if err == nil { + reports = append(reports, report) + } + } + result.Ranking = radar.RankAcrossRepositories(reports, opts.Limit) + return result, nil +} + // contributionRadarAt lets one cross-repository ranking use a single scoring // instant while keeping the public CLI service contract small. func (s *Service) contributionRadarAt(ctx context.Context, opts contracts.RadarOptions, evaluationTime time.Time) (*radar.Report, error) { diff --git a/internal/app/workflow_gate.go b/internal/app/workflow_gate.go new file mode 100644 index 00000000..b5d79ad2 --- /dev/null +++ b/internal/app/workflow_gate.go @@ -0,0 +1,28 @@ +package app + +import ( + "context" + "sync" +) + +// workflowGate serializes workflows that share one non-concurrent writer +// boundary. The service owns its lifetime; callers own cancellation while +// waiting and must release a successful acquisition. +type workflowGate struct { + once sync.Once + slot chan struct{} +} + +func (g *workflowGate) acquire(ctx context.Context) (func(), error) { + g.once.Do(func() { + g.slot = make(chan struct{}, 1) + g.slot <- struct{}{} + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-g.slot: + var releaseOnce sync.Once + return func() { releaseOnce.Do(func() { g.slot <- struct{}{} }) }, nil + } +} diff --git a/internal/app/workflow_gate_test.go b/internal/app/workflow_gate_test.go new file mode 100644 index 00000000..0966b865 --- /dev/null +++ b/internal/app/workflow_gate_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "context" + "errors" + "testing" +) + +func TestWorkflowGateSerializesAndCancelsWaiters(t *testing.T) { + t.Parallel() + var gate workflowGate + release, err := gate.acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := gate.acquire(cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("blocked acquisition error = %v", err) + } + + release() + release() // release is deliberately idempotent. + secondRelease, err := gate.acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + secondRelease() +} diff --git a/internal/contracts/workflow_contracts.go b/internal/contracts/workflow_contracts.go index 89fc1f0d..384ebb72 100644 --- a/internal/contracts/workflow_contracts.go +++ b/internal/contracts/workflow_contracts.go @@ -2,6 +2,7 @@ package contracts import ( "context" + "time" "github.com/morluto/gitcontribute/internal/radar" "github.com/morluto/gitcontribute/internal/research" @@ -182,6 +183,29 @@ type RadarOptions struct { Limit int } +// RadarBatchOptions bounds one deterministic cross-repository ranking. +type RadarBatchOptions struct { + Repositories []RepoRef + Limit int + MaxResultsPerRepository int +} + +// RadarBatchRepositoryResult preserves per-repository availability without +// discarding successful reports from the same batch. +type RadarBatchRepositoryResult struct { + Repository RepoRef + Report *radar.Report + Err error +} + +// RadarBatchResult contains reports computed at one shared evaluation time and +// their deterministic cross-repository ranking. +type RadarBatchResult struct { + GeneratedAt time.Time + Repositories []RadarBatchRepositoryResult + Ranking radar.CrossRepositoryRanking +} + // ReadinessService is the optional contribution readiness capability used by the CLI. type ReadinessService interface { OpportunityReadiness(ctx context.Context, opportunityID string) (*ReadinessResult, error) diff --git a/internal/corpus/pull_request_feedback.go b/internal/corpus/pull_request_feedback.go index 597e0965..c3e854ed 100644 --- a/internal/corpus/pull_request_feedback.go +++ b/internal/corpus/pull_request_feedback.go @@ -53,6 +53,7 @@ type FeedbackDiscovery struct { State FeedbackDiscoveryState DiscoveredPullRequests int Requests int + PullRequestState ThreadStateFilter Selection FeedbackSelection LastError string SourceUpdatedAt time.Time diff --git a/internal/corpus/pull_request_feedback_coverage.go b/internal/corpus/pull_request_feedback_coverage.go index 9b814407..d5c6a2f9 100644 --- a/internal/corpus/pull_request_feedback_coverage.go +++ b/internal/corpus/pull_request_feedback_coverage.go @@ -12,7 +12,7 @@ import ( "github.com/morluto/gitcontribute/internal/domain" ) -func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryID int64, selectedChannel FeedbackChannel, threadState feedbackThreadState) (FeedbackCoverageSummary, error) { +func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryID int64, selectedChannel FeedbackChannel, threadState feedbackThreadState, pullRequestState ThreadStateFilter) (FeedbackCoverageSummary, error) { coverage := FeedbackCoverageSummary{State: FeedbackCoverageUnknown, Channels: AllFeedbackSelection().Channels()} discovery, err := scanFeedbackDiscovery(tx.QueryRowContext(ctx, `SELECT repository_id,generation,state,next_page,complete,truncated,discovered_pull_requests,requests,channels_json,thread_state,last_error,source_updated_at,updated_at FROM pull_request_feedback_discovery WHERE repository_id=?`, repositoryID)) if errors.Is(err, sql.ErrNoRows) { @@ -25,11 +25,20 @@ func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryI coverage.Channels = []string{selectedChannel.String()} } var total int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM threads WHERE repository_id=? AND kind=?`, repositoryID, domain.PullRequestKind).Scan(&total); err != nil { + totalQuery := `SELECT COUNT(*) FROM threads WHERE repository_id=? AND kind=?` + totalArgs := []any{repositoryID, domain.PullRequestKind} + if !pullRequestState.IsAny() { + totalQuery += ` AND state=?` + totalArgs = append(totalArgs, pullRequestState.String()) + } + if err := tx.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total); err != nil { return coverage, err } coverage.TotalPullRequests = total discoveryComplete := discovery.IsComplete() + if !discovery.PullRequestState.IsAny() && discovery.PullRequestState.String() != pullRequestState.String() { + discoveryComplete = false + } if (threadState.isAny() || threadState.isResolved()) && discovery.Selection.threadState != AllFeedbackThreads { discoveryComplete = false } @@ -37,6 +46,11 @@ func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryI var incomplete int predicates := make([]string, 0, len(channels)) coverageArgs := []any{repositoryID, domain.PullRequestKind} + statePredicate := "" + if !pullRequestState.IsAny() { + statePredicate = " AND t.state=?" + coverageArgs = append(coverageArgs, pullRequestState.String()) + } for _, value := range channels { channel, err := ParseFeedbackChannel(value) if err != nil { @@ -50,7 +64,7 @@ func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryI coverageArgs = append(coverageArgs, args...) } if len(predicates) > 0 { - query := `SELECT COUNT(DISTINCT t.id) FROM threads t WHERE t.repository_id=? AND t.kind=? AND (` + strings.Join(predicates, " OR ") + `)` + query := `SELECT COUNT(DISTINCT t.id) FROM threads t WHERE t.repository_id=? AND t.kind=?` + statePredicate + ` AND (` + strings.Join(predicates, " OR ") + `)` if err := tx.QueryRowContext(ctx, query, coverageArgs...).Scan(&incomplete); err != nil { return coverage, err } @@ -177,7 +191,7 @@ func (c *Corpus) UpsertFeedbackDiscovery(ctx context.Context, value FeedbackDisc OR (excluded.generation = pull_request_feedback_discovery.generation AND excluded.source_updated_at >= pull_request_feedback_discovery.source_updated_at AND excluded.next_page >= pull_request_feedback_discovery.next_page) - `, value.RepositoryID, value.Generation, "all", value.NextPage, boolToInt(value.IsComplete()), boolToInt(value.IsTruncated()), + `, value.RepositoryID, value.Generation, feedbackDiscoveryStateValue(value.PullRequestState), value.NextPage, boolToInt(value.IsComplete()), boolToInt(value.IsTruncated()), value.DiscoveredPullRequests, value.Requests, string(channels), value.Selection.ThreadState(), value.LastError, encodeTime(value.SourceUpdatedAt), encodeTime(value.UpdatedAt)) if err != nil { @@ -218,9 +232,11 @@ func scanFeedbackDiscovery(row rowScanner) (*FeedbackDiscovery, error) { &value.DiscoveredPullRequests, &value.Requests, &channels, &threadState, &value.LastError, &source, &updated); err != nil { return nil, err } - if state != "all" { - return nil, fmt.Errorf("parse stored feedback discovery: unsupported pull-request state %q", state) + pullRequestState, err := parseFeedbackDiscoveryState(state) + if err != nil { + return nil, fmt.Errorf("parse stored feedback discovery: %w", err) } + value.PullRequestState = pullRequestState var channelValues []string if err := json.Unmarshal([]byte(channels), &channelValues); err != nil { return nil, fmt.Errorf("decode feedback discovery channels: %w", err) @@ -243,3 +259,17 @@ func scanFeedbackDiscovery(row rowScanner) (*FeedbackDiscovery, error) { value.SourceUpdatedAt, value.UpdatedAt = scanTime(source), scanTime(updated) return &value, nil } + +func parseFeedbackDiscoveryState(value string) (ThreadStateFilter, error) { + if value == "" || value == "all" { + return AnyThreadState(), nil + } + return ParseThreadStateFilter(value) +} + +func feedbackDiscoveryStateValue(state ThreadStateFilter) string { + if state.IsAny() { + return "all" + } + return state.String() +} diff --git a/internal/corpus/pull_request_feedback_search.go b/internal/corpus/pull_request_feedback_search.go index b1d0d736..87976105 100644 --- a/internal/corpus/pull_request_feedback_search.go +++ b/internal/corpus/pull_request_feedback_search.go @@ -141,7 +141,7 @@ func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, request Feedback page.NextCursor = encodeFeedbackCursor(feedbackSearchCursor{Scope: "pull_request_feedback", Filter: string(filterKeyBytes), Offset: offset + query.page.Limit()}) page.Truncated = true } - page.Coverage, err = c.feedbackCoverageTx(ctx, tx, request.repositoryID, query.channel, query.threadState) + page.Coverage, err = c.feedbackCoverageTx(ctx, tx, request.repositoryID, query.channel, query.threadState, query.state) if err != nil { return FeedbackSearchPage{}, err } diff --git a/internal/fixpatterns/analysis.go b/internal/fixpatterns/analysis.go new file mode 100644 index 00000000..a32b4d6a --- /dev/null +++ b/internal/fixpatterns/analysis.go @@ -0,0 +1,122 @@ +// Package fixpatterns owns the deterministic semantic classification used by +// repository fix-pattern analysis. It has no storage, network, or protocol +// dependencies. +package fixpatterns + +import ( + "slices" + "strings" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/relatedwork" +) + +type Outcome string + +const ( + Merged Outcome = "merged" + ClosedUnmerged Outcome = "closed_unmerged" + Superseded Outcome = "superseded" + Open Outcome = "open" + Unknown Outcome = "unknown" +) + +type Relationship string + +const ( + Closes Relationship = "closes" + References Relationship = "references" + ExplicitReplacement Relationship = "explicit_replacement" + SimilarityOnly Relationship = "similarity_only" +) + +type ProofStyle string + +const ( + RegressionTest ProofStyle = "regression_test" + Reproduction ProofStyle = "reproduction" + Benchmark ProofStyle = "benchmark" + BeforeAfter ProofStyle = "before_after" + Screenshot ProofStyle = "screenshot" +) + +type PullRequest struct { + State domain.ThreadState + Body string + Merge domain.MergeStatus +} + +type Classification struct { + Outcome Outcome + Relationship Relationship + RelatedRepository domain.RepoRef + RelatedKind domain.ThreadKind + RelatedNumber int + RelationshipEvidence string + ProofStyles []ProofStyle +} + +func Classify(repository domain.RepoRef, pullRequest PullRequest) Classification { + classification := Classification{Relationship: SimilarityOnly, ProofStyles: DetectProofStyles(pullRequest.Body)} + bestPriority := 0 + superseded := false + for _, ref := range relatedwork.Extract(pullRequest.Body, repository) { + if ref.Relation == relatedwork.RelationSupersededBy { + superseded = true + } + priority := relatedwork.Priority(ref.Relation) + if priority <= bestPriority { + continue + } + bestPriority = priority + classification.RelatedRepository = ref.Repo + classification.RelatedKind = ref.Kind + classification.RelatedNumber = ref.Number + classification.RelationshipEvidence = ref.Evidence + switch ref.Relation { + case relatedwork.RelationClaimsToClose: + classification.Relationship = Closes + case relatedwork.RelationReplaces, relatedwork.RelationSupersededBy: + classification.Relationship = ExplicitReplacement + default: + classification.Relationship = References + } + } + classification.Outcome = classifyOutcome(pullRequest, superseded) + return classification +} + +func classifyOutcome(pullRequest PullRequest, superseded bool) Outcome { + switch { + case pullRequest.Merge.IsMerged(): + return Merged + case pullRequest.State != domain.ClosedState: + return Open + case !pullRequest.Merge.Known(): + return Unknown + case superseded: + return Superseded + default: + return ClosedUnmerged + } +} + +func DetectProofStyles(body string) []ProofStyle { + lower := strings.ToLower(body) + var styles []ProofStyle + for _, candidate := range []struct { + style ProofStyle + terms []string + }{ + {RegressionTest, []string{"regression test", "unit test", "test coverage"}}, + {Reproduction, []string{"reproducer", "reproduction", "repro case"}}, + {Benchmark, []string{"benchmark", "throughput", "latency"}}, + {BeforeAfter, []string{"before and after", "before/after"}}, + {Screenshot, []string{"screenshot"}}, + } { + if slices.ContainsFunc(candidate.terms, func(term string) bool { return strings.Contains(lower, term) }) { + styles = append(styles, candidate.style) + } + } + return styles +} diff --git a/internal/fixpatterns/analysis_test.go b/internal/fixpatterns/analysis_test.go new file mode 100644 index 00000000..f4ef7145 --- /dev/null +++ b/internal/fixpatterns/analysis_test.go @@ -0,0 +1,31 @@ +package fixpatterns + +import ( + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) + +func TestClassifyRequiresExplicitClosingRelationshipForAcceptedFix(t *testing.T) { + repo, err := domain.NewRepoRef("acme", "rocket") + if err != nil { + t.Fatal(err) + } + merged := domain.MergedStatus(time.Unix(1, 0).UTC()) + classified := Classify(repo, PullRequest{State: domain.ClosedState, Merge: merged, Body: "Fixes #7\n\nIncludes a regression test and benchmark."}) + if classified.Outcome != Merged || classified.Relationship != Closes || classified.RelatedNumber != 7 { + t.Fatalf("classification = %+v", classified) + } + if len(classified.ProofStyles) != 2 || classified.ProofStyles[0] != RegressionTest || classified.ProofStyles[1] != Benchmark { + t.Fatalf("proof styles = %+v", classified.ProofStyles) + } +} + +func TestClassifyPreservesUnknownMergeOutcome(t *testing.T) { + repo, _ := domain.NewRepoRef("acme", "rocket") + classified := Classify(repo, PullRequest{State: domain.ClosedState, Merge: domain.UnknownMergeStatus(), Body: "Related to #9"}) + if classified.Outcome != Unknown || classified.Relationship != References { + t.Fatalf("classification = %+v", classified) + } +} diff --git a/internal/github/models.go b/internal/github/models.go index 2511350a..1d6bff3d 100644 --- a/internal/github/models.go +++ b/internal/github/models.go @@ -542,7 +542,7 @@ type ListIssueOptions struct { } // PullRequestListOptions selects one bounded repository-wide pull-request -// discovery page. State=all is required by repository feedback indexing. +// discovery page. type PullRequestListOptions struct { State string Sort string diff --git a/internal/github/pull_request_workflows.go b/internal/github/pull_request_workflows.go index e7f96d26..06efc2eb 100644 --- a/internal/github/pull_request_workflows.go +++ b/internal/github/pull_request_workflows.go @@ -244,7 +244,7 @@ const pullRequestFeedbackThreadsQuery = `query PullRequestFeedback($owner: Strin id isResolved isOutdated path line startLine resolvedBy { login } comments(first: $commentFirst) { totalCount - nodes { id databaseId body createdAt updatedAt path line startLine side startSide outdated commit { oid } author { login } replyTo { databaseId } } + nodes { id databaseId body createdAt updatedAt path line startLine outdated commit { oid } author { login } replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } @@ -291,13 +291,13 @@ type feedbackCommentConnection struct { } type feedbackCommentNode struct { - ID string - DatabaseID int64 `json:"databaseId"` - Body, Path, Side, StartSide string - CreatedAt, UpdatedAt time.Time - Line, StartLine *int - Outdated bool - Commit *struct { + ID string + DatabaseID int64 `json:"databaseId"` + Body, Path string + CreatedAt, UpdatedAt time.Time + Line, StartLine *int + Outdated bool + Commit *struct { OID string `json:"oid"` } Author *struct { @@ -313,7 +313,7 @@ const reviewThreadCommentsQuery = `query PullRequestReviewThreadComments($id: ID ... on PullRequestReviewThread { comments(first: $first, after: $after) { totalCount - nodes { id databaseId body createdAt updatedAt path line startLine side startSide outdated commit { oid } author { login } replyTo { databaseId } } + nodes { id databaseId body createdAt updatedAt path line startLine outdated commit { oid } author { login } replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } @@ -403,7 +403,7 @@ func (c *Client) feedbackReviewThreads(ctx context.Context, owner, repo string, func appendFeedbackComments(dst []FeedbackComment, comments []feedbackCommentNode) []FeedbackComment { for _, comment := range comments { - value := FeedbackComment{ID: comment.DatabaseID, NodeID: comment.ID, Body: comment.Body, Path: comment.Path, Line: comment.Line, StartLine: comment.StartLine, Side: comment.Side, StartSide: comment.StartSide, CreatedAt: comment.CreatedAt, UpdatedAt: comment.UpdatedAt, Outdated: comment.Outdated} + value := FeedbackComment{ID: comment.DatabaseID, NodeID: comment.ID, Body: comment.Body, Path: comment.Path, Line: comment.Line, StartLine: comment.StartLine, CreatedAt: comment.CreatedAt, UpdatedAt: comment.UpdatedAt, Outdated: comment.Outdated} if comment.Author != nil { value.Author = comment.Author.Login } diff --git a/internal/github/pull_request_workflows_test.go b/internal/github/pull_request_workflows_test.go index bc11ab6b..b018c82e 100644 --- a/internal/github/pull_request_workflows_test.go +++ b/internal/github/pull_request_workflows_test.go @@ -17,6 +17,17 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func TestReviewThreadQueriesUseCurrentPullRequestReviewCommentFields(t *testing.T) { + for name, query := range map[string]string{ + "thread page": pullRequestFeedbackThreadsQuery, + "comment page": reviewThreadCommentsQuery, + } { + if strings.Contains(query, " side") || strings.Contains(query, " startSide") { + t.Fatalf("%s selects a field removed from GitHub's PullRequestReviewComment schema: %s", name, query) + } + } +} + func TestGetPullRequestFeedbackPreservesChannelsAndThreadState(t *testing.T) { requests := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/mcpcontract/actor_contracts.go b/internal/mcpcontract/actor_contracts.go index e2ec8232..06c93074 100644 --- a/internal/mcpcontract/actor_contracts.go +++ b/internal/mcpcontract/actor_contracts.go @@ -14,6 +14,16 @@ const ( ToolSearchContributions = "corpus.search_contributions" ) +type GetAuthenticatedIdentityInput struct{} + +type AuthenticatedIdentityOutput struct { + Login string `json:"login"` + DatabaseID int64 `json:"database_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + ObservedAt string `json:"observed_at"` + Rate GitHubRateOutput `json:"rate"` +} + // ActorSelector is a discriminated exact GitHub identity selector. type ActorSelector struct { Type string `json:"type" jsonschema:"Identity selector: login or node_id"` diff --git a/internal/mcpcontract/fix_pattern_contracts.go b/internal/mcpcontract/fix_pattern_contracts.go index 8c2ae7d3..f387f99e 100644 --- a/internal/mcpcontract/fix_pattern_contracts.go +++ b/internal/mcpcontract/fix_pattern_contracts.go @@ -1,15 +1,10 @@ package mcpcontract -import "fmt" - const ( DefaultFixPatternCandidateLimit = 100 - DefaultFixPatternHydrationLimit = 25 DefaultFixPatternRepresentativeLimit = 5 ) -// FixPatternOutcome is a pull-request disposition. Merge state comes from -// GitHub; superseded requires an explicit replacement relationship. type FixPatternOutcome string const ( @@ -20,8 +15,6 @@ const ( FixPatternUnknown FixPatternOutcome = "unknown" ) -// FixPatternRelationship describes evidence connecting an issue and pull -// request. Similarity is intentionally distinct from an explicit link. type FixPatternRelationship string const ( @@ -31,16 +24,6 @@ const ( FixPatternSimilarityOnly FixPatternRelationship = "similarity_only" ) -// FixPatternReportStatus describes whether all bounded workflow evidence is -// complete or whether coverage limits or failures remain. -type FixPatternReportStatus string - -const ( - FixPatternReportComplete FixPatternReportStatus = "complete" - FixPatternReportPartial FixPatternReportStatus = "partial" -) - -// FixPatternProofStyle is a bounded evidence style detected in stored PR text. type FixPatternProofStyle string const ( @@ -51,47 +34,28 @@ const ( FixPatternScreenshot FixPatternProofStyle = "screenshot" ) -// FixPatternRelatedKind identifies the stored thread kind of a related target. -type FixPatternRelatedKind string - -const ( - FixPatternRelatedIssue FixPatternRelatedKind = "issue" - FixPatternRelatedPullRequest FixPatternRelatedKind = "pull_request" -) - -// FixPatternTimeWindow bounds stored thread observations considered by a -// repository pattern-mining workflow. type FixPatternTimeWindow struct { UpdatedAfter string `json:"updated_after" jsonschema:"Required RFC 3339 inclusive lower bound"` UpdatedBefore string `json:"updated_before,omitempty" jsonschema:"Optional RFC 3339 inclusive upper bound"` } -// FixPatternSymptom defines one caller-owned symptom category using literal -// search terms. Terms use any-term matching within the category. type FixPatternSymptom struct { Name string `json:"name" jsonschema:"Stable human-readable category name"` Terms []string `json:"terms" jsonschema:"One to 12 literal stored-thread search terms"` } -// MineRepositoryFixPatternsInput requests a bounded network-assisted analysis -// of accepted and rejected pull-request patterns in one stored repository. -type MineRepositoryFixPatternsInput struct { - Repository RepositoryRef `json:"repository" jsonschema:"Stored GitHub repository whose pull-request history should be analyzed"` - TimeWindow FixPatternTimeWindow `json:"time_window" jsonschema:"Inclusive stored-observation window used to select pull requests"` +// AnalyzeFixPatternsInput selects a bounded offline repository-history +// analysis. Missing source coverage is never hydrated implicitly. +type AnalyzeFixPatternsInput struct { + Repository RepositoryRef `json:"repository" jsonschema:"Stored repository whose pull-request history should be analyzed"` + TimeWindow FixPatternTimeWindow `json:"time_window" jsonschema:"Inclusive stored-observation window"` SymptomTaxonomy []FixPatternSymptom `json:"symptom_taxonomy" jsonschema:"One to 12 caller-defined symptom categories"` MergeOutcomes []FixPatternOutcome `json:"merge_outcomes,omitempty" jsonschema:"Outcomes whose representative examples should be returned"` CandidateLimit int `json:"candidate_limit,omitempty" jsonschema:"Maximum stored candidates examined per symptom from 1 to 100"` - HydrationLimit *int `json:"hydration_limit,omitempty" jsonschema:"Maximum unknown-state pull requests refreshed from GitHub from 0 to 100; defaults to 25"` RepresentativeLimit int `json:"representative_limit,omitempty" jsonschema:"Maximum examples returned per symptom from 1 to 20"` SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` } -// PreviewRepositoryFixPatternsInput is the read-only counterpart to the -// durable mining operation. It never creates a job or persists a report. -type PreviewRepositoryFixPatternsInput MineRepositoryFixPatternsInput - -// FixPatternOutcomeCounts preserves unknown state rather than treating it as -// unmerged. type FixPatternOutcomeCounts struct { Merged NonNegativeInt `json:"merged"` ClosedUnmerged NonNegativeInt `json:"closed_unmerged"` @@ -100,24 +64,19 @@ type FixPatternOutcomeCounts struct { Unknown NonNegativeInt `json:"unknown"` } -// FixPatternCoverage reports the bounded universe examined and the effect of -// finalist hydration. type FixPatternCoverage struct { CandidateMatches NonNegativeInt `json:"candidate_matches"` UniqueCandidates NonNegativeInt `json:"unique_candidates"` - UnknownBefore NonNegativeInt `json:"unknown_before"` - SelectedForHydration NonNegativeInt `json:"selected_for_hydration"` - Hydrated NonNegativeInt `json:"hydrated"` - HydrationFailed NonNegativeInt `json:"hydration_failed"` - UnknownAfter NonNegativeInt `json:"unknown_after"` + UnknownOutcomes NonNegativeInt `json:"unknown_outcomes"` + UnknownPullRequests []ThreadRef `json:"unknown_pull_requests,omitempty" jsonschema:"Bounded exact pull requests whose merge outcome requires explicit detail acquisition"` + UnknownRefsTruncated bool `json:"unknown_refs_truncated" jsonschema:"Whether more unknown-outcome pull requests exist than the exact recovery batch can carry"` CandidateTruncated bool `json:"candidate_truncated"` + HistoryStatus string `json:"history_status" jsonschema:"complete, partial, or unknown"` } -// FixPatternExample is one bounded, source-backed representative. type FixPatternExample struct { PullRequest ThreadRef `json:"pull_request"` RelatedThread *ThreadRef `json:"related_thread,omitempty"` - RelatedKind FixPatternRelatedKind `json:"related_kind,omitempty"` Title string `json:"title"` Outcome FixPatternOutcome `json:"outcome"` Relationship FixPatternRelationship `json:"relationship"` @@ -127,68 +86,25 @@ type FixPatternExample struct { UpdatedAt string `json:"updated_at"` } -// FixPatternCluster summarizes one caller-defined symptom category. type FixPatternCluster struct { Name string `json:"name"` Terms []string `json:"terms"` CandidateCount NonNegativeInt `json:"candidate_count"` - UnknownBefore NonNegativeInt `json:"unknown_before"` - UnknownAfter NonNegativeInt `json:"unknown_after"` Outcomes FixPatternOutcomeCounts `json:"outcomes"` Examples []FixPatternExample `json:"examples"` ExamplesTruncated bool `json:"examples_truncated"` } -// FixPatternHydrationFailure records one finalist that could not be refreshed. -type FixPatternHydrationFailure struct { - PullRequest ThreadRef `json:"pull_request"` - Reason string `json:"reason"` - Message string `json:"message"` - Retryable bool `json:"retryable"` -} - -// FixPatternReport is persisted as the typed result of a durable pattern -// mining job and is readable through an MCP resource. -type FixPatternReport struct { - Status FixPatternReportStatus `json:"status"` - Repository RepositoryRef `json:"repository"` - TimeWindow FixPatternTimeWindow `json:"time_window"` - GeneratedAt string `json:"generated_at"` - Coverage FixPatternCoverage `json:"coverage"` - Clusters []FixPatternCluster `json:"clusters"` - Failures []FixPatternHydrationFailure `json:"failures,omitempty"` - Limitations []string `json:"limitations,omitempty"` - Persisted bool `json:"persisted"` - SnapshotToken string `json:"snapshot_token"` - ArtifactDigest string `json:"artifact_digest,omitempty"` - ObservationWatermark int64 `json:"observation_watermark"` - QueryDigestSHA256 string `json:"query_digest_sha256"` - Complete bool `json:"complete"` - Truncated bool `json:"truncated"` - UnknownCoverage bool `json:"unknown_coverage"` - ExternalContextProvenance []string `json:"external_context_provenance,omitempty"` - Recovery *RecoveryPlan `json:"recovery,omitempty"` -} - -// Validate parses the redundant public coverage fields into one consistent -// report state before a persisted artifact is trusted. -func (r FixPatternReport) Validate() error { - switch r.Status { - case FixPatternReportComplete, FixPatternReportPartial: - default: - return fmt.Errorf("unsupported fix-pattern report status %q", r.Status) - } - if r.Complete != (r.Status == FixPatternReportComplete) { - return fmt.Errorf("fix-pattern complete flag contradicts status %q", r.Status) - } - if r.Truncated != r.Coverage.CandidateTruncated { - return fmt.Errorf("fix-pattern truncation contradicts candidate coverage") - } - if r.UnknownCoverage != (r.Coverage.UnknownAfter > 0) { - return fmt.Errorf("fix-pattern unknown-coverage flag contradicts candidate outcomes") - } - if r.Complete && (r.Truncated || r.UnknownCoverage || len(r.Failures) > 0) { - return fmt.Errorf("complete fix-pattern report contains incomplete evidence") - } - return nil +type AnalyzeFixPatternsOutput struct { + Status string `json:"status" jsonschema:"complete or partial"` + Repository RepositoryRef `json:"repository"` + TimeWindow FixPatternTimeWindow `json:"time_window"` + Coverage FixPatternCoverage `json:"coverage"` + Clusters []FixPatternCluster `json:"clusters"` + Limitations []string `json:"limitations"` + SnapshotToken string `json:"snapshot_token"` + Truncated bool `json:"truncated"` + UnknownCoverage bool `json:"unknown_coverage"` + Recovery *RecoveryPlan `json:"recovery,omitempty"` + Provenance CorpusReadProvenance `json:"provenance"` } diff --git a/internal/mcpcontract/fix_pattern_contracts_test.go b/internal/mcpcontract/fix_pattern_contracts_test.go deleted file mode 100644 index 6531cff9..00000000 --- a/internal/mcpcontract/fix_pattern_contracts_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package mcpcontract - -import "testing" - -func TestFixPatternReportValidateRejectsContradictoryCoverage(t *testing.T) { - valid := FixPatternReport{Status: FixPatternReportComplete, Complete: true} - if err := valid.Validate(); err != nil { - t.Fatalf("valid report: %v", err) - } - - tests := map[string]func(*FixPatternReport){ - "status": func(report *FixPatternReport) { - report.Status = FixPatternReportStatus("unknown") - }, - "complete flag": func(report *FixPatternReport) { - report.Complete = false - }, - "truncated flag": func(report *FixPatternReport) { - report.Truncated = true - }, - "unknown flag": func(report *FixPatternReport) { - report.UnknownCoverage = true - }, - "failure in complete report": func(report *FixPatternReport) { - report.Failures = []FixPatternHydrationFailure{{Reason: "unavailable"}} - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - report := valid - mutate(&report) - if err := report.Validate(); err == nil { - t.Fatal("contradictory fix-pattern report was accepted") - } - }) - } -} - -func TestFixPatternReportValidateAcceptsConsistentPartialCoverage(t *testing.T) { - report := FixPatternReport{ - Status: FixPatternReportPartial, - Coverage: FixPatternCoverage{ - CandidateTruncated: true, - UnknownAfter: 1, - }, - Truncated: true, - UnknownCoverage: true, - Failures: []FixPatternHydrationFailure{{Reason: "rate_limited"}}, - } - if err := report.Validate(); err != nil { - t.Fatalf("consistent partial report: %v", err) - } -} diff --git a/internal/mcpcontract/github_acquisition_contracts.go b/internal/mcpcontract/github_acquisition_contracts.go index 0fe52e38..62cf27d3 100644 --- a/internal/mcpcontract/github_acquisition_contracts.go +++ b/internal/mcpcontract/github_acquisition_contracts.go @@ -217,6 +217,7 @@ type SearchCodeBatchInput struct { 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"` + Cursor string `json:"cursor,omitempty" jsonschema:"Opaque cursor for a one-query continuation"` SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` } diff --git a/internal/mcpcontract/issue_set_contracts.go b/internal/mcpcontract/issue_set_contracts.go deleted file mode 100644 index c283c219..00000000 --- a/internal/mcpcontract/issue_set_contracts.go +++ /dev/null @@ -1,108 +0,0 @@ -package mcpcontract - -// PrepareIssueSetInput selects an exact, bounded issue set for offline -// contribution-evidence preparation. -type PrepareIssueSetInput struct { - Owner string `json:"owner" jsonschema:"GitHub repository owner"` - Repo string `json:"repo" jsonschema:"GitHub repository name"` - IssueNumbers []int `json:"issue_numbers" jsonschema:"One to 20 distinct positive issue numbers"` - PrecedentLimit int `json:"precedent_limit,omitempty" jsonschema:"Maximum accepted examples per issue from 1 to 10"` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise omits issue bodies and detailed relation evidence; detailed includes them"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` -} - -// IssueSetGap identifies evidence that is absent or incomplete and gives the -// exact bounded recovery call without treating the absence as negative proof. -type IssueSetGap struct { - Code string `json:"code"` - Facet string `json:"facet"` - Status string `json:"status"` - Message string `json:"message"` - Recovery *RecoveryPlan `json:"recovery"` -} - -// IssueSetRelatedWork is one corpus-supported issue, pull request, or external -// thread related to a supplied issue. -type IssueSetRelatedWork struct { - Ref string `json:"ref"` - Kind string `json:"kind"` - Number int `json:"number"` - Title string `json:"title,omitempty"` - State string `json:"state,omitempty"` - Relation string `json:"relation"` - Direction string `json:"direction"` - URL string `json:"url,omitempty"` - Merged *bool `json:"merged,omitempty"` - MergedAt string `json:"merged_at,omitempty"` - SourceUpdatedAt string `json:"source_updated_at,omitempty"` - EvidenceKinds []string `json:"evidence_kinds,omitempty"` -} - -// IssueSetDuplicateCluster reports stored duplicate-candidate evidence for one -// supplied issue. -type IssueSetDuplicateCluster struct { - StableID string `json:"stable_id"` - CanonicalRef string `json:"canonical_ref"` - CandidateCount int `json:"candidate_count"` -} - -// IssueSetLinkageCandidate is deliberately advisory. Callers must confirm the -// final PR-to-issue relationship after reviewing the actual implementation. -type IssueSetLinkageCandidate struct { - IssueNumber int `json:"issue_number"` - Relation string `json:"relation"` - AllowedRelations []string `json:"allowed_relations"` - RequiresConfirmation bool `json:"requires_confirmation"` - Basis string `json:"basis"` -} - -// ContributionDisposition is a conservative, evidence-backed recommendation -// made before an implementation workspace is created. -type ContributionDisposition struct { - Status string `json:"status"` - Confidence string `json:"confidence"` - EvidenceRefs []string `json:"evidence_refs,omitempty"` - Unknowns []string `json:"unknowns,omitempty"` - Recovery *RecoveryPlan `json:"recovery,omitempty"` -} - -// PreparedIssueEvidence contains stored facts and bounded derived evidence for -// one exact supplied issue. -type PreparedIssueEvidence struct { - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - StateReason string `json:"state_reason,omitempty"` - Labels []string `json:"labels,omitempty"` - BodyStatus string `json:"body_status"` - Body string `json:"body,omitempty"` - SourceUpdatedAt string `json:"source_updated_at,omitempty"` - Coverage []FacetCoverageOutput `json:"coverage"` - Gaps []IssueSetGap `json:"gaps,omitempty"` - RelatedWork []IssueSetRelatedWork `json:"related_work"` - RelatedWorkTotal int `json:"related_work_total"` - RelatedWorkTotalKnown bool `json:"related_work_total_known"` - RelatedWorkTruncated bool `json:"related_work_truncated"` - AcceptedExamples []PrecedentOutput `json:"accepted_examples"` - DuplicateCluster *IssueSetDuplicateCluster `json:"duplicate_cluster,omitempty"` - Linkage IssueSetLinkageCandidate `json:"linkage"` - ContributionDisposition ContributionDisposition `json:"contribution_disposition"` -} - -// PrepareIssueSetOutput preserves requested issue order and reports all -// bounded-population and recovery information needed to interpret the result. -type PrepareIssueSetOutput struct { - Status string `json:"status"` - Owner string `json:"owner"` - Repo string `json:"repo"` - ResponseFormat string `json:"response_format"` - SourceAsOf string `json:"source_as_of,omitempty"` - Items []BatchItem[PreparedIssueEvidence] `json:"items"` - Coverage []FacetCoverageOutput `json:"coverage"` - Gaps []IssueSetGap `json:"gaps,omitempty"` - RelationshipPopulation int `json:"relationship_population"` - RelationshipConsidered int `json:"relationship_considered"` - Truncated bool `json:"truncated"` - RecoveryPlans []RecoveryPlan `json:"recovery_plans,omitempty"` - SnapshotToken string `json:"snapshot_token"` -} diff --git a/internal/mcpcontract/operation_contracts.go b/internal/mcpcontract/operation_contracts.go index 205571f5..e8530472 100644 --- a/internal/mcpcontract/operation_contracts.go +++ b/internal/mcpcontract/operation_contracts.go @@ -196,9 +196,6 @@ type JobReference struct { FollowUp *JobFollowUp `json:"follow_up,omitempty"` } -// BuildRepositoryDossierInput selects a repository for durable dossier generation. -type BuildRepositoryDossierInput RepoInput - // DurableArtifactReference identifies a persisted object whose canonical // detailed representation is available through MCP resources. type DurableArtifactReference struct { @@ -275,20 +272,6 @@ type CheckOutput struct { Recovery *RecoveryPlan `json:"recovery,omitempty"` } -// FindRelatedWorkInput selects one workflow target and related-work populations. -type FindRelatedWorkInput struct { - Target string `json:"target" jsonschema:"Target scope: hypothesis or opportunity"` - ID string `json:"id" jsonschema:"Hypothesis or opportunity ID"` - Kinds []string `json:"kinds,omitempty" jsonschema:"Related-work populations: duplicates and/or competing_pull_requests; defaults to both"` - Limit int `json:"limit,omitempty" jsonschema:"Maximum findings per population from 1 to 100"` -} - -// FindRelatedWorkOutput groups independently derived related-work populations. -type FindRelatedWorkOutput struct { - Duplicates *CheckOutput `json:"duplicates,omitempty"` - CompetingPullRequests *CheckOutput `json:"competing_pull_requests,omitempty"` -} - // PromoteOpportunityInput converts a hypothesis into a scoped opportunity. type PromoteOpportunityInput struct { HypothesisID string `json:"hypothesis_id" jsonschema:"Hypothesis ID to promote"` diff --git a/internal/mcpcontract/pull_request_feedback_contracts.go b/internal/mcpcontract/pull_request_feedback_contracts.go index e639240d..b31637c6 100644 --- a/internal/mcpcontract/pull_request_feedback_contracts.go +++ b/internal/mcpcontract/pull_request_feedback_contracts.go @@ -5,7 +5,7 @@ package mcpcontract // operation also persists the local repository and pull-request identities // required by those exact facet writes; it does not require prior broad sync. type SyncPullRequestFeedbackInput struct { - PullRequests []ThreadRef `json:"pull_requests" jsonschema:"One to 50 exact pull requests"` + PullRequests []ThreadRef `json:"pull_requests" jsonschema:"One to 100 exact pull requests"` ThreadState string `json:"thread_state,omitempty" jsonschema:"Review threads to return: unresolved or all"` Channels []string `json:"channels" jsonschema:"One or more of issue_comments, submitted_reviews, inline_comments, review_threads"` MaxItemsPerChannel int `json:"max_items_per_channel,omitempty" jsonschema:"Maximum items per requested channel from 1 to 1000"` @@ -17,6 +17,7 @@ type SyncPullRequestFeedbackInput struct { // the durable discovery checkpoint for the repository. type IndexPullRequestFeedbackInput struct { Repository RepositoryRef `json:"repository" jsonschema:"One GitHub repository"` + State string `json:"state,omitempty" jsonschema:"Pull requests to discover: open, closed, or all"` Channels []string `json:"channels" jsonschema:"Feedback channels: issue_comments, submitted_reviews, inline_comments, review_threads"` ThreadState string `json:"thread_state,omitempty" jsonschema:"Review threads to index: unresolved or all"` MaxPullRequests int `json:"max_pull_requests,omitempty" jsonschema:"Maximum pull requests retained in one job from 1 to 1000"` diff --git a/internal/mcpcontract/recovery_contracts.go b/internal/mcpcontract/recovery_contracts.go index 60e535b8..38a4d867 100644 --- a/internal/mcpcontract/recovery_contracts.go +++ b/internal/mcpcontract/recovery_contracts.go @@ -68,6 +68,7 @@ func RecoveryActionPrototypes() []ToolCall { RecoveryAction(GetThreadFacetsInput{}), RecoveryAction(SearchPullRequestFeedbackInput{}), RecoveryAction(GetRepositoriesInput{}), + RecoveryAction(MaterializeRepositoryDossierInput{}), RecoveryAction(EnsureCoverageInput{}), RecoveryAction(SyncRepositoryContextInput{}), RecoveryAction(SyncThreadsInput{}), @@ -80,16 +81,16 @@ func RecoveryActionPrototypes() []ToolCall { RecoveryAction(IndexRepositoriesInput{}), RecoveryAction(FindClustersInput{}), RecoveryAction(FindNeighborsInput{}), - RecoveryAction(RankOpportunitiesInput{}), - RecoveryAction(MineRepositoryFixPatternsInput{}), - RecoveryAction(PreviewRepositoryFixPatternsInput{}), RecoveryAction(SearchGitHubRepositoriesInput{}), RecoveryAction(SearchGitHubThreadsInput{}), - RecoveryAction(SearchCodeInput{}), + RecoveryAction(SearchCodeBatchInput{}), + RecoveryAction(AnalyzeFixPatternsInput{}), + RecoveryAction(RankContributionCandidatesInput{}), RecoveryAction(ReadSourceFilesInput{}), RecoveryAction(InspectCommitChangesInput{}), RecoveryAction(CheckMergeConflictsInput{}), - RecoveryAction(FindRelatedWorkInput{}), + RecoveryAction(CheckDuplicatesInput{}), + RecoveryAction(CheckCollisionsInput{}), RecoveryAction(ListConcernsInput{}), RecoveryAction(ListPullRequestPortfolioInput{}), RecoveryAction(ExportManifestInput{}), @@ -97,7 +98,7 @@ func RecoveryActionPrototypes() []ToolCall { } type recoveryActionInput interface { - GetJobsInput | GetRepositoriesInput | EnsureCoverageInput | SyncRepositoryContextInput | SyncThreadsInput | HydrateThreadsInput | SyncPortfolioInput | SyncPullRequestFeedbackInput | IndexPullRequestFeedbackInput | SyncCIFailuresInput | DeepWikiInput | IndexRepositoriesInput | FindClustersInput | FindNeighborsInput | RankOpportunitiesInput | MineRepositoryFixPatternsInput | PreviewRepositoryFixPatternsInput | SearchGitHubRepositoriesInput | SearchGitHubThreadsInput | SearchCodeInput | ReadSourceFilesInput | InspectCommitChangesInput | CheckMergeConflictsInput | FindRelatedWorkInput | ListConcernsInput | ListPullRequestPortfolioInput | ExportManifestInput | ResourceReadAction | SnapshotReadAction | GetThreadsInput | GetThreadFacetsInput | SearchPullRequestFeedbackInput + GetJobsInput | GetRepositoriesInput | MaterializeRepositoryDossierInput | EnsureCoverageInput | SyncRepositoryContextInput | SyncThreadsInput | HydrateThreadsInput | SyncPortfolioInput | SyncPullRequestFeedbackInput | IndexPullRequestFeedbackInput | SyncCIFailuresInput | DeepWikiInput | IndexRepositoriesInput | FindClustersInput | FindNeighborsInput | SearchGitHubRepositoriesInput | SearchGitHubThreadsInput | SearchCodeBatchInput | AnalyzeFixPatternsInput | RankContributionCandidatesInput | ReadSourceFilesInput | InspectCommitChangesInput | CheckMergeConflictsInput | CheckDuplicatesInput | CheckCollisionsInput | ListConcernsInput | ListPullRequestPortfolioInput | ExportManifestInput | ResourceReadAction | SnapshotReadAction | GetThreadsInput | GetThreadFacetsInput | SearchPullRequestFeedbackInput } type followUpActionInput interface { @@ -207,6 +208,8 @@ func recoveryActionName(input any) (string, bool) { return "search_pull_request_feedback", true case GetRepositoriesInput: return "get_repositories", true + case MaterializeRepositoryDossierInput: + return "materialize_repository_dossier", true case EnsureCoverageInput: return "ensure_coverage", true case SyncRepositoryContextInput: @@ -231,26 +234,26 @@ func recoveryActionName(input any) (string, bool) { return "find_clusters", true case FindNeighborsInput: return "find_neighbors", true - case RankOpportunitiesInput: - return "rank_opportunities", true - case MineRepositoryFixPatternsInput: - return "mine_repository_fix_patterns", true - case PreviewRepositoryFixPatternsInput: - return "preview_fix_patterns", true case SearchGitHubRepositoriesInput: return "search_github_repositories", true case SearchGitHubThreadsInput: return "search_github_threads", true - case SearchCodeInput: + case SearchCodeBatchInput: return "search_code", true + case AnalyzeFixPatternsInput: + return "analyze_fix_patterns", true + case RankContributionCandidatesInput: + return "rank_contribution_candidates", true case ReadSourceFilesInput: return "read_source_files", true case InspectCommitChangesInput: return "inspect_commit_changes", true case CheckMergeConflictsInput: return "check_merge_conflicts", true - case FindRelatedWorkInput: - return "find_related_work", true + case CheckDuplicatesInput: + return "find_duplicates", true + case CheckCollisionsInput: + return "find_competing_pull_requests", true case ListConcernsInput: return "list_concerns", true case ListPullRequestPortfolioInput: @@ -293,6 +296,8 @@ func parseRecoveryAction(data []byte) (ToolCall, error) { return decodeRecoveryAction[SearchPullRequestFeedbackInput](input) case "get_repositories": return decodeRecoveryAction[GetRepositoriesInput](input) + case "materialize_repository_dossier": + return decodeRecoveryAction[MaterializeRepositoryDossierInput](input) case "ensure_coverage": return decodeRecoveryAction[EnsureCoverageInput](input) case "sync_repository_context": @@ -317,26 +322,26 @@ func parseRecoveryAction(data []byte) (ToolCall, error) { return decodeRecoveryAction[FindClustersInput](input) case "find_neighbors": return decodeRecoveryAction[FindNeighborsInput](input) - case "rank_opportunities": - return decodeRecoveryAction[RankOpportunitiesInput](input) - case "mine_repository_fix_patterns": - return decodeRecoveryAction[MineRepositoryFixPatternsInput](input) - case "preview_fix_patterns": - return decodeRecoveryAction[PreviewRepositoryFixPatternsInput](input) case "search_github_repositories": return decodeRecoveryAction[SearchGitHubRepositoriesInput](input) case "search_github_threads": return decodeRecoveryAction[SearchGitHubThreadsInput](input) case "search_code": - return decodeRecoveryAction[SearchCodeInput](input) + return decodeRecoveryAction[SearchCodeBatchInput](input) + case "analyze_fix_patterns": + return decodeRecoveryAction[AnalyzeFixPatternsInput](input) + case "rank_contribution_candidates": + return decodeRecoveryAction[RankContributionCandidatesInput](input) case "read_source_files": return decodeRecoveryAction[ReadSourceFilesInput](input) case "inspect_commit_changes": return decodeRecoveryAction[InspectCommitChangesInput](input) case "check_merge_conflicts": return decodeRecoveryAction[CheckMergeConflictsInput](input) - case "find_related_work": - return decodeRecoveryAction[FindRelatedWorkInput](input) + case "find_duplicates": + return decodeRecoveryAction[CheckDuplicatesInput](input) + case "find_competing_pull_requests": + return decodeRecoveryAction[CheckCollisionsInput](input) case "list_concerns": return decodeRecoveryAction[ListConcernsInput](input) case "list_pull_request_portfolio": diff --git a/internal/mcpcontract/resource_contracts.go b/internal/mcpcontract/resource_contracts.go index 2f7a760d..5f7888d9 100644 --- a/internal/mcpcontract/resource_contracts.go +++ b/internal/mcpcontract/resource_contracts.go @@ -330,7 +330,7 @@ type Reader interface { Thread(context.Context, ThreadInput) (ThreadOutput, error) ThreadByNumber(context.Context, ThreadByNumberInput) (ThreadOutput, error) Dossier(context.Context, RepoInput) (DossierOutput, error) - SearchCode(context.Context, SearchCodeInput) (SearchCodeOutput, error) + SearchCodeBatch(context.Context, SearchCodeBatchInput) (SearchCodeBatchOutput, error) ExplainMatch(context.Context, ExplainMatchInput) (ExplainMatchOutput, error) GetJob(context.Context, GetJobInput) (GetJobOutput, error) Investigation(context.Context, InvestigationInput) (InvestigationOutput, error) @@ -349,6 +349,13 @@ type RepoInput struct { Repo string `json:"repo" jsonschema:"GitHub repository name"` } +// MaterializeRepositoryDossierInput selects one stored repository whose +// deterministic dossier projection should be refreshed from local corpus data. +type MaterializeRepositoryDossierInput struct { + Owner string `json:"owner" jsonschema:"GitHub repository owner"` + Repo string `json:"repo" jsonschema:"GitHub repository name"` +} + // ThreadInput identifies an issue or pull request for an MCP operation. type ThreadInput struct { Owner string `json:"owner" jsonschema:"GitHub repository owner"` @@ -570,16 +577,6 @@ func (p *CorpusReadProvenance) UnmarshalJSON(data []byte) error { return nil } -// SearchCodeInput describes an offline code search page. -type SearchCodeInput struct { - Query string `json:"query" jsonschema:"Code search query"` - Owner string `json:"owner,omitempty" jsonschema:"Optional repository owner"` - Repo string `json:"repo,omitempty" jsonschema:"Optional repository name"` - Limit int `json:"limit,omitempty" jsonschema:"Maximum results from 1 to 100"` - Cursor string `json:"cursor,omitempty" jsonschema:"Opaque cursor returned by the previous page"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` -} - // CodeMatchOutput identifies one stored code match. type CodeMatchOutput struct { ID string `json:"id"` diff --git a/internal/mcpcontract/scalable_contracts.go b/internal/mcpcontract/scalable_contracts.go index ffee30f1..f6a393fe 100644 --- a/internal/mcpcontract/scalable_contracts.go +++ b/internal/mcpcontract/scalable_contracts.go @@ -290,46 +290,6 @@ type SyncPortfolioInput struct { StatusMaxPages int `json:"status_max_pages,omitempty" jsonschema:"Maximum pages per pull-request health facet from 1 to 20; defaults to 3"` } -// ContributionPreflightInput describes one prospective contribution before -// any local opportunity or workspace is created. The operation is deliberately -// bounded and does not persist its observations. -type ContributionPreflightInput struct { - Repository RepositoryRef `json:"repository" jsonschema:"Repository being considered"` - Fork *RepositoryRef `json:"fork,omitempty" jsonschema:"Optional contributor fork; otherwise inferred from an inspected workspace or existing pull request"` - Candidate ContributionPreflightCandidate `json:"candidate" jsonschema:"Candidate title, issue, branch, or change context"` - WorkspacePaths []string `json:"workspace_paths,omitempty" jsonschema:"Optional local Git worktree roots to inspect"` - Limit int `json:"limit,omitempty" jsonschema:"Maximum authored pull requests and related threads to inspect from 1 to 100"` - MaxRequests int `json:"max_requests,omitempty" jsonschema:"Maximum bounded GitHub reads from 2 to 1000"` -} - -// ContributionPreflightCandidate is intentionally descriptive rather than a -// durable workflow identity. At least one field should identify the proposed -// work; local branch and commit hints are especially useful for existing PRs. -type ContributionPreflightCandidate struct { - Title string `json:"title,omitempty" jsonschema:"Proposed issue or change title"` - Query string `json:"query,omitempty" jsonschema:"Optional focused search text; defaults to title"` - Body string `json:"body,omitempty" jsonschema:"Optional concise problem or change context"` - IssueNumber int `json:"issue_number,omitempty" jsonschema:"Related issue number when known"` - HeadRef string `json:"head_ref,omitempty" jsonschema:"Expected local or pull-request branch"` - HeadSHA string `json:"head_sha,omitempty" jsonschema:"Expected candidate commit SHA"` - ChangedFiles []string `json:"changed_files,omitempty" jsonschema:"Optional changed paths for local context"` -} - -// ContributionPreflightOutput is a routing decision, not a claim that all -// related work has been exhaustively enumerated. -type ContributionPreflightOutput struct { - Status string `json:"status" jsonschema:"new_work, existing_pr, or coverage_unknown"` - Repository RepositoryRef `json:"repository"` - Identity string `json:"identity,omitempty"` - Existing *ExistingContributionOutput `json:"existing,omitempty"` - LocalMatches []LocalContributionMatch `json:"local_matches,omitempty"` - Related []RelatedContributionThread `json:"related,omitempty"` - ForkFreshness *ForkFreshnessOutput `json:"fork_freshness,omitempty"` - Coverage string `json:"coverage" jsonschema:"live_verified or coverage_unknown"` - CoverageReasons []string `json:"coverage_reasons,omitempty"` - NextAction string `json:"next_action"` -} - // ForkFreshnessOutput reports whether the contributor fork's default branch // can safely serve as the base for the current contribution branch. It is // present only when a fork can be identified from the input or live context. @@ -352,34 +312,11 @@ type ForkFreshnessOutput struct { NextAction string `json:"next_action"` } -// ExistingContributionOutput identifies an authored pull request that may -// already implement the candidate. Issue is populated only when the input or -// live related-thread search supplied an exact related issue. -type ExistingContributionOutput struct { - Issue int `json:"issue,omitempty"` - PullRequest int `json:"pull_request"` - Head string `json:"head,omitempty"` - HeadSHA string `json:"head_sha,omitempty"` - URL string `json:"url,omitempty"` -} - -// LocalContributionMatch associates an inspected local worktree with an -// existing authored pull request without taking ownership of the path. -type LocalContributionMatch struct { - Path string `json:"path"` - Branch string `json:"branch,omitempty"` - Remote string `json:"remote,omitempty"` - PullRequest int `json:"pull_request"` -} - -// RelatedContributionThread is a bounded live-search result retained as -// context for the routing decision. -type RelatedContributionThread struct { - Kind string `json:"kind"` - Number int `json:"number"` - Title string `json:"title"` - Author string `json:"author,omitempty"` - URL string `json:"url,omitempty"` +type CompareForkInput struct { + Upstream RepositoryRef `json:"upstream" jsonschema:"Upstream GitHub repository"` + Fork RepositoryRef `json:"fork" jsonschema:"Contributor fork repository"` + ContributionBranch string `json:"contribution_branch,omitempty" jsonschema:"Optional contribution branch for provenance"` + ContributionSHA string `json:"contribution_sha,omitempty" jsonschema:"Optional contribution commit SHA for provenance"` } // SyncCIFailuresInput refreshes normalized CI observations for exact pull diff --git a/internal/mcpcontract/tool_contracts.go b/internal/mcpcontract/tool_contracts.go index 6142ff3a..dd43afb7 100644 --- a/internal/mcpcontract/tool_contracts.go +++ b/internal/mcpcontract/tool_contracts.go @@ -43,24 +43,26 @@ func Unavailable(code, message string, actions ...ToolCall) error { const ( ToolSearchRepositories = "corpus.search_repositories" ToolSearchThreads = "corpus.search_threads" - ToolSearchCode = "corpus.search_code_legacy" ToolSearchCodeBatch = "corpus.search_code" ToolGetRepositories = "corpus.get_repositories" + ToolMaterializeRepositoryDossier = "corpus.materialize_repository_dossier" ToolGetThreads = "corpus.get_threads" ToolGetThreadFacets = "corpus.get_thread_facets" - ToolRankThreads = "corpus.rank_contribution_candidates" + ToolRankContributionCandidates = "corpus.rank_contribution_candidates" ToolFindPrecedents = "corpus.find_precedents" - ToolPrepareIssueSet = "workflow.prepare_issue_set" + ToolAnalyzeFixPatterns = "corpus.analyze_fix_patterns" ToolExplainMatch = "corpus.explain_match" ToolFindClusters = "corpus.find_clusters" ToolFindNeighbors = "corpus.find_neighbors" + ToolFindDuplicates = "corpus.find_duplicates" + ToolFindCompetingPullRequests = "corpus.find_competing_pull_requests" ToolGetCoverage = "corpus.get_coverage" ToolEnsureCoverage = "corpus.ensure_coverage" ToolGetSourceAuditWorkflow = "workflow.get_source_audit_contract" ToolGetCatalogContract = "workflow.get_catalog_contract" - ToolBuildRepositoryDossier = "workflow.build_repository_dossier" - ToolMineRepositoryFixPatterns = "workflow.mine_repository_fix_patterns" - ToolPreviewRepositoryFixPatterns = "corpus.preview_fix_patterns" + ToolQueryDeepWiki = "research.query_deepwiki" + ToolGetAuthenticatedIdentity = "github.get_authenticated_identity" + ToolCompareFork = "github.compare_fork" ToolGetJob = "jobs.get" ToolCancelJob = "jobs.cancel" ToolSearchGitHubRepositories = "github.search_repositories" @@ -70,7 +72,6 @@ const ( ToolSyncThreads = "github.sync_threads" ToolHydrateThreads = "github.sync_thread_facets" ToolSyncPortfolio = "github.sync_pull_request_portfolio" - ToolPreflightContribution = "workflow.preflight_contribution" ToolSyncPullRequestFeedback = "github.sync_pull_request_feedback" ToolWaitPullRequestChecks = "github.wait_pull_request_checks" ToolIndexPullRequestFeedback = "github.index_pull_request_feedback" @@ -83,7 +84,6 @@ const ( ToolCheckMergeConflicts = "workspace.check_merge_conflicts" ToolInspectCommitChanges = "workspace.inspect_commit_changes" ToolPlanSemanticCommits = "workspace.plan_semantic_commits" - ToolQueryDeepWiki = "research.query_deepwiki" ToolCreateWorkspace = "workspace.create" ToolAdoptWorkspace = "workspace.adopt" ToolDefineValidation = "validation.define" @@ -93,7 +93,6 @@ const ( ToolAttachJUnitReport = "validation.attach_junit_report" ToolStartInvestigation = "workflow.start_investigation" ToolRecordHypothesis = "workflow.record_hypothesis" - ToolFindRelatedWork = "workflow.find_related_work" ToolPromoteOpportunity = "workflow.promote_opportunity" ToolPrepareContribution = "workflow.prepare_contribution" ToolVerifyPublishedDraft = "workflow.verify_published_draft" @@ -478,25 +477,24 @@ type EvidenceOutput struct { Evidence []EvidenceItem `json:"evidence"` } -// RankOpportunitiesInput bounds ranking across stored repositories. -type RankOpportunitiesInput struct { +// RankContributionCandidatesInput bounds deterministic ranking across stored repositories. +type RankContributionCandidatesInput struct { Repositories []RepositoryRef `json:"repositories" jsonschema:"Required 1-50 stored repositories"` Limit int `json:"limit,omitempty" jsonschema:"Result bound from 1-100"` MaxResultsPerRepository int `json:"max_results_per_repository,omitempty" jsonschema:"Per-repository bound from 1-100"` SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` } -// OpportunityCandidateOutput describes one ranked contribution candidate. -type OpportunityCandidateOutput struct { +type ContributionCandidateOutput struct { Rank int `json:"rank"` Ref string `json:"ref"` Repo string `json:"repo"` Number int `json:"number"` Title string `json:"title"` URL string `json:"url"` - Score RadarScore `json:"score" jsonschema:"Deterministic Contribution Radar score from 0 to 100"` + Score RadarScore `json:"score"` Eligibility string `json:"eligibility"` - Confidence string `json:"confidence" jsonschema:"Categorical evidence confidence such as low, medium, or high"` + Confidence string `json:"confidence"` PositiveSignals []string `json:"positive_signals,omitempty"` Risks []string `json:"risks,omitempty"` Blockers []string `json:"blockers,omitempty"` @@ -506,8 +504,7 @@ type OpportunityCandidateOutput struct { SourceUpdatedAt string `json:"source_updated_at,omitempty"` } -// RepositoryOpportunitySummaryOutput reports ranking coverage for one repository. -type RepositoryOpportunitySummaryOutput struct { +type RepositoryCandidateRankingOutput struct { Repo string `json:"repo"` TotalOpenIssues int `json:"total_open_issues"` Considered int `json:"considered"` @@ -517,17 +514,16 @@ type RepositoryOpportunitySummaryOutput struct { Recovery *RecoveryPlan `json:"recovery,omitempty"` } -// RankOpportunitiesOutput combines deterministic cross-repository ranking with -// per-repository coverage or availability results. -type RankOpportunitiesOutput struct { - Status string `json:"status"` - Candidates []OpportunityCandidateOutput `json:"candidates"` - Repositories []BatchItem[RepositoryOpportunitySummaryOutput] `json:"repositories"` - GeneratedAt string `json:"generated_at"` - Total int `json:"total"` - Truncated bool `json:"truncated"` - SnapshotToken string `json:"snapshot_token"` - Recovery *RecoveryPlan `json:"recovery,omitempty"` +type RankContributionCandidatesOutput struct { + Status string `json:"status"` + Candidates []ContributionCandidateOutput `json:"candidates"` + Repositories []BatchItem[RepositoryCandidateRankingOutput] `json:"repositories"` + GeneratedAt string `json:"generated_at"` + Total int `json:"total"` + Truncated bool `json:"truncated"` + SnapshotToken string `json:"snapshot_token"` + Recovery *RecoveryPlan `json:"recovery,omitempty"` + Provenance CorpusReadProvenance `json:"provenance"` } // ReadinessInput selects a contribution opportunity readiness report. diff --git a/internal/mcpserver/actors.go b/internal/mcpserver/actors.go index 1dc52459..89ac0028 100644 --- a/internal/mcpserver/actors.go +++ b/internal/mcpserver/actors.go @@ -11,6 +11,12 @@ import ( func (s *Server) registerActorTools() { readOnly := readOnlyAnnotations() + addCatalogTool(s, catalogTool[mcpcontract.GetAuthenticatedIdentityInput, mcpcontract.AuthenticatedIdentityOutput]{ + name: mcpcontract.ToolGetAuthenticatedIdentity, title: "Get authenticated GitHub identity", + description: "Resolve the configured GitHub account. Performs one explicit provider read and does not persist actor data or discover contributions.", + annotations: externalReadAnnotations(), supportedBy: supports[AuthenticatedIdentityReader], input: inputSchema[mcpcontract.GetAuthenticatedIdentityInput](noSchemaCustomization), + output: outputSchema[mcpcontract.AuthenticatedIdentityOutput]("Authenticated GitHub identity and provider rate state."), handler: s.getAuthenticatedIdentity, + }) addCatalogTool(s, catalogTool[mcpcontract.SearchActorsInput, mcpcontract.SearchActorsOutput]{ name: mcpcontract.ToolSearchActors, title: "Search stored GitHub actors", description: "Search indexed GitHub users, bots, organizations, and other actors by stored profile facts. Offline; returns snapshot-bound facts and coverage.", @@ -140,6 +146,15 @@ func (s *Server) registerActorTools() { }) } +func (s *Server) getAuthenticatedIdentity(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.GetAuthenticatedIdentityInput) (*mcp.CallToolResult, mcpcontract.AuthenticatedIdentityOutput, error) { + reader, ok := s.reader.(AuthenticatedIdentityReader) + if !ok { + return nil, mcpcontract.AuthenticatedIdentityOutput{}, errors.New("authenticated GitHub identity lookup is not available") + } + out, err := reader.GetAuthenticatedIdentity(ctx, in) + return nil, out, err +} + func (s *Server) searchActors(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchActorsInput) (*mcp.CallToolResult, mcpcontract.SearchActorsOutput, error) { reader, ok := s.reader.(ActorReader) if !ok { diff --git a/internal/mcpserver/actors_test.go b/internal/mcpserver/actors_test.go index d6d6539c..b348af0d 100644 --- a/internal/mcpserver/actors_test.go +++ b/internal/mcpserver/actors_test.go @@ -60,11 +60,6 @@ func TestActorCapabilitiesAdvertiseAtomicTools(t *testing.T) { t.Errorf("atomic actor tool %q was not advertised", name) } } - for _, removed := range []string{mcpcontract.ToolRankThreads, mcpcontract.ToolBuildRepositoryDossier, mcpcontract.ToolFindRelatedWork} { - if tools[removed] != nil { - t.Errorf("removed composite tool %q was advertised", removed) - } - } } func TestSearchGitHubUsersRejectsWhitespaceOnlyQuery(t *testing.T) { diff --git a/internal/mcpserver/agent_eval_heldout_test.go b/internal/mcpserver/agent_eval_heldout_test.go index d90fefc4..7547c76a 100644 --- a/internal/mcpserver/agent_eval_heldout_test.go +++ b/internal/mcpserver/agent_eval_heldout_test.go @@ -81,24 +81,15 @@ func (r *heldOutReader) GetCoverage(ctx context.Context, in mcpcontract.GetCover return out, nil } -func (*heldOutReader) PreviewRepositoryFixPatterns(_ context.Context, in mcpcontract.PreviewRepositoryFixPatternsInput) (mcpcontract.FixPatternReport, error) { - return mcpcontract.FixPatternReport{ - Status: "complete", Repository: in.Repository, Persisted: false, - SnapshotToken: "ephemeral:heldout", Complete: true, - Limitations: []string{"preview is read-only"}, - }, nil -} - func connectHeldOut(t *testing.T, base *heldOutReader) (*mcp.ClientSession, func()) { t.Helper() optional := &fakeOptionalCapabilities{base: base.fakeReader} reader := completeTestReader{ Reader: base, NeighborReader: optional, ScalableReader: optional, ThreadFacetReader: optional, - threadFacetResourceReader: base.fakeReader, IssueSetReader: optional, - PortfolioReader: optional, GitHubOperator: optional, PullRequestFeedbackOperator: optional, + threadFacetResourceReader: base.fakeReader, + PortfolioReader: optional, GitHubOperator: optional, PullRequestFeedbackOperator: optional, CoverageOperator: optional, - CIFailureOperator: optional, FixPatternOperator: optional, FixPatternReader: base.fakeReader, - FixPatternPreviewReader: base, CodeIndexer: optional, MergeConflictReader: optional, + CIFailureOperator: optional, CodeIndexer: optional, MergeConflictReader: optional, ResearchReader: optional, CommitPlannerReader: base.fakeReader, PortfolioOperator: optional, Operator: base.fakeReader, ConcernReader: base.fakeReader, ConcernOperator: base.fakeReader, WorkspaceCreator: base.fakeReader, WorkspaceAdopter: base.fakeReader, diff --git a/internal/mcpserver/capabilities_test.go b/internal/mcpserver/capabilities_test.go index 9826bd5c..194f7e40 100644 --- a/internal/mcpserver/capabilities_test.go +++ b/internal/mcpserver/capabilities_test.go @@ -7,10 +7,8 @@ import ( ) type fakeOptionalCapabilities struct { - base *fakeReader - syncThreadsInput mcpcontract.SyncThreadsInput - fixPatternCalls int - lastFixPatternRequest mcpcontract.MineRepositoryFixPatternsInput + base *fakeReader + syncThreadsInput mcpcontract.SyncThreadsInput } func (*fakeOptionalCapabilities) FindNeighbors(context.Context, mcpcontract.FindNeighborsInput) (mcpcontract.FindNeighborsOutput, error) { @@ -50,32 +48,17 @@ func (*fakeOptionalCapabilities) GetThreads(_ context.Context, in mcpcontract.Ge func (*fakeOptionalCapabilities) GetThreadFacets(context.Context, mcpcontract.GetThreadFacetsInput) (mcpcontract.GetThreadFacetsOutput, error) { return mcpcontract.GetThreadFacetsOutput{Status: "complete"}, nil } -func (f *fakeOptionalCapabilities) RankOpportunities(context.Context, mcpcontract.RankOpportunitiesInput) (mcpcontract.RankOpportunitiesOutput, error) { - score := 87 - if f.base.radarScore != 0 { - score = f.base.radarScore - } - return mcpcontract.RankOpportunitiesOutput{ - Status: "complete", - Candidates: []mcpcontract.OpportunityCandidateOutput{{ - Rank: 1, - Ref: "thread:acme/rocket/issue/7", - Repo: "acme/rocket", - Number: 7, - Title: "engine stalls", - URL: "https://github.com/acme/rocket/issues/7", - Score: mcpcontract.RadarScore(score), - Eligibility: "needs_coordination", - Confidence: "medium", - }}, - Total: 1, - }, nil -} func (*fakeOptionalCapabilities) FindPrecedents(context.Context, mcpcontract.FindPrecedentsInput) (mcpcontract.FindPrecedentsOutput, error) { return mcpcontract.FindPrecedentsOutput{Status: "complete"}, nil } -func (*fakeOptionalCapabilities) PrepareIssueSet(context.Context, mcpcontract.PrepareIssueSetInput) (mcpcontract.PrepareIssueSetOutput, error) { - return mcpcontract.PrepareIssueSetOutput{Status: "complete"}, nil +func (*fakeOptionalCapabilities) MaterializeRepositoryDossier(_ context.Context, in mcpcontract.MaterializeRepositoryDossierInput) (mcpcontract.DossierOutput, error) { + return mcpcontract.DossierOutput{Owner: in.Owner, Repo: in.Repo}, nil +} +func (*fakeOptionalCapabilities) AnalyzeFixPatterns(_ context.Context, in mcpcontract.AnalyzeFixPatternsInput) (mcpcontract.AnalyzeFixPatternsOutput, error) { + return mcpcontract.AnalyzeFixPatternsOutput{Status: "complete", Repository: in.Repository}, nil +} +func (*fakeOptionalCapabilities) RankContributionCandidates(_ context.Context, _ mcpcontract.RankContributionCandidatesInput) (mcpcontract.RankContributionCandidatesOutput, error) { + return mcpcontract.RankContributionCandidatesOutput{Status: "complete"}, nil } func (f *fakeOptionalCapabilities) GetJobs(ctx context.Context, in mcpcontract.GetJobsInput) (mcpcontract.GetJobsOutput, error) { items := make([]mcpcontract.BatchItem[mcpcontract.GetJobOutput], len(in.IDs)) @@ -112,11 +95,6 @@ func (f *fakeOptionalCapabilities) SyncThreads(_ context.Context, in mcpcontract func (*fakeOptionalCapabilities) HydrateThreads(context.Context, mcpcontract.HydrateThreadsInput) (mcpcontract.JobReference, error) { return mcpcontract.JobReference{ID: "job-hydrate", Status: "queued"}, nil } -func (f *fakeOptionalCapabilities) MineRepositoryFixPatterns(_ context.Context, in mcpcontract.MineRepositoryFixPatternsInput) (mcpcontract.JobReference, error) { - f.fixPatternCalls++ - f.lastFixPatternRequest = in - return mcpcontract.JobReference{ID: "job-fix-patterns", Kind: "mine_repository_fix_patterns", Status: "queued"}, nil -} func (*fakeOptionalCapabilities) SyncPortfolio(context.Context, mcpcontract.SyncPortfolioInput) (mcpcontract.JobReference, error) { return mcpcontract.JobReference{ID: "job-portfolio", Kind: "sync_pull_request_portfolio", Status: "queued"}, nil } @@ -145,14 +123,22 @@ func (f *fakeOptionalCapabilities) DeepWiki(context.Context, mcpcontract.DeepWik func (*fakeOptionalCapabilities) LinkPullRequest(context.Context, mcpcontract.LinkPullRequestInput) (mcpcontract.LinkPullRequestOutput, error) { return mcpcontract.LinkPullRequestOutput{}, nil } +func (*fakeOptionalCapabilities) GetAuthenticatedIdentity(context.Context, mcpcontract.GetAuthenticatedIdentityInput) (mcpcontract.AuthenticatedIdentityOutput, error) { + return mcpcontract.AuthenticatedIdentityOutput{Login: "octocat"}, nil +} +func (*fakeOptionalCapabilities) CompareFork(_ context.Context, in mcpcontract.CompareForkInput) (mcpcontract.ForkFreshnessOutput, error) { + return mcpcontract.ForkFreshnessOutput{Status: "current", Coverage: "verified", Upstream: in.Upstream, Fork: in.Fork}, nil +} type completeTestReader struct { mcpcontract.Reader NeighborReader ScalableReader + DossierMaterializer + FixPatternAnalyzer + ContributionCandidateRanker ThreadFacetReader threadFacetResourceReader - IssueSetReader PortfolioReader GitHubOperator CoverageOperator @@ -160,14 +146,13 @@ type completeTestReader struct { PullRequestFeedbackIndexer PullRequestFeedbackSearcher CIFailureOperator - FixPatternOperator - FixPatternReader - FixPatternPreviewReader CodeIndexer MergeConflictReader CommitPlannerReader ResearchReader PortfolioOperator + AuthenticatedIdentityReader + ForkComparisonReader Operator ConcernReader ConcernOperator @@ -180,13 +165,14 @@ type completeTestReader struct { func completeFakeReader(base *fakeReader) mcpcontract.Reader { optional := &fakeOptionalCapabilities{base: base} return completeTestReader{ - Reader: base, NeighborReader: optional, ScalableReader: optional, ThreadFacetReader: optional, threadFacetResourceReader: base, IssueSetReader: optional, + Reader: base, NeighborReader: optional, ScalableReader: optional, DossierMaterializer: optional, FixPatternAnalyzer: optional, ContributionCandidateRanker: optional, ThreadFacetReader: optional, threadFacetResourceReader: base, PortfolioReader: optional, GitHubOperator: optional, PullRequestFeedbackOperator: optional, PullRequestFeedbackIndexer: optional, PullRequestFeedbackSearcher: optional, CIFailureOperator: optional, - CoverageOperator: optional, - FixPatternOperator: optional, FixPatternReader: base, CodeIndexer: optional, + CoverageOperator: optional, + CodeIndexer: optional, MergeConflictReader: optional, ResearchReader: optional, CommitPlannerReader: base, PortfolioOperator: optional, Operator: base, + AuthenticatedIdentityReader: optional, ForkComparisonReader: optional, ConcernReader: base, ConcernOperator: base, WorkspaceCreator: base, WorkspaceAdopter: base, ValidationReceiptOperator: base, PublishedDraftVerifier: base, diff --git a/internal/mcpserver/catalog.go b/internal/mcpserver/catalog.go index 810cca15..c5aedb1e 100644 --- a/internal/mcpserver/catalog.go +++ b/internal/mcpserver/catalog.go @@ -18,9 +18,6 @@ type catalogTool[In, Out any] struct { } func addCatalogTool[In, Out any](server *Server, tool catalogTool[In, Out]) { - if removedCompositeTools[tool.name] { - return - } if tool.supportedBy != nil && !tool.supportedBy(server.reader) { return } @@ -47,21 +44,6 @@ func addCatalogTool[In, Out any](server *Server, tool catalogTool[In, Out]) { mcp.AddTool(server.server, mcpTool, structuredToolErrors(tool.handler)) } -// removedCompositeTools is the breaking facts-first catalog boundary. The -// implementations remain temporarily available to internal callers while -// agents compose the smaller acquisition and corpus primitives instead. -var removedCompositeTools = map[string]bool{ - mcpcontract.ToolSearchCode: true, - mcpcontract.ToolRankThreads: true, - mcpcontract.ToolPrepareIssueSet: true, - mcpcontract.ToolBuildRepositoryDossier: true, - mcpcontract.ToolMineRepositoryFixPatterns: true, - mcpcontract.ToolPreviewRepositoryFixPatterns: true, - mcpcontract.ToolPreflightContribution: true, - mcpcontract.ToolQueryDeepWiki: true, - mcpcontract.ToolFindRelatedWork: true, -} - func supports[T any](reader mcpcontract.Reader) bool { _, ok := any(reader).(T) return ok @@ -137,10 +119,6 @@ func processReadAnnotations() *mcp.ToolAnnotations { return &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: boolPtr(false), DestructiveHint: boolPtr(false)} } -func preflightAnnotations() *mcp.ToolAnnotations { - return &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: boolPtr(true), DestructiveHint: boolPtr(false)} -} - func cancellationAnnotations() *mcp.ToolAnnotations { return &mcp.ToolAnnotations{ ReadOnlyHint: false, diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index e2ac5080..927b5d3a 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -119,6 +119,21 @@ func TestReadOnlyModeFiltersEverySideEffectingTool(t *testing.T) { } } +func TestAnalysisAndMaterializerAnnotationsMatchTheirEffects(t *testing.T) { + tools, closeSessions := listedToolsFromReader(t, completeFakeReader(&fakeReader{searchStarted: make(chan struct{})})) + defer closeSessions() + for _, name := range []string{mcpcontract.ToolAnalyzeFixPatterns, mcpcontract.ToolRankContributionCandidates} { + tool := tools[name] + if tool == nil || tool.Annotations == nil || !tool.Annotations.ReadOnlyHint || !tool.Annotations.IdempotentHint || tool.Annotations.OpenWorldHint == nil || *tool.Annotations.OpenWorldHint { + t.Fatalf("offline analysis annotations for %s = %+v", name, tool) + } + } + materializer := tools[mcpcontract.ToolMaterializeRepositoryDossier] + if materializer == nil || materializer.Annotations == nil || materializer.Annotations.ReadOnlyHint || !materializer.Annotations.IdempotentHint || materializer.Annotations.DestructiveHint == nil || *materializer.Annotations.DestructiveHint || materializer.Annotations.OpenWorldHint == nil || *materializer.Annotations.OpenWorldHint { + t.Fatalf("dossier materializer annotations = %+v", materializer) + } +} + func TestUnsupportedOptionalCapabilitiesAreNotAdvertised(t *testing.T) { base := &fakeReader{searchStarted: make(chan struct{})} server, err := New(struct{ mcpcontract.Reader }{Reader: base}, "test") @@ -163,9 +178,8 @@ func TestOptionalCapabilitiesAreAdvertisedIndependently(t *testing.T) { }{Reader: base, ResearchReader: research} tools, closeSessions := listedToolsFromReader(t, reader) defer closeSessions() - - if tools[mcpcontract.ToolQueryDeepWiki] != nil { - t.Fatal("removed derived-research workflow was advertised") + if tools[mcpcontract.ToolQueryDeepWiki] == nil { + t.Fatal("supported derived-research tool was not advertised") } for _, name := range []string{mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts, mcpcontract.ToolListPullRequestPortfolio} { if tools[name] != nil { @@ -174,6 +188,40 @@ func TestOptionalCapabilitiesAreAdvertisedIndependently(t *testing.T) { } } +func TestContributionFactsAreAdvertisedIndependently(t *testing.T) { + base := &fakeReader{searchStarted: make(chan struct{})} + optional := &fakeOptionalCapabilities{base: base} + reader := struct { + mcpcontract.Reader + AuthenticatedIdentityReader + ForkComparisonReader + }{Reader: base, AuthenticatedIdentityReader: optional, ForkComparisonReader: optional} + tools, closeSessions := listedToolsFromReader(t, reader) + defer closeSessions() + + for _, name := range []string{mcpcontract.ToolGetAuthenticatedIdentity, mcpcontract.ToolCompareFork} { + if tools[name] == nil { + t.Errorf("supported contribution fact tool %q was not advertised", name) + } else if tools[name].Annotations == nil || !tools[name].Annotations.ReadOnlyHint || tools[name].Annotations.OpenWorldHint == nil || !*tools[name].Annotations.OpenWorldHint { + t.Errorf("contribution fact tool %q annotations = %+v", name, tools[name].Annotations) + } + } + client, closeReadOnly := connectServer(t, reader, true) + defer closeReadOnly() + readOnlyTools := make(map[string]bool) + for tool, err := range client.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + readOnlyTools[tool.Name] = true + } + for _, name := range []string{mcpcontract.ToolGetAuthenticatedIdentity, mcpcontract.ToolCompareFork} { + if !readOnlyTools[name] { + t.Errorf("read-only catalog omitted external fact tool %q", name) + } + } +} + func TestToolSchemasExposeMachineReadableContracts(t *testing.T) { tools, closeSessions := listedTools(t) defer closeSessions() @@ -301,7 +349,7 @@ func TestInvalidToolCallEvaluation(t *testing.T) { {mcpcontract.ToolSearchThreads, map[string]any{"query": "race", "kind": "discussion"}}, {mcpcontract.ToolSearchThreads, map[string]any{"query": "race", "limit": 101}}, {mcpcontract.ToolSearchGitHubRepositories, map[string]any{"limit": 20}}, - {mcpcontract.ToolSearchCode, map[string]any{"query": "race", "owner": "acme"}}, + {mcpcontract.ToolSearchCodeBatch, map[string]any{"queries": []any{"race"}, "owner": "acme"}}, {mcpcontract.ToolGetThreads, map[string]any{"threads": []any{map[string]any{"owner": "acme", "repo": "rocket", "kind": "issue", "number": 0}}}}, {mcpcontract.ToolGetCoverage, map[string]any{"targets": []any{}}}, {mcpcontract.ToolCancelJob, map[string]any{"ids": []any{}}}, @@ -365,14 +413,6 @@ func TestCoverageTargetSchemaAcceptsRepositoryAndExactThreadOnly(t *testing.T) { } } -func TestFixPatternWorkflowSchemaRejectsInvalidNestedInputBeforeHandler(t *testing.T) { - tools, closeSessions := listedTools(t) - defer closeSessions() - if tools[mcpcontract.ToolMineRepositoryFixPatterns] != nil { - t.Fatal("removed fix-pattern workflow was advertised") - } -} - func TestSyncThreadsDefaultsRemainSelectionSpecific(t *testing.T) { base := &fakeReader{searchStarted: make(chan struct{})} optional := &fakeOptionalCapabilities{base: base} diff --git a/internal/mcpserver/dossier.go b/internal/mcpserver/dossier.go new file mode 100644 index 00000000..cc4f7a2f --- /dev/null +++ b/internal/mcpserver/dossier.go @@ -0,0 +1,27 @@ +package mcpserver + +import ( + "context" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func (s *Server) materializeRepositoryDossier(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.MaterializeRepositoryDossierInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { + owner, repo, err := normalizeRepository(in.Owner, in.Repo) + if err != nil { + return nil, mcpcontract.DurableArtifactReference{}, err + } + materializer, ok := s.reader.(DossierMaterializer) + if !ok { + return nil, mcpcontract.DurableArtifactReference{}, errors.New("repository dossier materialization is not available") + } + if _, err := materializer.MaterializeRepositoryDossier(ctx, mcpcontract.MaterializeRepositoryDossierInput{Owner: owner, Repo: repo}); err != nil { + return nil, mcpcontract.DurableArtifactReference{}, err + } + uri := fmt.Sprintf("gitcontribute://dossier/%s/%s", owner, repo) + ref := mcpcontract.DurableArtifactReference{Kind: "repository_dossier", ID: owner + "/" + repo, URI: uri} + return linkedResource(uri, "repository-dossier", "Repository dossier", "Persisted deterministic dossier derived from local corpus facts."), ref, nil +} diff --git a/internal/mcpserver/fix_patterns.go b/internal/mcpserver/fix_patterns.go new file mode 100644 index 00000000..31c7486e --- /dev/null +++ b/internal/mcpserver/fix_patterns.go @@ -0,0 +1,18 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func (s *Server) analyzeFixPatterns(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.AnalyzeFixPatternsInput) (*mcp.CallToolResult, mcpcontract.AnalyzeFixPatternsOutput, error) { + analyzer, ok := s.reader.(FixPatternAnalyzer) + if !ok { + return nil, mcpcontract.AnalyzeFixPatternsOutput{}, errors.New("fix-pattern analysis is not available") + } + out, err := analyzer.AnalyzeFixPatterns(ctx, in) + return nil, out, err +} diff --git a/internal/mcpserver/portfolio.go b/internal/mcpserver/portfolio.go index 4b9bc5af..8e020d74 100644 --- a/internal/mcpserver/portfolio.go +++ b/internal/mcpserver/portfolio.go @@ -94,38 +94,24 @@ func (s *Server) linkPullRequest(ctx context.Context, _ *mcp.CallToolRequest, in return nil, out, err } -func (s *Server) preflightContribution(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ContributionPreflightInput) (*mcp.CallToolResult, mcpcontract.ContributionPreflightOutput, error) { - owner, repo, err := normalizeRepository(in.Repository.Owner, in.Repository.Repo) +func (s *Server) compareFork(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.CompareForkInput) (*mcp.CallToolResult, mcpcontract.ForkFreshnessOutput, error) { + upstreamOwner, upstreamRepo, err := normalizeRepository(in.Upstream.Owner, in.Upstream.Repo) if err != nil { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) + return nil, mcpcontract.ForkFreshnessOutput{}, mcpcontract.InvalidArgument("upstream", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) } - in.Repository = mcpcontract.RepositoryRef{Owner: owner, Repo: repo} - if in.Fork != nil { - forkOwner, forkRepo, forkErr := normalizeRepository(in.Fork.Owner, in.Fork.Repo) - if forkErr != nil { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("fork", "owner and repo are required when fork is provided", map[string]any{"owner": "alice", "repo": "rocket"}) - } - in.Fork = &mcpcontract.RepositoryRef{Owner: forkOwner, Repo: forkRepo} - if strings.EqualFold(forkOwner, owner) && strings.EqualFold(forkRepo, repo) { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("fork", "fork must differ from the upstream repository", nil) - } - } - if strings.TrimSpace(in.Candidate.Title) == "" && strings.TrimSpace(in.Candidate.Query) == "" && strings.TrimSpace(in.Candidate.Body) == "" && in.Candidate.IssueNumber < 1 && strings.TrimSpace(in.Candidate.HeadRef) == "" && strings.TrimSpace(in.Candidate.HeadSHA) == "" && len(in.Candidate.ChangedFiles) == 0 && len(in.WorkspacePaths) == 0 { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("candidate", "candidate or workspace_paths must provide title, query, body, issue_number, head_ref, head_sha, or changed_files", nil) - } - if in.Candidate.IssueNumber < 0 { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("candidate.issue_number", "issue_number must be positive when provided", map[string]any{"issue_number": 1}) - } - if in.Limit < 0 || in.Limit > 100 { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("limit", "limit must be between 1 and 100 when provided", map[string]any{"limit": 20}) + forkOwner, forkRepo, err := normalizeRepository(in.Fork.Owner, in.Fork.Repo) + if err != nil { + return nil, mcpcontract.ForkFreshnessOutput{}, mcpcontract.InvalidArgument("fork", "owner and repo are required", map[string]any{"owner": "alice", "repo": "rocket"}) } - if in.MaxRequests < 0 || in.MaxRequests > 1000 || (in.MaxRequests > 0 && in.MaxRequests < 2) { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("max_requests", "max_requests must be between 2 and 1000 when provided", map[string]any{"max_requests": 100}) + if strings.EqualFold(upstreamOwner, forkOwner) && strings.EqualFold(upstreamRepo, forkRepo) { + return nil, mcpcontract.ForkFreshnessOutput{}, mcpcontract.InvalidArgument("fork", "fork must differ from the upstream repository", nil) } - reader, ok := s.reader.(ContributionPreflightReader) + in.Upstream = mcpcontract.RepositoryRef{Owner: upstreamOwner, Repo: upstreamRepo} + in.Fork = mcpcontract.RepositoryRef{Owner: forkOwner, Repo: forkRepo} + reader, ok := s.reader.(ForkComparisonReader) if !ok { - return nil, mcpcontract.ContributionPreflightOutput{}, errors.New("contribution preflight is not available") + return nil, mcpcontract.ForkFreshnessOutput{}, errors.New("fork comparison is not available") } - out, err := reader.PreflightContribution(ctx, in) + out, err := reader.CompareFork(ctx, in) return nil, out, err } diff --git a/internal/mcpserver/radar.go b/internal/mcpserver/radar.go new file mode 100644 index 00000000..85d717af --- /dev/null +++ b/internal/mcpserver/radar.go @@ -0,0 +1,18 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func (s *Server) rankContributionCandidates(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.RankContributionCandidatesInput) (*mcp.CallToolResult, mcpcontract.RankContributionCandidatesOutput, error) { + ranker, ok := s.reader.(ContributionCandidateRanker) + if !ok { + return nil, mcpcontract.RankContributionCandidatesOutput{}, errors.New("contribution candidate ranking is not available") + } + out, err := ranker.RankContributionCandidates(ctx, in) + return nil, out, err +} diff --git a/internal/mcpserver/recovery_schema_test.go b/internal/mcpserver/recovery_schema_test.go index 4752035f..5af3d6b6 100644 --- a/internal/mcpserver/recovery_schema_test.go +++ b/internal/mcpserver/recovery_schema_test.go @@ -1,6 +1,10 @@ package mcpserver -import "testing" +import ( + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) func TestToolCallSchemaUsesExclusiveVariants(t *testing.T) { t.Parallel() @@ -8,8 +12,8 @@ func TestToolCallSchemaUsesExclusiveVariants(t *testing.T) { if err != nil { t.Fatal(err) } - if len(schema.OneOf) != 32 { - t.Fatalf("tool-call variants = %d, want 32", len(schema.OneOf)) + if len(schema.OneOf) != len(mcpcontract.RecoveryActionPrototypes()) { + t.Fatalf("tool-call variants = %d, want %d", len(schema.OneOf), len(mcpcontract.RecoveryActionPrototypes())) } seen := make(map[string]bool, len(schema.OneOf)) for _, variant := range schema.OneOf { diff --git a/internal/mcpserver/resource_templates.go b/internal/mcpserver/resource_templates.go index 67bcc141..3f21f255 100644 --- a/internal/mcpserver/resource_templates.go +++ b/internal/mcpserver/resource_templates.go @@ -11,6 +11,7 @@ type resourceTemplateDefinition struct { func (s *Server) registerResourceTemplates() { templates := []resourceTemplateDefinition{ {"gitcontribute://repository/{owner}/{repo}", "Repository", "Local repository record"}, + {"gitcontribute://dossier/{owner}/{repo}", "Repository dossier", "Persisted deterministic repository dossier"}, {"gitcontribute://thread/{owner}/{repo}/{kind}/{number}", "Thread", "Local issue or pull request"}, {"gitcontribute://investigation/{id}", "Investigation", "Local investigation workspace"}, {"gitcontribute://opportunity/{id}", "Opportunity", "Local contribution opportunity"}, diff --git a/internal/mcpserver/resources.go b/internal/mcpserver/resources.go index 1d177cf0..40c321b9 100644 --- a/internal/mcpserver/resources.go +++ b/internal/mcpserver/resources.go @@ -108,6 +108,8 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an switch req.host { case "repository": return s.readRepositoryResource(ctx, req) + case "dossier": + return s.readDossierResource(ctx, req) case "thread": if len(req.parts) == 6 && req.parts[4] == "facet" { return s.readThreadFacetResource(ctx, req) @@ -168,6 +170,13 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an } } +func (s *Server) readDossierResource(ctx context.Context, req resourceRequest) (any, error) { + if len(req.parts) != 2 { + return nil, mcp.ResourceNotFoundError(req.uri) + } + return s.reader.Dossier(ctx, mcpcontract.RepoInput{Owner: req.parts[0], Repo: req.parts[1]}) +} + func (s *Server) readActorResource(ctx context.Context, req resourceRequest) (any, error) { reader, ok := s.reader.(actorResourceReader) if !ok || (len(req.parts) != 1 && (len(req.parts) != 3 || req.parts[1] != "facet")) || strings.TrimSpace(req.parts[0]) == "" { diff --git a/internal/mcpserver/scalable.go b/internal/mcpserver/scalable.go index e8eb143e..d431602d 100644 --- a/internal/mcpserver/scalable.go +++ b/internal/mcpserver/scalable.go @@ -14,7 +14,7 @@ import ( ) const serverInstructions = "GitContribute exposes source-backed GitHub facts. " + - "corpus.* tools are offline reads and never refresh implicitly; github.* tools are explicit bounded network reads that may update only the local corpus. " + + "corpus.* tools never access the network or refresh source observations implicitly; most are reads, while explicitly named materializers may update only derived local projections. github.* tools are explicit bounded network reads that may update only the local corpus. " + "Coverage is part of the result: missing, stale, paginated, or truncated observations are unknown rather than negative evidence. " + "Tools returning a job require polling through jobs.get before rereading the corpus. " + "Use exact returned resource URIs with MCP resources/read; treat them as opaque. " + @@ -127,10 +127,17 @@ func (s *Server) registerCatalogContract(annotations *mcp.ToolAnnotations) { func (s *Server) registerScalable() { readOnly := readOnlyAnnotations() + localWrite := localWriteAnnotations(true) s.registerCatalogContract(readOnly) addCatalogTool(s, catalogTool[mcpcontract.GetRepositoriesInput, mcpcontract.GetRepositoriesOutput]{name: mcpcontract.ToolGetRepositories, title: "Get stored repositories in one batch", description: "Read metadata, coverage, and dossier availability for up to 100 stored repositories. Use for comparison before reading dossier resources. Missing metadata includes a sync action. Offline.", annotations: readOnly, supportedBy: supports[ScalableReader], input: inputSchema[mcpcontract.GetRepositoriesInput](func(sc *schemaBuilder) { setArrayBounds(sc, "repositories", 1, 100) }), output: outputSchema[mcpcontract.GetRepositoriesOutput]("Ordered repository batch with item-level status and dossier availability."), handler: s.getRepositories}) + addCatalogTool(s, catalogTool[mcpcontract.MaterializeRepositoryDossierInput, mcpcontract.DurableArtifactReference]{ + name: mcpcontract.ToolMaterializeRepositoryDossier, title: "Materialize repository dossier", + description: "Build and persist one deterministic repository dossier from already stored corpus facts. This performs no network access, never refreshes source observations, and returns the exact dossier resource URI.", + annotations: localWrite, supportedBy: supports[DossierMaterializer], input: inputSchema[mcpcontract.MaterializeRepositoryDossierInput](noSchemaCustomization), + output: outputSchema[mcpcontract.DurableArtifactReference]("Compact reference to the refreshed dossier resource."), handler: s.materializeRepositoryDossier, + }) addCatalogTool(s, catalogTool[mcpcontract.GetThreadsInput, mcpcontract.GetThreadsOutput]{name: mcpcontract.ToolGetThreads, title: "Get stored threads in one batch", description: "Read exact stored issue or pull-request headers and optional complete bodies for up to 100 inputs. Choose compact for triage and full for finalist body reads; this tool is offline.", annotations: readOnly, supportedBy: supports[ScalableReader], input: inputSchema[mcpcontract.GetThreadsInput](func(sc *schemaBuilder) { setArrayBounds(sc, "threads", 1, 100) setEnum(sc, "view", "compact", "full") @@ -141,31 +148,35 @@ func (s *Server) registerScalable() { setArrayBounds(sc, "facets", 1, 10) setArrayEnum(sc, "facets", facets.AllNames()...) }), output: outputSchema[mcpcontract.GetThreadFacetsOutput]("Ordered stored-facet metadata with canonical resource links."), handler: s.getThreadFacets}) - addCatalogTool(s, catalogTool[mcpcontract.RankOpportunitiesInput, mcpcontract.RankOpportunitiesOutput]{name: mcpcontract.ToolRankThreads, title: "Rank stored threads for contribution", description: "Rank open issues across 1-50 required stored repositories. Bounded or population-capped results include typed sync-and-rerank recovery; this operation never persists opportunities.", annotations: readOnly, supportedBy: supports[ScalableReader], input: inputSchema[mcpcontract.RankOpportunitiesInput](func(sc *schemaBuilder) { - setArrayBounds(sc, "repositories", 1, 50) - setRange(sc, "limit", 1, 100) - setDefault(sc, "limit", 20) - setRange(sc, "max_results_per_repository", 1, 100) - setDefault(sc, "max_results_per_repository", 10) - }), output: outputSchema[mcpcontract.RankOpportunitiesOutput]("Bounded cross-repository Radar ranking."), handler: s.rankOpportunities}) + addCatalogTool(s, catalogTool[mcpcontract.RankContributionCandidatesInput, mcpcontract.RankContributionCandidatesOutput]{ + name: mcpcontract.ToolRankContributionCandidates, title: "Rank contribution candidates", + description: "Rank open issues across 1 to 50 stored repositories using the deterministic Contribution Radar domain service. Returns transparent signals, blockers, unknowns, per-repository coverage, and one shared snapshot identity. Offline; acquire repository context and issue headers separately.", + annotations: readOnly, supportedBy: supports[ContributionCandidateRanker], input: inputSchema[mcpcontract.RankContributionCandidatesInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "repositories", 1, 50) + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + setRange(sc, "max_results_per_repository", 1, 100) + setDefault(sc, "max_results_per_repository", 10) + }), output: outputSchema[mcpcontract.RankContributionCandidatesOutput]("Deterministic cross-repository candidate ranking with explicit per-repository coverage."), handler: s.rankContributionCandidates, + }) addCatalogTool(s, catalogTool[mcpcontract.FindPrecedentsInput, mcpcontract.FindPrecedentsOutput]{name: mcpcontract.ToolFindPrecedents, title: "Find historical issue and pull-request precedents", description: "Find similar closed issues and pull requests for up to 20 source threads, including completed, not-planned, duplicate, and merged evidence. This is an offline historical read, not a current opportunity search. Missing or incomplete repository history returns item-level ensure_coverage recovery; poll jobs.get and retry before treating the result as exhaustive.", annotations: readOnly, supportedBy: supports[ScalableReader], input: inputSchema[mcpcontract.FindPrecedentsInput](func(sc *schemaBuilder) { setArrayBounds(sc, "threads", 1, 20) setRange(sc, "limit", 1, 100) setDefault(sc, "limit", 20) }), output: outputSchema[mcpcontract.FindPrecedentsOutput]("Historical precedents grouped by source thread."), handler: s.findPrecedents}) - addCatalogTool(s, catalogTool[mcpcontract.PrepareIssueSetInput, mcpcontract.PrepareIssueSetOutput]{name: mcpcontract.ToolPrepareIssueSet, title: "Prepare contribution evidence from exact issues", description: "Compose stored facts, coverage gaps, related work, merged precedents, and linkage candidates for 1-20 exact issues. Prefer this canonical issue-audit entrypoint to manual reads. Offline; creates no opportunity or draft. If coverage is partial, follow each returned typed recovery action, poll its job, then retry this read.", annotations: readOnly, supportedBy: supports[IssueSetReader], input: inputSchema[mcpcontract.PrepareIssueSetInput](func(sc *schemaBuilder) { - setArrayBounds(sc, "issue_numbers", 1, 20) - if numbers := property(sc, "issue_numbers"); numbers != nil { - numbers.UniqueItems = true - if numbers.Items != nil { - numbers.Items.Minimum = jsonschema.Ptr(1.0) - } - } - setRange(sc, "precedent_limit", 1, 10) - setDefault(sc, "precedent_limit", 3) - setEnum(sc, "response_format", "concise", "detailed") - setDefault(sc, "response_format", "concise") - }), output: outputSchema[mcpcontract.PrepareIssueSetOutput]("Contribution-facing evidence for an exact stored issue set."), handler: s.prepareIssueSet}) + addCatalogTool(s, catalogTool[mcpcontract.AnalyzeFixPatternsInput, mcpcontract.AnalyzeFixPatternsOutput]{ + name: mcpcontract.ToolAnalyzeFixPatterns, title: "Analyze repository fix patterns", + description: "Analyze bounded stored pull-request history by caller-defined symptom categories. Classifies observed outcomes, explicit closing or replacement relationships, and proof styles. Offline and snapshot-bound: it never hydrates unknown pull requests or persists a report; typed recovery identifies exact acquisition and rerun steps.", + annotations: readOnly, supportedBy: supports[FixPatternAnalyzer], input: inputSchema[mcpcontract.AnalyzeFixPatternsInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "symptom_taxonomy", 1, 12) + configureFixPatternAnalysisSchema(sc) + setRange(sc, "candidate_limit", 1, 100) + setDefault(sc, "candidate_limit", mcpcontract.DefaultFixPatternCandidateLimit) + setRange(sc, "representative_limit", 1, 20) + setDefault(sc, "representative_limit", mcpcontract.DefaultFixPatternRepresentativeLimit) + setArrayEnum(sc, "merge_outcomes", "merged", "closed_unmerged", "superseded", "open", "unknown") + }), output: outputSchema[mcpcontract.AnalyzeFixPatternsOutput]("Source-bound fix-pattern analysis with explicit coverage and recovery."), handler: s.analyzeFixPatterns, + }) addCatalogTool(s, catalogTool[mcpcontract.GetJobsInput, mcpcontract.GetJobsOutput]{name: mcpcontract.ToolGetJob, title: "Get durable jobs in one batch", description: "Poll up to 100 jobs with execution state, terminal outcome, progress, and artifact links. Use concise while polling and detailed after completion. Offline; executor blobs stay hidden.", annotations: readOnly, input: inputSchema[mcpcontract.GetJobsInput](func(sc *schemaBuilder) { setArrayBounds(sc, "ids", 1, 100) setEnum(sc, "response_format", "concise", "detailed") @@ -217,66 +228,6 @@ func (s *Server) registerScalable() { setRange(sc, "max_pages", 1, 100) setDefault(sc, "max_pages", 3) }), output: outputSchema[mcpcontract.JobReference]("Reference to a bounded exact-thread hydration job."), handler: s.hydrateThreads}) - addCatalogTool(s, catalogTool[mcpcontract.MineRepositoryFixPatternsInput, mcpcontract.JobReference]{ - name: mcpcontract.ToolMineRepositoryFixPatterns, title: "Mine repository fix patterns", - description: "Mine repository-level evidence about how similar problems were accepted, rejected, or superseded. Searches stored pull requests, refreshes only bounded unknown-state finalists, and persists a report separating explicit links from similarity. Prefer this over repeated search and hydration loops; not for live competing-work checks. Performs GitHub reads and local writes, never GitHub mutation.", - annotations: networkReadAnnotations(), supportedBy: supports[FixPatternWorkflow], - input: inputSchema[mcpcontract.MineRepositoryFixPatternsInput](func(sc *schemaBuilder) { - setArrayBounds(sc, "symptom_taxonomy", 1, 12) - setArrayBounds(sc, "merge_outcomes", 1, 5) - property(sc, "merge_outcomes").UniqueItems = true - setRange(sc, "candidate_limit", 1, 100) - setDefault(sc, "candidate_limit", mcpcontract.DefaultFixPatternCandidateLimit) - setRange(sc, "hydration_limit", 0, 100) - setDefault(sc, "hydration_limit", mcpcontract.DefaultFixPatternHydrationLimit) - setRange(sc, "representative_limit", 1, 20) - setDefault(sc, "representative_limit", mcpcontract.DefaultFixPatternRepresentativeLimit) - symptoms := property(sc, "symptom_taxonomy") - if symptoms != nil && symptoms.Items != nil { - name := symptoms.Items.Properties["name"] - if name != nil { - name.MinLength = jsonschema.Ptr(1) - name.Pattern = nonWhitespacePattern - } - terms := symptoms.Items.Properties["terms"] - if terms != nil { - terms.MinItems = jsonschema.Ptr(1) - terms.MaxItems = jsonschema.Ptr(12) - terms.UniqueItems = true - if terms.Items != nil { - terms.Items.MinLength = jsonschema.Ptr(1) - terms.Items.Pattern = nonWhitespacePattern - } - } - } - window := property(sc, "time_window") - if window != nil { - for _, field := range []string{"updated_after", "updated_before"} { - if value := window.Properties[field]; value != nil { - value.Format = "date-time" - } - } - } - }), - output: outputSchema[mcpcontract.JobReference]("Reference to a bounded repository fix-pattern mining job."), - handler: s.mineRepositoryFixPatterns, - }) - addCatalogTool(s, catalogTool[mcpcontract.PreviewRepositoryFixPatternsInput, mcpcontract.FixPatternReport]{ - name: mcpcontract.ToolPreviewRepositoryFixPatterns, title: "Preview repository fix patterns", - description: "Analyze bounded stored pull-request patterns without network access, hydration, jobs, artifacts, or persistence. The result is explicitly marked persisted=false and includes its snapshot token.", - annotations: readOnly, supportedBy: supports[FixPatternPreviewReader], - input: inputSchema[mcpcontract.PreviewRepositoryFixPatternsInput](func(sc *schemaBuilder) { - setArrayBounds(sc, "symptom_taxonomy", 1, 12) - setArrayBounds(sc, "merge_outcomes", 1, 5) - property(sc, "merge_outcomes").UniqueItems = true - setRange(sc, "candidate_limit", 1, 100) - setDefault(sc, "candidate_limit", mcpcontract.DefaultFixPatternCandidateLimit) - setConst(sc, "hydration_limit", 0) - setRange(sc, "representative_limit", 1, 20) - setDefault(sc, "representative_limit", mcpcontract.DefaultFixPatternRepresentativeLimit) - }), - output: outputSchema[mcpcontract.FixPatternReport]("Bounded offline fix-pattern analysis; never persisted."), handler: s.previewRepositoryFixPatterns, - }) addCatalogTool(s, catalogTool[mcpcontract.SyncPortfolioInput, mcpcontract.JobReference]{name: mcpcontract.ToolSyncPortfolio, title: "Synchronize a pull-request portfolio", description: "selection is required: use authored only for the authenticated user's PR portfolio, optionally scoped to one repository, or explicit with 1-100 exact pull_requests. This tool is not repository-wide comment discovery; for all feedback by a reviewer use github.index_pull_request_feedback, jobs.get, and corpus.search_pull_request_feedback with feedback_author. Refreshes PR details, merge state, checks, review state, unresolved threads, merge queue, files, and closing issues in one durable job; incomplete discovery is surfaced with a typed retry action.", annotations: networkReadAnnotations(), supportedBy: supports[GitHubOperator], input: inputSchema[mcpcontract.SyncPortfolioInput](func(sc *schemaBuilder) { setEnum(sc, "selection", "authored", "explicit") setArrayBounds(sc, "pull_requests", 1, 100) @@ -288,20 +239,9 @@ func (s *Server) registerScalable() { setDefault(sc, "status_max_pages", 3) configureSyncPortfolioModes(sc) }), output: outputSchema[mcpcontract.JobReference]("Reference to a pull-request portfolio synchronization job."), handler: s.syncPortfolio}) - addCatalogTool(s, catalogTool[mcpcontract.ContributionPreflightInput, mcpcontract.ContributionPreflightOutput]{name: mcpcontract.ToolPreflightContribution, title: "Preflight an existing contribution", description: "Before creating local contribution work, resolve the authenticated identity, search bounded open authored pull requests and related repository threads, inspect optional local worktrees, and report fork freshness when a fork is supplied or unambiguously inferred. Returns existing_pr, new_work only after live absence is verified, or coverage_unknown when identity, search, local inspection, or fork freshness is incomplete. Read-only; creates no GitHub or workflow state.", annotations: preflightAnnotations(), supportedBy: supports[ContributionPreflightReader], input: inputSchema[mcpcontract.ContributionPreflightInput](func(sc *schemaBuilder) { - setRange(sc, "limit", 1, 100) - setDefault(sc, "limit", 20) - setRange(sc, "max_requests", 2, 1000) - setDefault(sc, "max_requests", 100) - setArrayBounds(sc, "workspace_paths", 0, 20) - if candidate := sc.schema.Defs["ContributionPreflightCandidate"]; candidate != nil { - candidateBuilder := &schemaBuilder{schema: candidate, err: sc.err} - setMinimum(candidateBuilder, "issue_number", 1) - setArrayBounds(candidateBuilder, "changed_files", 0, 200) - } - }), output: outputSchema[mcpcontract.ContributionPreflightOutput]("Bounded contribution routing decision with explicit live coverage."), handler: s.preflightContribution}) - addCatalogTool(s, catalogTool[mcpcontract.SyncPullRequestFeedbackInput, mcpcontract.JobReference]{name: mcpcontract.ToolSyncPullRequestFeedback, title: "Synchronize feedback for exact pull requests", description: "Use this when the pull-request numbers are already known and you need current issue comments, submitted reviews, inline comments, or review-thread topology for 1-50 exact pull requests. It performs bounded GitHub network reads and writes only local corpus observations; it never mutates GitHub and does not discover other PRs. Poll jobs.get, then read the returned feedback resources; for repository-wide author or state audits, use github.index_pull_request_feedback instead.", annotations: networkReadAnnotations(), supportedBy: supports[PullRequestFeedbackOperator], input: inputSchema[mcpcontract.SyncPullRequestFeedbackInput](func(sc *schemaBuilder) { - setArrayBounds(sc, "pull_requests", 1, 50) + addCatalogTool(s, catalogTool[mcpcontract.CompareForkInput, mcpcontract.ForkFreshnessOutput]{name: mcpcontract.ToolCompareFork, title: "Compare a contributor fork", description: "Verify one explicit contributor fork relationship and compare its default branch with the upstream default branch. Performs bounded GitHub reads, never updates either repository, and reports unavailable evidence as unknown rather than current.", annotations: externalReadAnnotations(), supportedBy: supports[ForkComparisonReader], input: inputSchema[mcpcontract.CompareForkInput](noSchemaCustomization), output: outputSchema[mcpcontract.ForkFreshnessOutput]("Explicit upstream/fork relationship and default-branch ancestry evidence."), handler: s.compareFork}) + addCatalogTool(s, catalogTool[mcpcontract.SyncPullRequestFeedbackInput, mcpcontract.JobReference]{name: mcpcontract.ToolSyncPullRequestFeedback, title: "Synchronize feedback for exact pull requests", description: "Use this when the pull-request numbers are already known and you need current issue comments, submitted reviews, inline comments, or review-thread topology for 1-100 exact pull requests. One durable job preserves ordered outcomes and serializes feedback persistence, so callers do not need to split and parallelize a known set. It performs bounded GitHub network reads and writes only local corpus observations; it never mutates GitHub and does not discover other PRs. Poll jobs.get, then read the returned feedback resources; for repository-wide author or state audits, use github.index_pull_request_feedback instead.", annotations: networkReadAnnotations(), supportedBy: supports[PullRequestFeedbackOperator], input: inputSchema[mcpcontract.SyncPullRequestFeedbackInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "pull_requests", 1, 100) constrainPullRequestRefs(sc, "pull_requests") setArrayBounds(sc, "channels", 1, 4) setArrayEnum(sc, "channels", "issue_comments", "submitted_reviews", "inline_comments", "review_threads") @@ -310,10 +250,11 @@ func (s *Server) registerScalable() { setRange(sc, "max_items_per_channel", 1, 1000) setRange(sc, "max_requests", 1, 1000) }), output: outputSchema[mcpcontract.JobReference]("Reference to a bounded pull-request feedback job."), handler: s.syncPullRequestFeedback}) - addCatalogTool(s, catalogTool[mcpcontract.IndexPullRequestFeedbackInput, mcpcontract.JobReference]{name: mcpcontract.ToolIndexPullRequestFeedback, title: "Find every repository feedback comment by exact author", description: "Use this for repository-wide audits such as finding every pull-request comment written by a reviewer or bot. It discovers every reachable PR with state=all, then performs bounded GitHub reads and writes only local feedback observations; it never mutates GitHub. Poll jobs.get, then call corpus.search_pull_request_feedback with the exact feedback_author login. Missing, truncated, or partial discovery is unknown, not absence; follow the returned recovery action and retry the offline search.", annotations: networkReadAnnotations(), supportedBy: supports[PullRequestFeedbackIndexer], input: inputSchema[mcpcontract.IndexPullRequestFeedbackInput](func(sc *schemaBuilder) { + addCatalogTool(s, catalogTool[mcpcontract.IndexPullRequestFeedbackInput, mcpcontract.JobReference]{name: mcpcontract.ToolIndexPullRequestFeedback, title: "Find repository feedback comments by exact author", description: "Use this for repository-wide audits such as finding pull-request comments written by a reviewer or bot. It discovers pull requests in the selected state (open, closed, or all; default all), then performs bounded GitHub reads and writes only local feedback observations; it never mutates GitHub. Poll jobs.get, then call corpus.search_pull_request_feedback with the exact feedback_author login. Missing, truncated, or partial discovery is unknown, not absence; follow the returned recovery action and retry the offline search.", annotations: networkReadAnnotations(), supportedBy: supports[PullRequestFeedbackIndexer], input: inputSchema[mcpcontract.IndexPullRequestFeedbackInput](func(sc *schemaBuilder) { setArrayBounds(sc, "channels", 1, 4) setArrayEnum(sc, "channels", "issue_comments", "submitted_reviews", "inline_comments", "review_threads") property(sc, "channels").UniqueItems = true + setEnum(sc, "state", "open", "closed", "all") setEnum(sc, "thread_state", "unresolved", "all") setRange(sc, "max_pull_requests", 1, 1000) setRange(sc, "max_items_per_channel", 1, 1000) @@ -447,20 +388,6 @@ func (s *Server) getThreadFacets(ctx context.Context, _ *mcp.CallToolRequest, in out, err := r.GetThreadFacets(ctx, in) return nil, out, err } -func (s *Server) rankOpportunities(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.RankOpportunitiesInput) (*mcp.CallToolResult, mcpcontract.RankOpportunitiesOutput, error) { - if in.Limit == 0 { - in.Limit = 20 - } - if in.MaxResultsPerRepository == 0 { - in.MaxResultsPerRepository = 10 - } - r, err := s.scalableReader() - if err != nil { - return nil, mcpcontract.RankOpportunitiesOutput{}, err - } - out, err := r.RankOpportunities(ctx, in) - return nil, out, err -} func (s *Server) findPrecedents(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.FindPrecedentsInput) (*mcp.CallToolResult, mcpcontract.FindPrecedentsOutput, error) { if in.Limit == 0 { in.Limit = 20 @@ -563,15 +490,6 @@ func (s *Server) ensureCoverage(ctx context.Context, _ *mcp.CallToolRequest, in return nil, out, err } -func (s *Server) prepareIssueSet(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.PrepareIssueSetInput) (*mcp.CallToolResult, mcpcontract.PrepareIssueSetOutput, error) { - r, ok := s.reader.(IssueSetReader) - if !ok { - return nil, mcpcontract.PrepareIssueSetOutput{}, errors.New("issue-set preparation is not available") - } - out, err := r.PrepareIssueSet(ctx, in) - return nil, out, err -} - func (s *Server) searchGitHubRepositories(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchGitHubRepositoriesInput) (*mcp.CallToolResult, mcpcontract.SearchGitHubRepositoriesOutput, error) { if err := validateRepositorySearchInput(in); err != nil { return nil, mcpcontract.SearchGitHubRepositoriesOutput{}, err @@ -636,23 +554,6 @@ func (s *Server) hydrateThreads(ctx context.Context, _ *mcp.CallToolRequest, in return nil, out, err } -func (s *Server) mineRepositoryFixPatterns(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.MineRepositoryFixPatternsInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - operator, ok := s.reader.(FixPatternOperator) - if !ok { - return nil, mcpcontract.JobReference{}, errors.New("repository fix-pattern mining is not available") - } - out, err := operator.MineRepositoryFixPatterns(ctx, in) - return nil, out, err -} - -func (s *Server) previewRepositoryFixPatterns(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.PreviewRepositoryFixPatternsInput) (*mcp.CallToolResult, mcpcontract.FixPatternReport, error) { - reader, ok := s.reader.(FixPatternPreviewReader) - if !ok { - return nil, mcpcontract.FixPatternReport{}, errors.New("fix-pattern preview is not available") - } - out, err := reader.PreviewRepositoryFixPatterns(ctx, in) - return nil, out, err -} func (s *Server) syncPortfolio(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncPortfolioInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { if in.Repository != nil { repository, err := normalizeLiveRepository(*in.Repository) @@ -755,6 +656,7 @@ func (s *Server) checkMergeConflicts(ctx context.Context, _ *mcp.CallToolRequest out, err := op.CheckMergeConflicts(ctx, in) return nil, out, err } + func (s *Server) deepWiki(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.DeepWikiInput) (*mcp.CallToolResult, mcpcontract.DeepWikiOutput, error) { op, ok := s.reader.(ResearchReader) if !ok { @@ -763,7 +665,6 @@ func (s *Server) deepWiki(ctx context.Context, _ *mcp.CallToolRequest, in mcpcon out, err := op.DeepWiki(ctx, in) return nil, out, err } - func setArrayBounds(schema *schemaBuilder, name string, minimum, maximum int) { p := property(schema, name) if p == nil { diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index 309cf793..fa3fdec1 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -102,31 +102,6 @@ func inferredSchema[T any]() schemaDefinition { Description: "Terminal job outcome; omitted until execution is terminal.", Enum: []any{mcpcontract.JobOutcomeSucceeded, mcpcontract.JobOutcomePartial, mcpcontract.JobOutcomeFailed, mcpcontract.JobOutcomeCancelled}, }, - reflect.TypeFor[mcpcontract.FixPatternOutcome](): { - Type: "string", - Description: "Pull-request outcome; merged state comes from GitHub and superseded requires an explicit replacement relationship.", - Enum: []any{mcpcontract.FixPatternMerged, mcpcontract.FixPatternClosedUnmerged, mcpcontract.FixPatternSuperseded, mcpcontract.FixPatternOpen, mcpcontract.FixPatternUnknown}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelationship](): { - Type: "string", - Description: "Evidence connecting a pull request to an issue.", - Enum: []any{mcpcontract.FixPatternCloses, mcpcontract.FixPatternReferences, mcpcontract.FixPatternExplicitReplacement, mcpcontract.FixPatternSimilarityOnly}, - }, - reflect.TypeFor[mcpcontract.FixPatternReportStatus](): { - Type: "string", - Description: "Whether the bounded report is complete or retains coverage limits or failures.", - Enum: []any{mcpcontract.FixPatternReportComplete, mcpcontract.FixPatternReportPartial}, - }, - reflect.TypeFor[mcpcontract.FixPatternProofStyle](): { - Type: "string", - Description: "Evidence style detected in stored pull-request text.", - Enum: []any{mcpcontract.FixPatternRegressionTest, mcpcontract.FixPatternReproduction, mcpcontract.FixPatternBenchmark, mcpcontract.FixPatternBeforeAfter, mcpcontract.FixPatternScreenshot}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelatedKind](): { - Type: "string", - Description: "Stored thread kind of a related target.", - Enum: []any{mcpcontract.FixPatternRelatedIssue, mcpcontract.FixPatternRelatedPullRequest}, - }, } provenanceSchema, err := jsonschema.For[corpusReadProvenanceSchemaShape](&jsonschema.ForOptions{TypeSchemas: typeSchemas}) if err != nil { @@ -249,6 +224,48 @@ func constrainPullRequestRefs(builder *schemaBuilder, name string) { } } +func configureFixPatternAnalysisSchema(builder *schemaBuilder) { + symptom := builder.schema.Defs["FixPatternSymptom"] + if symptom == nil { + if taxonomy := property(builder, "symptom_taxonomy"); taxonomy != nil { + symptom = taxonomy.Items + } + } + if symptom == nil { + *builder.err = fmt.Errorf("MCP schema definition %q not found", "FixPatternSymptom") + return + } + symptomBuilder := &schemaBuilder{schema: symptom, err: builder.err} + if name := property(symptomBuilder, "name"); name != nil { + name.MinLength = jsonschema.Ptr(1) + name.Pattern = nonWhitespacePattern + } + if terms := property(symptomBuilder, "terms"); terms != nil { + terms.MinItems = jsonschema.Ptr(1) + terms.MaxItems = jsonschema.Ptr(12) + terms.UniqueItems = true + if terms.Items != nil { + terms.Items.MinLength = jsonschema.Ptr(1) + terms.Items.Pattern = nonWhitespacePattern + } + } + + window := builder.schema.Defs["FixPatternTimeWindow"] + if window == nil { + window = property(builder, "time_window") + } + if window == nil { + *builder.err = fmt.Errorf("MCP schema definition %q not found", "FixPatternTimeWindow") + return + } + windowBuilder := &schemaBuilder{schema: window, err: builder.err} + for _, name := range []string{"updated_after", "updated_before"} { + if value := property(windowBuilder, name); value != nil { + value.Format = "date-time" + } + } +} + func setConst(schema *schemaBuilder, name string, value any) { p := property(schema, name) if p != nil { diff --git a/internal/mcpserver/schemas_test.go b/internal/mcpserver/schemas_test.go index db5ac8b6..00009bdd 100644 --- a/internal/mcpserver/schemas_test.go +++ b/internal/mcpserver/schemas_test.go @@ -15,11 +15,11 @@ func TestInferredSchemaIsFreshByType(t *testing.T) { } func TestCustomizedSchemasDoNotShareMutableState(t *testing.T) { - first := inputSchema[mcpcontract.SearchCodeInput](func(schema *schemaBuilder) { + first := inputSchema[mcpcontract.SearchCodeBatchInput](func(schema *schemaBuilder) { setRange(schema, "limit", 1, 7) requireTogether(schema, "owner", "repo") }) - second := inputSchema[mcpcontract.SearchCodeInput](func(schema *schemaBuilder) { + second := inputSchema[mcpcontract.SearchCodeBatchInput](func(schema *schemaBuilder) { setRange(schema, "limit", 1, 99) }) if first.err != nil || second.err != nil { @@ -57,6 +57,28 @@ func TestNestedDefinitionsAndArrayItemsRemainCustomizable(t *testing.T) { } } +func TestFixPatternSchemaAdvertisesNestedHandlerConstraints(t *testing.T) { + definition := inputSchema[mcpcontract.AnalyzeFixPatternsInput](configureFixPatternAnalysisSchema) + if definition.err != nil { + t.Fatal(definition.err) + } + symptom := definition.schema.Defs["FixPatternSymptom"] + if symptom == nil { + symptom = definition.schema.Properties["symptom_taxonomy"].Items + } + name, terms := symptom.Properties["name"], symptom.Properties["terms"] + if name == nil || name.Pattern != nonWhitespacePattern || terms == nil || terms.MinItems == nil || *terms.MinItems != 1 || terms.MaxItems == nil || *terms.MaxItems != 12 || !terms.UniqueItems || terms.Items == nil || terms.Items.Pattern != nonWhitespacePattern { + t.Fatalf("symptom schema = %#v", symptom) + } + window := definition.schema.Defs["FixPatternTimeWindow"] + if window == nil { + window = definition.schema.Properties["time_window"] + } + if window.Properties["updated_after"].Format != "date-time" || window.Properties["updated_before"].Format != "date-time" { + t.Fatalf("time-window schema = %#v", window) + } +} + func TestServerConstructionProducesStableCatalog(t *testing.T) { first, err := New(&fakeReader{}, "test") if err != nil { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 921b16f8..263b23c3 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -30,22 +30,33 @@ type NeighborReader interface { type ScalableReader interface { GetRepositories(context.Context, mcpcontract.GetRepositoriesInput) (mcpcontract.GetRepositoriesOutput, error) GetThreads(context.Context, mcpcontract.GetThreadsInput) (mcpcontract.GetThreadsOutput, error) - RankOpportunities(context.Context, mcpcontract.RankOpportunitiesInput) (mcpcontract.RankOpportunitiesOutput, error) FindPrecedents(context.Context, mcpcontract.FindPrecedentsInput) (mcpcontract.FindPrecedentsOutput, error) GetJobs(context.Context, mcpcontract.GetJobsInput) (mcpcontract.GetJobsOutput, error) } +// DossierMaterializer owns the explicit local write that refreshes a derived +// dossier projection. It must remain offline. +type DossierMaterializer interface { + MaterializeRepositoryDossier(context.Context, mcpcontract.MaterializeRepositoryDossierInput) (mcpcontract.DossierOutput, error) +} + +// FixPatternAnalyzer exposes reusable deterministic repository-history +// analysis without acquiring or persisting source data. +type FixPatternAnalyzer interface { + AnalyzeFixPatterns(context.Context, mcpcontract.AnalyzeFixPatternsInput) (mcpcontract.AnalyzeFixPatternsOutput, error) +} + +// ContributionCandidateRanker exposes the application-owned deterministic +// cross-repository Radar use case as an offline read. +type ContributionCandidateRanker interface { + RankContributionCandidates(context.Context, mcpcontract.RankContributionCandidatesInput) (mcpcontract.RankContributionCandidatesOutput, error) +} + // ThreadFacetReader exposes the bounded offline facet metadata surface. type ThreadFacetReader interface { GetThreadFacets(context.Context, mcpcontract.GetThreadFacetsInput) (mcpcontract.GetThreadFacetsOutput, error) } -// IssueSetReader prepares bounded contribution evidence from exact stored -// issues without requiring or creating durable workflow state. -type IssueSetReader interface { - PrepareIssueSet(context.Context, mcpcontract.PrepareIssueSetInput) (mcpcontract.PrepareIssueSetOutput, error) -} - // PortfolioReader exposes bounded offline pull-request portfolio reads. type PortfolioReader interface { ListPullRequestPortfolio(context.Context, mcpcontract.ListPullRequestPortfolioInput) (mcpcontract.ListPullRequestPortfolioOutput, error) @@ -58,12 +69,6 @@ type PortfolioOperator interface { LinkPullRequest(context.Context, mcpcontract.LinkPullRequestInput) (mcpcontract.LinkPullRequestOutput, error) } -// ContributionPreflightReader performs a bounded live read plus local -// worktree inspection without creating or mutating workflow state. -type ContributionPreflightReader interface { - PreflightContribution(context.Context, mcpcontract.ContributionPreflightInput) (mcpcontract.ContributionPreflightOutput, error) -} - // GitHubOperator exposes bounded GitHub reads that update only the local corpus. type GitHubOperator interface { SearchGitHubRepositories(context.Context, mcpcontract.SearchGitHubRepositoriesInput) (mcpcontract.SearchGitHubRepositoriesOutput, error) @@ -92,6 +97,18 @@ type GitHubActorOperator interface { SyncUserContributions(context.Context, mcpcontract.SyncUserContributionsInput) (mcpcontract.JobReference, error) } +// AuthenticatedIdentityReader exposes the configured GitHub account as one +// explicit provider read. +type AuthenticatedIdentityReader interface { + GetAuthenticatedIdentity(context.Context, mcpcontract.GetAuthenticatedIdentityInput) (mcpcontract.AuthenticatedIdentityOutput, error) +} + +// ForkComparisonReader verifies one explicit upstream/fork relationship and +// its default-branch ancestry without mutating either repository. +type ForkComparisonReader interface { + CompareFork(context.Context, mcpcontract.CompareForkInput) (mcpcontract.ForkFreshnessOutput, error) +} + type ActorReader interface { SearchActors(context.Context, mcpcontract.SearchActorsInput) (mcpcontract.SearchActorsOutput, error) GetActors(context.Context, mcpcontract.GetActorsInput) (mcpcontract.GetActorsOutput, error) @@ -99,13 +116,6 @@ type ActorReader interface { SearchContributions(context.Context, mcpcontract.SearchContributionsInput) (mcpcontract.SearchContributionsOutput, 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) } @@ -129,31 +139,6 @@ type CIFailureOperator interface { SyncCIFailures(context.Context, mcpcontract.SyncCIFailuresInput) (mcpcontract.JobReference, error) } -// FixPatternOperator owns the bounded repository-level search, finalist -// hydration, and typed report workflow. It performs GitHub reads and updates -// only the local corpus. -type FixPatternOperator interface { - MineRepositoryFixPatterns(context.Context, mcpcontract.MineRepositoryFixPatternsInput) (mcpcontract.JobReference, error) -} - -// FixPatternReader exposes only terminal persisted reports and remains offline. -type FixPatternReader interface { - GetFixPatternReport(context.Context, string) (mcpcontract.FixPatternReport, error) -} - -// FixPatternPreviewReader exposes bounded analytical fix-pattern reads without -// job creation, hydration, persistence, or network access. -type FixPatternPreviewReader interface { - PreviewRepositoryFixPatterns(context.Context, mcpcontract.PreviewRepositoryFixPatternsInput) (mcpcontract.FixPatternReport, error) -} - -// FixPatternWorkflow keeps submission and persisted report retrieval together -// so an advertised workflow never returns an unreadable resource link. -type FixPatternWorkflow interface { - FixPatternOperator - FixPatternReader -} - // CodeIndexer safely acquires and indexes repository code. type CodeIndexer interface { IndexRepositories(context.Context, mcpcontract.IndexRepositoriesInput) (mcpcontract.JobReference, error) @@ -200,7 +185,6 @@ type ResearchReader interface { // Operator is the optional explicit network-read/local-write capability. type Operator interface { - BuildRepositoryDossier(context.Context, mcpcontract.BuildRepositoryDossierInput) (mcpcontract.JobReference, error) StartInvestigation(context.Context, mcpcontract.StartInvestigationInput) (mcpcontract.InvestigationOutput, error) RecordHypothesis(context.Context, mcpcontract.RecordHypothesisInput) (mcpcontract.HypothesisOutput, error) CheckDuplicates(context.Context, mcpcontract.CheckDuplicatesInput) (mcpcontract.CheckOutput, error) @@ -251,8 +235,6 @@ type PublishedDraftVerifier interface { // SourceRef records provenance for an MCP result or workflow artifact. -// SearchCodeInput describes an offline code search page. - // CodeMatchOutput identifies one stored code match. // CodeIndexCoverageOutput reports one selected snapshot's indexing coverage. @@ -408,20 +390,11 @@ func (s *Server) ServeStdio(ctx context.Context) error { func (s *Server) register() { readOnly := readOnlyAnnotations() - addCatalogTool(s, catalogTool[mcpcontract.SearchCodeInput, mcpcontract.SearchCodeOutput]{ - name: mcpcontract.ToolSearchCode, title: "Search stored code", - description: "Search indexed code and return bounded snippets plus selected-snapshot coverage, including for zero scoped matches. Missing, unknown, or truncated indexes include an exact typed code.index_repositories recovery action, while additional result pages include a typed cursor action. Optional owner/repo scope; offline.", - annotations: readOnly, input: inputSchema[mcpcontract.SearchCodeInput](func(schema *schemaBuilder) { - setRange(schema, "limit", 1, 100) - setDefault(schema, "limit", 20) - 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], + description: "Run 1-20 ordered code searches against one shared immutable local corpus revision. This is the canonical single-query and batch route; one-query calls may continue with the returned cursor. Offline, with no GitHub fallback or mutation.", + annotations: readOnly, input: inputSchema[mcpcontract.SearchCodeBatchInput](func(sc *schemaBuilder) { requireTogether(sc, "owner", "repo") setArrayBounds(sc, "queries", 1, 20) @@ -478,28 +451,19 @@ func (s *Server) register() { func boolPtr(v bool) *bool { return &v } -func (s *Server) searchCode(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchCodeInput) (*mcp.CallToolResult, mcpcontract.SearchCodeOutput, error) { - in.Query = strings.TrimSpace(in.Query) - if in.Query == "" { - return nil, mcpcontract.SearchCodeOutput{}, mcpcontract.InvalidArgument("query", "is required", map[string]any{"query": "MIDI"}) - } - if in.Limit == 0 { - in.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { - return nil, mcpcontract.SearchCodeOutput{}, mcpcontract.InvalidArgument("limit", "must be between 1 and 100", map[string]any{"limit": 20}) - } - if (in.Owner == "") != (in.Repo == "") { - return nil, mcpcontract.SearchCodeOutput{}, mcpcontract.InvalidArgument("owner", "owner and repo must be provided together", map[string]any{"owner": "acme", "repo": "synth"}) - } - out, err := s.reader.SearchCode(ctx, in) - 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"}}) } + for i := range in.Queries { + in.Queries[i] = strings.TrimSpace(in.Queries[i]) + if in.Queries[i] == "" { + return nil, mcpcontract.SearchCodeBatchOutput{}, mcpcontract.InvalidArgument("queries", "must not contain empty queries", map[string]any{"queries": []string{"MIDI", "latency"}}) + } + } + if in.Cursor != "" && len(in.Queries) != 1 { + return nil, mcpcontract.SearchCodeBatchOutput{}, mcpcontract.InvalidArgument("cursor", "requires exactly one query", nil) + } if in.Limit == 0 { in.Limit = 20 } @@ -509,11 +473,7 @@ func (s *Server) searchCodeBatch(ctx context.Context, _ *mcp.CallToolRequest, in 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) + out, err := s.reader.SearchCodeBatch(ctx, in) return nil, out, err } diff --git a/internal/mcpserver/server_contract_test.go b/internal/mcpserver/server_contract_test.go index 7df9af00..8624dd62 100644 --- a/internal/mcpserver/server_contract_test.go +++ b/internal/mcpserver/server_contract_test.go @@ -180,6 +180,13 @@ func TestDurableProducerReferencesRoundTripThroughResources(t *testing.T) { uri string kind string }{ + { + name: "materialize dossier", + tool: mcpcontract.ToolMaterializeRepositoryDossier, + args: map[string]any{"owner": "acme", "repo": "rocket"}, + uri: "gitcontribute://dossier/acme/rocket", + kind: "repository_dossier", + }, { name: "record hypothesis returns parent investigation", tool: mcpcontract.ToolRecordHypothesis, @@ -434,22 +441,6 @@ func TestToolsAreReadOnlyAndReturnStructuredOutput(t *testing.T) { } } -func TestRankOpportunitiesAcceptsPercentageScoreAndCategoricalConfidence(t *testing.T) { - tools, closeSessions := listedTools(t) - defer closeSessions() - if tools[mcpcontract.ToolRankThreads] != nil { - t.Fatal("removed ranking workflow was advertised") - } -} - -func TestRankOpportunitiesRejectsOutOfRangeOutputAtProtocolBoundary(t *testing.T) { - tools, closeSessions := listedTools(t) - defer closeSessions() - if tools[mcpcontract.ToolRankThreads] != nil { - t.Fatal("removed ranking workflow was advertised") - } -} - func TestMultiModeSchemasAcceptEveryBranch(t *testing.T) { reader := &fakeReader{searchStarted: make(chan struct{})} client, closeSessions := connect(t, reader) diff --git a/internal/mcpserver/server_input_resources_test.go b/internal/mcpserver/server_input_resources_test.go index ee534cef..5dfd85b5 100644 --- a/internal/mcpserver/server_input_resources_test.go +++ b/internal/mcpserver/server_input_resources_test.go @@ -103,7 +103,7 @@ func TestReadResourceRoutesEscapedFeedbackIDAsOneOpaqueSegment(t *testing.T) { func TestSearchCodeRejectsWhitespaceOnlyQuery(t *testing.T) { server := &Server{reader: &fakeReader{}} - _, _, err := server.searchCode(context.Background(), nil, mcpcontract.SearchCodeInput{Query: " \t "}) + _, _, err := server.searchCodeBatch(context.Background(), nil, mcpcontract.SearchCodeBatchInput{Owner: "acme", Repo: "rocket", Queries: []string{" \t "}}) if err == nil { t.Fatal("whitespace-only code search query was accepted") } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 6e992dec..6694434f 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -14,7 +14,6 @@ import ( type fakeReader struct { searchStarted chan struct{} validationInput mcpcontract.RunValidationInput - radarScore int calls map[string]int } @@ -30,7 +29,6 @@ type canonicalIDReader struct { manifest mcpcontract.ExportManifestInput junit mcpcontract.AttachJUnitReportInput explanation mcpcontract.ExplainMatchInput - dossier mcpcontract.BuildRepositoryDossierInput investigation mcpcontract.StartInvestigationInput concern mcpcontract.CreateConcernInput commitInspect mcpcontract.InspectCommitChangesInput @@ -87,11 +85,6 @@ func (r *canonicalIDReader) ExplainMatch(_ context.Context, in mcpcontract.Expla return mcpcontract.ExplainMatchOutput{Owner: in.Owner, Repo: in.Repo, Kind: in.Kind}, nil } -func (r *canonicalIDReader) BuildRepositoryDossier(_ context.Context, in mcpcontract.BuildRepositoryDossierInput) (mcpcontract.JobReference, error) { - r.dossier = in - return mcpcontract.JobReference{ID: "job-dossier", Status: "queued"}, nil -} - func (r *canonicalIDReader) StartInvestigation(_ context.Context, in mcpcontract.StartInvestigationInput) (mcpcontract.InvestigationOutput, error) { r.investigation = in return mcpcontract.InvestigationOutput{ID: "inv-1", Owner: in.Owner, Repo: in.Repo}, nil @@ -177,15 +170,6 @@ func (r *canonicalRepositoryReader) SearchPullRequestFeedback(_ context.Context, var _ PublishedDraftVerifier = (*fakeReader)(nil) var _ ValidationReceiptOperator = (*fakeReader)(nil) -func (*fakeReader) GetFixPatternReport(context.Context, string) (mcpcontract.FixPatternReport, error) { - return mcpcontract.FixPatternReport{ - Status: "complete", - Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, - TimeWindow: mcpcontract.FixPatternTimeWindow{UpdatedAfter: "2026-07-01T00:00:00Z"}, - Coverage: mcpcontract.FixPatternCoverage{UniqueCandidates: 21}, - }, nil -} - func (f *fakeReader) recordCall(name string) { if f.calls == nil { f.calls = make(map[string]int) @@ -289,20 +273,8 @@ func (*fakeReader) Dossier(context.Context, mcpcontract.RepoInput) (mcpcontract. return mcpcontract.DossierOutput{Owner: "acme", Repo: "rocket", Sections: mcpcontract.DossierSections{OpenIssues: 1}}, nil } -func (*fakeReader) SearchCode(_ context.Context, in mcpcontract.SearchCodeInput) (mcpcontract.SearchCodeOutput, error) { - return mcpcontract.SearchCodeOutput{ - Query: in.Query, - Total: 1, - Matches: []mcpcontract.CodeMatchOutput{{ - ID: "owner/repo@abc:main.go", - Repo: "owner/repo", - Commit: "abc", - Path: "main.go", - Language: "go", - Snippet: "func main()", - Bytes: 12, - }}, - }, nil +func (*fakeReader) SearchCodeBatch(_ context.Context, in mcpcontract.SearchCodeBatchInput) (mcpcontract.SearchCodeBatchOutput, error) { + return mcpcontract.SearchCodeBatchOutput{Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: in.Owner, Repo: in.Repo}}, nil } func (*fakeReader) Investigation(_ context.Context, in mcpcontract.InvestigationInput) (mcpcontract.InvestigationOutput, error) { @@ -456,14 +428,6 @@ func (*fakeReader) ThreadByNumber(_ context.Context, in mcpcontract.ThreadByNumb return mcpcontract.ThreadOutput{Owner: in.Owner, Repo: in.Repo, Kind: "issue", Number: in.Number, Title: "issue"}, nil } -func (*fakeReader) BuildRepositoryDossier(_ context.Context, in mcpcontract.BuildRepositoryDossierInput) (mcpcontract.JobReference, error) { - id := "job-dossier-" + in.Owner + "-" + in.Repo - return mcpcontract.JobReference{ - ID: id, Ref: "job:" + id, Kind: "build_repository_dossier", Status: "queued", PollAfterMS: 1000, - FollowUp: &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.GetJobsInput{IDs: []string{"job-1"}}), Reason: "Poll this durable job ID."}, - }, nil -} - func (f *fakeReader) StartInvestigation(_ context.Context, in mcpcontract.StartInvestigationInput) (mcpcontract.InvestigationOutput, error) { f.recordCall("start_investigation") return mcpcontract.InvestigationOutput{ID: "inv-1", Owner: in.Owner, Repo: in.Repo, Status: "open"}, nil @@ -652,14 +616,6 @@ func TestReadOnlyToolsReturnStructuredOutput(t *testing.T) { t.Fatalf("marshal %s: %v", tt.name, err) } switch tt.name { - case mcpcontract.ToolSearchCode: - var out mcpcontract.SearchCodeOutput - if err := json.Unmarshal(payload, &out); err != nil { - t.Fatalf("decode %s: %v", tt.name, err) - } - if out.Total != tt.wantTotal || len(out.Matches) != tt.wantTotal { - t.Fatalf("%s output = %+v", tt.name, out) - } case mcpcontract.ToolFindClusters: var out mcpcontract.FindClustersOutput if err := json.Unmarshal(payload, &out); err != nil { @@ -708,32 +664,3 @@ func TestInvestigationOpportunityEvidenceResources(t *testing.T) { t.Fatal("expected resource-not-found error") } } - -func TestFixPatternResourceTemplateTracksReaderCapability(t *testing.T) { - base := &fakeReader{searchStarted: make(chan struct{})} - for _, test := range []struct { - name string - reader mcpcontract.Reader - want bool - }{ - {name: "supported", reader: base, want: false}, - {name: "unsupported", reader: struct{ mcpcontract.Reader }{Reader: base}}, - } { - t.Run(test.name, func(t *testing.T) { - client, closeSessions := connect(t, test.reader) - defer closeSessions() - found := false - for template, err := range client.ResourceTemplates(context.Background(), nil) { - if err != nil { - t.Fatal(err) - } - if template.URITemplate == "gitcontribute://fix-pattern-report/{job_id}" { - found = true - } - } - if found != test.want { - t.Fatalf("fix-pattern resource template advertised = %t, want %t", found, test.want) - } - }) - } -} diff --git a/internal/mcpserver/server_workflows_test.go b/internal/mcpserver/server_workflows_test.go index e0cc72c2..6a7fa857 100644 --- a/internal/mcpserver/server_workflows_test.go +++ b/internal/mcpserver/server_workflows_test.go @@ -131,7 +131,7 @@ func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { if _, _, err := server.recordHypothesis(ctx, nil, mcpcontract.RecordHypothesisInput{InvestigationID: " inv-1 ", Title: "title", Description: "description", Category: "bug"}); err != nil { t.Fatal(err) } - if _, _, err := server.findRelatedWork(ctx, nil, mcpcontract.FindRelatedWorkInput{Target: " HYPOTHESIS ", ID: " hyp-1 ", Kinds: []string{"duplicates"}, Limit: 1}); err != nil { + if _, _, err := server.findDuplicates(ctx, nil, mcpcontract.CheckDuplicatesInput{Target: " HYPOTHESIS ", ID: " hyp-1 ", Limit: 1}); err != nil { t.Fatal(err) } if _, _, err := server.promoteOpportunity(ctx, nil, mcpcontract.PromoteOpportunityInput{HypothesisID: " hyp-1 ", ProblemStatement: " problem ", Scope: " scope ", Impact: " impact ", ExpectedEffort: " small "}); err != nil { @@ -152,9 +152,6 @@ func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { if _, _, err := server.explainMatch(ctx, nil, mcpcontract.ExplainMatchInput{Owner: " acme ", Repo: " rocket ", Kind: " code ", Path: " main.go ", Commit: " abc123 "}); err != nil { t.Fatal(err) } - if _, _, err := server.buildRepositoryDossier(ctx, nil, mcpcontract.BuildRepositoryDossierInput{Owner: " acme ", Repo: " rocket "}); err != nil { - t.Fatal(err) - } if _, _, err := server.startInvestigation(ctx, nil, mcpcontract.StartInvestigationInput{Owner: " acme ", Repo: " rocket ", CommitSHA: " abc123 ", Lens: " reliability "}); err != nil { t.Fatal(err) } @@ -180,8 +177,8 @@ func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { if reader.explanation.Owner != "acme" || reader.explanation.Repo != "rocket" || reader.explanation.Path != "main.go" || reader.explanation.Commit != "abc123" { t.Fatalf("explanation identity was not canonical: %+v", reader.explanation) } - if reader.dossier.Owner != "acme" || reader.dossier.Repo != "rocket" || reader.investigation.Owner != "acme" || reader.investigation.Repo != "rocket" || reader.investigation.CommitSHA != "abc123" || reader.investigation.Lens != "reliability" { - t.Fatalf("repository workflow identities were not canonical: dossier=%+v investigation=%+v", reader.dossier, reader.investigation) + if reader.investigation.Owner != "acme" || reader.investigation.Repo != "rocket" || reader.investigation.CommitSHA != "abc123" || reader.investigation.Lens != "reliability" { + t.Fatalf("repository workflow identity was not canonical: investigation=%+v", reader.investigation) } if reader.concern.Owner != "acme" || reader.concern.Repo != "rocket" || reader.concern.CommitSHA != "abc123" { t.Fatalf("concern identity was not canonical: %+v", reader.concern) @@ -339,6 +336,11 @@ func TestV1ParityToolsAndResources(t *testing.T) { } } + dossier, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "gitcontribute://dossier/acme/rocket"}) + if err != nil || len(dossier.Contents) != 1 || dossier.Contents[0].Text == "" { + t.Fatalf("read canonical dossier resource: result=%+v err=%v", dossier, err) + } + templates := map[string]bool{} for template, err := range client.ResourceTemplates(context.Background(), nil) { if err != nil { diff --git a/internal/mcpserver/v1.go b/internal/mcpserver/v1.go index b6977902..222e67f8 100644 --- a/internal/mcpserver/v1.go +++ b/internal/mcpserver/v1.go @@ -29,8 +29,6 @@ import ( // V1 operation inputs and outputs. -// BuildRepositoryDossierInput selects a repository for durable dossier generation. - // StartInvestigationInput creates a local investigation for a repository revision. // RecordHypothesisInput records a structured hypothesis and its provenance. @@ -85,12 +83,6 @@ func (s *Server) registerV1() { setDefault(schema, "limit", 20) }), output: outputSchema[mcpcontract.ExplainMatchOutput]("Stored facts and score signals explaining one search match."), handler: s.explainMatch, }) - addCatalogTool(s, catalogTool[mcpcontract.BuildRepositoryDossierInput, mcpcontract.JobReference]{ - name: mcpcontract.ToolBuildRepositoryDossier, title: "Build repository dossier", - description: "Start an asynchronous local job that rebuilds and persists a source-backed dossier from the existing corpus. It performs no network access; read the returned dossier resource after the job succeeds.", - annotations: localWriteAnnotations(true), supportedBy: supports[Operator], input: inputSchema[mcpcontract.BuildRepositoryDossierInput](noSchemaCustomization), - output: outputSchema[mcpcontract.JobReference]("Reference to a newly queued dossier build job."), handler: s.buildRepositoryDossier, - }) addCatalogTool(s, catalogTool[mcpcontract.CreateWorkspaceInput, mcpcontract.JobReference]{ name: mcpcontract.ToolCreateWorkspace, title: "Create managed Git workspace", description: "Start an asynchronous job that clones the specified remote and creates a managed worktree for an investigation. This performs network reads, Git process execution, filesystem writes, and local metadata writes, but never mutates GitHub.", @@ -129,16 +121,23 @@ func (s *Server) registerV1() { setEnum(schema, "category", "bug", "performance", "architecture", "testing", "documentation", "maintenance", "compatibility", "security", "other") }), output: outputSchema[mcpcontract.DurableArtifactReference]("Compact reference to the updated investigation resource."), handler: s.recordHypothesis, }) - addCatalogTool(s, catalogTool[mcpcontract.FindRelatedWorkInput, mcpcontract.FindRelatedWorkOutput]{ - name: mcpcontract.ToolFindRelatedWork, title: "Find related issues and open pull requests", - description: "Search the local corpus for duplicate issues and pull requests, open pull requests that may compete or conflict, or both for one hypothesis or opportunity. Returns status, coverage, truncation, and exact typed recovery actions when the repository is absent or the bounded result is not exhaustive; this records no evidence, tests no Git merge conflicts, and performs no network access.", - annotations: readOnly, supportedBy: supports[Operator], input: inputSchema[mcpcontract.FindRelatedWorkInput](func(schema *schemaBuilder) { + addCatalogTool(s, catalogTool[mcpcontract.CheckDuplicatesInput, mcpcontract.CheckOutput]{ + name: mcpcontract.ToolFindDuplicates, title: "Find duplicate candidates", + description: "Find stored duplicate-candidate issues for one hypothesis or opportunity. Returns explicit local coverage and an exact recovery action when the repository is absent or the bounded result is not exhaustive. Offline; records no evidence.", + annotations: readOnly, supportedBy: supports[Operator], input: inputSchema[mcpcontract.CheckDuplicatesInput](func(schema *schemaBuilder) { setEnum(schema, "target", "hypothesis", "opportunity") - setArrayBounds(schema, "kinds", 1, 2) - setArrayEnum(schema, "kinds", "duplicates", "competing_pull_requests") setRange(schema, "limit", 1, 100) setDefault(schema, "limit", 20) - }), output: outputSchema[mcpcontract.FindRelatedWorkOutput]("Requested related-work populations grouped by kind."), handler: s.findRelatedWork, + }), output: outputSchema[mcpcontract.CheckOutput]("Bounded duplicate candidates with local coverage."), handler: s.findDuplicates, + }) + addCatalogTool(s, catalogTool[mcpcontract.CheckCollisionsInput, mcpcontract.CheckOutput]{ + name: mcpcontract.ToolFindCompetingPullRequests, title: "Find competing pull requests", + description: "Find stored open pull requests that may compete or conflict with one hypothesis or opportunity. This is semantic related-work analysis, not a Git merge-conflict test. Returns explicit local coverage and exact recovery. Offline; records no evidence.", + annotations: readOnly, supportedBy: supports[Operator], input: inputSchema[mcpcontract.CheckCollisionsInput](func(schema *schemaBuilder) { + setEnum(schema, "target", "hypothesis", "opportunity") + setRange(schema, "limit", 1, 100) + setDefault(schema, "limit", 20) + }), output: outputSchema[mcpcontract.CheckOutput]("Bounded competing pull requests with local coverage."), handler: s.findCompetingPullRequests, }) addCatalogTool(s, catalogTool[mcpcontract.PromoteOpportunityInput, mcpcontract.DurableArtifactReference]{ name: mcpcontract.ToolPromoteOpportunity, title: "Promote hypothesis to opportunity", @@ -315,20 +314,6 @@ func (s *Server) explainMatch(ctx context.Context, _ *mcp.CallToolRequest, in mc return nil, out, err } -func (s *Server) buildRepositoryDossier(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.BuildRepositoryDossierInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - owner, repo, err := normalizeRepository(in.Owner, in.Repo) - if err != nil { - return nil, mcpcontract.JobReference{}, err - } - in.Owner, in.Repo = owner, repo - operator, ok := s.reader.(Operator) - if !ok { - return nil, mcpcontract.JobReference{}, errors.New("dossier build is not available") - } - out, err := operator.BuildRepositoryDossier(ctx, in) - return nil, out, err -} - func (s *Server) startInvestigation(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.StartInvestigationInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { owner, repo, err := normalizeRepository(in.Owner, in.Repo) if err != nil { @@ -378,51 +363,29 @@ func (s *Server) recordHypothesis(ctx context.Context, _ *mcp.CallToolRequest, i return linkedResource(uri, "investigation", "Investigation", "Investigation containing the persisted hypothesis."), ref, nil } -func (s *Server) findRelatedWork(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.FindRelatedWorkInput) (*mcp.CallToolResult, mcpcontract.FindRelatedWorkOutput, error) { - check := mcpcontract.CheckDuplicatesInput{Target: in.Target, ID: in.ID, Limit: in.Limit} - if err := validateCheckInput(&check); err != nil { - return nil, mcpcontract.FindRelatedWorkOutput{}, err - } - if in.Limit == 0 { - in.Limit = 20 - check.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { - return nil, mcpcontract.FindRelatedWorkOutput{}, mcpcontract.InvalidArgument("limit", "must be between 1 and 100", map[string]any{"limit": 20}) +func (s *Server) findDuplicates(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.CheckDuplicatesInput) (*mcp.CallToolResult, mcpcontract.CheckOutput, error) { + if err := validateCheckInput(&in); err != nil { + return nil, mcpcontract.CheckOutput{}, err } operator, ok := s.reader.(Operator) if !ok { - return nil, mcpcontract.FindRelatedWorkOutput{}, errors.New("related-work checks are not available") + return nil, mcpcontract.CheckOutput{}, errors.New("duplicate-candidate analysis is not available") } - kinds := in.Kinds - if len(kinds) == 0 { - kinds = []string{"duplicates", "competing_pull_requests"} + out, err := operator.CheckDuplicates(ctx, in) + return nil, out, err +} + +func (s *Server) findCompetingPullRequests(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.CheckCollisionsInput) (*mcp.CallToolResult, mcpcontract.CheckOutput, error) { + canonical := mcpcontract.CheckDuplicatesInput(in) + if err := validateCheckInput(&canonical); err != nil { + return nil, mcpcontract.CheckOutput{}, err } - var out mcpcontract.FindRelatedWorkOutput - seen := make(map[string]bool, len(kinds)) - for _, kind := range kinds { - if seen[kind] { - return nil, mcpcontract.FindRelatedWorkOutput{}, mcpcontract.InvalidArgument("kinds", "must not contain duplicates", map[string]any{"kinds": []string{"duplicates", "competing_pull_requests"}}) - } - seen[kind] = true - switch kind { - case "duplicates": - value, err := operator.CheckDuplicates(ctx, check) - if err != nil { - return nil, mcpcontract.FindRelatedWorkOutput{}, err - } - out.Duplicates = &value - case "competing_pull_requests": - value, err := operator.CheckCollisions(ctx, mcpcontract.CheckCollisionsInput(check)) - if err != nil { - return nil, mcpcontract.FindRelatedWorkOutput{}, err - } - out.CompetingPullRequests = &value - default: - return nil, mcpcontract.FindRelatedWorkOutput{}, mcpcontract.InvalidArgument("kinds", "must contain duplicates or competing_pull_requests", map[string]any{"kinds": []string{"duplicates", "competing_pull_requests"}}) - } + operator, ok := s.reader.(Operator) + if !ok { + return nil, mcpcontract.CheckOutput{}, errors.New("competing-pull-request analysis is not available") } - return nil, out, nil + out, err := operator.CheckCollisions(ctx, mcpcontract.CheckCollisionsInput(canonical)) + return nil, out, err } func validateCheckInput(in *mcpcontract.CheckDuplicatesInput) error { @@ -431,6 +394,12 @@ func validateCheckInput(in *mcpcontract.CheckDuplicatesInput) error { return err } in.ID = id + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 { + return mcpcontract.InvalidArgument("limit", "must be between 1 and 100", map[string]any{"limit": 20}) + } in.Target = strings.ToLower(strings.TrimSpace(in.Target)) if in.Target != "hypothesis" && in.Target != "opportunity" { return mcpcontract.InvalidArgument("target", "must be hypothesis or opportunity", map[string]any{"target": "hypothesis"}) diff --git a/internal/radar/cross_repository.go b/internal/radar/cross_repository.go new file mode 100644 index 00000000..cb8fa8ed --- /dev/null +++ b/internal/radar/cross_repository.go @@ -0,0 +1,44 @@ +package radar + +import "sort" + +// CrossRepositoryRanking is the deterministic merge of already-computed +// repository reports. Acquisition and repository availability remain owned by +// the application layer. +type CrossRepositoryRanking struct { + Candidates []Candidate + Total int + Truncated bool +} + +// RankAcrossRepositories performs no I/O and applies one stable ordering to +// candidates produced under the same evaluation time and score version. +func RankAcrossRepositories(reports []*Report, limit int) CrossRepositoryRanking { + var ranking CrossRepositoryRanking + for _, report := range reports { + if report == nil { + continue + } + ranking.Total += report.CandidatePopulation + ranking.Truncated = ranking.Truncated || report.PopulationCapped || len(report.Candidates) < report.CandidatePopulation + ranking.Candidates = append(ranking.Candidates, report.Candidates...) + } + sort.SliceStable(ranking.Candidates, func(i, j int) bool { + left, right := ranking.Candidates[i], ranking.Candidates[j] + if left.Eligibility != right.Eligibility { + return eligibilitySeverity(left.Eligibility) < eligibilitySeverity(right.Eligibility) + } + if left.Score != right.Score { + return left.Score > right.Score + } + return left.Ref < right.Ref + }) + if len(ranking.Candidates) > limit { + ranking.Candidates = ranking.Candidates[:limit] + ranking.Truncated = true + } + for index := range ranking.Candidates { + ranking.Candidates[index].Rank = index + 1 + } + return ranking +} diff --git a/internal/radar/cross_repository_test.go b/internal/radar/cross_repository_test.go new file mode 100644 index 00000000..4dd0542e --- /dev/null +++ b/internal/radar/cross_repository_test.go @@ -0,0 +1,17 @@ +package radar + +import "testing" + +func TestRankAcrossRepositoriesUsesEligibilityThenScoreThenRef(t *testing.T) { + reports := []*Report{ + {CandidatePopulation: 2, Candidates: []Candidate{{Ref: "issue:b/z#2", Eligibility: EligibilityNeedsDiagnosis, Score: 99}, {Ref: "issue:a/z#1", Eligibility: EligibilityReadyToCode, Score: 50}}}, + {CandidatePopulation: 1, Candidates: []Candidate{{Ref: "issue:c/z#3", Eligibility: EligibilityReadyToCode, Score: 80}}}, + } + ranking := RankAcrossRepositories(reports, 2) + if !ranking.Truncated || ranking.Total != 3 || len(ranking.Candidates) != 2 || ranking.Candidates[0].Ref != "issue:c/z#3" || ranking.Candidates[1].Ref != "issue:a/z#1" { + t.Fatalf("ranking = %+v", ranking) + } + if ranking.Candidates[0].Rank != 1 || ranking.Candidates[1].Rank != 2 { + t.Fatalf("ranks = %+v", ranking.Candidates) + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 672287d7..d96e977e 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/gofrs/flock" + "github.com/morluto/gitcontribute/internal/mcpcontract" ) const serverName = "gitcontribute" @@ -21,7 +22,7 @@ const codexSkillDir = "gitcontribute" const codexSkillOwnershipMarker = "" -var codexSkillContent = []byte(`--- +var codexSkillContent = []byte(fmt.Sprintf(`--- name: gitcontribute description: > Use for source-backed GitHub contribution workflows: repository and code research, issue drafting and triage, pre-filing duplicate checks, pull request review, contributor portfolio analysis, contribution preparation, competing-work detection, investigations, workspaces, and validation evidence. Trigger on requests such as "check for a related issue before filing", "avoid duplicate reports", or "triage these findings". Do not use for simple one-off GitHub lookups, ordinary local git commands, or GitHub mutations. @@ -31,10 +32,10 @@ description: > When the user's request matches the description above, prefer the GitContribute MCP server. Discover its tools (names prefixed with mcp__gitcontribute__) and choose the narrowest tool for the task. Let the tool schemas and contracts guide arguments; do not invent unsupported fields. -For issue drafting, triage, and duplicate checks: start exact issue audits with workflow.prepare_issue_set or corpus.get_coverage; inspect corpus coverage and freshness in the returned result; when an item is missing or incomplete, replay its typed exact-thread or repository recovery action, poll jobs.get, and reread the stored result before continuing. Search stored threads offline; broaden strict multi-term searches when needed; hydrate only exact finalists; then verify current state with live GitHub before filing. Never treat zero matches as absence when coverage is incomplete or the query may be too strict. +For issue drafting, triage, and duplicate checks: start exact issue audits with %s and %s; inspect corpus coverage and freshness in each result; when an item is missing or incomplete, replay its typed exact-thread or repository recovery action, poll %s, and reread the stored result before continuing. Search stored threads offline; broaden strict multi-term searches when needed; hydrate only exact finalists; then verify current state with live GitHub before filing. Never treat zero matches as absence when coverage is incomplete or the query may be too strict. Use native GitHub tools for final live-state verification and every mutation. If no GitContribute tool fits, fall back to ordinary tools. -`) +`, mcpcontract.ToolGetCoverage, mcpcontract.ToolGetThreads, mcpcontract.ToolGetJob)) type codexSkillState string diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index b3b98a01..96a4f236 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -442,6 +442,9 @@ func TestRunInstallsAndRemovesCodexSkillIdempotently(t *testing.T) { !bytes.Contains(written, []byte("inspect corpus coverage and freshness")) { t.Fatalf("skill lacks issue-triage routing:\n%s", written) } + if bytes.Contains(written, []byte("workflow.prepare_issue_set")) { + t.Fatalf("skill references a removed MCP operation:\n%s", written) + } report, err = Run(Options{Operation: Configure, Clients: []Client{Codex}, Home: home, Executable: "/bin/gitcontribute"}) if err != nil {