From 07d9f65ac7f00c2dfd99adc6e79f8b39b38a1799 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:36:21 +0800 Subject: [PATCH 1/3] refactor: seal remaining domain boundaries --- docs/adr/0006-retire-crawl-frontier.md | 37 + internal/app/app.go | 216 ++--- internal/app/app_test.go | 9 +- internal/app/clustering.go | 8 +- internal/app/collection.go | 28 +- internal/app/concerns.go | 31 +- internal/app/concerns_test.go | 6 +- internal/app/contribution.go | 4 +- internal/app/control.go | 51 +- internal/app/control_test.go | 10 +- internal/app/corpus_lifecycle.go | 16 +- internal/app/corpus_lifecycle_test.go | 2 +- internal/app/coverage_target.go | 2 +- internal/app/discovery.go | 96 +-- internal/app/discovery_test.go | 55 +- internal/app/dossier.go | 12 +- internal/app/dossier_test.go | 34 +- internal/app/draft_verification.go | 9 +- internal/app/draft_verification_test.go | 4 +- internal/app/duplicates_test.go | 23 +- internal/app/evidence.go | 94 ++- internal/app/evidence_freshness.go | 20 +- internal/app/evidence_freshness_test.go | 8 +- internal/app/github_search.go | 67 ++ internal/app/guidance_test.go | 2 +- internal/app/health.go | 6 - internal/app/health_test.go | 6 +- internal/app/hydration.go | 218 ++--- internal/app/hydration_freshness_test.go | 18 +- internal/app/hydration_refresh.go | 22 +- internal/app/hydration_repo.go | 179 ----- internal/app/hydration_request.go | 83 ++ internal/app/hydration_test.go | 157 +--- internal/app/investigation.go | 59 +- internal/app/investigation_thread.go | 2 +- internal/app/investigation_thread_test.go | 2 +- internal/app/job_executor.go | 46 +- .../app/job_executor_terminal_write_test.go | 8 +- internal/app/job_executor_test.go | 10 +- internal/app/jobs.go | 8 +- internal/app/lens.go | 79 +- internal/app/manifest.go | 64 +- internal/app/mcp.go | 63 +- internal/app/mcp_actor_facets.go | 226 ++++-- internal/app/mcp_actor_facets_test.go | 109 +++ internal/app/mcp_actors.go | 166 +++- internal/app/mcp_advanced_reads.go | 114 +-- internal/app/mcp_authored_sync.go | 30 +- internal/app/mcp_code_search.go | 166 ++-- internal/app/mcp_commitplan.go | 2 +- internal/app/mcp_concerns.go | 13 +- internal/app/mcp_contribution_artifacts.go | 20 +- internal/app/mcp_contribution_fork.go | 14 +- internal/app/mcp_contribution_preflight.go | 122 +-- .../app/mcp_contribution_preflight_test.go | 34 +- internal/app/mcp_deepwiki.go | 85 ++ internal/app/mcp_deepwiki_test.go | 30 +- internal/app/mcp_ensure_coverage.go | 75 +- internal/app/mcp_explain_evidence_test.go | 4 +- internal/app/mcp_fix_patterns.go | 308 +++++--- internal/app/mcp_fix_patterns_test.go | 34 +- internal/app/mcp_github_acquisition.go | 322 ++++++-- internal/app/mcp_github_acquisition_test.go | 19 +- internal/app/mcp_issue_set.go | 124 +-- internal/app/mcp_issue_set_test.go | 16 +- internal/app/mcp_job_artifacts.go | 120 ++- internal/app/mcp_jobs.go | 24 +- internal/app/mcp_jobs_test.go | 27 +- internal/app/mcp_local_repository_search.go | 29 +- internal/app/mcp_portfolio_reads.go | 23 +- internal/app/mcp_portfolio_refs.go | 5 +- internal/app/mcp_portfolio_relationships.go | 38 +- .../app/mcp_portfolio_relationships_test.go | 3 +- internal/app/mcp_portfolio_sync.go | 27 +- internal/app/mcp_portfolio_test.go | 20 +- internal/app/mcp_pr_check_wait.go | 174 ++-- internal/app/mcp_pr_check_wait_test.go | 34 +- internal/app/mcp_pr_health.go | 369 ++++++--- internal/app/mcp_pr_workflows.go | 212 ++--- internal/app/mcp_pr_workflows_test.go | 87 +- internal/app/mcp_precedent_reads.go | 4 +- .../app/mcp_pull_request_feedback_index.go | 82 +- .../mcp_pull_request_feedback_index_test.go | 26 +- .../app/mcp_pull_request_feedback_search.go | 55 +- internal/app/mcp_radar_eligibility_test.go | 2 +- internal/app/mcp_read_provenance.go | 17 +- internal/app/mcp_read_provenance_test.go | 30 +- internal/app/mcp_recovery_test.go | 11 +- internal/app/mcp_related_work.go | 143 ++-- internal/app/mcp_repository_search.go | 161 ++-- internal/app/mcp_resource_reads.go | 208 ++--- internal/app/mcp_scalable_inputs_test.go | 10 +- internal/app/mcp_scalable_operations.go | 743 +++++++++++++----- internal/app/mcp_scalable_reads.go | 114 ++- internal/app/mcp_scalable_test.go | 168 +++- internal/app/mcp_snapshot_token_test.go | 13 +- internal/app/mcp_stdio_e2e_test.go | 6 +- internal/app/mcp_test.go | 14 +- internal/app/mcp_thread_facets.go | 80 +- internal/app/mcp_thread_facets_test.go | 20 +- internal/app/mcp_thread_search.go | 102 ++- internal/app/mcp_v1.go | 18 +- internal/app/neighbors.go | 295 ++++--- internal/app/neighbors_test.go | 7 +- internal/app/radar.go | 22 +- internal/app/radar_related_work.go | 34 +- internal/app/radar_test.go | 39 +- internal/app/reader.go | 15 +- internal/app/readiness.go | 8 +- internal/app/readiness_test.go | 2 +- internal/app/repeat_validation_request.go | 178 +++++ .../app/repeat_validation_request_test.go | 46 ++ internal/app/repeated_validation_mcp.go | 45 +- internal/app/research.go | 23 +- internal/app/research_reader.go | 30 +- internal/app/research_test.go | 8 +- internal/app/response_format.go | 35 + internal/app/search.go | 391 ++++----- internal/app/search_request.go | 314 ++++++++ internal/app/search_test.go | 66 +- internal/app/service_dependencies_test.go | 33 + internal/app/setup.go | 169 ++-- internal/app/setup_request.go | 120 +++ internal/app/setup_test.go | 34 +- internal/app/setup_verification_test.go | 13 +- internal/app/surfaces_extra.go | 110 ++- internal/app/surfaces_test.go | 28 +- internal/app/sync_budget.go | 7 +- internal/app/sync_budget_test.go | 34 +- internal/app/sync_headers.go | 44 +- internal/app/sync_metadata_test.go | 6 +- internal/app/sync_options_test.go | 34 +- .../app/sync_repository_context_request.go | 56 ++ internal/app/sync_request.go | 212 +++++ internal/app/sync_thread_operations.go | 40 +- internal/app/sync_threads_request.go | 61 +- internal/app/thread_reference.go | 45 ++ internal/app/tracking.go | 23 +- internal/app/tui.go | 16 +- internal/app/tui_actions.go | 4 +- internal/app/tui_actions_test.go | 6 +- internal/app/tui_test.go | 3 +- internal/app/upgrade.go | 184 ++--- internal/app/upgrade_activation.go | 41 +- internal/app/upgrade_activation_test.go | 35 +- internal/app/upgrade_config_test.go | 21 - internal/app/upgrade_environment.go | 149 ++++ internal/app/upgrade_environment_test.go | 41 + internal/app/upgrade_registration_test.go | 36 +- internal/app/upgrade_setup_test.go | 14 +- internal/app/upgrade_stages.go | 36 +- internal/app/upgrade_test.go | 153 ++-- internal/app/workspace.go | 12 +- internal/cli/cli.go | 51 +- internal/cli/cli_dependencies_test.go | 15 + internal/cli/output.go | 2 +- internal/clustering/candidate_text.go | 7 +- internal/clustering/cluster.go | 2 +- internal/clustering/models.go | 39 +- internal/clustering/neighbors.go | 3 +- internal/clustering/references.go | 2 +- internal/commitplan/models.go | 46 +- internal/commitplan/planner.go | 34 +- internal/concern/models.go | 140 +++- internal/concern/models_test.go | 41 + internal/config/config.go | 62 +- internal/contracts/application_contracts.go | 45 +- internal/contracts/archive_contracts.go | 44 +- .../contracts/setup_tracking_contracts.go | 4 - internal/contribution/models.go | 12 +- internal/contribution/renderer.go | 9 +- internal/contribution/validate.go | 72 +- internal/contribution/validate_test.go | 32 +- internal/corpus/actor_facets.go | 147 ++-- internal/corpus/actor_search_request.go | 141 ++++ internal/corpus/actors.go | 84 +- internal/corpus/actors_test.go | 101 ++- internal/corpus/batch_reads.go | 14 +- internal/corpus/batch_reads_test.go | 16 +- internal/corpus/cluster_governance.go | 20 +- internal/corpus/cluster_projection.go | 79 +- internal/corpus/cluster_projection_test.go | 62 +- internal/corpus/code.go | 83 +- internal/corpus/code_test.go | 2 +- internal/corpus/concerns.go | 50 +- internal/corpus/concerns_test.go | 44 +- .../corpus/contribution_search_request.go | 292 +++++++ .../contribution_search_request_test.go | 73 ++ internal/corpus/control.go | 16 +- internal/corpus/corpus.go | 33 +- internal/corpus/corpus_test.go | 63 +- internal/corpus/discovery.go | 51 +- internal/corpus/discovery_test.go | 26 + internal/corpus/evidence_freshness.go | 23 +- internal/corpus/evidence_freshness_test.go | 23 +- internal/corpus/feedback_search_request.go | 263 +++++++ .../corpus/feedback_search_request_test.go | 54 ++ internal/corpus/feedback_selection.go | 163 ++++ internal/corpus/feedback_selection_test.go | 47 ++ internal/corpus/frontier.go | 323 -------- internal/corpus/frontier_test.go | 173 ---- internal/corpus/hydration.go | 7 - internal/corpus/hydration_test.go | 14 +- internal/corpus/inventory.go | 4 +- internal/corpus/inventory_test.go | 10 +- internal/corpus/jobs.go | 66 +- internal/corpus/jobs_test.go | 34 +- internal/corpus/lifecycle.go | 30 +- internal/corpus/lifecycle_test.go | 43 +- internal/corpus/lock.go | 27 +- internal/corpus/migration_test.go | 63 +- .../migrations/016_retire_crawl_frontier.sql | 62 ++ internal/corpus/models.go | 112 ++- internal/corpus/observations.go | 66 +- internal/corpus/observations_test.go | 54 +- internal/corpus/organize.go | 149 ++-- internal/corpus/organize_test.go | 58 +- internal/corpus/portfolio.go | 17 +- internal/corpus/portfolio_relationships.go | 513 +++++++++--- .../corpus/portfolio_relationships_test.go | 169 +++- internal/corpus/portfolio_test.go | 4 +- internal/corpus/precedent_test.go | 6 +- internal/corpus/projections.go | 68 +- internal/corpus/projections_test.go | 38 +- internal/corpus/pull_request_feedback.go | 95 ++- .../corpus/pull_request_feedback_coverage.go | 154 ++-- .../corpus/pull_request_feedback_search.go | 173 ++-- internal/corpus/pull_request_feedback_test.go | 144 +++- internal/corpus/read_snapshot.go | 102 ++- internal/corpus/read_snapshot_test.go | 35 +- internal/corpus/repository_removal.go | 4 - internal/corpus/repository_removal_test.go | 4 +- internal/corpus/repository_search.go | 43 +- internal/corpus/resolutions.go | 2 +- internal/corpus/resolutions_test.go | 2 +- internal/corpus/restore.go | 18 +- internal/corpus/schema_inspect.go | 2 +- internal/corpus/search.go | 114 ++- internal/corpus/search_modes.go | 347 ++++++++ internal/corpus/search_test.go | 148 ++-- internal/corpus/tracking.go | 60 +- internal/corpus/tracking_outcomes.go | 10 +- internal/corpus/tracking_test.go | 41 +- internal/corpus/workflow.go | 6 +- internal/corpus/workflow_test.go | 26 + internal/corpus/workflow_validation.go | 82 +- internal/deepwiki/client.go | 44 +- internal/deepwiki/client_test.go | 42 +- internal/deepwiki/request.go | 124 +++ internal/deepwiki/request_test.go | 80 ++ internal/discovery/checkpoint.go | 61 -- internal/discovery/checkpoint_store_test.go | 60 ++ internal/discovery/gharchive.go | 132 ++-- internal/discovery/gharchive_fetcher.go | 17 +- .../gharchive_fetcher_support_test.go | 10 + internal/discovery/gharchive_fetcher_test.go | 4 +- internal/discovery/gharchive_test.go | 34 +- internal/domain/types.go | 44 +- internal/domain/types_test.go | 14 + internal/dossier/builder_test.go | 12 +- internal/evidence/compare.go | 4 +- internal/evidence/external_manifest.go | 113 ++- internal/evidence/external_receipt.go | 2 +- internal/evidence/freshness.go | 226 ++++-- internal/evidence/freshness_test.go | 61 +- internal/evidence/junit.go | 71 ++ internal/evidence/junit_test.go | 38 + internal/evidence/mcp_runner.go | 24 +- internal/evidence/models.go | 180 ++++- internal/evidence/models_test.go | 65 ++ internal/evidence/repeat.go | 43 +- internal/evidence/runner.go | 30 +- internal/evidence/telemetry.go | 12 +- internal/exporter/exporter_test.go | 12 +- internal/facets/facets.go | 152 +++- internal/facets/facets_test.go | 36 +- internal/github/acquisition_models.go | 23 +- internal/github/auth.go | 50 +- internal/github/client.go | 7 +- internal/github/client_acquisition.go | 29 +- internal/github/client_acquisition_test.go | 6 +- internal/github/client_portfolio_test.go | 3 +- internal/github/client_test.go | 25 +- internal/github/errors.go | 6 - internal/github/models.go | 16 +- internal/github/pull_request_index_test.go | 4 +- internal/github/retry_test.go | 6 - internal/github/user_graphql.go | 16 +- internal/health/compute.go | 36 +- internal/health/health_test.go | 49 +- internal/health/types.go | 78 +- internal/investigation/models.go | 51 +- internal/investigation/repository.go | 17 +- internal/investigation/service.go | 31 +- internal/investigation/service_test.go | 2 +- internal/lens/lens.go | 38 +- internal/manifest/models.go | 161 +++- internal/manifest/models_test.go | 97 +++ internal/mcpcontract/actor_contracts.go | 10 +- internal/mcpcontract/coverage_workflow.go | 26 +- internal/mcpcontract/fix_pattern_contracts.go | 58 ++ .../mcpcontract/fix_pattern_contracts_test.go | 53 ++ .../github_acquisition_contracts.go | 9 + internal/mcpcontract/operation_contracts.go | 14 +- internal/mcpcontract/read_provenance_test.go | 29 + internal/mcpcontract/resource_contracts.go | 389 ++++++++- .../mcpcontract/resource_contracts_test.go | 117 +++ internal/mcpcontract/scalable_contracts.go | 10 +- internal/mcpcontract/schema_values.go | 67 ++ internal/mcpcontract/schema_values_test.go | 32 + internal/mcpserver/capabilities_test.go | 6 +- internal/mcpserver/catalog.go | 2 +- internal/mcpserver/commit_planning.go | 14 +- internal/mcpserver/concerns.go | 8 +- internal/mcpserver/contribution_v1.go | 33 +- internal/mcpserver/draft_verification.go | 4 +- internal/mcpserver/github_acquisition.go | 21 +- internal/mcpserver/portfolio.go | 63 +- internal/mcpserver/pr_checks.go | 6 +- internal/mcpserver/resources.go | 45 +- internal/mcpserver/scalable.go | 46 +- internal/mcpserver/schemas.go | 189 +++-- internal/mcpserver/server.go | 27 +- .../mcpserver/server_input_resources_test.go | 18 +- internal/mcpserver/server_test.go | 330 +++++++- internal/mcpserver/v1.go | 70 +- internal/mcpserver/validation_v1.go | 13 +- internal/mcpserver/workspace_v1.go | 4 +- internal/precedent/models.go | 4 +- internal/radar/eligibility_test.go | 8 +- internal/radar/radar.go | 36 +- internal/radar/radar_test.go | 34 +- internal/radar/related_work.go | 24 +- internal/relatedwork/references.go | 13 +- internal/relatedwork/references_test.go | 4 +- internal/research/builder.go | 33 +- internal/research/builder_test.go | 12 +- internal/research/markdown.go | 2 +- internal/research/model.go | 162 +++- internal/research/model_test.go | 54 ++ internal/setup/client_adapters.go | 114 ++- internal/setup/setup.go | 139 ++-- internal/setup/setup_edit.go | 109 +-- internal/setup/setup_test.go | 66 +- internal/similarity/duplicate.go | 4 +- internal/similarity/precedent.go | 3 - internal/similarity/text.go | 12 +- internal/similarity/text_test.go | 4 +- internal/tracking/models.go | 92 ++- internal/tracking/service.go | 46 +- internal/tui/actions_test.go | 30 +- internal/tui/run.go | 9 - internal/tui/snapshot_test.go | 2 +- internal/tui/tui.go | 123 +-- internal/tui/tui_test.go | 16 +- internal/tui/update.go | 90 ++- internal/tui/view.go | 62 +- internal/tui/view_overlays.go | 14 +- internal/tuicontract/contracts.go | 99 ++- internal/tuicontract/contracts_test.go | 45 ++ internal/workspace/adopt.go | 12 +- internal/workspace/adopt_test.go | 2 +- internal/workspace/snapshot.go | 129 ++- internal/workspace/snapshot_test.go | 67 ++ internal/workspace/workspace.go | 128 ++- internal/workspace/workspace_test.go | 28 +- 366 files changed, 15695 insertions(+), 7205 deletions(-) create mode 100644 docs/adr/0006-retire-crawl-frontier.md create mode 100644 internal/app/github_search.go delete mode 100644 internal/app/hydration_repo.go create mode 100644 internal/app/hydration_request.go create mode 100644 internal/app/mcp_actor_facets_test.go create mode 100644 internal/app/mcp_deepwiki.go create mode 100644 internal/app/repeat_validation_request.go create mode 100644 internal/app/repeat_validation_request_test.go create mode 100644 internal/app/response_format.go create mode 100644 internal/app/search_request.go create mode 100644 internal/app/service_dependencies_test.go create mode 100644 internal/app/setup_request.go create mode 100644 internal/app/sync_repository_context_request.go create mode 100644 internal/app/sync_request.go create mode 100644 internal/app/thread_reference.go delete mode 100644 internal/app/upgrade_config_test.go create mode 100644 internal/app/upgrade_environment.go create mode 100644 internal/app/upgrade_environment_test.go create mode 100644 internal/cli/cli_dependencies_test.go create mode 100644 internal/concern/models_test.go create mode 100644 internal/corpus/actor_search_request.go create mode 100644 internal/corpus/contribution_search_request.go create mode 100644 internal/corpus/contribution_search_request_test.go create mode 100644 internal/corpus/feedback_search_request.go create mode 100644 internal/corpus/feedback_search_request_test.go create mode 100644 internal/corpus/feedback_selection.go create mode 100644 internal/corpus/feedback_selection_test.go delete mode 100644 internal/corpus/frontier.go delete mode 100644 internal/corpus/frontier_test.go create mode 100644 internal/corpus/migrations/016_retire_crawl_frontier.sql create mode 100644 internal/corpus/search_modes.go create mode 100644 internal/deepwiki/request.go create mode 100644 internal/deepwiki/request_test.go create mode 100644 internal/discovery/checkpoint_store_test.go create mode 100644 internal/discovery/gharchive_fetcher_support_test.go create mode 100644 internal/evidence/models_test.go create mode 100644 internal/mcpcontract/fix_pattern_contracts_test.go create mode 100644 internal/mcpcontract/read_provenance_test.go create mode 100644 internal/mcpcontract/resource_contracts_test.go create mode 100644 internal/mcpcontract/schema_values_test.go create mode 100644 internal/research/model_test.go create mode 100644 internal/tuicontract/contracts_test.go create mode 100644 internal/workspace/snapshot_test.go diff --git a/docs/adr/0006-retire-crawl-frontier.md b/docs/adr/0006-retire-crawl-frontier.md new file mode 100644 index 00000000..6a8a6d53 --- /dev/null +++ b/docs/adr/0006-retire-crawl-frontier.md @@ -0,0 +1,37 @@ +# ADR 0006: Retire the orphaned crawl frontier + +- Status: Accepted +- Date: 2026-08-10 + +## Context + +The durable crawl frontier originally fed a bounded worker. That executor and +its only application entry points were later removed, while repository +discovery continued to enqueue work and status continued to report it as +ready. No supported operation could lease or complete those rows. The queue +therefore represented planned work that the product could never perform. + +Frontier rows contain scheduling hints derived from already stored repository +identities. They are neither source observations nor derived projections, and +they have no independent recovery value after the executor's removal. + +## Decision + +Repository discovery stores its observations and checkpoints directly and no +longer creates frontier rows. Migration 016 drops the unused queue and its +revision triggers. Its Down section recreates the legacy schema empty; the +discarded hints cannot be reconstructed, so the explicit migration workflow's +verified backup is the data rollback path. + +The existing `frontier_ready` and `frontier_items` JSON fields remain as +zero-valued compatibility fields. Internal status, repository-removal, and +storage models no longer carry the retired concept. + +## Consequences + +- Discovery no longer creates permanently pending work or a misleading status + warning. +- Corpus observations, checkpoints, projections, and explicit hydration + capabilities are unchanged. +- Downgrading the schema recreates an empty legacy queue; restoring the + pre-migration backup is required to inspect discarded scheduling hints. diff --git a/internal/app/app.go b/internal/app/app.go index 470f745d..409f8e20 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "strings" "sync" "time" @@ -44,6 +43,8 @@ type Service struct { archiveFetcher discovery.ArchiveFetcher deepWikiReader deepwiki.Reader clock func() time.Time + executable func() (string, error) + upgradeEnv upgradeEnvironment version string logger *slog.Logger lifecycleCtx context.Context @@ -65,10 +66,11 @@ func NewWithContext(ctx context.Context, paths *config.Paths, version string, lo } lifecycleCtx, cancelLifecycle := context.WithCancel(ctx) s := &Service{ - paths: paths, version: version, clock: time.Now, logger: logger, + paths: paths, version: version, clock: time.Now, executable: os.Executable, logger: logger, + upgradeEnv: productionUpgradeEnvironment(), lifecycleCtx: lifecycleCtx, cancelLifecycle: cancelLifecycle, } - if _, err := s.loadConfig(false); err != nil { + if _, err := s.loadConfig(); err != nil { cancelLifecycle() return nil, err } @@ -85,28 +87,6 @@ func (s *Service) now() time.Time { return clock() } -// SetClock overrides the time source. It is intended for tests. -func (s *Service) SetClock(clock func() time.Time) { - s.mu.Lock() - defer s.mu.Unlock() - s.clock = clock -} - -// SetGitHubReader overrides the GitHub reader. It is intended for tests. -func (s *Service) SetGitHubReader(r github.Reader) { - s.mu.Lock() - defer s.mu.Unlock() - s.ghReader = r -} - -// SetDeepWikiReader overrides the derived external knowledge reader. It is -// intended for tests and embedding. -func (s *Service) SetDeepWikiReader(r deepwiki.Reader) { - s.mu.Lock() - defer s.mu.Unlock() - s.deepWikiReader = r -} - func (s *Service) deepWiki() deepwiki.Reader { s.mu.Lock() defer s.mu.Unlock() @@ -116,13 +96,6 @@ func (s *Service) deepWiki() deepwiki.Reader { return s.deepWikiReader } -// SetArchiveFetcher overrides the GH Archive fetcher. It is intended for tests. -func (s *Service) SetArchiveFetcher(f discovery.ArchiveFetcher) { - s.mu.Lock() - defer s.mu.Unlock() - s.archiveFetcher = f -} - func (s *Service) getArchiveFetcher() discovery.ArchiveFetcher { s.mu.Lock() defer s.mu.Unlock() @@ -159,42 +132,76 @@ func (s *Service) Close() error { return closeErr } -func (s *Service) loadConfig(save bool) (*config.Config, error) { +type configSource uint8 + +const ( + defaultConfig configSource = iota + storedConfig +) + +type loadedConfig struct { + value *config.Config + path string + source configSource +} + +func (s *Service) readConfig() (loadedConfig, error) { cfgFile, err := s.paths.ConfigFile() if err != nil { - return nil, err + return loadedConfig{}, err } var cfg *config.Config - exists := false + source := defaultConfig if _, err := os.Stat(cfgFile); err == nil { cfg, err = config.LoadFile(cfgFile) if err != nil { - return nil, fmt.Errorf("load config: %w", err) + return loadedConfig{}, fmt.Errorf("load config: %w", err) } - exists = true + source = storedConfig } else if errors.Is(err, os.ErrNotExist) { cfg = config.Default() } else { - return nil, fmt.Errorf("inspect config: %w", err) + return loadedConfig{}, fmt.Errorf("inspect config: %w", err) } if err := config.ApplyDefaults(cfg, s.paths); err != nil { - return nil, err + return loadedConfig{}, err } if err := config.ApplyEnv(cfg, os.Getenv); err != nil { - return nil, err + return loadedConfig{}, err } if err := config.Validate(cfg); err != nil { - return nil, fmt.Errorf("validate config: %w", err) - } - if save && !exists { - if err := config.Save(cfgFile, cfg); err != nil { - return nil, fmt.Errorf("save config: %w", err) - } + return loadedConfig{}, fmt.Errorf("validate config: %w", err) } + return loadedConfig{value: cfg, path: cfgFile, source: source}, nil +} + +func (s *Service) cacheConfig(cfg *config.Config) { s.mu.Lock() s.cfg = cfg s.mu.Unlock() - return cfg, nil +} + +func (s *Service) loadConfig() (*config.Config, error) { + loaded, err := s.readConfig() + if err != nil { + return nil, err + } + s.cacheConfig(loaded.value) + return loaded.value, nil +} + +func (s *Service) loadConfigForInitialization() (*config.Config, error) { + loaded, err := s.readConfig() + if err != nil { + return nil, err + } + if loaded.source == defaultConfig { + if err := config.Save(loaded.path, loaded.value); err != nil { + return nil, fmt.Errorf("save config: %w", err) + } + } + s.cacheConfig(loaded.value) + return loaded.value, nil } func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) { @@ -205,7 +212,7 @@ func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) { return c, nil } s.mu.Unlock() - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -256,7 +263,7 @@ func (s *Service) openReadOnlyCorpus(ctx context.Context) (*corpus.Corpus, error return c, nil } s.mu.Unlock() - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -378,17 +385,16 @@ func (s *Service) newGitHubReader() (github.Reader, error) { } func tokenSource(cfg *config.Config) github.TokenSource { - method := strings.ToLower(cfg.TokenSource.Method) - switch method { - case "env": + switch cfg.TokenSource.Method { + case config.TokenSourceEnv: name := cfg.TokenSource.Key if name == "" { name = github.DefaultEnvToken } return github.RequireToken(github.EnvTokenSource(name)) - case "gh-cli": - return github.RequireToken(github.GhCLITokenSource(nil)) - case "keyring": + case config.TokenSourceGHCLI: + return github.RequireToken(github.GhCLITokenSource()) + case config.TokenSourceKeyring: return github.RequireToken(github.KeyringTokenSource(cfg.TokenSource.Key)) } return github.StaticTokenSource("") @@ -427,7 +433,7 @@ func (s *Service) databasePath() string { // Init opens or creates the configured corpus and persists a default // configuration if one does not already exist. func (s *Service) Init(ctx context.Context) (*contracts.InitResult, error) { - cfg, err := s.loadConfig(true) + cfg, err := s.loadConfigForInitialization() if err != nil { return nil, err } @@ -473,17 +479,6 @@ func (s *Service) Status(ctx context.Context) (*contracts.StatusResult, error) { }, nil } -// SyncOptions bounds and filters an explicit repository synchronization. -type SyncOptions struct { - Kind string - State string - Since time.Time - Numbers []int - MaxItems int - MaxPages int - MaxRequests int -} - const ( defaultSyncMaxRequests = 100 maxSyncRequests = 1000 @@ -513,90 +508,19 @@ func (b *syncRequestBudget) take() error { return nil } -type syncRequestPlan struct { - threadRequestCeiling int - plannedRequests int -} - -func planThreadSyncOptions(opts SyncOptions) (SyncOptions, syncRequestPlan, error) { - normalized, err := normalizeThreadSyncOptions(opts) +func threadFromIssue(issue github.Issue) (corpus.Thread, string, error) { + kind, err := domain.ParseThreadKind(string(issue.Kind)) if err != nil { - return SyncOptions{}, syncRequestPlan{}, err - } - requestCeiling := normalized.MaxPages - if len(normalized.Numbers) > 0 { - requestCeiling = len(normalized.Numbers) - if requestCeiling > normalized.MaxRequests { - return SyncOptions{}, syncRequestPlan{}, fmt.Errorf( - "exact thread selection requires at least %d requests; max requests is %d", - requestCeiling, normalized.MaxRequests, - ) - } - } else if requestCeiling > normalized.MaxRequests { - requestCeiling = normalized.MaxRequests - } - return normalized, syncRequestPlan{ - threadRequestCeiling: requestCeiling, - plannedRequests: requestCeiling, - }, nil -} - -func normalizeThreadSyncOptions(opts SyncOptions) (SyncOptions, error) { - if opts.Kind == "" { - opts.Kind = "both" - } - if opts.Kind != "issue" && opts.Kind != "pull_request" && opts.Kind != "both" { - return SyncOptions{}, errors.New("kind must be issue, pull_request, or both") - } - if opts.State == "" { - opts.State = "all" - } - if opts.State != "open" && opts.State != "closed" && opts.State != "all" { - return SyncOptions{}, fmt.Errorf("state must be open, closed, or all") - } - if opts.MaxPages <= 0 { - opts.MaxPages = 1000 - } - if opts.MaxPages > 1000 { - return SyncOptions{}, errors.New("max pages cannot exceed 1000") - } - if opts.MaxItems < 0 || opts.MaxItems > 1000 { - return SyncOptions{}, errors.New("max items must be between 0 and 1000") - } - if opts.MaxRequests == 0 { - opts.MaxRequests = defaultSyncMaxRequests - } - if opts.MaxRequests < 1 || opts.MaxRequests > maxSyncRequests { - return SyncOptions{}, fmt.Errorf("max requests must be between 1 and %d", maxSyncRequests) + return corpus.Thread{}, "", err } - if len(opts.Numbers) > 100 { - return SyncOptions{}, errors.New("exact thread selection cannot exceed 100 numbers") - } - if len(opts.Numbers) > 0 && (opts.State != "all" || !opts.Since.IsZero()) { - return SyncOptions{}, errors.New("state and since filters cannot be combined with exact thread numbers") - } - seen := make(map[int]struct{}, len(opts.Numbers)) - numbers := make([]int, 0, len(opts.Numbers)) - for _, number := range opts.Numbers { - if number <= 0 { - return SyncOptions{}, errors.New("thread numbers must be positive") - } - if _, ok := seen[number]; ok { - continue - } - seen[number] = struct{}{} - numbers = append(numbers, number) + state, err := domain.ParseThreadState(issue.State) + if err != nil { + return corpus.Thread{}, "", err } - sort.Ints(numbers) - opts.Numbers = numbers - return opts, nil -} - -func threadFromIssue(issue github.Issue) (corpus.Thread, string, error) { thread := corpus.Thread{ - Kind: string(issue.Kind), + Kind: kind, Number: issue.Number, - State: issue.State, + State: state, StateReason: issue.StateReason, Title: issue.Title, Body: issue.Body, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c513059a..e1524dcf 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -199,7 +199,7 @@ func newTestService(t *testing.T, srv *httptest.Server) *Service { return svc } -func TestDiscoveryCrawlPersistsRepositoryFrontierAndCheckpoint(t *testing.T) { +func TestDiscoveryCrawlPersistsRepositoryAndCheckpoint(t *testing.T) { t.Parallel() ctx := context.Background() srv, tracked := newTrackedTestServer("octocat", "discovered") @@ -243,13 +243,6 @@ func TestDiscoveryCrawlPersistsRepositoryFrontierAndCheckpoint(t *testing.T) { if repo == nil || repo.ExternalID != "R_123" { t.Fatalf("repository = %+v", repo) } - frontier, err := c.GetFrontierItem(ctx, "repository:octocat/discovered:threads") - if err != nil { - t.Fatal(err) - } - if frontier == nil || frontier.Source != "active-go" { - t.Fatalf("frontier = %+v", frontier) - } checkpoint, exists, err := c.GetTime(ctx, "source:active-go") if err != nil || !exists || checkpoint.IsZero() { t.Fatalf("checkpoint = %v exists=%v err=%v", checkpoint, exists, err) diff --git a/internal/app/clustering.go b/internal/app/clustering.go index d0d100d6..b4494f1f 100644 --- a/internal/app/clustering.go +++ b/internal/app/clustering.go @@ -29,7 +29,7 @@ func (s *Service) ListClusters(ctx context.Context, repo contracts.RepoRef, limi if err != nil { return nil, err } - projection, err := c.ListClusterProjection(ctx, ref, clustering.ClusterState(""), limit) + projection, err := c.ListClusterProjection(ctx, ref, "", limit) if err != nil { return nil, fmt.Errorf("list clusters: %w", err) } @@ -163,12 +163,12 @@ func clusterToCLI(cl clustering.Cluster, memberLimit int) *contracts.ClusterResu break } members = append(members, contracts.ClusterMember{ - Kind: m.Ref.Kind, + Kind: string(m.Ref.Kind), Owner: m.Ref.Owner, Repo: m.Ref.Repo, Number: m.Ref.Number, Title: m.Title, - State: m.State, + State: string(m.State), Score: m.Score, Reason: m.Reason, Included: m.Included, @@ -178,7 +178,7 @@ func clusterToCLI(cl clustering.Cluster, memberLimit int) *contracts.ClusterResu return &contracts.ClusterResult{ StableID: cl.StableID, State: string(cl.State), - Canonical: contracts.ClusterMember{Kind: cl.Canonical.Kind, Owner: cl.Canonical.Owner, Repo: cl.Canonical.Repo, Number: cl.Canonical.Number}, + Canonical: contracts.ClusterMember{Kind: string(cl.Canonical.Kind), Owner: cl.Canonical.Owner, Repo: cl.Canonical.Repo, Number: cl.Canonical.Number}, MemberCount: len(cl.Members), Members: members, } diff --git a/internal/app/collection.go b/internal/app/collection.go index 79dbd490..429005d2 100644 --- a/internal/app/collection.go +++ b/internal/app/collection.go @@ -78,13 +78,20 @@ func parseCollectionMember(member contracts.CollectionMember) (corpus.Collection if err != nil { return corpus.CollectionMember{}, err } - return corpus.CollectionMember{Kind: kind, Ref: parsed.String()}, nil + return corpus.NewRepositoryCollectionMember(parsed) case "issue", "pull_request", "thread": - parsed, err := parseCollectionThreadRef(kind, ref) + repository, number, err := parseCollectionThreadRef(kind, ref) if err != nil { return corpus.CollectionMember{}, err } - return corpus.CollectionMember{Kind: kind, Ref: parsed}, nil + if kind == "thread" { + return corpus.NewAnyThreadCollectionMember(repository, number) + } + threadKind, err := domain.ParseThreadKind(kind) + if err != nil { + return corpus.CollectionMember{}, err + } + return corpus.NewThreadCollectionMember(threadKind, repository, number) case "opportunity", "investigation": if len(ref) > 64 { return corpus.CollectionMember{}, fmt.Errorf("invalid %s reference %q: exceeds 64 bytes", kind, ref) @@ -93,26 +100,29 @@ func parseCollectionMember(member contracts.CollectionMember) (corpus.Collection if err != nil { return corpus.CollectionMember{}, fmt.Errorf("invalid %s reference %q: expected durable id", kind, ref) } - return corpus.CollectionMember{Kind: kind, Ref: id.String()}, nil + if kind == "opportunity" { + return corpus.NewOpportunityCollectionMember(id.String()) + } + return corpus.NewInvestigationCollectionMember(id.String()) default: return corpus.CollectionMember{}, fmt.Errorf("unsupported collection member kind %q", kind) } } -func parseCollectionThreadRef(kind, ref string) (string, error) { +func parseCollectionThreadRef(kind, ref string) (domain.RepoRef, int, error) { if strings.Count(ref, "#") != 1 { - return "", fmt.Errorf("invalid %s reference %q: expected OWNER/REPO#NUMBER", kind, ref) + return domain.RepoRef{}, 0, fmt.Errorf("invalid %s reference %q: expected OWNER/REPO#NUMBER", kind, ref) } repoRef, numberText, _ := strings.Cut(ref, "#") parsedRepo, err := domain.ParseRepoRef(repoRef) if err != nil { - return "", fmt.Errorf("invalid %s reference %q: %w", kind, ref, err) + return domain.RepoRef{}, 0, fmt.Errorf("invalid %s reference %q: %w", kind, ref, err) } number, err := strconv.Atoi(strings.TrimSpace(numberText)) if err != nil || number <= 0 { - return "", fmt.Errorf("invalid %s reference %q: expected positive number", kind, ref) + return domain.RepoRef{}, 0, fmt.Errorf("invalid %s reference %q: expected positive number", kind, ref) } - return fmt.Sprintf("%s#%d", parsedRepo, number), nil + return parsedRepo, number, nil } // ListCollections returns all named collections. diff --git a/internal/app/concerns.go b/internal/app/concerns.go index 76121579..b641eff7 100644 --- a/internal/app/concerns.go +++ b/internal/app/concerns.go @@ -67,9 +67,14 @@ func (s *Service) ListConcerns(ctx context.Context, opts contracts.ConcernListOp if err != nil { return nil, err } - page, err := svc.List(ctx, concern.Filter{ - Repo: ref, Status: concern.Status(opts.Status), Query: opts.Query, Limit: opts.Limit, Offset: opts.Offset, - }) + var status concern.Status + if strings.TrimSpace(opts.Status) != "" { + status, err = concern.ParseStatus(opts.Status) + if err != nil { + return nil, mapConcernError(err) + } + } + page, err := svc.List(ctx, concern.Filter{Repo: ref, Status: status, Query: opts.Query, Limit: opts.Limit, Offset: opts.Offset}) if err != nil { return nil, mapConcernError(err) } @@ -150,7 +155,11 @@ func (s *Service) SetConcernStatus(ctx context.Context, id, status, rationale st if err != nil { return nil, err } - item, err := svc.SetStatus(ctx, id, concern.Status(strings.TrimSpace(status)), rationale) + next, err := concern.ParseStatus(status) + if err != nil { + return nil, mapConcernError(err) + } + item, err := svc.SetStatus(ctx, id, next, rationale) if err != nil { return nil, mapConcernError(err) } @@ -163,7 +172,11 @@ func (s *Service) LinkConcern(ctx context.Context, id string, opts contracts.Con if err != nil { return nil, err } - if err := svc.Link(ctx, id, concern.Link{Kind: concern.LinkKind(opts.Kind), TargetType: opts.TargetType, TargetID: opts.TargetID, Note: opts.Note}); err != nil { + kind, err := concern.ParseLinkKind(opts.Kind) + if err != nil { + return nil, mapConcernError(err) + } + if err := svc.Link(ctx, id, concern.Link{Kind: kind, TargetType: opts.TargetType, TargetID: opts.TargetID, Note: opts.Note}); err != nil { return nil, mapConcernError(err) } return s.ShowConcern(ctx, id) @@ -179,9 +192,9 @@ func (s *Service) PromoteConcern(ctx context.Context, id string, opts contracts. if err != nil { return nil, mapConcernError(err) } - category := investigation.Category(strings.TrimSpace(opts.Category)) - if !investigation.ValidCategory(category) { - return nil, investigation.ErrInvalidCategory + category, err := investigation.ParseCategory(opts.Category) + if err != nil { + return nil, err } kind := strings.TrimSpace(opts.Kind) if kind != "investigation" && kind != "opportunity" { @@ -243,7 +256,7 @@ func (s *Service) concernResult(ctx context.Context, item *concern.Concern) (*co result.Links = append(result.Links, contracts.ConcernLinkResult{Kind: string(link.Kind), TargetType: link.TargetType, TargetID: link.TargetID, Note: link.Note}) } if item.Promotion != nil { - result.Promotion = &contracts.ConcernPromotionResult{Kind: item.Promotion.Kind, InvestigationID: item.Promotion.InvestigationID, HypothesisID: item.Promotion.HypothesisID, OpportunityID: item.Promotion.OpportunityID} + result.Promotion = &contracts.ConcernPromotionResult{Kind: item.Promotion.Kind(), InvestigationID: item.Promotion.InvestigationID(), HypothesisID: item.Promotion.HypothesisID(), OpportunityID: item.Promotion.OpportunityID()} } return result, nil } diff --git a/internal/app/concerns_test.go b/internal/app/concerns_test.go index 1831e4c3..c66e5ed4 100644 --- a/internal/app/concerns_test.go +++ b/internal/app/concerns_test.go @@ -144,7 +144,11 @@ func TestConcernFreshnessIsDerivedFromCurrentCorpus(t *testing.T) { if _, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "R1", time.Unix(10, 0).UTC(), `{}`); err != nil { t.Fatal(err) } - revision, err := c.CurrentSourceRevision(ctx, evidence.SourceSubject{Kind: evidence.SourceSubjectRepository, Owner: "owner", Repo: "repo"}) + subject, err := evidence.NewRepositorySourceSubject(domain.MustRepoRef("owner", "repo")) + if err != nil { + t.Fatal(err) + } + revision, err := c.CurrentSourceRevision(ctx, subject) if err != nil { t.Fatal(err) } diff --git a/internal/app/contribution.go b/internal/app/contribution.go index 06aedcef..d9f68329 100644 --- a/internal/app/contribution.go +++ b/internal/app/contribution.go @@ -366,7 +366,7 @@ func draftResult(identity contribution.DraftIdentity, opportunityID, title, body ID: identity.ID, Revision: identity.Revision, OpportunityID: opportunityID, - Kind: identity.Kind, + Kind: string(identity.Kind), Repository: identity.Repository, Title: title, Body: body, @@ -380,7 +380,7 @@ func draftResult(identity contribution.DraftIdentity, opportunityID, title, body } for _, warning := range identity.Warnings { result.Warnings = append(result.Warnings, contracts.DraftDiagnosticResult{ - Code: warning.Code, Severity: warning.Severity, Message: warning.Message, ByteOffset: warning.ByteOffset, + Code: warning.Code, Severity: string(warning.Severity), Message: warning.Message, ByteOffset: warning.ByteOffset, }) } return result diff --git a/internal/app/control.go b/internal/app/control.go index 409e831e..cabe373e 100644 --- a/internal/app/control.go +++ b/internal/app/control.go @@ -29,7 +29,7 @@ func (s *Service) Metadata(ctx context.Context) (*contracts.MetadataResult, erro if err := ctx.Err(); err != nil { return nil, err } - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -80,16 +80,10 @@ func (s *Service) Metadata(ctx context.Context) (*contracts.MetadataResult, erro ConfigPath: configPath, CorpusPath: cfg.Database, Capabilities: capabilities, - Features: map[string]bool{ - "contribution_radar": true, - "contribution_readiness": true, - "evidence_freshness": true, - "github_mutations": false, - "mcp_stdio": true, - "semantic_search": false, - "thread_investigation": true, - "thread_research": true, - "validation_exec": true, + Features: contracts.MetadataFeatures{ + ContributionRadar: true, ContributionReadiness: true, + EvidenceFreshness: true, MCPStdio: true, + ThreadInvestigation: true, ThreadResearch: true, ValidationExec: true, }, }, nil } @@ -109,7 +103,9 @@ func (s *Service) Configure(ctx context.Context, opts contracts.ConfigureOptions return nil, err } before := *cfg - applyConfigureOptions(cfg, opts) + if err := applyConfigureOptions(cfg, opts); err != nil { + return nil, err + } if err := config.Validate(cfg); err != nil { return nil, fmt.Errorf("validate configuration: %w", err) } @@ -125,7 +121,7 @@ func (s *Service) Configure(ctx context.Context, opts contracts.ConfigureOptions if err := config.Save(path, cfg); err != nil { return nil, err } - if _, err := s.loadConfig(false); err != nil { + if _, err := s.loadConfig(); err != nil { return nil, fmt.Errorf("reload configuration: %w", err) } } @@ -140,7 +136,7 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe return nil, err } now := s.now() - stats, err := c.ControlStats(ctx, now) + stats, err := c.ControlStats(ctx) if err != nil { return nil, err } @@ -176,9 +172,6 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe if stats.Repositories == 0 { warnings = append(warnings, "corpus has no repositories") } - if stats.FrontierReady > 0 { - warnings = append(warnings, fmt.Sprintf("%d frontier items are ready", stats.FrontierReady)) - } if stats.ActiveRuns > 0 || stats.ActiveJobs > 0 { warnings = append(warnings, "background work is active") } @@ -191,12 +184,11 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe Version: s.version, SchemaVersion: version, Counts: contracts.ControlCounts{ - Repositories: stats.Repositories, - Threads: stats.Threads, - Sources: stats.Sources, - FrontierReady: stats.FrontierReady, - ActiveRuns: stats.ActiveRuns, - ActiveJobs: stats.ActiveJobs, + Repositories: stats.Repositories, + Threads: stats.Threads, + Sources: stats.Sources, + ActiveRuns: stats.ActiveRuns, + ActiveJobs: stats.ActiveJobs, }, FreshestSource: formatTime(stats.Freshest), RateLimits: rateLimits, @@ -227,7 +219,7 @@ func (s *Service) doctor(ctx context.Context) (*contracts.DoctorResult, error) { _, pathErr := s.paths.ConfigFile() var cfg *config.Config if pathErr == nil { - cfg, pathErr = s.loadConfig(false) + cfg, pathErr = s.loadConfig() } add("config", true, pathErr, "configuration is readable and valid") @@ -372,12 +364,16 @@ func (s *Service) persistedConfig(path string) (*config.Config, error) { return cfg, nil } -func applyConfigureOptions(cfg *config.Config, opts contracts.ConfigureOptions) { +func applyConfigureOptions(cfg *config.Config, opts contracts.ConfigureOptions) error { if opts.Database != nil { cfg.Database = strings.TrimSpace(*opts.Database) } if opts.TokenSource != nil { - cfg.TokenSource.Method = strings.ToLower(strings.TrimSpace(*opts.TokenSource)) + method, err := config.ParseTokenSourceMethod(*opts.TokenSource) + if err != nil { + return err + } + cfg.TokenSource.Method = method } if opts.TokenSourceKey != nil { cfg.TokenSource.Key = strings.TrimSpace(*opts.TokenSourceKey) @@ -394,12 +390,13 @@ func applyConfigureOptions(cfg *config.Config, opts contracts.ConfigureOptions) if opts.CrawlTimeout != nil { cfg.Crawl.Timeout = strings.TrimSpace(*opts.CrawlTimeout) } + return nil } func configResult(cfg *config.Config) contracts.ConfigResult { return contracts.ConfigResult{ Database: cfg.Database, - TokenSource: cfg.TokenSource.Method, + TokenSource: string(cfg.TokenSource.Method), TokenSourceKey: cfg.TokenSource.Key, CrawlBudget: cfg.Crawl.Budget, CrawlConcurrency: cfg.Crawl.Concurrency, diff --git a/internal/app/control_test.go b/internal/app/control_test.go index 1a97035b..0a984fe5 100644 --- a/internal/app/control_test.go +++ b/internal/app/control_test.go @@ -40,19 +40,19 @@ func TestMetadataIsLocalAndDoesNotCreateCorpus(t *testing.T) { if result.SchemaVersion != 0 { t.Fatalf("schema version = %d, want 0 before corpus open", result.SchemaVersion) } - if !result.Features["contribution_radar"] || !containsString(result.Capabilities, "contribution-radar") { + if !result.Features.ContributionRadar || !containsString(result.Capabilities, "contribution-radar") { t.Fatalf("radar capability missing from metadata: %+v", result) } - if !result.Features["contribution_readiness"] || !containsString(result.Capabilities, "contribution-readiness") { + if !result.Features.ContributionReadiness || !containsString(result.Capabilities, "contribution-readiness") { t.Fatalf("readiness capability missing from metadata: %+v", result) } - if !result.Features["thread_research"] || !containsString(result.Capabilities, "thread-research-brief") { + if !result.Features.ThreadResearch || !containsString(result.Capabilities, "thread-research-brief") { t.Fatalf("thread research capability missing from metadata: %+v", result) } - if !result.Features["thread_investigation"] || !containsString(result.Capabilities, "thread-investigation-start") { + if !result.Features.ThreadInvestigation || !containsString(result.Capabilities, "thread-investigation-start") { t.Fatalf("thread investigation capability missing from metadata: %+v", result) } - if !result.Features["evidence_freshness"] || !containsString(result.Capabilities, "evidence-freshness") { + if !result.Features.EvidenceFreshness || !containsString(result.Capabilities, "evidence-freshness") { t.Fatalf("evidence freshness capability missing from metadata: %+v", result) } if _, err := os.Stat(result.CorpusPath); !os.IsNotExist(err) { diff --git a/internal/app/corpus_lifecycle.go b/internal/app/corpus_lifecycle.go index 9d55e6ed..8e628eb0 100644 --- a/internal/app/corpus_lifecycle.go +++ b/internal/app/corpus_lifecycle.go @@ -15,7 +15,7 @@ import ( // InspectCorpus reports corpus compatibility without mutation. func (s *Service) InspectCorpus(ctx context.Context) (*contracts.CorpusInspectionResult, error) { - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -244,7 +244,7 @@ func repositoryRemovalResult(plan *corpus.RepositoryRemovalPlan, dryRun bool) *c ThreadObservations: plan.ThreadObservations, FacetObservations: plan.FacetObservations, FacetCoverage: plan.FacetCoverage, CodeSnapshots: plan.CodeSnapshots, CodeDocuments: plan.CodeDocuments, Dossiers: plan.Dossiers, ClusterRuns: plan.ClusterRuns, - Clusters: plan.Clusters, FrontierItems: plan.FrontierItems, + Clusters: plan.Clusters, DetachedTriageEvents: plan.DetachedTriageEvents, RemovedPortfolioLinks: plan.RemovedPortfolioLinks, RemovedResolutionRecords: plan.RemovedResolutionRecords, RemovedSignalSnapshots: plan.RemovedSignalSnapshots, DetachedClusterMembers: plan.DetachedClusterMembers, @@ -324,9 +324,9 @@ func corpusInspectionResult(inspection corpus.SchemaInspection) *contracts.Corpu } for _, step := range inspection.Pending { result.Pending = append(result.Pending, contracts.CorpusMigrationStep{ - Version: step.Version, Name: step.Name, Phase: "pending", AffectedRows: step.AffectedRows, + Version: step.Version, Name: step.Name, Phase: string(corpus.MigrationPending), AffectedRows: step.AffectedRows, EstimateAvailable: step.EstimateAvailable, Transactional: step.Transactional, - Resumable: step.Resumable, ResumeStrategy: step.ResumeStrategy, ProjectionRebuild: step.ProjectionRebuild, + Resumable: step.Resumable, ResumeStrategy: string(step.ResumeStrategy), ProjectionRebuild: step.ProjectionRebuild, }) } return result @@ -334,7 +334,7 @@ func corpusInspectionResult(inspection corpus.SchemaInspection) *contracts.Corpu // BackupCorpus creates a verified online backup at destination. func (s *Service) BackupCorpus(ctx context.Context, destination string) (*contracts.CorpusBackupResult, error) { - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -350,7 +350,7 @@ func (s *Service) BackupCorpus(ctx context.Context, destination string) (*contra // RestoreCorpus replaces the corpus from a verified backup after safety backup. func (s *Service) RestoreCorpus(ctx context.Context, source, safetyBackup string) (*contracts.CorpusRestoreResult, error) { - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -399,7 +399,7 @@ func (s *Service) RestoreCorpus(ctx context.Context, source, safetyBackup string // MigrateCorpus explicitly applies pending schema migrations with backup policy. func (s *Service) MigrateCorpus(ctx context.Context, opts contracts.CorpusMigrateOptions) (*contracts.CorpusMigrationResult, error) { - cfg, err := s.loadConfig(false) + cfg, err := s.loadConfig() if err != nil { return nil, err } @@ -433,7 +433,7 @@ func (s *Service) MigrateCorpus(ctx context.Context, opts contracts.CorpusMigrat } } backup, err := corpus.MigrateWithBackup(ctx, cfg.Database, destination, func(progress corpus.MigrationProgress) { - report.Steps = append(report.Steps, contracts.CorpusMigrationStep{Version: progress.Version, Name: progress.Name, Phase: progress.Phase}) + report.Steps = append(report.Steps, contracts.CorpusMigrationStep{Version: progress.Version, Name: progress.Name, Phase: string(progress.Phase)}) }) if backup != nil { report.Backup = corpusBackupResult(*backup) diff --git a/internal/app/corpus_lifecycle_test.go b/internal/app/corpus_lifecycle_test.go index 50ab636d..2f535341 100644 --- a/internal/app/corpus_lifecycle_test.go +++ b/internal/app/corpus_lifecycle_test.go @@ -57,9 +57,9 @@ func TestApplicationWriteOpenDoesNotMigrateExistingCorpus(t *testing.T) { if err != nil || !exists || version != 0 { t.Fatalf("schema after rejected write open = %d, exists=%v, err=%v", version, exists, err) } + second.stubExecutablePath(filepath.Join(home, "missing-runtime")) report, err := second.Setup(ctx, contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", - Executable: filepath.Join(home, "missing-runtime"), }) if err != nil { t.Fatal(err) diff --git a/internal/app/coverage_target.go b/internal/app/coverage_target.go index 430c9eb1..c4284b13 100644 --- a/internal/app/coverage_target.go +++ b/internal/app/coverage_target.go @@ -50,7 +50,7 @@ func (t threadCoverageTarget) wire() mcpcontract.CoverageTarget { func (t threadCoverageTarget) key() string { return fmt.Sprintf("%s/%s#%d", t.repo, t.kind, t.number) } -func (t threadCoverageTarget) expectedFacets() []string { return facets.DefaultFor(string(t.kind)) } +func (t threadCoverageTarget) expectedFacets() []string { return facets.DefaultFor(t.kind) } func parseCoverageTarget(input mcpcontract.CoverageTarget) (parsedCoverageTarget, mcpcontract.CoverageTarget, error) { repo, err := domain.NewRepoRef(input.Repository.Owner, input.Repository.Repo) diff --git a/internal/app/discovery.go b/internal/app/discovery.go index 99de5e28..43d5c6f6 100644 --- a/internal/app/discovery.go +++ b/internal/app/discovery.go @@ -60,7 +60,7 @@ func (s *Service) AddSearchSource(ctx context.Context, name, query string) (*con return nil, err } stored, err := c.SaveDiscoverySource(ctx, corpus.DiscoverySource{ - Name: name, Kind: "search", Definition: string(definition), Enabled: true, + Name: name, Kind: corpus.DiscoverySourceSearch, Definition: string(definition), Enabled: true, }) if err != nil { return nil, err @@ -94,7 +94,7 @@ func (s *Service) AddRepoSource(ctx context.Context, name string, refs []contrac return nil, err } stored, err := c.SaveDiscoverySource(ctx, corpus.DiscoverySource{ - Name: name, Kind: "repos", Definition: string(definition), Enabled: true, + Name: name, Kind: corpus.DiscoverySourceRepos, Definition: string(definition), Enabled: true, }) if err != nil { return nil, err @@ -108,12 +108,15 @@ func (s *Service) AddGHArchiveSource(ctx context.Context, name string, events [] if err := validateSourceName(name); err != nil { return nil, err } - for _, ev := range events { - if !discovery.IsKnownEventType(ev) { + parsedEvents := make([]string, len(events)) + for i, ev := range events { + eventType, err := discovery.ParseEventType(ev) + if err != nil { return nil, fmt.Errorf("unknown GH Archive event type %q", ev) } + parsedEvents[i] = string(eventType) } - definition, err := json.Marshal(ghArchiveSourceDefinition{Events: events}) + definition, err := json.Marshal(ghArchiveSourceDefinition{Events: parsedEvents}) if err != nil { return nil, err } @@ -122,7 +125,7 @@ func (s *Service) AddGHArchiveSource(ctx context.Context, name string, events [] return nil, err } stored, err := c.SaveDiscoverySource(ctx, corpus.DiscoverySource{ - Name: name, Kind: "gharchive", Definition: string(definition), Enabled: true, + Name: name, Kind: corpus.DiscoverySourceGHArchive, Definition: string(definition), Enabled: true, }) if err != nil { return nil, err @@ -169,7 +172,7 @@ func (s *Service) ListSources(ctx context.Context) (*contracts.SourceListResult, func sourceResult(source *corpus.DiscoverySource) *contracts.SourceResult { return &contracts.SourceResult{ - Name: source.Name, Kind: source.Kind, Definition: source.Definition, Enabled: source.Enabled, + Name: source.Name, Kind: string(source.Kind), Definition: source.Definition, Enabled: source.Enabled, } } @@ -194,11 +197,11 @@ func (s *Service) Crawl(ctx context.Context, name string, opts contracts.CrawlOp return nil, fmt.Errorf("discovery source %q not found or disabled", name) } switch source.Kind { - case "search": + case corpus.DiscoverySourceSearch: return s.crawlSearchSource(ctx, c, source, opts) - case "repos": + case corpus.DiscoverySourceRepos: return s.crawlRepoSource(ctx, c, source, opts) - case "gharchive": + case corpus.DiscoverySourceGHArchive: return s.crawlGHArchiveSource(ctx, c, source, opts) default: return nil, fmt.Errorf("source %q has unsupported kind %q", name, source.Kind) @@ -308,14 +311,6 @@ func (s *Service) crawlSearchSource(ctx context.Context, c *corpus.Corpus, sourc if err != nil { return nil, err } - _, _, err = c.EnqueueFrontierItem(ctx, corpus.FrontierItem{ - WorkKey: fmt.Sprintf("repository:%s/%s:threads", repo.Owner, repo.Name), - SubjectKind: "repository", Owner: repo.Owner, Repo: repo.Name, Facet: "threads", - Priority: 10, Reason: "discovered by " + source.Name, Source: source.Name, - }) - if err != nil { - return nil, err - } discovered++ } } @@ -371,13 +366,6 @@ func (s *Service) crawlRepoSource(ctx context.Context, c *corpus.Corpus, source if _, err := c.UpsertRepository(ctx, repo, string(payload)); err != nil { return nil, err } - if _, _, err := c.EnqueueFrontierItem(ctx, corpus.FrontierItem{ - WorkKey: fmt.Sprintf("repository:%s/%s:threads", ref.Owner(), ref.Repo()), - SubjectKind: "repository", Owner: ref.Owner(), Repo: ref.Repo(), Facet: "threads", - Priority: 10, Reason: "explicit source " + source.Name, Source: source.Name, - }); err != nil { - return nil, err - } processed++ } @@ -401,6 +389,11 @@ func (s *Service) crawlGHArchiveSource(ctx context.Context, c *corpus.Corpus, so if err := json.Unmarshal([]byte(source.Definition), &definition); err != nil { return nil, fmt.Errorf("decode source %q: %w", source.Name, err) } + reader, err := discovery.NewArchiveReader(definition.Events, nil) + if err != nil { + return nil, fmt.Errorf("decode source %q event selection: %w", source.Name, err) + } + reader.MaxTotalBytes = 1 << 30 // 1 GiB decompressed per hour run, err := c.StartRun(ctx, "crawl") if err != nil { @@ -410,8 +403,6 @@ func (s *Service) crawlGHArchiveSource(ctx context.Context, c *corpus.Corpus, so now := s.now().UTC() startHour, endHour := discovery.ArchiveHourRange(opts.Since, now) - reader := discovery.NewArchiveReader(definition.Events, nil) - reader.MaxTotalBytes = 1 << 30 // 1 GiB decompressed per hour fetcher := s.getArchiveFetcher() @@ -453,7 +444,7 @@ func (s *Service) crawlGHArchiveSource(ctx context.Context, c *corpus.Corpus, so stats.events++ hourSigs.repoSigs[sig.Repo] = sig if sig.ThreadNumber > 0 { - k := archiveThreadKey{ref: sig.Repo, kind: string(sig.ThreadKind), number: sig.ThreadNumber} + k := archiveThreadKey{ref: sig.Repo, kind: sig.ThreadKind, number: sig.ThreadNumber} hourSigs.threadSigs[k] = sig } return nil @@ -465,7 +456,7 @@ func (s *Service) crawlGHArchiveSource(ctx context.Context, c *corpus.Corpus, so continue } - if err := s.flushArchiveHour(ctx, c, source, hourSigs, repoSeen, threadSeen, repoIDByRef); err != nil { + if err := s.flushArchiveHour(ctx, c, hourSigs, repoSeen, threadSeen, repoIDByRef); err != nil { return nil, err } if err := c.MarkImported(ctx, key); err != nil { @@ -478,15 +469,19 @@ func (s *Service) crawlGHArchiveSource(ctx context.Context, c *corpus.Corpus, so stats.repositories = len(repoSeen) stats.threads = len(threadSeen) - statsJSON, _ := json.Marshal(map[string]any{ - "hours": stats.hours(), - "events": stats.events, - "repositories": stats.repositories, - "threads": stats.threads, - "requests": stats.requests, - "imported": stats.imported, - "skipped": stats.skipped, - "failures": stats.failures, + statsJSON, _ := json.Marshal(struct { + Hours int `json:"hours"` + Events int `json:"events"` + Repositories int `json:"repositories"` + Threads int `json:"threads"` + Requests int `json:"requests"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` + Failures int `json:"failures"` + }{ + Hours: stats.hours(), Events: stats.events, Repositories: stats.repositories, + Threads: stats.threads, Requests: stats.requests, Imported: stats.imported, + Skipped: stats.skipped, Failures: stats.failures, }) if stats.failures == 0 { if err := c.FinishRun(ctx, run.ID, string(statsJSON)); err != nil { @@ -529,7 +524,7 @@ type archiveHourSigs struct { type archiveThreadKey struct { ref domain.RepoRef - kind string + kind domain.ThreadKind number int } @@ -561,7 +556,7 @@ func (g *ghArchiveStats) advanceCheckpoint(hour time.Time) { } } -func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source *corpus.DiscoverySource, hourSigs archiveHourSigs, repoSeen map[domain.RepoRef]struct{}, threadSeen map[archiveThreadKey]struct{}, repoIDByRef map[domain.RepoRef]int64) error { +func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, hourSigs archiveHourSigs, repoSeen map[domain.RepoRef]struct{}, threadSeen map[archiveThreadKey]struct{}, repoIDByRef map[domain.RepoRef]int64) error { // Upsert repositories in deterministic order so tests are stable. repoRefs := make([]domain.RepoRef, 0, len(hourSigs.repoSigs)) for ref := range hourSigs.repoSigs { @@ -590,14 +585,6 @@ func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source } repoIDByRef[ref] = upserted.ID repoSeen[ref] = struct{}{} - _, _, err = c.EnqueueFrontierItem(ctx, corpus.FrontierItem{ - WorkKey: fmt.Sprintf("repository:%s/%s:threads", ref.Owner(), ref.Repo()), - SubjectKind: "repository", Owner: ref.Owner(), Repo: ref.Repo(), Facet: "threads", - Priority: 10, Reason: "discovered by " + source.Name, Source: source.Name, - }) - if err != nil { - return err - } } threadKeys := make([]archiveThreadKey, 0, len(hourSigs.threadSigs)) @@ -667,7 +654,7 @@ func mergeArchiveRepo(sig discovery.Signal, existing *corpus.Repository) corpus. func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thread) (corpus.Thread, bool) { t := corpus.Thread{ RepositoryID: repoID, - Kind: string(sig.ThreadKind), + Kind: sig.ThreadKind, Number: sig.ThreadNumber, } if existing != nil { @@ -678,17 +665,17 @@ func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thr } t = *existing t.RepositoryID = repoID - t.Kind = string(sig.ThreadKind) + t.Kind = sig.ThreadKind t.Number = sig.ThreadNumber } if sig.ThreadState != "" { - t.State = string(sig.ThreadState) + t.State = sig.ThreadState } else if t.State == "" { switch strings.ToLower(sig.Action) { case "opened", "reopened": - t.State = string(domain.OpenState) + t.State = domain.OpenState case "closed": - t.State = string(domain.ClosedState) + t.State = domain.ClosedState } } if t.State == "" { @@ -730,6 +717,3 @@ func (s *budgetedRepositorySearch) page(ctx context.Context, query string, page, Query: query, PageOptions: github.PageOptions{Page: page, PerPage: perPage}, }) } - -// Ensure the fetcher interface is satisfied by the injected type. -var _ discovery.ArchiveFetcher = (*discovery.ArchiveClient)(nil) diff --git a/internal/app/discovery_test.go b/internal/app/discovery_test.go index 1184730f..c92aff88 100644 --- a/internal/app/discovery_test.go +++ b/internal/app/discovery_test.go @@ -115,13 +115,6 @@ func TestAddRepoSourceAndCrawl(t *testing.T) { if r == nil { t.Fatalf("missing repository %s/%s", ownerRepo.owner, ownerRepo.repo) } - item, err := c.GetFrontierItem(ctx, fmt.Sprintf("repository:%s/%s:threads", ownerRepo.owner, ownerRepo.repo)) - if err != nil { - t.Fatal(err) - } - if item == nil { - t.Fatalf("missing frontier for %s/%s", ownerRepo.owner, ownerRepo.repo) - } } } @@ -278,7 +271,7 @@ func TestAddGHArchiveSourceAndCrawl(t *testing.T) { t.Fatal("missing repository from archive") } for _, n := range []int{1, 2} { - thread, err := c.GetThread(ctx, r.ID, corpus.ThreadKindIssue, n) + thread, err := c.GetThread(ctx, r.ID, domain.IssueKind, n) if err != nil { t.Fatal(err) } @@ -286,13 +279,6 @@ func TestAddGHArchiveSourceAndCrawl(t *testing.T) { t.Fatalf("missing thread %d", n) } } - frontier, err := c.GetFrontierItem(ctx, "repository:owner/repo:threads") - if err != nil { - t.Fatal(err) - } - if frontier == nil { - t.Fatal("missing frontier item") - } } func TestGHArchiveCrawlSkipsImportedHours(t *testing.T) { @@ -506,6 +492,39 @@ func TestGHArchiveCrawlMalformedArchive(t *testing.T) { } } +func TestGHArchiveCrawlRejectsInvalidStoredEventSelectionBeforeStartingRun(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newTestServiceNoNetwork(t) + defer func() { _ = svc.Close() }() + + c, err := svc.openCorpus(ctx) + if err != nil { + t.Fatal(err) + } + _, err = c.SaveDiscoverySource(ctx, corpus.DiscoverySource{ + Name: "invalid-events", + Kind: corpus.DiscoverySourceGHArchive, + Definition: `{"events":["UnknownEvent"]}`, + Enabled: true, + }) + if err != nil { + t.Fatalf("save source: %v", err) + } + + _, err = svc.Crawl(ctx, "invalid-events", contracts.CrawlOptions{Since: time.Hour, Budget: 1}) + if err == nil || !strings.Contains(err.Error(), `unsupported GH Archive event type "UnknownEvent"`) { + t.Fatalf("Crawl() error = %v, want invalid stored event selection", err) + } + runs, err := c.ListRuns(ctx, 1) + if err != nil { + t.Fatalf("list runs: %v", err) + } + if len(runs) != 0 { + t.Fatalf("runs = %+v, want no lifecycle record for an invalid source definition", runs) + } +} + func TestGHArchiveCrawlFetchFailureContinues(t *testing.T) { t.Parallel() ctx := context.Background() @@ -560,7 +579,7 @@ func TestArchiveMergePreservesNewerProjection(t *testing.T) { if got := mergeArchiveRepo(signal, &repo); got.Description != "current" || !got.SourceUpdatedAt.IsZero() { t.Fatalf("repository regressed: %+v", got) } - thread := corpus.Thread{ID: 2, RepositoryID: 1, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "current", SourceUpdatedAt: newer} + thread := corpus.Thread{ID: 2, RepositoryID: 1, Kind: domain.IssueKind, Number: 7, State: "open", Title: "current", SourceUpdatedAt: newer} if got, ok := mergeArchiveThread(signal, 1, &thread); !ok || got.State != "open" || got.Title != "current" || !got.SourceUpdatedAt.IsZero() { t.Fatalf("thread regressed: %+v", got) } @@ -601,7 +620,7 @@ func TestArchiveDiscoveryCannotOutrankCanonicalSync(t *testing.T) { t.Fatal(err) } if _, err := c.UpsertThread(ctx, corpus.Thread{ - RepositoryID: canonicalRepo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "canonical title", Body: "canonical body", SourceUpdatedAt: canonicalTime, + RepositoryID: canonicalRepo.ID, Kind: domain.IssueKind, Number: 7, State: "open", Title: "canonical title", Body: "canonical body", SourceUpdatedAt: canonicalTime, }, `{"source":"github"}`); err != nil { t.Fatal(err) } @@ -610,7 +629,7 @@ func TestArchiveDiscoveryCannotOutrankCanonicalSync(t *testing.T) { if gotRepo.Description != "canonical metadata" || gotRepo.Stars != 99 { t.Fatalf("canonical repository did not win: %+v", gotRepo) } - gotThread, _ := c.GetThread(ctx, canonicalRepo.ID, corpus.ThreadKindIssue, 7) + gotThread, _ := c.GetThread(ctx, canonicalRepo.ID, domain.IssueKind, 7) if gotThread.Title != "canonical title" || gotThread.Body != "canonical body" { t.Fatalf("canonical thread did not win: %+v", gotThread) } diff --git a/internal/app/dossier.go b/internal/app/dossier.go index 97a898b0..2baf043b 100644 --- a/internal/app/dossier.go +++ b/internal/app/dossier.go @@ -178,7 +178,7 @@ func (s *Service) ExtractSeeds(ctx context.Context, repo contracts.RepoRef, opts return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, ref) } - threads, err := c.ListThreads(ctx, repoProjection.ID, "", 10000) + threads, err := c.ListThreads(ctx, repoProjection.ID, corpus.AnyThreadKind(), 10000) if err != nil { return nil, fmt.Errorf("list threads: %w", err) } @@ -253,7 +253,7 @@ func selectedSeedPolarities(polarities []domain.SeedPolarity) (map[domain.SeedPo func classForThread(t corpus.Thread) (domain.SeedSourceClass, bool) { switch t.Kind { - case corpus.ThreadKindPullRequest: + case domain.PullRequestKind: if t.Merge.IsMerged() { return domain.SeedSourceClassMergedPR, true } @@ -261,7 +261,7 @@ func classForThread(t corpus.Thread) (domain.SeedSourceClass, bool) { return domain.SeedSourceClassClosedUnmergedPR, true } return "", false - case corpus.ThreadKindIssue: + case domain.IssueKind: return domain.SeedSourceClassIssue, true default: return "", false @@ -297,7 +297,7 @@ func buildSeed(ctx context.Context, c *corpus.Corpus, t corpus.Thread, class dom Number: t.Number, Title: t.Title, Author: t.Author, - State: t.State, + State: string(t.State), Labels: sortedCopy(t.Labels), CreatedAt: t.SourceCreatedAt, UpdatedAt: t.SourceUpdatedAt, @@ -315,7 +315,7 @@ func buildSeed(ctx context.Context, c *corpus.Corpus, t corpus.Thread, class dom func latestPRPayload(ctx context.Context, c *corpus.Corpus, t corpus.Thread) (prPayloadFields, error) { var out prPayloadFields - if t.Kind != corpus.ThreadKindPullRequest { + if t.Kind != domain.PullRequestKind { return out, nil } obs, err := c.LatestThreadObservation(ctx, t.ID) @@ -349,7 +349,7 @@ func extractEvidence(t corpus.Thread, class domain.SeedSourceClass, pr prPayload ValidationIndicators: sortedUnique(extractValidationIndicators(text, t.Labels)), ApproximateScope: extractApproximateScope(pr), ScopeEvidence: scopeEvidence(pr), - RejectionOrSupersession: extractRejectionContext(class, t.State, t.StateReason, t.Title, t.Body, t.Labels), + RejectionOrSupersession: extractRejectionContext(class, string(t.State), t.StateReason, t.Title, t.Body, t.Labels), ProblemAreas: sortedUnique(extractProblemAreas(t.Title, t.Body, t.Labels)), } return ev diff --git a/internal/app/dossier_test.go b/internal/app/dossier_test.go index 7b58d68d..2df7bc80 100644 --- a/internal/app/dossier_test.go +++ b/internal/app/dossier_test.go @@ -59,7 +59,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { base := time.Unix(2000, 0).UTC() if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 10, State: "closed", Title: "fix(pkg/parser): resolve crash", @@ -76,7 +76,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 9, State: "closed", Title: "feat(ui): add button", @@ -93,7 +93,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 8, State: "closed", Title: "header-only closed pull request", @@ -106,7 +106,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "parser crashes on empty input", @@ -187,7 +187,7 @@ func TestCorpusReaderDoesNotTruncateRepositoriesAboveOneThousandThreads(t *testi for number := 1; number <= 1001; number++ { if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: number, State: "open", Title: fmt.Sprintf("issue %d", number), @@ -244,7 +244,7 @@ func TestExtractSeeds(t *testing.T) { base := time.Unix(2000, 0).UTC() if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 5, State: "closed", Title: "fix(pkg/parser): resolve crash", @@ -261,7 +261,7 @@ func TestExtractSeeds(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 4, State: "closed", Title: "feat(ui): add button", @@ -276,7 +276,7 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("upsert closed pr: %v", err) } if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 3, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "header-only closed PR", SourceCreatedAt: base, SourceUpdatedAt: base.Add(2 * time.Hour), }, `{}`); err != nil { t.Fatalf("upsert unknown-merge pr: %v", err) @@ -284,7 +284,7 @@ func TestExtractSeeds(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "parser crashes on empty input", @@ -298,7 +298,7 @@ func TestExtractSeeds(t *testing.T) { } if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 2, State: "closed", StateReason: "not_planned", @@ -422,42 +422,42 @@ func TestSeedPolarityUsesOnlyStructuredOutcomeEvidence(t *testing.T) { }{ { name: "merged PR remains positive despite rejection text", - thread: corpus.Thread{Kind: corpus.ThreadKindPullRequest, State: "closed", Merge: domain.MergedStatus(time.Time{}), Title: "rejected experiment"}, + thread: corpus.Thread{Kind: domain.PullRequestKind, State: "closed", Merge: domain.MergedStatus(time.Time{}), Title: "rejected experiment"}, class: domain.SeedSourceClassMergedPR, want: domain.SeedPolarityPositive, wantReason: "GitHub reports this pull request was merged", }, { name: "closed unmerged PR is negative", - thread: corpus.Thread{Kind: corpus.ThreadKindPullRequest, State: "closed", Merge: domain.UnmergedStatus()}, + thread: corpus.Thread{Kind: domain.PullRequestKind, State: "closed", Merge: domain.UnmergedStatus()}, class: domain.SeedSourceClassClosedUnmergedPR, want: domain.SeedPolarityNegative, wantReason: "GitHub reports this pull request was closed without merging", }, { name: "issue text cannot imply rejection", - thread: corpus.Thread{Kind: corpus.ThreadKindIssue, State: "closed", StateReason: "completed", Title: "rejected idea", Body: "superseded elsewhere"}, + thread: corpus.Thread{Kind: domain.IssueKind, State: "closed", StateReason: "completed", Title: "rejected idea", Body: "superseded elsewhere"}, class: domain.SeedSourceClassIssue, want: domain.SeedPolarityContext, wantReason: "issue evidence provides problem context, not an implementation outcome", }, { name: "open issue rejection label remains context", - thread: corpus.Thread{Kind: corpus.ThreadKindIssue, State: "open", Labels: []string{"duplicate"}}, + thread: corpus.Thread{Kind: domain.IssueKind, State: "open", Labels: []string{"duplicate"}}, class: domain.SeedSourceClassIssue, want: domain.SeedPolarityContext, wantReason: "issue evidence provides problem context, not an implementation outcome", }, { name: "not planned issue is negative", - thread: corpus.Thread{Kind: corpus.ThreadKindIssue, State: "closed", StateReason: "not_planned"}, + thread: corpus.Thread{Kind: domain.IssueKind, State: "closed", StateReason: "not_planned"}, class: domain.SeedSourceClassIssue, want: domain.SeedPolarityNegative, wantReason: "GitHub reports this issue was closed as not planned", }, { name: "closed duplicate issue is negative", - thread: corpus.Thread{Kind: corpus.ThreadKindIssue, State: "closed", StateReason: "completed", Labels: []string{"Duplicate"}}, + thread: corpus.Thread{Kind: domain.IssueKind, State: "closed", StateReason: "completed", Labels: []string{"Duplicate"}}, class: domain.SeedSourceClassIssue, want: domain.SeedPolarityNegative, wantReason: "closed issue has rejection or supersession label: Duplicate", @@ -499,7 +499,7 @@ func TestExtractSeedsRequiresNoNetwork(t *testing.T) { if _, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "a bug", diff --git a/internal/app/draft_verification.go b/internal/app/draft_verification.go index 063425ee..b5f6f7b7 100644 --- a/internal/app/draft_verification.go +++ b/internal/app/draft_verification.go @@ -8,11 +8,16 @@ import ( "strings" "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/domain" ) // VerifyPublishedDraft compares one immutable local revision with one // explicitly synchronized GitHub thread and never performs a network read. func (s *Service) VerifyPublishedDraft(ctx context.Context, in contracts.VerifyPublishedDraftInput) (*contracts.PublishedDraftVerification, error) { + kind, err := domain.ParseThreadKind(in.Kind) + if err != nil { + return nil, err + } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err @@ -26,7 +31,7 @@ func (s *Service) VerifyPublishedDraft(ctx context.Context, in contracts.VerifyP PublishedRef: fmt.Sprintf("%s/%s#%d", in.Owner, in.Repo, in.Number), DraftTitleSHA256: draft.TitleSHA256, DraftBodySHA256: draft.BodySHA256, CoverageStatus: "unknown", } - if in.Owner+"/"+in.Repo != draft.Repository || in.Kind != draft.Kind { + if in.Owner+"/"+in.Repo != draft.Repository || kind != draft.Kind { out.Reason = "published target identity does not match the stored draft" return out, nil } @@ -38,7 +43,7 @@ func (s *Service) VerifyPublishedDraft(ctx context.Context, in contracts.VerifyP out.Reason = "repository is not stored; explicitly sync the target thread" return out, nil } - thread, err := c.GetThread(ctx, repository.ID, in.Kind, in.Number) + thread, err := c.GetThread(ctx, repository.ID, kind, in.Number) if err != nil { return nil, err } diff --git a/internal/app/draft_verification_test.go b/internal/app/draft_verification_test.go index 50267af7..6ce818a5 100644 --- a/internal/app/draft_verification_test.go +++ b/internal/app/draft_verification_test.go @@ -42,7 +42,7 @@ func TestVerifyPublishedDraftExactMismatchAndStale(t *testing.T) { } observedAt := time.Now().UTC().Add(time.Minute) _, err = fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: fixture.repoID, Kind: domain.IssueKind, Number: 1, State: "open", Title: draft.Title, Body: draft.Body, SourceUpdatedAt: observedAt, }, `{}`) if err != nil { @@ -52,7 +52,7 @@ func TestVerifyPublishedDraftExactMismatchAndStale(t *testing.T) { t.Fatalf("exact result = %+v", result) } _, err = fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: fixture.repoID, Kind: domain.IssueKind, Number: 1, State: "open", Title: draft.Title, Body: draft.Body + "\nliteral change", SourceUpdatedAt: observedAt.Add(time.Minute), }, `{}`) if err != nil { diff --git a/internal/app/duplicates_test.go b/internal/app/duplicates_test.go index a9d62fad..c8b87dc9 100644 --- a/internal/app/duplicates_test.go +++ b/internal/app/duplicates_test.go @@ -7,6 +7,7 @@ import ( "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" "github.com/morluto/gitcontribute/internal/investigation" ) @@ -36,7 +37,7 @@ func TestDuplicateAndCollisionChecks(t *testing.T) { now := time.Now().UTC() if _, err := c.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "race in parser", @@ -49,7 +50,7 @@ func TestDuplicateAndCollisionChecks(t *testing.T) { } if _, err := c.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "fix race in parser", @@ -84,6 +85,17 @@ func TestDuplicateAndCollisionChecks(t *testing.T) { if _, err := svc.CheckHypothesisDuplicates(ctx, h.ID, maxResultLimit+1); err == nil { t.Fatal("oversized duplicate limit was accepted") } + opportunity, err := svc.PromoteOpportunity(ctx, h.ID, "data race under load", "parser", "panic", "small", 0.8) + if err != nil { + t.Fatalf("promote opportunity: %v", err) + } + opportunityDuplicates, err := svc.CheckOpportunityDuplicates(ctx, " "+opportunity.ID+" ", 0) + if err != nil { + t.Fatalf("check opportunity duplicates: %v", err) + } + if opportunityDuplicates.OpportunityID != opportunity.ID || opportunityDuplicates.HypothesisID != "" { + t.Fatalf("opportunity duplicate identity = %+v", opportunityDuplicates) + } coll, err := svc.CheckHypothesisCollisions(ctx, h.ID, 0) if err != nil { @@ -97,4 +109,11 @@ func TestDuplicateAndCollisionChecks(t *testing.T) { t.Fatalf("collision finding should be contradicting, got %q", f.Relation) } } + opportunityCollisions, err := svc.CheckOpportunityCollisions(ctx, opportunity.ID, 0) + if err != nil { + t.Fatalf("check opportunity collisions: %v", err) + } + if opportunityCollisions.OpportunityID != opportunity.ID || opportunityCollisions.HypothesisID != h.ID { + t.Fatalf("opportunity collision identity = %+v", opportunityCollisions) + } } diff --git a/internal/app/evidence.go b/internal/app/evidence.go index 4f97e446..f46e7862 100644 --- a/internal/app/evidence.go +++ b/internal/app/evidence.go @@ -95,27 +95,27 @@ func (s *Service) ShowValidation(ctx context.Context, id string) (*contracts.Val // RunValidation executes a stored validation definition against the base or candidate workspace. func (s *Service) RunValidation(ctx context.Context, id string, opts contracts.RunValidationOptions) (*contracts.ValidationRunResult, error) { - if !opts.Execute { - return nil, evidence.ErrExecutionNotAuthorized - } - runKind := evidence.RunKind(opts.Kind) - if runKind != evidence.RunKindBase && runKind != evidence.RunKindCandidate { - return nil, fmt.Errorf("invalid run kind %q: must be base or candidate", opts.Kind) + request, err := parseValidationRunOptions(id, opts) + if err != nil { + return nil, err } + return s.runValidation(ctx, request) +} +func (s *Service) runValidation(ctx context.Context, request validationRunRequest) (*contracts.ValidationRunResult, error) { c, err := s.openCorpus(ctx) if err != nil { return nil, err } - def, err := c.GetValidationDefinition(ctx, id) + def, err := c.GetValidationDefinition(ctx, request.definitionID) if err != nil { return nil, mapEvidenceError(err) } workspaceID := def.WorkspaceID - if runKind == evidence.RunKindBase && def.BaseWorkspaceID != "" { + if request.kind == evidence.RunKindBase && def.BaseWorkspaceID != "" { workspaceID = def.BaseWorkspaceID } - if runKind == evidence.RunKindCandidate && def.CandidateWorkspaceID != "" { + if request.kind == evidence.RunKindCandidate && def.CandidateWorkspaceID != "" { workspaceID = def.CandidateWorkspaceID } var before workspace.Snapshot @@ -132,7 +132,7 @@ func (s *Service) RunValidation(ctx context.Context, id string, opts contracts.R } } evSvc := evidence.NewService(c, evidence.NewExecRunner()) - run, err := evSvc.RunValidation(ctx, id, runKind) + run, err := evSvc.RunValidation(ctx, request.definitionID, request.kind) if err != nil { return nil, mapEvidenceError(err) } @@ -192,7 +192,7 @@ func (s *Service) resolveValidationWorkspaces(ctx context.Context, c *corpus.Cor } func bindValidationWorkspace(ctx context.Context, service *Service, c *corpus.Corpus, run *evidence.ValidationRun, managed *workspace.Workspace, before workspace.Snapshot, beforeErr error) error { - run.WorkspaceBindingStatus = "unavailable" + run.WorkspaceBindingStatus = evidence.WorkspaceBindingUnavailable switch { case beforeErr != nil: run.WorkspaceBindingReason = "capture pre-run workspace snapshot: " + beforeErr.Error() @@ -214,14 +214,14 @@ func bindValidationWorkspace(ctx context.Context, service *Service, c *corpus.Co } run.WorkspaceSnapshotAfter = after.SHA256 switch { - case !before.Complete || !after.Complete: - run.WorkspaceBindingStatus = "incomplete" + case !before.Complete() || !after.Complete(): + run.WorkspaceBindingStatus = evidence.WorkspaceBindingIncomplete run.WorkspaceBindingReason = "workspace snapshot contains explicitly unbound content" case before.SHA256 != after.SHA256: - run.WorkspaceBindingStatus = "changed" + run.WorkspaceBindingStatus = evidence.WorkspaceBindingChanged run.WorkspaceBindingReason = "workspace changed while validation was running" default: - run.WorkspaceBindingStatus = "bound" + run.WorkspaceBindingStatus = evidence.WorkspaceBindingBound run.WorkspaceBindingReason = "pre-run and post-run workspace identities match" } } @@ -235,21 +235,19 @@ func bindValidationWorkspace(ctx context.Context, service *Service, c *corpus.Co // RunValidationGroup executes a bounded repeat/stress validation group. func (s *Service) RunValidationGroup(ctx context.Context, id string, opts contracts.RepeatValidationOptions) (*contracts.ValidationRunGroupResult, error) { - if !opts.Execute { - return nil, evidence.ErrExecutionNotAuthorized - } - kinds := make([]evidence.RunKind, len(opts.Kinds)) - for index, kind := range opts.Kinds { - kinds[index] = evidence.RunKind(kind) + request, err := parseRepeatValidationOptions(id, opts) + if err != nil { + return nil, mapEvidenceError(err) } + return s.runValidationGroup(ctx, request) +} + +func (s *Service) runValidationGroup(ctx context.Context, request repeatValidationRequest) (*contracts.ValidationRunGroupResult, error) { c, err := s.openCorpus(ctx) if err != nil { return nil, err } - group, err := evidence.NewService(c, evidence.NewExecRunner()).RunValidationGroup(ctx, id, evidence.RepeatValidationOptions{ - Kinds: kinds, RunCount: opts.RunCount, Concurrency: opts.Concurrency, - PerRunTimeout: opts.PerRunTimeout, OverallTimeout: opts.OverallTimeout, SampleInterval: opts.SampleInterval, - }) + group, err := evidence.NewService(c, evidence.NewExecRunner()).RunValidationGroup(ctx, request.definitionID, request.options) if err != nil { return nil, mapEvidenceError(err) } @@ -278,6 +276,14 @@ func (s *Service) CompareValidation(ctx context.Context, baseRunID, candidateRun // AttachValidationReceipt imports a structured external receipt without // executing its declared command. func (s *Service) AttachValidationReceipt(ctx context.Context, receipt contracts.ExternalValidationReceipt) (*contracts.ValidationRunResult, error) { + kind, err := evidence.ParseRunKind(receipt.Kind) + if err != nil { + return nil, err + } + classification, err := evidence.ParseRunClassification(receipt.Classification) + if err != nil { + return nil, err + } c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -298,11 +304,11 @@ func (s *Service) AttachValidationReceipt(ctx context.Context, receipt contracts run, err := evidence.NewService(c, nil).AttachExternalReceipt(ctx, evidence.ExternalReceipt{ SchemaVersion: receipt.SchemaVersion, Producer: receipt.Producer, ReceiptSHA256: receipt.ReceiptSHA256, ValidationID: receipt.ValidationID, - InvestigationID: receipt.InvestigationID, OpportunityID: receipt.OpportunityID, Kind: evidence.RunKind(receipt.Kind), + InvestigationID: receipt.InvestigationID, OpportunityID: receipt.OpportunityID, Kind: kind, Repository: receipt.Repository, Revision: receipt.Revision, ArtifactSHA256: receipt.ArtifactSHA256, Provider: receipt.Provider, ExternalRunID: receipt.ExternalRunID, Command: receipt.Command, WorkingDir: receipt.WorkingDir, Environment: receipt.Environment, Artifacts: receipt.Artifacts, StartedAt: receipt.StartedAt, CompletedAt: receipt.CompletedAt, - ExitCode: receipt.ExitCode, Classification: evidence.RunClassification(receipt.Classification), + ExitCode: receipt.ExitCode, Classification: classification, Stdout: receipt.Stdout, Stderr: receipt.Stderr, Truncated: receipt.Truncated, Limitations: receipt.Limitations, Incomplete: receipt.Incomplete, }) @@ -366,6 +372,14 @@ func (s *Service) RecordEvidence(ctx context.Context, input contracts.RecordEvid if strings.TrimSpace(input.Description) == "" { return nil, errors.New("evidence description is required") } + evidenceType, err := evidence.ParseEvidenceType(input.Type) + if err != nil { + return nil, err + } + relation, err := evidence.ParseRelation(input.Relation) + if err != nil { + return nil, err + } invSvc, err := s.writeInvestigationSvc(ctx) if err != nil { @@ -401,8 +415,12 @@ func (s *Service) RecordEvidence(ctx context.Context, input contracts.RecordEvid sourceRefs := append([]domain.SourceRef(nil), input.SourceRefs...) provenance := append([]evidence.SourceRevision(nil), input.SourceProvenance...) - if len(provenance) == 0 && evidence.EvidenceType(input.Type) == evidence.EvidenceTypeGitHubSource && inv.ThreadBaseline != nil { - provenance = []evidence.SourceRevision{sourceRevisionFromThreadBaseline(*inv.ThreadBaseline)} + if len(provenance) == 0 && evidenceType == evidence.EvidenceTypeGitHubSource && inv.ThreadBaseline != nil { + revision, err := sourceRevisionFromThreadBaseline(*inv.ThreadBaseline) + if err != nil { + return nil, err + } + provenance = []evidence.SourceRevision{revision} if len(sourceRefs) == 0 { sourceRefs = []domain.SourceRef{inv.ThreadBaseline.Source} } @@ -412,8 +430,8 @@ func (s *Service) RecordEvidence(ctx context.Context, input contracts.RecordEvid InvestigationID: investigationID, HypothesisID: hypothesisID, OpportunityID: opportunityID, - Type: evidence.EvidenceType(input.Type), - Relation: evidence.Relation(input.Relation), + Type: evidenceType, + Relation: relation, Description: strings.TrimSpace(input.Description), SourceRefs: sourceRefs, SourceProvenance: provenance, @@ -486,15 +504,15 @@ func validationRunResult(run *evidence.ValidationRun) *contracts.ValidationRunRe CompletedAt: formatTime(run.CompletedAt), WorkspaceSnapshotBefore: run.WorkspaceSnapshotBefore, WorkspaceSnapshotAfter: run.WorkspaceSnapshotAfter, - WorkspaceBindingStatus: run.WorkspaceBindingStatus, + WorkspaceBindingStatus: string(run.WorkspaceBindingStatus), WorkspaceBindingReason: run.WorkspaceBindingReason, Process: validationProcessIdentity(run.Process), Phases: validationPhases(run.Phases), - TimeoutPhase: run.TimeoutPhase, - FailurePhase: run.FailurePhase, + TimeoutPhase: string(run.TimeoutPhase), + FailurePhase: string(run.FailurePhase), Resources: validationResources(run.Resources), Cleanup: validationCleanup(run.Cleanup), - ExecutionOrigin: run.ExecutionOrigin, + ExecutionOrigin: string(run.ExecutionOrigin), } if run.External != nil { result.External = &contracts.ExternalValidationProvenance{ @@ -533,7 +551,7 @@ func validationRunGroupResult(group *evidence.ValidationRunGroup) *contracts.Val Index: attempt.Index, Kind: string(attempt.Kind), RunID: attempt.RunID, StartedAt: formatTime(attempt.StartedAt), CompletedAt: formatTime(attempt.CompletedAt), ExitCode: attempt.ExitCode, Classification: string(attempt.Classification), ObservationStatus: string(attempt.ObservationStatus), - TimeoutPhase: attempt.TimeoutPhase, FailurePhase: attempt.FailurePhase, + TimeoutPhase: string(attempt.TimeoutPhase), FailurePhase: string(attempt.FailurePhase), Error: attempt.Error, Process: validationProcessIdentity(attempt.Process), Phases: validationPhases(attempt.Phases), Resources: validationResources(attempt.Resources), Cleanup: validationCleanup(attempt.Cleanup), @@ -544,7 +562,7 @@ func validationRunGroupResult(group *evidence.ValidationRunGroup) *contracts.Val Kind: string(aggregate.Kind), Requested: aggregate.Requested, Completed: aggregate.Completed, Passing: aggregate.Passing, Failing: aggregate.Failing, Inconclusive: aggregate.Inconclusive, Cancelled: aggregate.Cancelled, Classification: string(aggregate.Classification), - ResourceClassification: aggregate.ResourceClassification, + ResourceClassification: string(aggregate.ResourceClassification), }) } if group.Comparison != nil { @@ -577,7 +595,7 @@ func validationResources(value evidence.ResourceTelemetry) contracts.ValidationR } func validationCleanup(value evidence.CleanupResult) contracts.ValidationCleanupResult { - result := contracts.ValidationCleanupResult{Status: value.Status, Reason: value.Reason, CheckedAt: formatTime(value.CheckedAt)} + result := contracts.ValidationCleanupResult{Status: string(value.Status), Reason: value.Reason, CheckedAt: formatTime(value.CheckedAt)} for _, survivor := range value.Survivors { result.Survivors = append(result.Survivors, validationProcessIdentity(survivor)) } diff --git a/internal/app/evidence_freshness.go b/internal/app/evidence_freshness.go index 29c89f86..1cc58fc5 100644 --- a/internal/app/evidence_freshness.go +++ b/internal/app/evidence_freshness.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" @@ -24,16 +25,17 @@ func evidenceItemResult(ctx context.Context, c *corpus.Corpus, item *evidence.Ev }, nil } -func sourceRevisionFromThreadBaseline(baseline investigation.ThreadBaseline) evidence.SourceRevision { +func sourceRevisionFromThreadBaseline(baseline investigation.ThreadBaseline) (evidence.SourceRevision, error) { + subject, err := evidence.NewThreadSourceSubject(baseline.Repo, baseline.Kind, baseline.Number) + if err != nil { + return evidence.SourceRevision{}, fmt.Errorf("parse thread baseline source: %w", err) + } return evidence.SourceRevision{ - Subject: evidence.SourceSubject{ - Kind: evidence.SourceSubjectThread, Owner: baseline.Repo.Owner(), Repo: baseline.Repo.Repo(), - ThreadKind: string(baseline.Kind), Number: baseline.Number, - }, + Subject: subject, SourceUpdatedAt: baseline.SourceUpdatedAt, ObservationSequence: baseline.ObservationSequence, ObservedAt: baseline.ObservedAt, - } + }, nil } func evidenceSourceRevisionResults(values []evidence.SourceRevision) []contracts.EvidenceSourceRevisionResult { @@ -42,10 +44,12 @@ func evidenceSourceRevisionResults(values []evidence.SourceRevision) []contracts } result := make([]contracts.EvidenceSourceRevisionResult, len(values)) for i, value := range values { + repository := value.Subject.Repository() + threadKind, number, _ := value.Subject.Thread() result[i] = contracts.EvidenceSourceRevisionResult{ Subject: contracts.EvidenceSourceSubjectResult{ - Kind: string(value.Subject.Kind), Owner: value.Subject.Owner, Repo: value.Subject.Repo, - ThreadKind: value.Subject.ThreadKind, Number: value.Subject.Number, Facet: value.Subject.Facet, + Kind: value.Subject.Kind().String(), Owner: repository.Owner(), Repo: repository.Repo(), + ThreadKind: string(threadKind), Number: number, Facet: value.Subject.Facet(), }, SourceUpdatedAt: formatTime(value.SourceUpdatedAt), ObservationSequence: value.ObservationSequence, diff --git a/internal/app/evidence_freshness_test.go b/internal/app/evidence_freshness_test.go index a015c18e..23bfcca8 100644 --- a/internal/app/evidence_freshness_test.go +++ b/internal/app/evidence_freshness_test.go @@ -33,8 +33,10 @@ func TestEvidenceFreshnessFromThreadInvestigationBaseline(t *testing.T) { if len(recorded.SourceRefs) != 1 || len(recorded.SourceProvenance) != 1 { t.Fatalf("baseline source was not inherited: %+v", recorded) } - if got := recorded.SourceProvenance[0]; got.Subject.Kind != evidence.SourceSubjectThread || - got.Subject.ThreadKind != string(domain.IssueKind) || got.Subject.Number != 1 || + got := recorded.SourceProvenance[0] + threadKind, number, threadScoped := got.Subject.Thread() + if got.Subject.Kind() != evidence.SourceSubjectThread || + !threadScoped || threadKind != domain.IssueKind || number != 1 || got.ObservationSequence != started.Investigation.ThreadBaseline.ObservationSequence { t.Fatalf("unexpected inherited provenance: %+v", got) } @@ -56,7 +58,7 @@ func TestEvidenceFreshnessFromThreadInvestigationBaseline(t *testing.T) { t.Fatal(err) } if _, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: fixture.repoID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Retry parser cancellation updated", Body: thread.Body, Author: thread.Author, AuthorAssociation: thread.AuthorAssociation, Labels: thread.Labels, SourceCreatedAt: thread.SourceCreatedAt, SourceUpdatedAt: fixture.now.Add(time.Hour), diff --git a/internal/app/github_search.go b/internal/app/github_search.go new file mode 100644 index 00000000..0fd1c5b4 --- /dev/null +++ b/internal/app/github_search.go @@ -0,0 +1,67 @@ +package app + +import ( + "errors" + "strings" +) + +type githubSearchOrder uint8 + +const ( + githubSearchOrderUnspecified githubSearchOrder = iota + githubSearchAscending + githubSearchDescending +) + +func parseGitHubSearchOrder(value string, fallback githubSearchOrder) (githubSearchOrder, error) { + switch strings.TrimSpace(value) { + case "": + return fallback, nil + case "asc": + return githubSearchAscending, nil + case "desc": + return githubSearchDescending, nil + default: + return 0, errors.New("order must be asc or desc") + } +} + +func (o githubSearchOrder) String() string { + switch o { + case githubSearchAscending: + return "asc" + case githubSearchDescending: + return "desc" + default: + return "" + } +} + +type githubSearchPage struct { + number int + limit int +} + +type githubSearchPageProblem uint8 + +const ( + githubSearchPageValid githubSearchPageProblem = iota + githubSearchLimitInvalid + githubSearchPageInvalid +) + +func parseGitHubSearchPage(limit, page int) (githubSearchPage, githubSearchPageProblem) { + if limit == 0 { + limit = 20 + } + if limit < 1 || limit > 100 { + return githubSearchPage{}, githubSearchLimitInvalid + } + if page == 0 { + page = 1 + } + if page < 1 || page > 1000 || (page-1)*limit >= 1000 { + return githubSearchPage{}, githubSearchPageInvalid + } + return githubSearchPage{number: page, limit: limit}, githubSearchPageValid +} diff --git a/internal/app/guidance_test.go b/internal/app/guidance_test.go index 0196fc2b..3521e568 100644 --- a/internal/app/guidance_test.go +++ b/internal/app/guidance_test.go @@ -76,7 +76,7 @@ func TestRadarClassifiesStoredPolicyAndNaturalLanguageClaimOffline(t *testing.T) } issue, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 3, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 3, State: "open", Title: "Claimed help-wanted work", Body: "Steps to reproduce and expected behavior are documented.", Labels: []string{"help wanted"}, SourceUpdatedAt: fixture.now, }, `{}`) diff --git a/internal/app/health.go b/internal/app/health.go index 8a40aa7b..ac7b7406 100644 --- a/internal/app/health.go +++ b/internal/app/health.go @@ -10,12 +10,6 @@ import ( "github.com/morluto/gitcontribute/internal/health" ) -// RepositoryHealth returns a deterministic repository health report derived from -// already stored corpus facts. It performs no network access. -func (s *Service) RepositoryHealth(ctx context.Context, repo contracts.RepoRef) (*health.Report, error) { - return s.RepositoryHealthWithOptions(ctx, repo, health.Options{}) -} - // RepositoryHealthWithOptions returns a deterministic repository health report // using the provided analysis window and stale threshold. func (s *Service) RepositoryHealthWithOptions(ctx context.Context, repo contracts.RepoRef, opts health.Options) (*health.Report, error) { diff --git a/internal/app/health_test.go b/internal/app/health_test.go index 71d4262c..875b5404 100644 --- a/internal/app/health_test.go +++ b/internal/app/health_test.go @@ -43,7 +43,7 @@ func TestRepositoryHealth(t *testing.T) { // External open PR with a recent review. openPR, err := upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 1, State: "open", Title: "open pr", @@ -71,7 +71,7 @@ func TestRepositoryHealth(t *testing.T) { // Owner merged PR. _, err = upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "merged pr", @@ -87,7 +87,7 @@ func TestRepositoryHealth(t *testing.T) { // PR with no author-association metadata should not be counted as external. _, err = upsertThread(ctx, svc.corpus, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 3, State: "open", Title: "unknown assoc pr", diff --git a/internal/app/hydration.go b/internal/app/hydration.go index 455784e5..7fe00a9c 100644 --- a/internal/app/hydration.go +++ b/internal/app/hydration.go @@ -33,50 +33,22 @@ const ( const maxHydrationPages = 100 -// HydrateResult reports the outcome of hydrating a thread. -type HydrateResult struct { - Repo contracts.RepoRef - Number int - Kind string - Facets []HydratedFacet - Pages int - Requests int - Capped bool - Message string -} - -// HydratedFacet reports coverage and counts for one hydrated facet. -type HydratedFacet struct { - Facet string - Count int - Pages int - Complete bool -} - -// HydrateOptions controls selective thread hydration. -type HydrateOptions struct { - // Kind selects the exact issue or pull request when a number is ambiguous. - Kind string - // Facets lists the facets to retrieve. An empty list hydrates all facets - // applicable to the thread kind. - Facets []string - // MaxPages bounds pagination per facet. Zero defaults to 50. - MaxPages int +// hydrateStoredThread parses a selective hydration boundary and fetches the +// requested facets without refreshing the thread header first. +func (s *Service) hydrateStoredThread(ctx context.Context, repo contracts.RepoRef, number int, input hydrateThreadInput) (*contracts.HydrateResult, error) { + target, err := parseHydrationTarget(repo, number, input) + if err != nil { + return nil, err + } + return s.hydrateThread(ctx, target) } -// HydrateThread fetches the requested facets for an issue or pull request and +// hydrateThread fetches the requested facets for an issue or pull request and // stores immutable facet observations. It is explicit, bounded, paginated, // cancellation-aware, and records independent facet coverage plus run // completion/failure statistics. -func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, number int, opts HydrateOptions) (*HydrateResult, error) { - ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) - if err != nil { - return nil, err - } - if number <= 0 { - return nil, errors.New("thread number must be positive") - } - +func (s *Service) hydrateThread(ctx context.Context, target hydrationTarget) (*contracts.HydrateResult, error) { + ref, number := target.repository, target.number c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -113,14 +85,10 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num } var thread *corpus.Thread - if opts.Kind == "" { + if !target.kind.specified() { thread, err = c.GetThreadByNumber(ctx, repoProjection.ID, number) } else { - if opts.Kind != corpus.ThreadKindIssue && opts.Kind != corpus.ThreadKindPullRequest { - hydrateErr = fmt.Errorf("thread kind must be issue or pull_request") - return nil, hydrateErr - } - thread, err = c.GetThread(ctx, repoProjection.ID, opts.Kind, number) + thread, err = c.GetThread(ctx, repoProjection.ID, target.kind.value, number) } if err != nil { hydrateErr = fmt.Errorf("get thread: %w", err) @@ -136,29 +104,20 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num return nil, hydrateErr } - facets, err := selectFacets(thread.Kind, opts.Facets) + selectedFacets, err := target.selection.For(thread.Kind) if err != nil { hydrateErr = err return nil, hydrateErr } - maxPages := opts.MaxPages - if maxPages <= 0 { - maxPages = 50 - } - if maxPages > maxHydrationPages { - hydrateErr = fmt.Errorf("max pages cannot exceed %d", maxHydrationPages) - return nil, hydrateErr - } - - result := &HydrateResult{ - Repo: repo, + result := &contracts.HydrateResult{ + Repo: target.wireRepository(), Number: number, - Kind: thread.Kind, - Facets: make([]HydratedFacet, 0, len(facets)), + Kind: string(thread.Kind), + Facets: make([]contracts.HydratedFacet, 0, len(selectedFacets)), } - for _, facet := range facets { + for _, facet := range selectedFacets { if err := ctx.Err(); err != nil { hydrateErr = err return nil, hydrateErr @@ -173,11 +132,11 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num repoID: repoProjection.ID, threadID: thread.ID, runID: run.ID, - maxPages: maxPages, + maxPages: target.maxPages, } - var facetResult HydratedFacet - switch facet { + var facetResult contracts.HydratedFacet + switch facet.String() { case FacetIssueComments: facetResult, err = f.hydrateIssueComments() case FacetPRDetails: @@ -189,7 +148,7 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num case FacetIssueTimeline: facetResult, err = f.hydrateIssueTimeline() default: - hydrateErr = fmt.Errorf("unknown facet %q", facet) + hydrateErr = fmt.Errorf("hydration facet %q has no executor", facet) return nil, hydrateErr } if err != nil { @@ -202,11 +161,11 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num result.Requests += facetResult.Pages } - statsPayload, _ := json.Marshal(map[string]any{ - "facets": len(result.Facets), - "pages": result.Pages, - "requests": result.Requests, - }) + statsPayload, _ := json.Marshal(struct { + Facets int `json:"facets"` + Pages int `json:"pages"` + Requests int `json:"requests"` + }{Facets: len(result.Facets), Pages: result.Pages, Requests: result.Requests}) if err := corpus.RetryBusy(ctx, func(ctx context.Context) error { return c.FinishRun(ctx, run.ID, string(statsPayload)) }); err != nil { @@ -218,45 +177,14 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num return result, nil } -func selectFacets(kind string, requested []string) ([]string, error) { - defaults := facets.DefaultFor(kind) - if len(defaults) == 0 { - return nil, fmt.Errorf("unknown thread kind %q", kind) - } - - if len(requested) == 0 { - return defaults, nil - } - allowed := facets.SelectableFor(kind) - - allowedSet := make(map[string]struct{}, len(allowed)) - for _, f := range allowed { - allowedSet[f] = struct{}{} - } - - out := make([]string, 0, len(requested)) - seen := make(map[string]struct{}, len(requested)) - for _, f := range requested { - if _, ok := allowedSet[f]; !ok { - return nil, fmt.Errorf("facet %q is not applicable to %s threads", f, kind) - } - if _, ok := seen[f]; ok { - continue - } - seen[f] = struct{}{} - out = append(out, f) - } - return out, nil -} - -func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) { +func (f *facetRunner) hydrateIssueTimeline() (contracts.HydratedFacet, error) { reader, ok := f.reader.(github.IssueTimelineReader) if !ok { - return HydratedFacet{}, errors.New("GitHub reader does not support issue timelines") + return contracts.HydratedFacet{}, errors.New("GitHub reader does not support issue timelines") } expectedSequence, err := f.facetBaseline(FacetIssueTimeline) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } opts := github.PageOptions{Page: 1, PerPage: 100} var total, pages int @@ -266,11 +194,11 @@ func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) { var events []github.IssueTimelineEvent for pages < f.maxPages { if err := f.ctx.Err(); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } res, err := reader.ListIssueTimeline(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number, opts) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } pages++ pageUpdatedAt := sourceUpdatedAt @@ -281,7 +209,7 @@ func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) { } payload, err := json.Marshal(res.Items) if err != nil { - return HydratedFacet{}, fmt.Errorf("marshal issue timeline: %w", err) + return contracts.HydratedFacet{}, fmt.Errorf("marshal issue timeline: %w", err) } pageObservations = append(pageObservations, corpus.FacetObservationInput{ SourceUpdatedAt: pageUpdatedAt, @@ -303,33 +231,33 @@ func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) { if _, err := corpus.RetryBusyValue(f.ctx, func(ctx context.Context) (bool, error) { return f.c.AdvanceFacetCAS(ctx, f.repoID, &f.threadID, FacetIssueTimeline, sourceUpdatedAt, false, f.runID, expectedSequence) }); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } - return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: false}, nil + return contracts.HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: false}, nil } collapseFacetSearchText(pageObservations) applied, err := corpus.RetryBusyValue(f.ctx, func(ctx context.Context) (bool, error) { return f.c.ApplyFacetObservationSetCAS(ctx, f.repoID, &f.threadID, FacetIssueTimeline, sourceUpdatedAt, pageObservations, true, f.runID, expectedSequence) }) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } if !applied { - return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil + return contracts.HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil } coverage, err := f.c.GetCoverage(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } if coverage == nil || !coverage.Complete || !coverage.SourceUpdatedAt.Equal(sourceUpdatedAt.Truncate(time.Second)) { // A newer stored snapshot won the stale-write comparison. Do not attach // this older derivation to that snapshot's observation identities. - return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil + return contracts.HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil } if err := f.persistTimelineResolution(events, sourceUpdatedAt); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } - return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil + return contracts.HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil } func (f *facetRunner) persistTimelineResolution(events []github.IssueTimelineEvent, sourceUpdatedAt time.Time) error { @@ -353,7 +281,11 @@ func (f *facetRunner) persistTimelineResolution(events []github.IssueTimelineEve if err != nil { return err } - refs = []corpus.ObservationRef{{Kind: "thread", ID: observation.ID}} + ref, err := corpus.NewThreadObservationRef(observation.ID) + if err != nil { + return err + } + refs = []corpus.ObservationRef{ref} } else { observations, _, err := f.c.ListFacetObservationsBounded(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline, 100) if err != nil { @@ -366,7 +298,11 @@ func (f *facetRunner) persistTimelineResolution(events []github.IssueTimelineEve } for _, event := range page { if event.Event == "closed" && event.CommitID == selectedCommit { - refs = append(refs, corpus.ObservationRef{Kind: "facet", ID: observation.ID}) + ref, err := corpus.NewFacetObservationRef(observation.ID) + if err != nil { + return err + } + refs = append(refs, ref) break } } @@ -401,10 +337,10 @@ type paginatedFacetSpec[T any] struct { searchText func([]T) string } -func hydratePaginatedFacet[T any](f *facetRunner, spec paginatedFacetSpec[T]) (HydratedFacet, error) { +func hydratePaginatedFacet[T any](f *facetRunner, spec paginatedFacetSpec[T]) (contracts.HydratedFacet, error) { expectedSequence, err := f.facetBaseline(spec.facet) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } opts := github.PageOptions{Page: 1, PerPage: 100} var total, pages int @@ -413,11 +349,11 @@ func hydratePaginatedFacet[T any](f *facetRunner, spec paginatedFacetSpec[T]) (H sourceUpdatedAt := f.thread.SourceUpdatedAt for pages < f.maxPages { if err := f.ctx.Err(); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } res, err := spec.fetch(opts) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } pages++ pageUpdated := spec.latest(res.Items) @@ -429,7 +365,7 @@ func hydratePaginatedFacet[T any](f *facetRunner, spec paginatedFacetSpec[T]) (H } payload, err := json.Marshal(res.Items) if err != nil { - return HydratedFacet{}, fmt.Errorf("marshal %s: %w", spec.marshalContext, err) + return contracts.HydratedFacet{}, fmt.Errorf("marshal %s: %w", spec.marshalContext, err) } pageObservations = append(pageObservations, corpus.FacetObservationInput{ SourceUpdatedAt: pageUpdated, @@ -445,23 +381,23 @@ func hydratePaginatedFacet[T any](f *facetRunner, spec paginatedFacetSpec[T]) (H } if err := f.ctx.Err(); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } if !complete { if _, err := corpus.RetryBusyValue(f.ctx, func(ctx context.Context) (bool, error) { return f.c.AdvanceFacetCAS(ctx, f.repoID, &f.threadID, spec.facet, sourceUpdatedAt, false, f.runID, expectedSequence) }); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } - return HydratedFacet{Facet: spec.facet, Count: total, Pages: pages, Complete: false}, nil + return contracts.HydratedFacet{Facet: spec.facet, Count: total, Pages: pages, Complete: false}, nil } collapseFacetSearchText(pageObservations) if _, err := corpus.RetryBusyValue(f.ctx, func(ctx context.Context) (bool, error) { return f.c.ApplyFacetObservationSetCAS(ctx, f.repoID, &f.threadID, spec.facet, sourceUpdatedAt, pageObservations, true, f.runID, expectedSequence) }); err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } - return HydratedFacet{Facet: spec.facet, Count: total, Pages: pages, Complete: true}, nil + return contracts.HydratedFacet{Facet: spec.facet, Count: total, Pages: pages, Complete: true}, nil } func (f *facetRunner) facetBaseline(facet string) (int64, error) { @@ -475,7 +411,7 @@ func (f *facetRunner) facetBaseline(facet string) (int64, error) { return coverage.ObservationSequence, nil } -func (f *facetRunner) hydrateIssueComments() (HydratedFacet, error) { +func (f *facetRunner) hydrateIssueComments() (contracts.HydratedFacet, error) { return hydratePaginatedFacet(f, paginatedFacetSpec[github.IssueComment]{ facet: FacetIssueComments, marshalContext: "issue comments", @@ -487,19 +423,19 @@ func (f *facetRunner) hydrateIssueComments() (HydratedFacet, error) { }) } -func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { +func (f *facetRunner) hydratePullRequestDetails() (contracts.HydratedFacet, error) { expectedSequence, err := f.facetBaseline(FacetPRDetails) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } pr, _, err := f.reader.GetPullRequestDetails(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } payload, err := json.Marshal(pr) if err != nil { - return HydratedFacet{}, fmt.Errorf("marshal pr details: %w", err) + return contracts.HydratedFacet{}, fmt.Errorf("marshal pr details: %w", err) } updatedAt := pr.UpdatedAt if updatedAt.IsZero() { @@ -511,14 +447,18 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { return f.c.ApplyFacetObservationSetCAS(ctx, f.repoID, &f.threadID, FacetPRDetails, updatedAt, pages, true, f.runID, expectedSequence) }) if err != nil { - return HydratedFacet{}, err + return contracts.HydratedFacet{}, err } if !applied { - return HydratedFacet{Facet: FacetPRDetails, Count: 1, Pages: 1, Complete: true}, nil + return contracts.HydratedFacet{Facet: FacetPRDetails, Count: 1, Pages: 1, Complete: true}, nil } projection := *f.thread if pr.State != "" { - projection.State = pr.State + state, err := domain.ParseThreadState(pr.State) + if err != nil { + return contracts.HydratedFacet{}, fmt.Errorf("parse pull-request state: %w", err) + } + projection.State = state } projection.Title = pr.Title projection.Body = pr.Body @@ -531,7 +471,7 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { projection.Milestone = pr.Milestone merge, err := parseGitHubMergeStatus(pr) if err != nil { - return HydratedFacet{}, fmt.Errorf("parse pull-request merge status: %w", err) + return contracts.HydratedFacet{}, fmt.Errorf("parse pull-request merge status: %w", err) } projection.Merge = merge projection.SourceUpdatedAt = updatedAt @@ -547,14 +487,14 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { return f.c.UpsertThread(ctx, projection, string(payload)) }) if err != nil { - return HydratedFacet{}, fmt.Errorf("project pr details: %w", err) + return contracts.HydratedFacet{}, fmt.Errorf("project pr details: %w", err) } *f.thread = *stored - return HydratedFacet{Facet: FacetPRDetails, Count: 1, Pages: 1, Complete: true}, nil + return contracts.HydratedFacet{Facet: FacetPRDetails, Count: 1, Pages: 1, Complete: true}, nil } -func (f *facetRunner) hydratePullRequestReviews() (HydratedFacet, error) { +func (f *facetRunner) hydratePullRequestReviews() (contracts.HydratedFacet, error) { return hydratePaginatedFacet(f, paginatedFacetSpec[github.Review]{ facet: FacetPRReviews, marshalContext: "pr reviews", @@ -566,7 +506,7 @@ func (f *facetRunner) hydratePullRequestReviews() (HydratedFacet, error) { }) } -func (f *facetRunner) hydratePullRequestReviewComments() (HydratedFacet, error) { +func (f *facetRunner) hydratePullRequestReviewComments() (contracts.HydratedFacet, error) { return hydratePaginatedFacet(f, paginatedFacetSpec[github.ReviewComment]{ facet: FacetPRReviewComments, marshalContext: "pr review comments", diff --git a/internal/app/hydration_freshness_test.go b/internal/app/hydration_freshness_test.go index 60c6c4dd..bff9189c 100644 --- a/internal/app/hydration_freshness_test.go +++ b/internal/app/hydration_freshness_test.go @@ -6,7 +6,7 @@ import ( "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" ) @@ -28,7 +28,7 @@ func TestHydrateRefreshesStaleThreadHeaderBeforeEmptyFacet(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 223) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 223) current := thread.SourceUpdatedAt.Add(24 * time.Hour) reader := &exactHydrationReader{ fakeHydrationReader: &fakeHydrationReader{issueCommentsPages: [][]github.IssueComment{{}}}, @@ -36,7 +36,7 @@ func TestHydrateRefreshesStaleThreadHeaderBeforeEmptyFacet(t *testing.T) { RepositoryOwner: "owner", RepositoryName: "repo", Number: 223, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, State: "open", Title: "current title", UpdatedAt: current, @@ -73,7 +73,7 @@ func TestHydrateFetchesMissingExactThreadHeader(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, _ := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, _ := seedRepoAndThread(t, svc, domain.IssueKind, 1) current := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) reader := &exactHydrationReader{ fakeHydrationReader: &fakeHydrationReader{issueCommentsPages: [][]github.IssueComment{{}}}, @@ -81,7 +81,7 @@ func TestHydrateFetchesMissingExactThreadHeader(t *testing.T) { RepositoryOwner: "owner", RepositoryName: "repo", Number: 224, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, State: "open", Title: "new issue", UpdatedAt: current, @@ -109,12 +109,12 @@ func TestHydrateThreadsReportsHeaderRefresh(t *testing.T) { t.Parallel() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + seedRepoAndThread(t, svc, domain.IssueKind, 1) svc.SetGitHubReader(&exactHydrationReader{ fakeHydrationReader: &fakeHydrationReader{issueCommentsPages: [][]github.IssueComment{{}}}, header: github.Issue{ RepositoryOwner: "owner", RepositoryName: "repo", Number: 224, - Kind: corpus.ThreadKindIssue, State: "open", Title: "new issue", + Kind: domain.IssueKind, State: "open", Title: "new issue", UpdatedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), }, }) @@ -126,8 +126,8 @@ func TestHydrateThreadsReportsHeaderRefresh(t *testing.T) { if err != nil { t.Fatal(err) } - items, ok := out["items"].([]map[string]any) - if !ok || len(items) != 1 || items[0]["header_refreshed"] != true || items[0]["requests"] != 2 { + items := out.Items + if len(items) != 1 || items[0].success == nil || items[0].success.requests != 2 { t.Fatalf("hydration result = %+v", out) } } diff --git a/internal/app/hydration_refresh.go b/internal/app/hydration_refresh.go index 481d2b43..a584949a 100644 --- a/internal/app/hydration_refresh.go +++ b/internal/app/hydration_refresh.go @@ -2,30 +2,14 @@ package app import ( "context" - "errors" "fmt" - - "github.com/morluto/gitcontribute/internal/contracts" - "github.com/morluto/gitcontribute/internal/domain" ) // refreshHydrationThreadHeader fetches the current exact thread header before // child facets. It reuses the sync projection path so hydration cannot derive // coverage freshness from a stale or missing local header. -func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contracts.RepoRef, kind string, number int) error { - ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) - if err != nil { - return err - } - if number <= 0 { - return errors.New("thread number must be positive") - } - if kind != "" && kind != "issue" && kind != "pull_request" { - return errors.New("thread kind must be issue or pull_request") - } - if kind == "" { - kind = "both" - } +func (s *Service) refreshHydrationThreadHeader(ctx context.Context, target hydrationTarget) error { + ref, number := target.repository, target.number c, err := s.openCorpus(ctx) if err != nil { @@ -51,7 +35,7 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac owner: ref.Owner(), repo: ref.Repo(), repositoryID: repository.ID, - kind: kind, + kind: target.kind.syncKind(), } _, err = syncExactThreadHeaders(ctx, reader, ref, []int{number}, newSyncRequestBudget(1), writer) return err diff --git a/internal/app/hydration_repo.go b/internal/app/hydration_repo.go deleted file mode 100644 index 4378a13e..00000000 --- a/internal/app/hydration_repo.go +++ /dev/null @@ -1,179 +0,0 @@ -package app - -import ( - "context" - "fmt" - - "github.com/morluto/gitcontribute/internal/contracts" - "github.com/morluto/gitcontribute/internal/corpus" - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/facets" -) - -const hydrateRepoThreadLimit = 10000 - -// HydrateRepositoryOptions controls repository-wide thread hydration. -type HydrateRepositoryOptions struct { - Facets []string - MaxPages int - State string - Numbers []int -} - -// HydrateRepository hydrates selected facets for threads in a repository. -// It is explicit, bounded, cancellation-aware, and aggregates per-thread results. -func (s *Service) HydrateRepository(ctx context.Context, repo contracts.RepoRef, opts HydrateRepositoryOptions) (*HydrateResult, error) { - ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) - if err != nil { - return nil, err - } - - c, err := s.openCorpus(ctx) - if err != nil { - return nil, err - } - - repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) - if err != nil { - return nil, fmt.Errorf("get repository: %w", err) - } - if repoProjection == nil { - return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, ref) - } - - maxPages := opts.MaxPages - if maxPages <= 0 { - maxPages = 50 - } - if maxPages > maxHydrationPages { - return nil, fmt.Errorf("max pages cannot exceed %d", maxHydrationPages) - } - - if err := validateRequestedFacets(opts.Facets); err != nil { - return nil, err - } - - result := &HydrateResult{ - Repo: repo, - Facets: make([]HydratedFacet, 0), - } - - var threads []*corpus.Thread - - if len(opts.Numbers) > 0 { - seen := make(map[int]struct{}, len(opts.Numbers)) - for _, n := range opts.Numbers { - if n <= 0 { - return nil, fmt.Errorf("thread number must be positive: %d", n) - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - thread, err := c.GetThreadByNumber(ctx, repoProjection.ID, n) - if err != nil { - return nil, fmt.Errorf("get thread %s#%d: %w", ref, n, err) - } - if thread == nil { - return nil, fmt.Errorf("thread %s#%d has not been synced", ref, n) - } - threads = append(threads, thread) - } - } else { - listed, err := c.ListThreads(ctx, repoProjection.ID, "", hydrateRepoThreadLimit) - if err != nil { - return nil, err - } - threads = make([]*corpus.Thread, len(listed)) - for i := range listed { - threads[i] = &listed[i] - } - if len(threads) == hydrateRepoThreadLimit { - total, err := c.CountThreadsFiltered(ctx, repoProjection.ID, "", "") - if err != nil { - return nil, err - } - result.Capped = total > len(threads) - } - } - - for _, t := range threads { - if err := ctx.Err(); err != nil { - return nil, err - } - - if opts.State != "" && opts.State != "all" && t.State != opts.State { - continue - } - - facets := applicableFacets(t.Kind, opts.Facets) - if len(opts.Facets) > 0 && len(facets) == 0 { - continue - } - - hr, err := s.HydrateThread(ctx, repo, t.Number, HydrateOptions{ - Kind: t.Kind, - Facets: facets, - MaxPages: maxPages, - }) - if err != nil { - return nil, fmt.Errorf("hydrate %s#%d: %w", ref, t.Number, err) - } - result.Pages += hr.Pages - result.Requests += hr.Requests - result.Facets = append(result.Facets, hr.Facets...) - } - - if result.Capped { - result.Message = fmt.Sprintf("hydrated repository %s (%d requests, %d pages, capped at %d threads)", ref, result.Requests, result.Pages, hydrateRepoThreadLimit) - } else { - result.Message = fmt.Sprintf("hydrated repository %s (%d requests, %d pages)", ref, result.Requests, result.Pages) - } - return result, nil -} - -func validateRequestedFacets(requested []string) error { - if len(requested) == 0 { - return nil - } - known := make(map[string]struct{}) - for _, f := range facets.SelectableNames() { - known[f] = struct{}{} - } - for _, f := range requested { - if _, ok := known[f]; !ok { - return fmt.Errorf("unknown facet %q", f) - } - } - return nil -} - -func applicableFacets(kind string, requested []string) []string { - defaults := facets.DefaultFor(kind) - if len(defaults) == 0 { - return nil - } - if len(requested) == 0 { - return defaults - } - allowed := facets.SelectableFor(kind) - - allowedSet := make(map[string]struct{}, len(allowed)) - for _, f := range allowed { - allowedSet[f] = struct{}{} - } - - out := make([]string, 0, len(requested)) - seen := make(map[string]struct{}, len(requested)) - for _, f := range requested { - if _, ok := allowedSet[f]; !ok { - continue - } - if _, ok := seen[f]; ok { - continue - } - seen[f] = struct{}{} - out = append(out, f) - } - return out -} diff --git a/internal/app/hydration_request.go b/internal/app/hydration_request.go new file mode 100644 index 00000000..81359b68 --- /dev/null +++ b/internal/app/hydration_request.go @@ -0,0 +1,83 @@ +package app + +import ( + "errors" + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/facets" +) + +type hydrateThreadInput struct { + Kind string + Facets []string + MaxPages int +} + +type hydrationTarget struct { + repository domain.RepoRef + number int + kind hydrationThreadKind + selection facets.Selection + maxPages int +} + +func parseHydrationTarget(repo contracts.RepoRef, number int, input hydrateThreadInput) (hydrationTarget, error) { + repository, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { + return hydrationTarget{}, err + } + if number <= 0 { + return hydrationTarget{}, errors.New("thread number must be positive") + } + kind, err := parseHydrationThreadKind(input.Kind) + if err != nil { + return hydrationTarget{}, err + } + selection, err := facets.ParseSelection(input.Facets) + if err != nil { + return hydrationTarget{}, err + } + maxPages := input.MaxPages + if maxPages <= 0 { + maxPages = 50 + } + if maxPages > maxHydrationPages { + return hydrationTarget{}, fmt.Errorf("max pages cannot exceed %d", maxHydrationPages) + } + return hydrationTarget{repository: repository, number: number, kind: kind, selection: selection, maxPages: maxPages}, nil +} + +func (t hydrationTarget) wireRepository() contracts.RepoRef { + return contracts.RepoRef{Owner: t.repository.Owner(), Repo: t.repository.Repo()} +} + +type hydrationThreadKind struct { + value domain.ThreadKind +} + +func parseHydrationThreadKind(value string) (hydrationThreadKind, error) { + if strings.TrimSpace(value) == "" { + return hydrationThreadKind{}, nil + } + kind, err := domain.ParseThreadKind(value) + if err != nil { + return hydrationThreadKind{}, errors.New("thread kind must be issue or pull_request") + } + return hydrationThreadKind{value: kind}, nil +} + +func (k hydrationThreadKind) specified() bool { return k.value != "" } + +func (k hydrationThreadKind) syncKind() syncThreadKind { + switch k.value { + case domain.IssueKind: + return syncIssues + case domain.PullRequestKind: + return syncPullRequests + default: + return syncAllThreads + } +} diff --git a/internal/app/hydration_test.go b/internal/app/hydration_test.go index 2f79198f..d9f545b4 100644 --- a/internal/app/hydration_test.go +++ b/internal/app/hydration_test.go @@ -12,6 +12,7 @@ import ( "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/facets" "github.com/morluto/gitcontribute/internal/github" ) @@ -108,7 +109,7 @@ func (f *fakeHydrationReader) ListPullRequestComments(_ context.Context, owner, return github.ListResult[github.ReviewComment]{Items: f.prReviewCommentsPages[idx], Page: page}, nil } -func seedRepoAndThread(t *testing.T, svc *Service, kind string, number int) (*corpus.Repository, *corpus.Thread) { +func seedRepoAndThread(t *testing.T, svc *Service, kind domain.ThreadKind, number int) (*corpus.Repository, *corpus.Thread) { t.Helper() ctx := context.Background() c, err := svc.openCorpus(ctx) @@ -147,7 +148,7 @@ func TestHydrateIssueCommentsPaginatesAndRecordsCoverage(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 1) reader := &fakeHydrationReader{ issueCommentsPages: [][]github.IssueComment{ { @@ -161,7 +162,7 @@ func TestHydrateIssueCommentsPaginatesAndRecordsCoverage(t *testing.T) { } svc.SetGitHubReader(reader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}) if err != nil { t.Fatalf("hydrate: %v", err) } @@ -224,7 +225,7 @@ func TestHydratePullRequestFacets(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindPullRequest, 2) + repo, thread := seedRepoAndThread(t, svc, domain.PullRequestKind, 2) reader := &fakeHydrationReader{ issueCommentsPages: [][]github.IssueComment{ {{ID: 5, Body: "main conversation", UpdatedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}}, @@ -243,11 +244,11 @@ func TestHydratePullRequestFacets(t *testing.T) { } svc.SetGitHubReader(reader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, HydrateOptions{}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, hydrateThreadInput{}) if err != nil { t.Fatalf("hydrate: %v", err) } - if result.Kind != corpus.ThreadKindPullRequest { + if result.Kind != string(domain.PullRequestKind) { t.Fatalf("kind = %q, want pull_request", result.Kind) } if len(result.Facets) != 4 { @@ -271,7 +272,7 @@ func TestHydratePullRequestFacets(t *testing.T) { t.Fatalf("expected complete coverage for %s", facet) } } - projected, err := c.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, thread.Number) + projected, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, thread.Number) if err != nil || projected == nil || !projected.Merge.Known() || projected.Merge.IsMerged() { t.Fatalf("projected PR merge state = %+v, %v", projected, err) } @@ -289,7 +290,7 @@ func TestHydratePullRequestDetailsDoesNotProjectStaleSnapshot(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindPullRequest, 2) + repo, thread := seedRepoAndThread(t, svc, domain.PullRequestKind, 2) thread.Merge = domain.MergedStatus(thread.SourceUpdatedAt) stored, err := svc.corpus.UpsertThread(ctx, *thread, `{"Merged":true}`) if err != nil { @@ -308,11 +309,11 @@ func TestHydratePullRequestDetailsDoesNotProjectStaleSnapshot(t *testing.T) { Number: 2, Merged: false, UpdatedAt: stored.SourceUpdatedAt.Add(time.Hour), }} svc.SetGitHubReader(reader) - if _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, HydrateOptions{Facets: []string{FacetPRDetails}}); err != nil { + if _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, hydrateThreadInput{Facets: []string{FacetPRDetails}}); err != nil { t.Fatalf("hydrate stale details: %v", err) } - projected, err := svc.corpus.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, 2) + projected, err := svc.corpus.GetThread(ctx, repo.ID, domain.PullRequestKind, 2) if err != nil { t.Fatal(err) } @@ -330,7 +331,7 @@ func TestHydratePullRequestReviewsAtPageCapPreservesCompleteSnapshot(t *testing. ctx := context.Background() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindPullRequest, 2) + repo, thread := seedRepoAndThread(t, svc, domain.PullRequestKind, 2) at := thread.SourceUpdatedAt oldPayload, _ := json.Marshal([]github.Review{{ID: 1, State: "APPROVED", SubmittedAt: at}}) if err := svc.corpus.ApplyFacetObservationSet(ctx, repo.ID, &thread.ID, FacetPRReviews, at, []corpus.FacetObservationInput{{SourceUpdatedAt: at, Payload: string(oldPayload)}}, true, 0); err != nil { @@ -338,7 +339,7 @@ func TestHydratePullRequestReviewsAtPageCapPreservesCompleteSnapshot(t *testing. } reader := &fakeHydrationReader{prReviewsPages: [][]github.Review{{{ID: 2, State: "COMMENTED", SubmittedAt: at.Add(time.Minute)}}, {{ID: 3, State: "APPROVED", SubmittedAt: at.Add(2 * time.Minute)}}}} svc.SetGitHubReader(reader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, HydrateOptions{Facets: []string{FacetPRReviews}, MaxPages: 1}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 2, hydrateThreadInput{Facets: []string{FacetPRReviews}, MaxPages: 1}) if err != nil { t.Fatal(err) } @@ -361,7 +362,7 @@ func TestHydrateBoundsPagination(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 1) pages := make([][]github.IssueComment, 10) for i := range pages { pages[i] = []github.IssueComment{{ID: int64(i + 1), UpdatedAt: time.Date(2024, 1, 1, 0, 0, i, 0, time.UTC)}} @@ -369,7 +370,7 @@ func TestHydrateBoundsPagination(t *testing.T) { reader := &fakeHydrationReader{issueCommentsPages: pages} svc.SetGitHubReader(reader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}, MaxPages: 3}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}, MaxPages: 3}) if err != nil { t.Fatalf("hydrate: %v", err) } @@ -395,10 +396,10 @@ func TestHydrateRejectsExcessivePagination(t *testing.T) { ctx := context.Background() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + seedRepoAndThread(t, svc, domain.IssueKind, 1) svc.SetGitHubReader(&fakeHydrationReader{}) - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{MaxPages: maxHydrationPages + 1}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{MaxPages: maxHydrationPages + 1}) if err == nil || err.Error() != "max pages cannot exceed 100" { t.Fatalf("expected maximum pagination error, got %v", err) } @@ -406,10 +407,18 @@ func TestHydrateRejectsExcessivePagination(t *testing.T) { func TestSelectFacetsDeduplicatesRequestedFacets(t *testing.T) { t.Parallel() - got, err := selectFacets(corpus.ThreadKindPullRequest, []string{FacetPRDetails, FacetPRDetails, FacetPRReviews}) + selection, err := facets.ParseSelection([]string{FacetPRDetails, FacetPRDetails, FacetPRReviews}) + if err != nil { + t.Fatalf("parse facets: %v", err) + } + names, err := selection.For(domain.PullRequestKind) if err != nil { t.Fatalf("select facets: %v", err) } + got := make([]string, len(names)) + for index, name := range names { + got[index] = name.String() + } want := []string{FacetPRDetails, FacetPRReviews} if !slices.Equal(got, want) { t.Fatalf("facets = %v, want %v", got, want) @@ -421,7 +430,7 @@ func TestHydrateIssueTimelinePersistsExplicitClosingCommitResolution(t *testing. ctx := context.Background() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 1) now := thread.SourceUpdatedAt.Add(time.Hour) reader := &fakeHydrationReader{issueTimelinePages: [][]github.IssueTimelineEvent{ {{ID: 1, Event: "cross-referenced", CreatedAt: now.Add(-time.Minute), SourceNumber: 9, SourceIsPullRequest: true}}, @@ -429,7 +438,7 @@ func TestHydrateIssueTimelinePersistsExplicitClosingCommitResolution(t *testing. }} svc.SetGitHubReader(reader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueTimeline}}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueTimeline}}) if err != nil { t.Fatal(err) } @@ -457,7 +466,7 @@ func TestHydrateCancellation(t *testing.T) { t.Parallel() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + seedRepoAndThread(t, svc, domain.IssueKind, 1) reader := &fakeHydrationReader{} svc.SetGitHubReader(reader) @@ -465,7 +474,7 @@ func TestHydrateCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -476,7 +485,7 @@ func TestHydrateRecordsRunFailure(t *testing.T) { ctx := context.Background() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - _, _ = seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + _, _ = seedRepoAndThread(t, svc, domain.IssueKind, 1) reader := &fakeHydrationReader{ issueCommentsPages: [][]github.IssueComment{{{ID: 1}}}, @@ -484,7 +493,7 @@ func TestHydrateRecordsRunFailure(t *testing.T) { } svc.SetGitHubReader(reader) - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}) if err == nil || err.Error() != "hydrate issue_comments: injected failure" { t.Fatalf("unexpected error: %v", err) } @@ -514,7 +523,7 @@ func TestHydrateRequiresSyncedRepositoryAndThread(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{}) if err == nil || err.Error() != "repository owner/repo has not been synced" { t.Fatalf("expected missing repo error, got %v", err) } @@ -525,7 +534,7 @@ func TestHydrateRequiresSyncedRepositoryAndThread(t *testing.T) { t.Fatalf("seed repo: %v", err) } - _, err = svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{}) + _, err = svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{}) if err == nil || err.Error() != "thread owner/repo#1 has not been synced" { t.Fatalf("expected missing thread error, got %v", err) } @@ -536,12 +545,12 @@ func TestHydrateRejectsInapplicableFacets(t *testing.T) { ctx := context.Background() svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + seedRepoAndThread(t, svc, domain.IssueKind, 1) reader := &fakeHydrationReader{} svc.SetGitHubReader(reader) - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetPRDetails}}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetPRDetails}}) if err == nil || err.Error() != `facet "pr_details" is not applicable to issue threads` { t.Fatalf("expected facet error, got %v", err) } @@ -553,7 +562,7 @@ func TestHydrateIssueCommentsInterruptPage2RetainsOldData(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 1) oldTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) oldReader := &fakeHydrationReader{ @@ -562,7 +571,7 @@ func TestHydrateIssueCommentsInterruptPage2RetainsOldData(t *testing.T) { }, } svc.SetGitHubReader(oldReader) - if _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}); err != nil { + if _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}); err != nil { t.Fatalf("seed hydrate: %v", err) } @@ -576,7 +585,7 @@ func TestHydrateIssueCommentsInterruptPage2RetainsOldData(t *testing.T) { failWith: errors.New("page 2 failure"), } svc.SetGitHubReader(newReader) - _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}) + _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}) if err == nil || err.Error() != "hydrate issue_comments: page 2 failure" { t.Fatalf("expected page 2 failure, got %v", err) } @@ -623,7 +632,7 @@ func TestHydrateIssueCommentsSuccessfulReplacement(t *testing.T) { svc := newTestServiceNoNetwork(t) defer func() { _ = svc.Close() }() - repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) + repo, thread := seedRepoAndThread(t, svc, domain.IssueKind, 1) oldTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) oldReader := &fakeHydrationReader{ @@ -632,7 +641,7 @@ func TestHydrateIssueCommentsSuccessfulReplacement(t *testing.T) { }, } svc.SetGitHubReader(oldReader) - if _, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}); err != nil { + if _, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}); err != nil { t.Fatalf("first hydrate: %v", err) } @@ -644,7 +653,7 @@ func TestHydrateIssueCommentsSuccessfulReplacement(t *testing.T) { }, } svc.SetGitHubReader(newReader) - result, err := svc.HydrateThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, HydrateOptions{Facets: []string{FacetIssueComments}}) + result, err := svc.hydrateStoredThread(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, 1, hydrateThreadInput{Facets: []string{FacetIssueComments}}) if err != nil { t.Fatalf("second hydrate: %v", err) } @@ -687,85 +696,3 @@ func TestHydrateIssueCommentsSuccessfulReplacement(t *testing.T) { t.Fatalf("coverage source updated at = %v, want %v", cov.SourceUpdatedAt, wantLatest) } } - -func TestHydrateRepositoryExactNumbersAreNotLimitedByList(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newTestServiceNoNetwork(t) - defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 2) - svc.SetGitHubReader(&fakeHydrationReader{issueCommentsPages: [][]github.IssueComment{ - {{ID: 1, UpdatedAt: time.Now()}}, - }}) - - result, err := svc.HydrateRepository(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, HydrateRepositoryOptions{Numbers: []int{2}}) - if err != nil { - t.Fatalf("hydrate repository: %v", err) - } - if len(result.Facets) != 1 || result.Facets[0].Facet != FacetIssueComments { - t.Fatalf("expected one issue_comments facet, got %+v", result.Facets) - } -} - -func TestHydrateRepositoryExactNumberMissingReturnsError(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newTestServiceNoNetwork(t) - defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) - svc.SetGitHubReader(&fakeHydrationReader{}) - - _, err := svc.HydrateRepository(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, HydrateRepositoryOptions{Numbers: []int{99}}) - if err == nil || !strings.Contains(err.Error(), "has not been synced") { - t.Fatalf("expected missing thread error, got %v", err) - } -} - -func TestHydrateRepositoryUnknownFacetErrors(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newTestServiceNoNetwork(t) - defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) - svc.SetGitHubReader(&fakeHydrationReader{}) - - _, err := svc.HydrateRepository(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, HydrateRepositoryOptions{Facets: []string{"unknown"}}) - if err == nil || !strings.Contains(err.Error(), `unknown facet "unknown"`) { - t.Fatalf("expected unknown facet error, got %v", err) - } -} - -func TestHydrateRepositorySkipsKnownInapplicableFacets(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newTestServiceNoNetwork(t) - defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) - seedRepoAndThread(t, svc, corpus.ThreadKindPullRequest, 2) - svc.SetGitHubReader(&fakeHydrationReader{ - prDetails: github.PullRequestDetails{Number: 2, Title: "Add feature", UpdatedAt: time.Now()}, - }) - - result, err := svc.HydrateRepository(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, HydrateRepositoryOptions{Facets: []string{FacetPRDetails}}) - if err != nil { - t.Fatalf("hydrate repository: %v", err) - } - if len(result.Facets) != 1 || result.Facets[0].Facet != FacetPRDetails { - t.Fatalf("expected one pr_details facet, got %+v", result.Facets) - } -} - -func TestHydrateRepositoryRejectsInvalidExactNumber(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newTestServiceNoNetwork(t) - defer func() { _ = svc.Close() }() - seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) - svc.SetGitHubReader(&fakeHydrationReader{}) - - _, err := svc.HydrateRepository(ctx, contracts.RepoRef{Owner: "owner", Repo: "repo"}, HydrateRepositoryOptions{Numbers: []int{0}}) - if err == nil || !strings.Contains(err.Error(), "must be positive") { - t.Fatalf("expected invalid thread number error, got %v", err) - } -} diff --git a/internal/app/investigation.go b/internal/app/investigation.go index 9b9e9f01..09351132 100644 --- a/internal/app/investigation.go +++ b/internal/app/investigation.go @@ -3,7 +3,6 @@ package app import ( "context" "errors" - "fmt" "strings" "time" @@ -62,11 +61,15 @@ func (s *Service) ListInvestigations(ctx context.Context) (*contracts.Investigat // AddHypothesis records a hypothesis under an investigation. func (s *Service) AddHypothesis(ctx context.Context, investigationID, title, description, category string) (*contracts.HypothesisResult, error) { + parsedCategory, err := investigation.ParseCategory(category) + if err != nil { + return nil, err + } invSvc, err := s.writeInvestigationSvc(ctx) if err != nil { return nil, err } - h, err := invSvc.RecordHypothesis(ctx, investigationID, title, description, investigation.Category(category), nil) + h, err := invSvc.RecordHypothesis(ctx, investigationID, title, description, parsedCategory, nil) if err != nil { return nil, err } @@ -138,7 +141,7 @@ func (s *Service) ListOpportunities(ctx context.Context, investigationID string) // SetOpportunityStatus transitions an opportunity with a recorded rationale. func (s *Service) SetOpportunityStatus(ctx context.Context, id, status, rationale string) (*contracts.OpportunityResult, error) { - opStatus, err := parseOpportunityStatus(status) + opStatus, err := investigation.ParseOpportunityStatus(status) if err != nil { return nil, err } @@ -205,7 +208,10 @@ func (s *Service) UpdateHypothesisFields(ctx context.Context, hypothesisID strin input.Description = *opts.Description } if opts.Category != nil { - input.Category = investigation.Category(*opts.Category) + input.Category, err = investigation.ParseCategory(*opts.Category) + if err != nil { + return nil, err + } } if opts.ExpectedBehavior != nil { input.ExpectedBehavior = *opts.ExpectedBehavior @@ -227,7 +233,7 @@ func (s *Service) UpdateHypothesisFields(ctx context.Context, hypothesisID strin // TransitionHypothesis advances a hypothesis through its lifecycle with rationale. func (s *Service) TransitionHypothesis(ctx context.Context, hypothesisID, status, rationale string) (*investigation.Hypothesis, error) { - hStatus, err := parseHypothesisStatus(status) + hStatus, err := investigation.ParseHypothesisStatus(status) if err != nil { return nil, err } @@ -253,7 +259,7 @@ func (s *Service) PromoteOpportunityWithInput(ctx context.Context, hypothesisID // UpdateOpportunityCollisionStatus explicitly sets the collision status with rationale. func (s *Service) UpdateOpportunityCollisionStatus(ctx context.Context, opportunityID, status, rationale string) (*investigation.Opportunity, error) { - cStatus, err := parseCollisionStatus(status) + cStatus, err := investigation.ParseCollisionStatus(status) if err != nil { return nil, err } @@ -341,44 +347,3 @@ func mapInvestigationError(err error) error { } return err } - -func parseOpportunityStatus(status string) (investigation.OpportunityStatus, error) { - switch investigation.OpportunityStatus(status) { - case investigation.OpportunityHypothesis, - investigation.OpportunityReproduced, - investigation.OpportunityValidated, - investigation.OpportunityMaintainerAligned, - investigation.OpportunityImplemented, - investigation.OpportunitySubmitted, - investigation.OpportunityMerged, - investigation.OpportunityRejected, - investigation.OpportunityDeferred, - investigation.OpportunitySuperseded: - return investigation.OpportunityStatus(status), nil - } - return "", fmt.Errorf("invalid opportunity status %q", status) -} - -func parseHypothesisStatus(status string) (investigation.HypothesisStatus, error) { - switch investigation.HypothesisStatus(status) { - case investigation.HypothesisProposed, - investigation.HypothesisPromoted, - investigation.HypothesisRejected, - investigation.HypothesisDeferred, - investigation.HypothesisSuperseded: - return investigation.HypothesisStatus(status), nil - } - return "", fmt.Errorf("invalid hypothesis status %q", status) -} - -func parseCollisionStatus(status string) (investigation.CollisionStatus, error) { - switch investigation.CollisionStatus(status) { - case investigation.CollisionUnknown, - investigation.CollisionNone, - investigation.CollisionPossible, - investigation.CollisionConfirmed, - investigation.CollisionBlocked: - return investigation.CollisionStatus(status), nil - } - return "", fmt.Errorf("invalid collision status %q", status) -} diff --git a/internal/app/investigation_thread.go b/internal/app/investigation_thread.go index a0045bb4..e2f50706 100644 --- a/internal/app/investigation_thread.go +++ b/internal/app/investigation_thread.go @@ -45,7 +45,7 @@ func (s *Service) StartInvestigationFromThread(ctx context.Context, requested re if thread == nil { return nil, failure.NotFound(fmt.Errorf("%w: %s#%d", research.ErrThreadNotFound, requested.Repo, requested.Number)) } - storedKind := domain.ThreadKind(thread.Kind) + storedKind := thread.Kind if requested.Kind != "" && requested.Kind != storedKind { return nil, failure.NotFound(research.KindMismatchError(requested.Kind, storedKind)) } diff --git a/internal/app/investigation_thread_test.go b/internal/app/investigation_thread_test.go index 946d3259..c7a42bb6 100644 --- a/internal/app/investigation_thread_test.go +++ b/internal/app/investigation_thread_test.go @@ -37,7 +37,7 @@ func TestStartInvestigationFromThreadPreservesExactBaselineAndReusesOpenPair(t * assertThreadStartResult(t, first, thread, observation) newer, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: fixture.repoID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "new title after baseline", Body: "new body after baseline", Author: "alice", SourceCreatedAt: thread.SourceCreatedAt, SourceUpdatedAt: fixture.now.Add(time.Hour), }, `{"revision":"new"}`) diff --git a/internal/app/job_executor.go b/internal/app/job_executor.go index 1aed5d75..c089c0ba 100644 --- a/internal/app/job_executor.go +++ b/internal/app/job_executor.go @@ -29,13 +29,13 @@ type jobStore interface { DeleteJobOwner(context.Context, string) error GetJob(context.Context, string) (*corpus.Job, error) HeartbeatJobOwner(context.Context, string, time.Time) error - ListJobs(context.Context, string, int) ([]corpus.Job, error) + ListJobs(context.Context, corpus.JobStatus, int) ([]corpus.Job, error) RecordJobEvent(context.Context, string, string, string) error RegisterJobOwner(context.Context, string, int, time.Time) error ReconcileInterruptedJobs(context.Context, time.Duration) error RequestJobCancellation(context.Context, string) error StartJobAs(context.Context, string, string) error - TransitionJob(context.Context, string, string, string, string, string) error + TransitionJob(context.Context, string, corpus.JobTransition, string, string) error UpdateJobProgress(context.Context, string, string, string) error } @@ -187,7 +187,7 @@ func (e *JobExecutor) Get(ctx context.Context, id string) (*corpus.Job, error) { } // List returns recent jobs, optionally filtered by status. -func (e *JobExecutor) List(ctx context.Context, status string, limit int) ([]corpus.Job, error) { +func (e *JobExecutor) List(ctx context.Context, status corpus.JobStatus, limit int) ([]corpus.Job, error) { return e.corpus.ListJobs(ctx, status, limit) } @@ -355,7 +355,7 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc defer cleanupCancel() _ = e.corpus.TransitionJob( cleanupCtx, id, - corpus.JobStatusQueued, corpus.JobStatusCancelled, "", "executor closed before start", + corpus.JobQueuedToCancelled, "", "executor closed before start", ) } return @@ -367,7 +367,7 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc e.mu.Unlock() cleanupCtx, cleanupCancel := e.cleanupContext(jobCtx) defer cleanupCancel() - _ = e.corpus.TransitionJob(cleanupCtx, id, corpus.JobStatusQueued, corpus.JobStatusCancelled, "", "executor closed before start") + _ = e.corpus.TransitionJob(cleanupCtx, id, corpus.JobQueuedToCancelled, "", "executor closed before start") return } e.mu.Unlock() @@ -380,13 +380,17 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc message := errors.Join(err, fmt.Errorf("get job after start failure: %w", getErr)).Error() // Best effort: there is no synchronous caller after the executor goroutine starts. //nolint:errcheck - _ = e.corpus.TransitionJob(writeCtx, id, corpus.JobStatusQueued, corpus.JobStatusFailed, "", message) + _ = e.corpus.TransitionJob(writeCtx, id, corpus.JobQueuedToFailed, "", message) return } - if job != nil && !isTerminalJobStatus(job.State.Status()) { + if job != nil && !job.State.Status().Terminal() { // Best effort: preserve the original start error in durable job state. //nolint:errcheck - _ = e.corpus.TransitionJob(writeCtx, id, job.State.Status(), corpus.JobStatusFailed, "", err.Error()) + transition := corpus.JobRunningToFailed + if job.State.Status() == corpus.JobStatusQueued { + transition = corpus.JobQueuedToFailed + } + _ = e.corpus.TransitionJob(writeCtx, id, transition, "", err.Error()) } return } @@ -405,49 +409,45 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc if err != nil { // Best effort: preserve the read error in durable job state. //nolint:errcheck - _ = e.finishJob(writeCtx, id, corpus.JobStatusFailed, "", fmt.Errorf("get job after execution: %w", err).Error()) + _ = e.finishJob(writeCtx, id, corpus.JobRunningToFailed, "", fmt.Errorf("get job after execution: %w", err).Error()) return } if job != nil && job.State.CancellationRequested() { - _ = e.finishJob(writeCtx, id, corpus.JobStatusCancelled, "", "cancelled by request") + _ = e.finishJob(writeCtx, id, corpus.JobRunningToCancelled, "", "cancelled by request") return } if jobCtx.Err() != nil { - _ = e.finishJob(writeCtx, id, corpus.JobStatusCancelled, "", jobCtx.Err().Error()) + _ = e.finishJob(writeCtx, id, corpus.JobRunningToCancelled, "", jobCtx.Err().Error()) return } if runErr != nil { - _ = e.finishJob(writeCtx, id, corpus.JobStatusFailed, "", runErr.Error()) + _ = e.finishJob(writeCtx, id, corpus.JobRunningToFailed, "", runErr.Error()) return } resultJSON, marshalErr := json.Marshal(result) if marshalErr != nil { - _ = e.finishJob(writeCtx, id, corpus.JobStatusFailed, "", marshalErr.Error()) + _ = e.finishJob(writeCtx, id, corpus.JobRunningToFailed, "", marshalErr.Error()) return } - if err := e.finishJob(writeCtx, id, corpus.JobStatusSucceeded, string(resultJSON), ""); err != nil { - _ = e.finishJob(writeCtx, id, corpus.JobStatusFailed, "", err.Error()) + if err := e.finishJob(writeCtx, id, corpus.JobRunningToSucceeded, string(resultJSON), ""); err != nil { + _ = e.finishJob(writeCtx, id, corpus.JobRunningToFailed, "", err.Error()) } } -func (e *JobExecutor) finishJob(ctx context.Context, id, status, result, errStr string) error { - err := e.corpus.TransitionJob(ctx, id, corpus.JobStatusRunning, status, result, errStr) +func (e *JobExecutor) finishJob(ctx context.Context, id string, transition corpus.JobTransition, result, errStr string) error { + err := e.corpus.TransitionJob(ctx, id, transition, result, errStr) if errors.Is(err, corpus.ErrJobCancelled) { // A cancellation request arrived during completion; finish as cancelled. - _ = e.corpus.TransitionJob(ctx, id, corpus.JobStatusRunning, corpus.JobStatusCancelled, "", err.Error()) + _ = e.corpus.TransitionJob(ctx, id, corpus.JobRunningToCancelled, "", err.Error()) return nil } if err != nil { return err } - _ = e.corpus.RecordJobEvent(ctx, id, "info", "job "+status) + _ = e.corpus.RecordJobEvent(ctx, id, "info", "job "+transition.To().String()) return nil } - -func isTerminalJobStatus(status string) bool { - return status == corpus.JobStatusSucceeded || status == corpus.JobStatusFailed || status == corpus.JobStatusCancelled -} diff --git a/internal/app/job_executor_terminal_write_test.go b/internal/app/job_executor_terminal_write_test.go index 0d6875c9..a951190e 100644 --- a/internal/app/job_executor_terminal_write_test.go +++ b/internal/app/job_executor_terminal_write_test.go @@ -17,14 +17,14 @@ type gatedFinishJobStore struct { once sync.Once } -func (s *gatedFinishJobStore) TransitionJob(ctx context.Context, id, from, to, result, errStr string) error { - if from != corpus.JobStatusRunning { - return s.jobStore.TransitionJob(ctx, id, from, to, result, errStr) +func (s *gatedFinishJobStore) TransitionJob(ctx context.Context, id string, transition corpus.JobTransition, result, errStr string) error { + if transition.From() != corpus.JobStatusRunning { + return s.jobStore.TransitionJob(ctx, id, transition, result, errStr) } s.once.Do(func() { close(s.entered) }) select { case <-s.release: - return s.jobStore.TransitionJob(ctx, id, from, to, result, errStr) + return s.jobStore.TransitionJob(ctx, id, transition, result, errStr) case <-ctx.Done(): close(s.timedOut) return ctx.Err() diff --git a/internal/app/job_executor_test.go b/internal/app/job_executor_test.go index b02cc360..93915e52 100644 --- a/internal/app/job_executor_test.go +++ b/internal/app/job_executor_test.go @@ -28,13 +28,13 @@ type blockingFinishJobStore struct { once sync.Once } -func (s *blockingFinishJobStore) TransitionJob(ctx context.Context, id, from, to, result, errStr string) error { - if from == corpus.JobStatusRunning { +func (s *blockingFinishJobStore) TransitionJob(ctx context.Context, id string, transition corpus.JobTransition, result, errStr string) error { + if transition.From() == corpus.JobStatusRunning { s.once.Do(func() { close(s.entered) }) <-ctx.Done() return ctx.Err() } - return s.jobStore.TransitionJob(ctx, id, from, to, result, errStr) + return s.jobStore.TransitionJob(ctx, id, transition, result, errStr) } func (s *faultingJobStore) GetJob(ctx context.Context, id string) (*corpus.Job, error) { @@ -88,7 +88,7 @@ func newJobExecutorOnService(t *testing.T, svc *Service, cfg jobExecutorConfig) return jobs } -func waitForJobStatus(t *testing.T, jobs *JobExecutor, id, want string, timeout time.Duration) { +func waitForJobStatus(t *testing.T, jobs *JobExecutor, id string, want corpus.JobStatus, timeout time.Duration) { t.Helper() ctx := context.Background() deadline := time.Now().Add(timeout) @@ -108,7 +108,7 @@ func waitForJobStatus(t *testing.T, jobs *JobExecutor, id, want string, timeout t.Fatalf("job did not reach status %q within %s", want, timeout) } -func waitForCorpusJobStatus(t *testing.T, c *corpus.Corpus, id, want string, timeout time.Duration) { +func waitForCorpusJobStatus(t *testing.T, c *corpus.Corpus, id string, want corpus.JobStatus, timeout time.Duration) { t.Helper() ctx := context.Background() deadline := time.Now().Add(timeout) diff --git a/internal/app/jobs.go b/internal/app/jobs.go index 19aeb635..f7a96c0f 100644 --- a/internal/app/jobs.go +++ b/internal/app/jobs.go @@ -15,11 +15,15 @@ func jobProgressCounts(completed, total int) string { // ListJobs returns bounded durable jobs for CLI and MCP adapters. func (s *Service) ListJobs(ctx context.Context, status string, limit int) (*contracts.JobListResult, error) { + statusFilter, err := corpus.ParseJobStatusFilter(status) + if err != nil { + return nil, err + } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } - items, err := c.ListJobs(ctx, status, limit) + items, err := c.ListJobs(ctx, statusFilter, limit) if err != nil { return nil, err } @@ -70,7 +74,7 @@ func (s *Service) CancelJob(ctx context.Context, id string) (*contracts.JobResul func jobResult(job *corpus.Job) contracts.JobResult { result := contracts.JobResult{ - ID: job.ID, Kind: job.Kind, Status: job.State.Status(), Request: job.Request, + ID: job.ID, Kind: job.Kind, Status: job.State.Status().String(), Request: job.Request, Result: job.Result, Error: job.Error, Progress: job.Progress, Statistics: job.Statistics, CreatedAt: formatTime(job.CreatedAt), Cancellation: job.State.CancellationRequested(), diff --git a/internal/app/lens.go b/internal/app/lens.go index 90e4d7e0..bed78a52 100644 --- a/internal/app/lens.go +++ b/internal/app/lens.go @@ -126,11 +126,19 @@ func (s *Service) ExplainLens(ctx context.Context, name, ref string, opts contra if kind == "" { kind = inferredKind } - matches, err := s.collectLensMatches(ctx, c, query, contracts.SearchOptions{ + request, err := parseServiceSearchRequest(query, contracts.SearchOptions{ Kind: kind, Repo: opts.Repo, State: opts.State, Author: opts.Author, Association: opts.Association, Assignee: opts.Assignee, - Labels: opts.Labels, UpdatedAfter: opts.UpdatedAfter, - }, now) + Labels: opts.Labels, UpdatedAfter: opts.UpdatedAfter, Lens: name, + }) + if err != nil { + return nil, err + } + lensRequest, ok := request.(lensSearchRequest) + if !ok { + return nil, errors.New("invalid parsed lens explanation search") + } + matches, err := s.collectLensMatches(ctx, c, query, lensRequest.selection) if err != nil { return nil, err } @@ -177,14 +185,14 @@ func (s *Service) resolveLensExplainTarget(ctx context.Context, c *corpus.Corpus case "repo": return s.resolveRepoLensTarget(ctx, c, rest) case "issue": - return s.resolveThreadLensTarget(ctx, c, rest, corpus.ThreadKindIssue) + return s.resolveThreadLensTarget(ctx, c, rest, domain.IssueKind) case "pr", "pull_request": - return s.resolveThreadLensTarget(ctx, c, rest, corpus.ThreadKindPullRequest) + return s.resolveThreadLensTarget(ctx, c, rest, domain.PullRequestKind) case "code": return s.resolveCodeLensTarget(ctx, c, rest) case "": if strings.Contains(ref, "#") { - return s.resolveThreadLensTarget(ctx, c, ref, "") + return s.resolveThreadLensTarget(ctx, c, ref, domain.ThreadKind("")) } return s.resolveRepoLensTarget(ctx, c, ref) default: @@ -214,7 +222,7 @@ func (s *Service) resolveRepoLensTarget(ctx context.Context, c *corpus.Corpus, r } return searchMatch{ Repo: repoRef, - Kind: "repo", + Kind: searchRepositoryMatch, Title: repoRef.String(), Body: repo.Description, URL: fmt.Sprintf("https://github.com/%s", repoRef), @@ -228,7 +236,7 @@ func (s *Service) resolveRepoLensTarget(ctx context.Context, c *corpus.Corpus, r }, "repos", nil } -func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, ref, kind string) (searchMatch, string, error) { +func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, ref string, kind domain.ThreadKind) (searchMatch, string, error) { repoRef, number, err := parseThreadRef(ref) if err != nil { return searchMatch{}, "", err @@ -252,30 +260,35 @@ func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, return searchMatch{}, "", fmt.Errorf("thread %q is a %s, not a %s", ref, thread.Kind, kind) } + matchKind, err := searchMatchKindForThread(thread.Kind) + if err != nil { + return searchMatch{}, "", err + } m := searchMatch{ - Repo: repoRef, - Kind: thread.Kind, - Number: thread.Number, - State: thread.State, - StateReason: thread.StateReason, - Title: thread.Title, - Body: thread.Body, - Author: thread.Author, - Labels: thread.Labels, - Assignees: thread.Assignees, - Draft: thread.Draft, - ClosedAt: thread.ClosedAt, - Merge: thread.Merge, - Language: repo.Language, - Archived: repo.Archived, - Stars: repo.Stars, - Watchers: repo.Watchers, - Forks: repo.Forks, - UpdatedAt: thread.SourceUpdatedAt, - Freshness: thread.SourceUpdatedAt, - URL: threadURL(repoRef, thread.Kind, thread.Number), - } - if thread.Kind == corpus.ThreadKindPullRequest { + Repo: repoRef, + Kind: matchKind, + Number: thread.Number, + State: string(thread.State), + StateReason: thread.StateReason, + Title: thread.Title, + Body: thread.Body, + Author: thread.Author, + AuthorAssociation: thread.AuthorAssociation, + Labels: thread.Labels, + Assignees: thread.Assignees, + Draft: thread.Draft, + ClosedAt: thread.ClosedAt, + Merge: thread.Merge, + Language: repo.Language, + Archived: repo.Archived, + Stars: repo.Stars, + Watchers: repo.Watchers, + Forks: repo.Forks, + UpdatedAt: thread.SourceUpdatedAt, + Freshness: thread.SourceUpdatedAt, + URL: threadURL(repoRef, thread.Kind, thread.Number), + } + if thread.Kind == domain.PullRequestKind { return m, "prs", nil } return m, "issues", nil @@ -309,7 +322,7 @@ func (s *Service) resolveCodeLensTarget(ctx context.Context, c *corpus.Corpus, r return searchMatch{ Repo: repoRef, - Kind: "code", + Kind: searchCodeMatch, Title: doc.Path, Body: doc.Content, URL: fmt.Sprintf("https://github.com/%s/blob/%s/%s", repoRef, doc.Commit, doc.Path), @@ -360,7 +373,7 @@ func buildLensExplainResult(record *corpus.LensRecord, found lens.Result, match } result.Candidate = contracts.LensExplainCandidate{ - Kind: match.Kind, + Kind: match.Kind.String(), Repo: contracts.RepoRef{Owner: match.Repo.Owner(), Repo: match.Repo.Repo()}, Number: match.Number, Title: match.Title, diff --git a/internal/app/manifest.go b/internal/app/manifest.go index 311b5ccb..fb993fb5 100644 --- a/internal/app/manifest.go +++ b/internal/app/manifest.go @@ -113,7 +113,7 @@ func (s *Service) assembleContributionManifest(ctx context.Context, c *corpus.Co Opportunity: manifest.OpportunityRecord{ ID: opp.ID, InvestigationID: opp.InvestigationID, HypothesisID: opp.HypothesisID, ProblemStatement: opp.ProblemStatement, Scope: opp.Scope, Impact: opp.Impact, - Status: string(opp.Status), SourceRefs: append([]domain.SourceRef(nil), opp.SourceRefs...), + Status: opp.Status, SourceRefs: append([]domain.SourceRef(nil), opp.SourceRefs...), }, } if err := s.addManifestWorkspace(ctx, c, inv.ID, inv.Repo.Owner(), inv.Repo.Repo(), opts.WorkspaceID, &predicate); err != nil { @@ -134,15 +134,11 @@ func (s *Service) assembleContributionManifest(ctx context.Context, c *corpus.Co return nil, err } } else { - predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "pull_request", Status: "not_requested", Reason: "no exact pull request was selected"}) + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "pull_request", Status: manifest.CompletenessNotRequested, Reason: "no exact pull request was selected"}) } if err := addManifestDrafts(ctx, c, opp.ID, &predicate); err != nil { return nil, err } - predicate.Status = "complete" - if len(predicate.Gaps) > 0 { - predicate.Status = "incomplete" - } sortManifestPredicate(&predicate) statement, err := manifest.Finalize(predicate) if err != nil { @@ -206,7 +202,7 @@ func (s *Service) ExportManifest(ctx context.Context, opportunityID string, opts func (s *Service) addManifestWorkspace(ctx context.Context, c *corpus.Corpus, investigationID, owner, repo, workspaceID string, predicate *manifest.Predicate) error { if workspaceID == "" { - predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "workspace", Status: "not_requested", Reason: "no workspace was selected"}) + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "workspace", Status: manifest.CompletenessNotRequested, Reason: "no workspace was selected"}) return nil } item, err := c.GetWorkspace(ctx, workspaceID) @@ -225,9 +221,9 @@ func (s *Service) addManifestWorkspace(ctx context.Context, c *corpus.Corpus, in return fmt.Errorf("snapshot workspace %q: %w", workspaceID, err) } predicate.Workspace = &snapshot - status, reason := "complete", "workspace content is fully digest-bound" - if !snapshot.Complete { - status, reason = "incomplete", "workspace snapshot has explicitly unbound content" + status, reason := manifest.CompletenessComplete, "workspace content is fully digest-bound" + if !snapshot.Complete() { + status, reason = manifest.CompletenessIncomplete, "workspace snapshot has explicitly unbound content" for _, gap := range snapshot.Gaps { predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: gap.Code, Facet: "workspace", Reason: gap.Reason}) } @@ -270,15 +266,15 @@ func addManifestValidations(ctx context.Context, c *corpus.Corpus, predicate *ma predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validation_run_missing", Facet: "validations", Reason: "definition " + definition.ID + " has no stored run"}) } } - status, reason := "complete", "all stored validation runs have compatible workspace bindings and observations" + status, reason := manifest.CompletenessComplete, "all stored validation runs have compatible workspace bindings and observations" if len(definitions) == 0 { - status, reason = "unknown", "no validation definitions are stored" + status, reason = manifest.CompletenessUnknown, "no validation definitions are stored" predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validations_missing", Facet: "validations", Reason: reason}) } else if len(runs) == 0 { - status, reason = "unknown", "no validation runs are stored" + status, reason = manifest.CompletenessUnknown, "no validation runs are stored" predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validations_missing", Facet: "validations", Reason: reason}) } else if hasManifestGap(predicate.Gaps, "validations") { - status, reason = "incomplete", "one or more validation claims are stale, unknown, or unverified" + status, reason = manifest.CompletenessIncomplete, "one or more validation claims are stale, unknown, or unverified" } predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "validations", Status: status, Reason: reason}) return nil @@ -299,10 +295,10 @@ func buildManifestValidation(definition *evidence.ValidationDefinition, run *evi return manifest.ValidationRecord{}, nil, err } record := manifest.ValidationRecord{ - DefinitionID: definition.ID, RunID: run.ID, Kind: string(run.Kind), Command: append([]string(nil), definition.Command...), + DefinitionID: definition.ID, RunID: run.ID, Kind: run.Kind, Command: append([]string(nil), definition.Command...), CommandSHA256: commandDigest, ExecutionContractSHA256: executionDigest, EnvironmentAllowlist: append([]string(nil), definition.Env...), Timeout: definition.Timeout.String(), MaxOutputBytes: definition.MaxOutputBytes, Observation: definition.Observation, - Classification: string(run.Classification), ObservationStatus: string(run.ObservationStatus), + Classification: run.Classification, ObservationStatus: run.ObservationStatus, Observations: append([]evidence.ObservationResult(nil), run.Observations...), StartedAt: run.StartedAt, CompletedAt: run.CompletedAt, WorkspaceSnapshotBefore: run.WorkspaceSnapshotBefore, WorkspaceSnapshotAfter: run.WorkspaceSnapshotAfter, WorkspaceBindingStatus: run.WorkspaceBindingStatus, ExecutionOrigin: run.ExecutionOrigin, @@ -367,14 +363,14 @@ type validationExecutionContract struct { } func validationWorkspaceCompatibility(run *evidence.ValidationRun, current *workspace.Snapshot) (string, string) { - if run.ExecutionOrigin == "external" { + if run.ExecutionOrigin == evidence.ExecutionOriginExternal { if run.External == nil || run.External.Incomplete { return "unknown", "external validation receipt identity is missing or producer-declared incomplete" } return "external_unverified", "receipt identity is preserved, but GitContribute did not execute or independently verify it" } - if run.WorkspaceBindingStatus != "bound" { - status := run.WorkspaceBindingStatus + if run.WorkspaceBindingStatus != evidence.WorkspaceBindingBound { + status := string(run.WorkspaceBindingStatus) if status == "" { status = "unknown" } @@ -404,10 +400,10 @@ func addManifestEvidence(ctx context.Context, c *corpus.Corpus, predicate *manif return err } record := manifest.EvidenceRecord{ - ID: item.ID, Type: string(item.Type), Relation: string(item.Relation), Description: item.Description, + ID: item.ID, Type: item.Type, Relation: item.Relation, Description: item.Description, ValidationRunID: item.ValidationRunID, SourceRefs: append([]domain.SourceRef(nil), item.SourceRefs...), SourceProvenance: append([]evidence.SourceRevision(nil), item.SourceProvenance...), - Freshness: string(assessment.Status), FreshnessReason: assessment.Reason, + Freshness: assessment.Status, FreshnessReason: assessment.Reason, Measurements: item.Measurements, External: item.External, } if item.External != nil { @@ -418,12 +414,12 @@ func addManifestEvidence(ctx context.Context, c *corpus.Corpus, predicate *manif predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "evidence_" + string(assessment.Status), Facet: "evidence", Reason: "evidence " + item.ID + ": " + assessment.Reason}) } } - status, reason := "complete", "all evidence is fresh or local-only" + status, reason := manifest.CompletenessComplete, "all evidence is fresh or local-only" if len(items) == 0 { - status, reason = "unknown", "no evidence is scoped to the opportunity" + status, reason = manifest.CompletenessUnknown, "no evidence is scoped to the opportunity" predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "evidence_missing", Facet: "evidence", Reason: reason}) } else if hasManifestGap(predicate.Gaps, "evidence") { - status, reason = "incomplete", "some evidence is stale or has unknown freshness" + status, reason = manifest.CompletenessIncomplete, "some evidence is stale or has unknown freshness" } predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "evidence", Status: status, Reason: reason}) return nil @@ -444,9 +440,9 @@ func (s *Service) addManifestReadiness(ctx context.Context, opportunityID string predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "readiness_" + check.Status, Facet: "readiness", Reason: check.RuleID + ": " + check.Summary}) } } - status, reason := "complete", "readiness has no blocking or unknown checks" + status, reason := manifest.CompletenessComplete, "readiness has no blocking or unknown checks" if hasManifestGap(predicate.Gaps, "readiness") { - status, reason = "incomplete", "readiness includes blocking or unknown checks" + status, reason = manifest.CompletenessIncomplete, "readiness includes blocking or unknown checks" } predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "readiness", Status: status, Reason: reason}) return nil @@ -460,16 +456,16 @@ func (s *Service) addManifestPullRequest(ctx context.Context, c *corpus.Corpus, if err != nil { return "", err } - thread, err := c.GetThread(ctx, storedRepo.ID, corpus.ThreadKindPullRequest, selector.Number) + thread, err := c.GetThread(ctx, storedRepo.ID, domain.PullRequestKind, selector.Number) if err != nil { return "", err } stored := corpus.PortfolioPullRequest{Owner: storedRepo.Owner, Repo: storedRepo.Name, Thread: *thread} - readSet, err := loadPortfolioReadSet(ctx, c, []corpus.PortfolioPullRequest{stored}, portfolioDetailed) + readSet, err := loadPortfolioReadSet(ctx, c, []corpus.PortfolioPullRequest{stored}, detailedResponse) if err != nil { return "", err } - item, err := portfolioItem(stored, now, readSet, portfolioDetailed) + item, err := portfolioItem(stored, now, readSet, detailedResponse) if err != nil { return "", err } @@ -477,7 +473,7 @@ func (s *Service) addManifestPullRequest(ctx context.Context, c *corpus.Corpus, return "", fmt.Errorf("%w: pull request head %s differs from workspace head %s", manifest.ErrIdentityMismatch, item.HeadSHA, predicate.Workspace.HeadSHA) } record := manifest.PullRequestRecord{ - Owner: item.Owner, Repo: item.Repo, Number: item.Number, State: item.State, + Owner: item.Owner, Repo: item.Repo, Number: item.Number, State: thread.State, HeadSHA: item.HeadSHA, BaseSHA: item.BaseSHA, ChecksStatus: item.ChecksStatus, ReviewDecision: item.ReviewDecision, UnresolvedReviewThreads: item.UnresolvedReviewThreads, MergeStateStatus: item.MergeStateStatus, MergeQueueState: item.MergeQueueState, @@ -504,9 +500,9 @@ func (s *Service) addManifestPullRequest(ctx context.Context, c *corpus.Corpus, } } predicate.PullRequest = &record - status, reason := "complete", "all requested pull-request health facets are complete and current" + status, reason := manifest.CompletenessComplete, "all requested pull-request health facets are complete and current" if !complete { - status, reason = "incomplete", "one or more pull-request health facets are missing, stale, or incomplete" + status, reason = manifest.CompletenessIncomplete, "one or more pull-request health facets are missing, stale, or incomplete" } predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "pull_request", Status: status, Reason: reason}) return fmt.Sprintf("%s/%s#%d", item.Owner, item.Repo, item.Number), nil @@ -514,12 +510,12 @@ func (s *Service) addManifestPullRequest(ctx context.Context, c *corpus.Corpus, func addManifestDrafts(ctx context.Context, c *corpus.Corpus, opportunityID string, predicate *manifest.Predicate) error { if draft, err := c.GetIssueDraft(ctx, opportunityID); err == nil { - predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: "issue", Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) + predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: domain.IssueKind, Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) } else if !errors.Is(err, contribution.ErrNotFound) { return err } if draft, err := c.GetPullRequestDraft(ctx, opportunityID); err == nil { - predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: "pull_request", Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) + predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: domain.PullRequestKind, Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) } else if !errors.Is(err, contribution.ErrNotFound) { return err } diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 279eb191..65224776 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -53,7 +53,8 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp if err != nil { return mcpcontract.ThreadOutput{}, err } - if in.Kind != "issue" && in.Kind != "pull_request" { + kind, err := domain.ParseThreadKind(in.Kind) + if err != nil { return mcpcontract.ThreadOutput{}, errors.New("kind must be issue or pull_request") } if in.Number < 1 { @@ -74,7 +75,7 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp if repo == nil { return mcpcontract.ThreadOutput{}, failure.NotFound(nil) } - thread, err := c.GetThread(ctx, repo.ID, in.Kind, in.Number) + thread, err := c.GetThread(ctx, repo.ID, kind, in.Number) if err != nil { return mcpcontract.ThreadOutput{}, fmt.Errorf("get thread: %w", err) } @@ -95,9 +96,9 @@ func corpusThreadToMCPOutput(t *corpus.Thread) mcpcontract.ThreadOutput { return mcpcontract.ThreadOutput{ Owner: "", // filled by caller Repo: "", - Kind: t.Kind, + Kind: string(t.Kind), Number: t.Number, - State: t.State, + State: string(t.State), StateReason: t.StateReason, Title: t.Title, Body: t.Body, @@ -105,15 +106,16 @@ func corpusThreadToMCPOutput(t *corpus.Thread) mcpcontract.ThreadOutput { AuthorAssociation: t.AuthorAssociation, Labels: t.Labels, Assignees: t.Assignees, - Draft: t.Draft, ClosedAt: formatTime(t.ClosedAt), MergedAt: formatTime(t.Merge.MergedAt()), Merged: knownMergePointer(t.Merge.IsMerged(), t.Merge.Known()), + Draft: t.Draft, ClosedAt: formatTime(t.ClosedAt), MergedAt: formatTime(t.Merge.MergedAt()), Merged: mergeStatusPointer(t.Merge), UpdatedAt: formatTime(t.SourceUpdatedAt), } } -func knownMergePointer(merged, known bool) *bool { - if !known { +func mergeStatusPointer(status domain.MergeStatus) *bool { + if !status.Known() { return nil } + merged := status.IsMerged() return &merged } @@ -327,11 +329,17 @@ func (r *MCPReader) Evidence(ctx context.Context, in mcpcontract.EvidenceInput) return mcpcontract.EvidenceOutput{}, errors.New("exactly one of investigation_id or opportunity_id is required") } if in.InvestigationID != "" { - if _, err := normalizeMCPID("investigation_id", in.InvestigationID); err != nil { + normalized, err := normalizeMCPID("investigation_id", in.InvestigationID) + if err != nil { return mcpcontract.EvidenceOutput{}, err } - } else if _, err := normalizeMCPID("opportunity_id", in.OpportunityID); err != nil { - return mcpcontract.EvidenceOutput{}, err + in.InvestigationID = normalized + } else { + normalized, err := normalizeMCPID("opportunity_id", in.OpportunityID) + if err != nil { + return mcpcontract.EvidenceOutput{}, err + } + in.OpportunityID = normalized } if in.Limit == 0 { in.Limit = 20 @@ -344,10 +352,11 @@ func (r *MCPReader) Evidence(ctx context.Context, in mcpcontract.EvidenceInput) OpportunityID: in.OpportunityID, } if in.Relation != "" { - if !isValidEvidenceRelation(in.Relation) { - return mcpcontract.EvidenceOutput{}, fmt.Errorf("invalid relation %q", in.Relation) + relation, err := evidence.ParseRelation(in.Relation) + if err != nil { + return mcpcontract.EvidenceOutput{}, err } - filter.Relation = evidence.Relation(in.Relation) + filter.Relation = relation } c, err := r.openReadOnlyCorpus(ctx) if err != nil { @@ -388,10 +397,12 @@ func evidenceSourceRevisionsToMCP(values []evidence.SourceRevision) []mcpcontrac } out := make([]mcpcontract.EvidenceSourceRevision, len(values)) for i, value := range values { + repository := value.Subject.Repository() + threadKind, number, _ := value.Subject.Thread() out[i] = mcpcontract.EvidenceSourceRevision{ Subject: mcpcontract.EvidenceSourceSubject{ - Kind: string(value.Subject.Kind), Owner: value.Subject.Owner, Repo: value.Subject.Repo, - ThreadKind: value.Subject.ThreadKind, Number: value.Subject.Number, Facet: value.Subject.Facet, + Kind: value.Subject.Kind().String(), Owner: repository.Owner(), Repo: repository.Repo(), + ThreadKind: string(threadKind), Number: number, Facet: value.Subject.Facet(), }, SourceUpdatedAt: formatTime(value.SourceUpdatedAt), ObservationSequence: value.ObservationSequence, ObservedAt: formatTime(value.ObservedAt), @@ -461,14 +472,6 @@ func sourceRefsToMCP(refs []domain.SourceRef) []mcpcontract.SourceRef { return out } -func isValidEvidenceRelation(s string) bool { - switch evidence.Relation(s) { - case evidence.RelationSupporting, evidence.RelationContradicting, evidence.RelationInconclusive, evidence.RelationStale, evidence.RelationInvalid: - return true - } - return false -} - func normalizeMCPID(field, value string) (string, error) { value = strings.TrimSpace(value) if value == "" { @@ -556,7 +559,7 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI } } } - out.Provenance, err = offlineReadProvenance("coverage", revision, in, !unknownCoverage, false, unknownCoverage) + out.Provenance, err = offlineReadProvenance("coverage", revision, in, false, unknownCoverage) if err != nil { return mcpcontract.GetCoverageOutput{}, err } @@ -594,7 +597,7 @@ func readParsedCoverageTarget(ctx context.Context, c *corpus.Corpus, target pars asOf := repo.SourceUpdatedAt kind, number, isThread := target.thread() if isThread { - thread, err := c.GetThread(ctx, repo.ID, string(kind), number) + thread, err := c.GetThread(ctx, repo.ID, kind, number) if err != nil { return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get thread: %w", err) } @@ -666,8 +669,8 @@ func coverageRecoveryPlan(target parsedCoverageTarget, value mcpcontract.Coverag repo := target.repository() ref := mcpcontract.ThreadRef{Owner: repo.Owner(), Repo: repo.Repo(), Kind: string(kind), Number: number} - selectable := make(map[string]struct{}, len(facets.SelectableFor(string(kind)))) - for _, name := range facets.SelectableFor(string(kind)) { + selectable := make(map[string]struct{}, len(facets.SelectableFor(kind))) + for _, name := range facets.SelectableFor(kind) { selectable[name] = struct{}{} } known := make(map[string]struct{}, len(facets.AllNames())) @@ -738,12 +741,12 @@ func clusterToMCP(cl clustering.Cluster, memberLimit int) mcpcontract.ClusterOut break } members = append(members, mcpcontract.ClusterMemberOutput{ - Kind: m.Ref.Kind, + Kind: string(m.Ref.Kind), Owner: m.Ref.Owner, Repo: m.Ref.Repo, Number: m.Ref.Number, Title: m.Title, - State: m.State, + State: string(m.State), Score: mcpcontract.SimilarityScore(m.Score), Reason: m.Reason, Included: m.Included, @@ -753,7 +756,7 @@ func clusterToMCP(cl clustering.Cluster, memberLimit int) mcpcontract.ClusterOut return mcpcontract.ClusterOutput{ StableID: cl.StableID, State: string(cl.State), - Canonical: mcpcontract.ClusterMemberOutput{Kind: cl.Canonical.Kind, Owner: cl.Canonical.Owner, Repo: cl.Canonical.Repo, Number: cl.Canonical.Number}, + Canonical: mcpcontract.ClusterMemberOutput{Kind: string(cl.Canonical.Kind), Owner: cl.Canonical.Owner, Repo: cl.Canonical.Repo, Number: cl.Canonical.Number}, MemberCount: len(cl.Members), Members: members, } diff --git a/internal/app/mcp_actor_facets.go b/internal/app/mcp_actor_facets.go index 2597bc92..bef813c4 100644 --- a/internal/app/mcp_actor_facets.go +++ b/internal/app/mcp_actor_facets.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "strings" "time" @@ -13,8 +12,121 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +type actorFacetSuccess interface{ actorFacetSuccess() } + +type actorFacetCountSuccess struct { + actorID string + login string + items int + complete bool +} + +func (actorFacetCountSuccess) actorFacetSuccess() {} + +type actorPinnedItemsSuccess struct { + actorFacetCountSuccess + showcaseKind string +} + +type actorRepositoriesSuccess struct { + actorFacetCountSuccess + relationship string +} + +type actorContributionsSuccess struct { + actorFacetCountSuccess + from string + to string +} + +type actorFacetFailure struct { + status mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS int +} + +type actorFacetItem struct { + key string + success actorFacetSuccess + failure *actorFacetFailure +} + +type actorFacetCountJSON struct { + ActorID string `json:"actor_id"` + Login string `json:"login"` + Items int `json:"items"` + Complete bool `json:"complete"` +} + +func (s actorFacetCountSuccess) jsonFields() actorFacetCountJSON { + return actorFacetCountJSON{ActorID: s.actorID, Login: s.login, Items: s.items, Complete: s.complete} +} + +func successfulActorFacetItem(key string, success actorFacetSuccess) actorFacetItem { + return actorFacetItem{key: key, success: success} +} + +func failedActorFacetItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) actorFacetItem { + return actorFacetItem{key: key, failure: &actorFacetFailure{status: status, reason: reason, message: message, retryAfterMS: retryAfterMS}} +} + +func (i actorFacetItem) MarshalJSON() ([]byte, error) { + if i.success != nil && i.failure == nil { + switch success := i.success.(type) { + case actorFacetCountSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + actorFacetCountJSON + }{i.key, mcpcontract.BatchItemComplete, success.jsonFields()}) + case actorPinnedItemsSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + actorFacetCountJSON + ShowcaseKind string `json:"showcase_kind"` + }{i.key, mcpcontract.BatchItemComplete, success.jsonFields(), success.showcaseKind}) + case actorRepositoriesSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + actorFacetCountJSON + Relationship string `json:"relationship"` + }{i.key, mcpcontract.BatchItemComplete, success.jsonFields(), success.relationship}) + case actorContributionsSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + actorFacetCountJSON + From string `json:"from"` + To string `json:"to"` + }{i.key, mcpcontract.BatchItemComplete, success.jsonFields(), success.from, success.to}) + default: + return nil, errors.New("actor facet item has an unsupported success outcome") + } + } + if i.success == nil && i.failure != nil { + if i.failure.status == "" || i.failure.status == mcpcontract.BatchItemComplete || i.failure.status == mcpcontract.BatchItemPartial { + return nil, errors.New("actor facet failure has a non-failure status") + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, i.failure.status, i.failure.reason, i.failure.message, i.failure.retryAfterMS}) + } + return nil, errors.New("actor facet item has no single outcome") +} + +type actorFacetBatchResult struct { + batchOperationSummary[actorFacetItem] +} + func (r *MCPReader) SyncUserSocialAccounts(ctx context.Context, in mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) { - return r.submitUserFacetJob(ctx, "sync_user_social_accounts", in, func(ctx context.Context, c *corpus.Corpus, reader github.Reader, selector parsedActorSelector) (map[string]any, error) { + return r.submitUserFacetJob(ctx, "sync_user_social_accounts", in, func(ctx context.Context, c *corpus.Corpus, reader github.Reader, selector parsedActorSelector) (actorFacetSuccess, error) { source, ok := reader.(github.UserSocialAccountReader) if !ok { return nil, errors.New("GitHub social-account reads are unavailable") @@ -45,12 +157,12 @@ func (r *MCPReader) SyncUserSocialAccounts(ctx context.Context, in mcpcontract.S if err := c.ReplaceActorSocialAccounts(ctx, actor.ID, items, complete, observed, observed, "public", raw); err != nil { return nil, err } - return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": complete}, nil + return actorFacetCountSuccess{actorID: actor.Key, login: login, items: len(items), complete: complete}, nil }) } func (r *MCPReader) SyncUserOrganizations(ctx context.Context, in mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) { - return r.submitUserFacetJob(ctx, "sync_user_organizations", in, func(ctx context.Context, c *corpus.Corpus, reader github.Reader, selector parsedActorSelector) (map[string]any, error) { + return r.submitUserFacetJob(ctx, "sync_user_organizations", in, func(ctx context.Context, c *corpus.Corpus, reader github.Reader, selector parsedActorSelector) (actorFacetSuccess, error) { source, ok := reader.(github.UserOrganizationReader) if !ok { return nil, errors.New("GitHub organization reads are unavailable") @@ -81,7 +193,7 @@ func (r *MCPReader) SyncUserOrganizations(ctx context.Context, in mcpcontract.Sy if err := c.ReplaceActorOrganizations(ctx, actor.ID, items, complete, observed, observed, "public", raw); err != nil { return nil, err } - return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": complete}, nil + return actorFacetCountSuccess{actorID: actor.Key, login: login, items: len(items), complete: complete}, nil }) } @@ -119,7 +231,7 @@ func (r *MCPReader) SyncUserPinnedItems(ctx context.Context, in mcpcontract.Sync if err != nil { return nil, err } - return r.runActorFacetItems(ctx, selectors, "pinned_items", report, func(selector parsedActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "pinned_items", report, func(selector parsedActorSelector) (actorFacetSuccess, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -137,7 +249,10 @@ func (r *MCPReader) SyncUserPinnedItems(ctx context.Context, in mcpcontract.Sync if err := c.ReplaceActorPinnedItems(ctx, actor.ID, items, result.Coverage.Complete, observed, observed, "public", raw); err != nil { return nil, err } - return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "showcase_kind": result.ShowcaseKind, "complete": result.Coverage.Complete}, nil + return actorPinnedItemsSuccess{ + actorFacetCountSuccess: actorFacetCountSuccess{actorID: actor.Key, login: login, items: len(items), complete: result.Coverage.Complete}, + showcaseKind: result.ShowcaseKind, + }, nil }) }) if err != nil { @@ -174,7 +289,7 @@ func (r *MCPReader) SyncUserRepositories(ctx context.Context, in mcpcontract.Syn if err != nil { return nil, err } - return r.runActorFacetItems(ctx, selectors, "repositories", report, func(selector parsedActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "repositories", report, func(selector parsedActorSelector) (actorFacetSuccess, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -212,7 +327,10 @@ func (r *MCPReader) SyncUserRepositories(ctx context.Context, in mcpcontract.Syn if err := c.ReplaceActorRepositoryAffiliations(ctx, actor.ID, in.Relationship, affiliations, complete, observed, observed, "public", raw); err != nil { return nil, err } - return map[string]any{"actor_id": actor.Key, "login": login, "items": len(repositories), "complete": complete, "relationship": in.Relationship}, nil + return actorRepositoriesSuccess{ + actorFacetCountSuccess: actorFacetCountSuccess{actorID: actor.Key, login: login, items: len(repositories), complete: complete}, + relationship: in.Relationship, + }, nil }) }) if err != nil { @@ -270,7 +388,7 @@ func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.Sy if err != nil { return nil, err } - return r.runActorFacetItems(ctx, selectors, "contributions", report, func(selector parsedActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "contributions", report, func(selector parsedActorSelector) (actorFacetSuccess, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -331,7 +449,11 @@ func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.Sy if err := c.ApplyActorContributionPeriod(ctx, corpus.ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: to, OrganizationNodeID: in.OrganizationNodeID, AuthorizationScope: "viewer", TotalCommits: intPointer(result.TotalCommits), TotalIssues: intPointer(result.TotalIssues), TotalPullRequests: intPointer(result.TotalPullRequests), TotalPullRequestReviews: intPointer(result.TotalPullRequestReviews), TotalRepositories: intPointer(result.TotalRepositories), RestrictedContributions: intPointer(result.RestrictedContributions), Complete: result.Complete, ObservedAt: r.now().UTC(), SourceUpdatedAt: result.EndedAt, Days: days, Items: items, RepositoryTotals: totals, RawPayload: raw}); err != nil { return nil, err } - return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": result.Complete, "from": in.From, "to": in.To}, nil + return actorContributionsSuccess{ + actorFacetCountSuccess: actorFacetCountSuccess{actorID: actor.Key, login: login, items: len(items), complete: result.Complete}, + from: in.From, + to: in.To, + }, nil }) }) if err != nil { @@ -342,43 +464,21 @@ func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.Sy // SearchContributions reads contribution observations from the local corpus. func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.SearchContributionsInput) (mcpcontract.SearchContributionsOutput, error) { - if len(in.Actors) > 100 || len(in.Repositories) > 100 || len(in.Kinds) > 20 { - return mcpcontract.SearchContributionsOutput{}, errors.New("actors and repositories are limited to 100 items; kinds is limited to 20") - } - if in.Source == "" { - in.Source = "github_profile" - } - if in.Source != "github_profile" { - return mcpcontract.SearchContributionsOutput{}, errors.New("source must be github_profile; corpus_observation is not yet an indexed contribution source") - } - parseBound := func(name, value string) (time.Time, error) { - if value == "" { - return time.Time{}, nil - } - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, fmt.Errorf("%s must be RFC 3339", name) - } - return parsed, nil - } - from, err := parseBound("from", in.From) - if err != nil { - return mcpcontract.SearchContributionsOutput{}, err - } - to, err := parseBound("to", in.To) - if err != nil { - return mcpcontract.SearchContributionsOutput{}, err - } - if !from.IsZero() && !to.IsZero() && !to.After(from) { - return mcpcontract.SearchContributionsOutput{}, errors.New("to must be after from") - } repositories := make([]string, len(in.Repositories)) for i, repository := range in.Repositories { - if strings.TrimSpace(repository.Owner) == "" || strings.TrimSpace(repository.Repo) == "" { - return mcpcontract.SearchContributionsOutput{}, fmt.Errorf("repositories[%d] requires owner and repo", i) - } repositories[i] = repository.Owner + "/" + repository.Repo } + request, err := corpus.ParseContributionSearch(corpus.ContributionSearchInput{ + ActorRefs: in.Actors, RepositoryRefs: repositories, Kinds: in.Kinds, Source: in.Source, + OrganizationNodeID: in.OrganizationNodeID, From: in.From, To: in.To, + Sort: in.Sort, Order: in.Order, Limit: in.Limit, Cursor: in.Cursor, + }) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + from, to := request.From(), request.To() + periodFrom, periodTo := request.FromString(), request.ToString() + organizationNodeID := request.OrganizationNodeID() c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.SearchContributionsOutput{}, err @@ -387,15 +487,15 @@ func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.Sear if err != nil { return mcpcontract.SearchContributionsOutput{}, err } - page, err := c.SearchActorContributions(ctx, corpus.ContributionSearchOptions{ActorRefs: in.Actors, RepositoryRefs: repositories, Kinds: in.Kinds, OrganizationNodeID: in.OrganizationNodeID, From: from, To: to, Sort: in.Sort, Order: in.Order, Limit: in.Limit, Cursor: in.Cursor}) + page, err := c.SearchActorContributions(ctx, request) if err != nil { return mcpcontract.SearchContributionsOutput{}, err } out := mcpcontract.SearchContributionsOutput{Items: make([]mcpcontract.ContributionOutput, len(page.Items)), Total: page.Total, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} for i, item := range page.Items { - out.Items[i] = mcpcontract.ContributionOutput{ActorID: item.ActorKey, Login: item.Login, Kind: item.Kind, Source: "github_profile", OccurredAt: formatTime(item.OccurredAt), RepositoryRef: item.RepositoryRef, TargetNodeID: item.TargetNodeID, TargetURL: item.TargetURL, Restricted: item.Restricted, Count: item.Count} + out.Items[i] = mcpcontract.ContributionOutput{ActorID: item.ActorKey, Login: item.Login, Kind: item.Kind.String(), Source: "github_profile", OccurredAt: formatTime(item.OccurredAt), RepositoryRef: item.RepositoryRef, TargetNodeID: item.TargetNodeID, TargetURL: item.TargetURL, Restricted: item.Restricted, Count: item.Count} } - coverageActors := in.Actors + coverageActors := request.ActorReferences() if len(coverageActors) == 0 { seen := map[string]bool{} for _, item := range page.Items { @@ -408,20 +508,20 @@ func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.Sear for _, ref := range coverageActors { actor, readErr := c.GetActor(ctx, ref) if readErr != nil { - out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_read_failed", PeriodFrom: in.From, PeriodTo: in.To, OrganizationNodeID: in.OrganizationNodeID}}) + out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_read_failed", PeriodFrom: periodFrom, PeriodTo: periodTo, OrganizationNodeID: organizationNodeID}}) continue } if actor == nil { - out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_not_indexed", PeriodFrom: in.From, PeriodTo: in.To, OrganizationNodeID: in.OrganizationNodeID}}) + out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_not_indexed", PeriodFrom: periodFrom, PeriodTo: periodTo, OrganizationNodeID: organizationNodeID}}) continue } - stored, readErr := c.GetActorContributionCoverage(ctx, actor.ID, in.OrganizationNodeID, from, to) + stored, readErr := c.GetActorContributionCoverage(ctx, actor.ID, organizationNodeID, from, to) if readErr != nil { return mcpcontract.SearchContributionsOutput{}, readErr } coverage := mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "facet_not_synchronized"} - coverage.PeriodFrom, coverage.PeriodTo = in.From, in.To - coverage.OrganizationNodeID = in.OrganizationNodeID + coverage.PeriodFrom, coverage.PeriodTo = periodFrom, periodTo + coverage.OrganizationNodeID = organizationNodeID if from.IsZero() || to.IsZero() { coverage.Reason = "bounded_period_required" } else if stored != nil { @@ -438,7 +538,7 @@ func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.Sear return out, nil } -func (r *MCPReader) submitUserFacetJob(ctx context.Context, kind string, in mcpcontract.SyncUserFacetInput, run func(context.Context, *corpus.Corpus, github.Reader, parsedActorSelector) (map[string]any, error)) (mcpcontract.JobReference, error) { +func (r *MCPReader) submitUserFacetJob(ctx context.Context, kind string, in mcpcontract.SyncUserFacetInput, run func(context.Context, *corpus.Corpus, github.Reader, parsedActorSelector) (actorFacetSuccess, error)) (mcpcontract.JobReference, error) { if len(in.Users) < 1 || len(in.Users) > 100 { return mcpcontract.JobReference{}, errors.New("users must contain 1 to 100 items") } @@ -459,7 +559,7 @@ func (r *MCPReader) submitUserFacetJob(ctx context.Context, kind string, in mcpc if err != nil { return nil, err } - return r.runActorFacetItems(ctx, selectors, kind, report, func(selector parsedActorSelector) (map[string]any, error) { return run(ctx, c, reader, selector) }) + return r.runActorFacetItems(ctx, selectors, kind, report, func(selector parsedActorSelector) (actorFacetSuccess, error) { return run(ctx, c, reader, selector) }) }) if err != nil { return mcpcontract.JobReference{}, err @@ -467,8 +567,8 @@ func (r *MCPReader) submitUserFacetJob(ctx context.Context, kind string, in mcpc return queuedJobReference(id, kind, "GitHub actor facet synchronization started"), nil } -func (r *MCPReader) runActorFacetItems(ctx context.Context, selectors []parsedActorSelector, phase string, report func(string, string) error, run func(parsedActorSelector) (map[string]any, error)) (map[string]any, error) { - items := make([]map[string]any, len(selectors)) +func (r *MCPReader) runActorFacetItems(ctx context.Context, selectors []parsedActorSelector, phase string, report func(string, string) error, run func(parsedActorSelector) (actorFacetSuccess, error)) (*actorFacetBatchResult, error) { + items := make([]actorFacetItem, len(selectors)) complete := 0 if err := report(phase, jobProgressCounts(0, len(selectors))); err != nil { return nil, err @@ -480,22 +580,22 @@ func (r *MCPReader) runActorFacetItems(ctx context.Context, selectors []parsedAc value, err := run(selector) if err != nil { itemStatus, reason, message, retry := githubBatchError(err) - items[i] = map[string]any{"key": selector.key(), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} + items[i] = failedActorFacetItem(selector.key(), itemStatus, reason, message, retry) } else { - value["key"] = selector.key() - value["status"] = "complete" - items[i] = value + items[i] = successfulActorFacetItem(selector.key(), value) complete++ } if err := report(phase, jobProgressCounts(i+1, len(selectors))); err != nil { return nil, err } } - status := "complete" + status := batchOperationComplete if complete != len(selectors) { - status = "partial" + status = batchOperationPartial } - return map[string]any{"status": status, "items": items, "completed": complete, "total": len(selectors)}, nil + return &actorFacetBatchResult{batchOperationSummary: batchOperationSummary[actorFacetItem]{ + Status: status, Items: items, Completed: complete, Total: len(selectors), + }}, nil } func normalizeFacetBounds(maxPages, maxItems, maxRequests *int, userCount int) error { diff --git a/internal/app/mcp_actor_facets_test.go b/internal/app/mcp_actor_facets_test.go new file mode 100644 index 00000000..9decb79e --- /dev/null +++ b/internal/app/mcp_actor_facets_test.go @@ -0,0 +1,109 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestActorFacetItemsPreserveDurableJSONShapes(t *testing.T) { + t.Parallel() + base := actorFacetCountSuccess{actorID: "github:node:U1", login: "alice", items: 2, complete: false} + tests := []struct { + name string + item actorFacetItem + expected string + }{ + { + name: "count", + item: successfulActorFacetItem("alice", base), + expected: `{"key":"alice","status":"complete","actor_id":"github:node:U1","login":"alice","items":2,"complete":false}`, + }, + { + name: "pinned items", + item: successfulActorFacetItem("alice", actorPinnedItemsSuccess{actorFacetCountSuccess: base, showcaseKind: "repository"}), + expected: `{"key":"alice","status":"complete","actor_id":"github:node:U1","login":"alice","items":2,"complete":false,"showcase_kind":"repository"}`, + }, + { + name: "repositories", + item: successfulActorFacetItem("alice", actorRepositoriesSuccess{actorFacetCountSuccess: base, relationship: "owned"}), + expected: `{"key":"alice","status":"complete","actor_id":"github:node:U1","login":"alice","items":2,"complete":false,"relationship":"owned"}`, + }, + { + name: "contributions", + item: successfulActorFacetItem("alice", actorContributionsSuccess{actorFacetCountSuccess: base, from: "2026-01-01T00:00:00Z", to: "2026-02-01T00:00:00Z"}), + expected: `{"key":"alice","status":"complete","actor_id":"github:node:U1","login":"alice","items":2,"complete":false,"from":"2026-01-01T00:00:00Z","to":"2026-02-01T00:00:00Z"}`, + }, + { + name: "failure", + item: failedActorFacetItem("alice", mcpcontract.BatchItemRetryable, "rate_limited", "wait", 0), + expected: `{"key":"alice","status":"retryable","reason":"rate_limited","message":"wait","retry_after_ms":0}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assertJSONDocumentEqual(t, test.item, test.expected) + }) + } +} + +func TestRunActorFacetItemsPreservesOrderAndPartialStatus(t *testing.T) { + t.Parallel() + selectors := []parsedActorSelector{actorLogin("Alice"), actorLogin("Bob")} + reports := 0 + result, err := (&MCPReader{}).runActorFacetItems(context.Background(), selectors, "social_accounts", func(string, string) error { + reports++ + return nil + }, func(selector parsedActorSelector) (actorFacetSuccess, error) { + if selector.key() == "bob" { + return nil, errors.New("provider failed") + } + return actorFacetCountSuccess{actorID: "github:node:U1", login: "Alice", items: 1, complete: true}, nil + }) + if err != nil { + t.Fatal(err) + } + if result.Status != batchOperationPartial || result.Completed != 1 || result.Total != 2 || reports != 3 { + t.Fatalf("result = %+v, reports = %d", result, reports) + } + assertJSONDocumentEqual(t, result, `{ + "status":"partial","items":[ + {"key":"alice","status":"complete","actor_id":"github:node:U1","login":"Alice","items":1,"complete":true}, + {"key":"bob","status":"failed","reason":"request_failed","message":"provider failed","retry_after_ms":0} + ],"completed":1,"total":2 + }`) +} + +func TestActorProfileItemsPreserveDurableJSONShapes(t *testing.T) { + t.Parallel() + tests := []struct { + name string + item actorProfileItem + expected string + }{ + { + name: "success", + item: successfulActorProfileItem("alice", "github:node:U1", "Alice"), + expected: `{"key":"alice","status":"complete","actor_id":"github:node:U1","login":"Alice"}`, + }, + { + name: "unresolved selector", + item: unresolvedActorProfileItem("U1", "actor_login_unknown", "node ID is not stored"), + expected: `{"key":"U1","status":"unavailable","reason":"actor_login_unknown","message":"node ID is not stored"}`, + }, + { + name: "provider failure", + item: failedActorProfileItem("alice", mcpcontract.BatchItemRetryable, "rate_limited", "wait", 0), + expected: `{"key":"alice","status":"retryable","reason":"rate_limited","message":"wait","retry_after_ms":0}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assertJSONDocumentEqual(t, test.item, test.expected) + }) + } +} diff --git a/internal/app/mcp_actors.go b/internal/app/mcp_actors.go index 9875daa0..5f7261b6 100644 --- a/internal/app/mcp_actors.go +++ b/internal/app/mcp_actors.go @@ -13,6 +13,84 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +type actorProfileOutcome interface{ actorProfileOutcome() } + +type actorProfileSuccess struct { + actorID string + login string +} + +func (actorProfileSuccess) actorProfileOutcome() {} + +type actorProfileResolutionFailure struct { + reason string + message string +} + +func (actorProfileResolutionFailure) actorProfileOutcome() {} + +type actorProfileRequestFailure struct { + status mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS int +} + +func (actorProfileRequestFailure) actorProfileOutcome() {} + +type actorProfileItem struct { + key string + outcome actorProfileOutcome +} + +func successfulActorProfileItem(key, actorID, login string) actorProfileItem { + return actorProfileItem{key: key, outcome: actorProfileSuccess{actorID: actorID, login: login}} +} + +func unresolvedActorProfileItem(key, reason, message string) actorProfileItem { + return actorProfileItem{key: key, outcome: actorProfileResolutionFailure{reason: reason, message: message}} +} + +func failedActorProfileItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) actorProfileItem { + return actorProfileItem{key: key, outcome: actorProfileRequestFailure{status: status, reason: reason, message: message, retryAfterMS: retryAfterMS}} +} + +func (i actorProfileItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case actorProfileSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + ActorID string `json:"actor_id"` + Login string `json:"login"` + }{i.key, mcpcontract.BatchItemComplete, outcome.actorID, outcome.login}) + case actorProfileResolutionFailure: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + }{i.key, mcpcontract.BatchItemUnavailable, outcome.reason, outcome.message}) + case actorProfileRequestFailure: + if outcome.status == "" || outcome.status == mcpcontract.BatchItemComplete || outcome.status == mcpcontract.BatchItemPartial { + return nil, errors.New("actor profile failure has a non-failure status") + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, outcome.status, outcome.reason, outcome.message, outcome.retryAfterMS}) + default: + return nil, errors.New("actor profile item has no supported outcome") + } +} + +type actorProfileBatchResult struct { + batchOperationSummary[actorProfileItem] +} + // SearchGitHubUsers performs one bounded live discovery page and persists only // identity observations; it never expands the result into N profile reads. func (r *MCPReader) SearchGitHubUsers(ctx context.Context, in mcpcontract.SearchGitHubUsersInput) (mcpcontract.SearchGitHubUsersOutput, error) { @@ -100,7 +178,7 @@ func (r *MCPReader) SyncUsers(ctx context.Context, in mcpcontract.SyncUsersInput return queuedJobReference(id, "sync_users", "GitHub user profile synchronization started"), nil } -func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelector, report func(string, string) error) (map[string]any, error) { +func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelector, report func(string, string) error) (*actorProfileBatchResult, error) { reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return nil, err @@ -113,7 +191,7 @@ func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelect if err != nil { return nil, err } - items := make([]map[string]any, len(selectors)) + items := make([]actorProfileItem, len(selectors)) complete := 0 if err := report("profiles", jobProgressCounts(0, len(selectors))); err != nil { return nil, err @@ -124,7 +202,7 @@ func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelect } login, resolveErr := selector.resolveLogin(ctx, c) if resolveErr != nil { - items[index] = map[string]any{"key": selector.key(), "status": "unavailable", "reason": "actor_login_unknown", "message": resolveErr.Error()} + items[index] = unresolvedActorProfileItem(selector.key(), "actor_login_unknown", resolveErr.Error()) if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } @@ -133,7 +211,7 @@ func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelect actor, _, readErr := profiles.GetUser(ctx, login) if readErr != nil { itemStatus, reason, message, retry := githubBatchError(readErr) - items[index] = map[string]any{"key": selector.key(), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} + items[index] = failedActorProfileItem(selector.key(), itemStatus, reason, message, retry) if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } @@ -149,17 +227,19 @@ func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelect if persistErr != nil { return nil, persistErr } - items[index] = map[string]any{"key": selector.key(), "status": "complete", "actor_id": stored.Key, "login": stored.Login} + items[index] = successfulActorProfileItem(selector.key(), stored.Key, stored.Login) complete++ if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } } - status := "complete" + status := batchOperationComplete if complete != len(selectors) { - status = "partial" + status = batchOperationPartial } - return map[string]any{"status": status, "items": items, "completed": complete, "total": len(selectors)}, nil + return &actorProfileBatchResult{batchOperationSummary: batchOperationSummary[actorProfileItem]{ + Status: status, Items: items, Completed: complete, Total: len(selectors), + }}, nil } func normalizeActorKind(kind string) string { @@ -173,6 +253,10 @@ func normalizeActorKind(kind string) string { // SearchActors is a local-only actor search. func (r *MCPReader) SearchActors(ctx context.Context, in mcpcontract.SearchActorsInput) (mcpcontract.SearchActorsOutput, error) { + request, err := corpus.ParseActorSearch(corpus.ActorSearchInput{Query: in.Query, Kinds: in.Kinds, Sort: in.Sort, Limit: in.Limit, Cursor: in.Cursor}) + if err != nil { + return mcpcontract.SearchActorsOutput{}, err + } c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.SearchActorsOutput{}, err @@ -181,7 +265,7 @@ func (r *MCPReader) SearchActors(ctx context.Context, in mcpcontract.SearchActor if err != nil { return mcpcontract.SearchActorsOutput{}, err } - page, err := c.SearchActors(ctx, corpus.ActorSearchOptions{Query: in.Query, Kinds: in.Kinds, Sort: in.Sort, Limit: in.Limit, Cursor: in.Cursor}) + page, err := c.SearchActors(ctx, request) if err != nil { return mcpcontract.SearchActorsOutput{}, err } @@ -210,14 +294,14 @@ func (r *MCPReader) GetActors(ctx context.Context, in mcpcontract.GetActorsInput } out := mcpcontract.GetActorsOutput{Items: make([]mcpcontract.ActorBatchItem[mcpcontract.ActorOutput], len(in.Actors)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} for index, ref := range in.Actors { - item := mcpcontract.ActorBatchItem[mcpcontract.ActorOutput]{Key: ref, Status: "complete"} + item := mcpcontract.ActorBatchItem[mcpcontract.ActorOutput]{Key: ref, Status: mcpcontract.BatchItemComplete} actor, readErr := c.GetActor(ctx, ref) if readErr != nil { - item.Status = "failed" + item.Status = mcpcontract.BatchItemFailed item.Reason = "actor_read_failed" item.Message = readErr.Error() } else if actor == nil { - item.Status = "unavailable" + item.Status = mcpcontract.BatchItemUnavailable item.Reason = "actor_not_indexed" item.Message = "actor is not present in the local corpus" } else { @@ -256,17 +340,17 @@ func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorF } out := mcpcontract.GetActorFacetsOutput{Items: make([]mcpcontract.ActorBatchItem[mcpcontract.ActorFacetReferenceOutput], len(in.Actors)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} for index, ref := range in.Actors { - item := mcpcontract.ActorBatchItem[mcpcontract.ActorFacetReferenceOutput]{Key: ref, Status: "complete"} + item := mcpcontract.ActorBatchItem[mcpcontract.ActorFacetReferenceOutput]{Key: ref, Status: mcpcontract.BatchItemComplete} actor, readErr := c.GetActor(ctx, ref) if readErr != nil { - item.Status = "failed" + item.Status = mcpcontract.BatchItemFailed item.Reason = "actor_read_failed" item.Message = readErr.Error() out.Items[index] = item continue } if actor == nil { - item.Status = "unavailable" + item.Status = mcpcontract.BatchItemUnavailable item.Reason = "actor_not_indexed" item.Message = "actor is not present in the local corpus" out.Items[index] = item @@ -280,7 +364,10 @@ func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorF for _, facet := range in.Facets { coverage := mcpcontract.ActorCoverageOutput{Facet: facet, Status: "unknown", Reason: "facet_not_synchronized"} if stored, ok := coverageByFacet[facet]; ok { - coverage.Status = map[bool]string{true: "complete", false: "truncated"}[stored.Complete] + coverage.Status = "truncated" + if stored.Complete { + coverage.Status = "complete" + } coverage.ObservedAt = formatTime(stored.ObservedAt) coverage.SourceUpdatedAt = formatTime(stored.SourceUpdatedAt) coverage.AuthorizationScope = stored.AuthorizationScope @@ -294,7 +381,7 @@ func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorF value.Facets = append(value.Facets, coverage) value.URIs = append(value.URIs, "gitcontribute://actor/"+url.PathEscape(actor.Key)+"/facet/"+url.PathEscape(facet)) if coverage.Status != "complete" { - item.Status = "unavailable" + item.Status = mcpcontract.BatchItemUnavailable item.Reason = "actor_facet_unknown" item.Message = "one or more requested actor facets are not completely synchronized" } @@ -308,34 +395,53 @@ func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorF return out, nil } -// ActorResource returns the canonical stored actor view without refreshing it. -func (r *MCPReader) ActorResource(ctx context.Context, ref, facet string) (any, error) { +// ActorProfileResource returns the canonical stored actor view without +// refreshing it. +func (r *MCPReader) ActorProfileResource(ctx context.Context, ref string) (mcpcontract.ActorOutput, error) { c, err := r.openReadOnlyCorpus(ctx) if err != nil { - return nil, err + return mcpcontract.ActorOutput{}, err } actor, err := c.GetActor(ctx, ref) if err != nil { - return nil, err + return mcpcontract.ActorOutput{}, err } if actor == nil { - return nil, mcpcontract.ErrNotFound + return mcpcontract.ActorOutput{}, mcpcontract.ErrNotFound } - if facet == "" || facet == "profile" { - return actorOutput(*actor), nil + return actorOutput(*actor), nil +} + +// ActorFacetResource returns one exact stored actor facet without refreshing +// it or widening its value into an untyped Go representation. +func (r *MCPReader) ActorFacetResource(ctx context.Context, ref, facet string) (mcpcontract.ActorFacetResource, error) { + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.ActorFacetResource{}, err + } + actor, err := c.GetActor(ctx, ref) + if err != nil { + return mcpcontract.ActorFacetResource{}, err + } + if actor == nil { + return mcpcontract.ActorFacetResource{}, mcpcontract.ErrNotFound } observation, err := c.GetActorFacetObservation(ctx, actor.ID, facet) if err != nil { - return nil, err + return mcpcontract.ActorFacetResource{}, err } if observation == nil { - return nil, mcpcontract.ErrNotFound + return mcpcontract.ActorFacetResource{}, mcpcontract.ErrNotFound } - var value any - if err := json.Unmarshal(observation.Payload, &value); err != nil { - value = string(observation.Payload) + value, err := mcpcontract.NewActorFacetResource( + actor.Key, facet, observation.Complete, formatTime(observation.ObservedAt), + formatTime(observation.SourceUpdatedAt), observation.AuthorizationScope, + observation.Payload, + ) + if err != nil { + return mcpcontract.ActorFacetResource{}, fmt.Errorf("decode actor facet %s: %w", facet, err) } - return map[string]any{"schema_version": "gitcontribute.actor-facet.v1", "actor_id": actor.Key, "facet": facet, "complete": observation.Complete, "observed_at": formatTime(observation.ObservedAt), "source_updated_at": formatTime(observation.SourceUpdatedAt), "authorization_scope": observation.AuthorizationScope, "value": value}, nil + return value, nil } func actorIdentityOutput(actor corpus.Actor) mcpcontract.ActorIdentityOutput { diff --git a/internal/app/mcp_advanced_reads.go b/internal/app/mcp_advanced_reads.go index e22b3425..7493e78d 100644 --- a/internal/app/mcp_advanced_reads.go +++ b/internal/app/mcp_advanced_reads.go @@ -44,12 +44,15 @@ func (r *MCPReader) FindClusters(ctx context.Context, in mcpcontract.FindCluster for i, target := range in.Targets { key := clusterTargetKey(target) item := mcpcontract.BatchItem[mcpcontract.ClusterSetOutput]{Key: key, Status: "complete"} - if err := validateClusterTarget(target); err != nil { + parsed, err := parseClusterTarget(target) + if err != nil { item.Status, item.Reason, item.Message = "failed", "invalid_reference", err.Error() out.Status = "partial" out.Items[i] = item continue } + key = parsed.key() + item.Key = key normalizedKey := strings.ToLower(key) if _, duplicate := seen[normalizedKey]; duplicate { return mcpcontract.FindClustersOutput{}, mcpcontract.InvalidArgument("targets", "must not contain duplicate targets", map[string]any{ @@ -57,23 +60,23 @@ func (r *MCPReader) FindClusters(ctx context.Context, in mcpcontract.FindCluster }) } seen[normalizedKey] = struct{}{} - value, err := findClustersTarget(ctx, c, target, in.Limit) + value, err := findClustersTarget(ctx, c, parsed, in.Limit) switch { case err == nil: if value.Truncated { item.Status, item.Reason, item.Message = "partial", "cluster_truncated", "the stored cluster population exceeded the requested bound" out.Status = "partial" nextLimit := min(100, max(in.Limit*2, in.Limit+1)) - value.Recovery = recoveryPlan("cluster_truncated", "The stored cluster population exceeded this bound. Request a larger cluster limit before treating the returned clusters as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.FindClustersInput{Targets: []mcpcontract.ClusterTarget{target}, Limit: nextLimit, SnapshotToken: in.SnapshotToken})) + value.Recovery = recoveryPlan("cluster_truncated", "The stored cluster population exceeded this bound. Request a larger cluster limit before treating the returned clusters as exhaustive.", mcpcontract.RecoveryAction(mcpcontract.FindClustersInput{Targets: []mcpcontract.ClusterTarget{parsed.wire()}, Limit: nextLimit, SnapshotToken: in.SnapshotToken})) } item.Value = &value case errors.Is(err, errRepositoryNotFound): item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", err.Error() - item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(target.Owner, target.Repo)) + item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(parsed.repository.Owner(), parsed.repository.Repo())) out.Status = "partial" case errors.Is(err, errThreadNotFound): item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", err.Error() - item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(mcpcontract.ThreadRef(target))) + item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(mcpcontract.ThreadRef(parsed.wire()))) out.Status = "partial" default: item.Status, item.Reason, item.Message = "failed", "read_failed", err.Error() @@ -87,31 +90,27 @@ func (r *MCPReader) FindClusters(ctx context.Context, in mcpcontract.FindCluster return out, nil } -func findClustersTarget(ctx context.Context, c *corpus.Corpus, target mcpcontract.ClusterTarget, limit int) (mcpcontract.ClusterSetOutput, error) { - ref, err := domain.NewRepoRef(target.Owner, target.Repo) - if err != nil { - return mcpcontract.ClusterSetOutput{}, err - } - repository, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) +func findClustersTarget(ctx context.Context, c *corpus.Corpus, target parsedClusterTarget, limit int) (mcpcontract.ClusterSetOutput, error) { + repository, err := c.GetRepository(ctx, target.repository.Owner(), target.repository.Repo()) if err != nil { return mcpcontract.ClusterSetOutput{}, err } if repository == nil { - return mcpcontract.ClusterSetOutput{}, fmt.Errorf("%w: %s", errRepositoryNotFound, ref) + return mcpcontract.ClusterSetOutput{}, fmt.Errorf("%w: %s", errRepositoryNotFound, target.repository) } - if target.Kind != "" { - thread, err := c.GetThread(ctx, repository.ID, target.Kind, target.Number) + if target.thread != nil { + thread, err := c.GetThread(ctx, repository.ID, target.thread.kind, target.thread.number) if err != nil { return mcpcontract.ClusterSetOutput{}, err } if thread == nil { - return mcpcontract.ClusterSetOutput{}, fmt.Errorf("%w: %s#%d", errThreadNotFound, ref, target.Number) + return mcpcontract.ClusterSetOutput{}, fmt.Errorf("%w: %s#%d", errThreadNotFound, target.repository, target.thread.number) } - projection, err := c.GetClusterProjectionForMemberWithIdentity(ctx, clustering.MemberRef{Kind: target.Kind, Owner: target.Owner, Repo: target.Repo, Number: target.Number}) + projection, err := c.GetClusterProjectionForMemberWithIdentity(ctx, clustering.MemberRef{Kind: target.thread.kind, Owner: target.repository.Owner(), Repo: target.repository.Repo(), Number: target.thread.number}) if err != nil { return mcpcontract.ClusterSetOutput{}, fmt.Errorf("find cluster member: %w", err) } - out := mcpcontract.ClusterSetOutput{Owner: target.Owner, Repo: target.Repo} + out := mcpcontract.ClusterSetOutput{Owner: target.repository.Owner(), Repo: target.repository.Repo()} if len(projection.Clusters) > 0 { out.Total = 1 out.Clusters = []mcpcontract.ClusterOutput{clusterToMCP(projection.Clusters[0], 20)} @@ -121,13 +120,13 @@ func findClustersTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac } return out, nil } - projection, err := c.ListClusterProjection(ctx, ref, clustering.ClusterOpen, limit) + projection, err := c.ListClusterProjection(ctx, target.repository, clustering.ClusterOpen, limit) if err != nil { return mcpcontract.ClusterSetOutput{}, fmt.Errorf("list clusters: %w", err) } out := mcpcontract.ClusterSetOutput{ - Owner: target.Owner, - Repo: target.Repo, + Owner: target.repository.Owner(), + Repo: target.repository.Repo(), Total: projection.Total, Truncated: projection.Truncated, Clusters: make([]mcpcontract.ClusterOutput, len(projection.Clusters)), @@ -141,20 +140,48 @@ func findClustersTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac return out, nil } -func validateClusterTarget(target mcpcontract.ClusterTarget) error { - if _, err := domain.NewRepoRef(target.Owner, target.Repo); err != nil { - return err +type clusterThreadTarget struct { + kind domain.ThreadKind + number int +} + +type parsedClusterTarget struct { + repository domain.RepoRef + thread *clusterThreadTarget +} + +func parseClusterTarget(target mcpcontract.ClusterTarget) (parsedClusterTarget, error) { + repository, err := domain.NewRepoRef(target.Owner, target.Repo) + if err != nil { + return parsedClusterTarget{}, err + } + kindValue := strings.TrimSpace(target.Kind) + if (kindValue == "") != (target.Number == 0) { + return parsedClusterTarget{}, errors.New("kind and number must be provided together") + } + if kindValue == "" { + return parsedClusterTarget{repository: repository}, nil } - if (target.Kind == "") != (target.Number == 0) { - return errors.New("kind and number must be provided together") + kind, err := domain.ParseThreadKind(kindValue) + if err != nil { + return parsedClusterTarget{}, errors.New("kind must be issue or pull_request") } - if target.Kind != "" && target.Kind != "issue" && target.Kind != "pull_request" { - return errors.New("kind must be issue or pull_request") + if target.Number < 1 { + return parsedClusterTarget{}, errors.New("number must be positive") } - if target.Number < 0 { - return errors.New("number must be positive") + return parsedClusterTarget{repository: repository, thread: &clusterThreadTarget{kind: kind, number: target.Number}}, nil +} + +func (t parsedClusterTarget) wire() mcpcontract.ClusterTarget { + out := mcpcontract.ClusterTarget{Owner: t.repository.Owner(), Repo: t.repository.Repo()} + if t.thread != nil { + out.Kind, out.Number = string(t.thread.kind), t.thread.number } - return nil + return out +} + +func (t parsedClusterTarget) key() string { + return clusterTargetKey(t.wire()) } func clusterTargetKey(target mcpcontract.ClusterTarget) string { @@ -196,12 +223,16 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb for i, thread := range in.Threads { key := fmt.Sprintf("%s/%s:%s#%d", thread.Owner, thread.Repo, thread.Kind, thread.Number) item := mcpcontract.BatchItem[mcpcontract.NeighborSetOutput]{Key: key, Status: "complete"} - if err := validateSimilarityThread(thread); err != nil { + target, err := parseSimilarityThread(contracts.RepoRef{Owner: thread.Owner, Repo: thread.Repo}, thread.Kind, thread.Number) + if err != nil { item.Status, item.Reason, item.Message = "failed", "invalid_reference", err.Error() out.Status = "partial" out.Items[i] = item continue } + wire := mcpcontract.ThreadRef{Owner: target.repository.Owner(), Repo: target.repository.Repo(), Kind: string(target.kind), Number: target.number} + key = fmt.Sprintf("%s/%s:%s#%d", wire.Owner, wire.Repo, wire.Kind, wire.Number) + item.Key = key normalizedKey := strings.ToLower(key) if _, duplicate := seen[normalizedKey]; duplicate { return mcpcontract.FindNeighborsOutput{}, mcpcontract.InvalidArgument("threads", "must not contain duplicate threads", map[string]any{ @@ -209,11 +240,11 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb }) } seen[normalizedKey] = struct{}{} - result, err := r.Neighbors(ctx, contracts.RepoRef{Owner: thread.Owner, Repo: thread.Repo}, thread.Kind, thread.Number, in.Limit) + result, err := r.neighborsForThread(ctx, target, in.Limit) switch { case err == nil: value := mcpcontract.NeighborSetOutput{ - Owner: thread.Owner, Repo: thread.Repo, Kind: result.Kind, Number: result.Number, SourceRevision: result.SourceRevision, + Owner: wire.Owner, Repo: wire.Repo, Kind: result.Kind, Number: result.Number, SourceRevision: result.SourceRevision, Neighbors: make([]mcpcontract.NeighborOutput, len(result.Neighbors)), } for j, neighbor := range result.Neighbors { @@ -225,11 +256,11 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb item.Value = &value case errors.Is(err, errRepositoryNotFound): item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", err.Error() - item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(thread.Owner, thread.Repo)) + item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(wire.Owner, wire.Repo)) out.Status = "partial" case errors.Is(err, errThreadNotFound): item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", err.Error() - item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(mcpcontract.ThreadRef{Owner: thread.Owner, Repo: thread.Repo, Kind: thread.Kind, Number: thread.Number})) + item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(wire)) out.Status = "partial" default: item.Status, item.Reason, item.Message = "failed", "read_failed", err.Error() @@ -242,16 +273,3 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb } return out, nil } - -func validateSimilarityThread(thread mcpcontract.ThreadRef) error { - if _, err := domain.NewRepoRef(thread.Owner, thread.Repo); err != nil { - return err - } - if thread.Kind != "issue" && thread.Kind != "pull_request" { - return errors.New("kind must be issue or pull_request") - } - if thread.Number <= 0 { - return errors.New("number must be positive") - } - return nil -} diff --git a/internal/app/mcp_authored_sync.go b/internal/app/mcp_authored_sync.go index 1d4fe172..692efd1b 100644 --- a/internal/app/mcp_authored_sync.go +++ b/internal/app/mcp_authored_sync.go @@ -15,7 +15,7 @@ import ( type authoredPullRequestSyncOptions struct { Repository *mcpcontract.RepositoryRef - State string + State syncThreadState UpdatedAfter time.Time Limit int MaxRequests int @@ -62,7 +62,7 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in authoredPullR } perPage := min(100, in.Limit-discovered) requests++ - options := github.AuthoredPullRequestSearchOptions{Login: identity.Login, State: in.State, UpdatedAfter: in.UpdatedAfter, PageOptions: github.PageOptions{Page: page, PerPage: perPage}} + options := github.AuthoredPullRequestSearchOptions{Login: identity.Login, State: in.State.String(), UpdatedAfter: in.UpdatedAfter, PageOptions: github.PageOptions{Page: page, PerPage: perPage}} if in.Repository != nil { options.RepositoryOwner = in.Repository.Owner options.RepositoryName = in.Repository.Repo @@ -132,7 +132,7 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in authoredPullR results[index] = authoredRepositorySyncResult{Key: current.key, Status: status, Reason: reason, Message: message, RetryAfterMS: retry} continue } - results[index] = authoredRepositorySyncResult{Key: current.key, Status: "complete", Updated: res.Updated, Requests: res.Requests} + results[index] = authoredRepositorySyncResult{Key: current.key, Status: mcpcontract.BatchItemComplete, Updated: res.Updated, Requests: res.Requests} } }() } @@ -147,17 +147,17 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in authoredPullR } close(jobs) wg.Wait() - status := "complete" + status := batchOperationComplete if boundedByLimit { - status = "partial" + status = batchOperationPartial } completed := 0 for _, result := range results { requests += result.Requests - if result.Status == "complete" { + if result.Status == mcpcontract.BatchItemComplete { completed++ } else { - status = "partial" + status = batchOperationPartial } } if err := report("authored_pull_request_headers", jobProgressCounts(len(tasks), len(tasks))); err != nil { @@ -172,7 +172,7 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in authoredPullR } type authoredPullRequestSyncResult struct { - Status string `json:"status"` + Status batchOperationStatus `json:"status"` Login string `json:"login"` PullRequests int `json:"pull_requests"` PullRequestRefs []string `json:"pull_request_refs"` @@ -186,11 +186,11 @@ type authoredPullRequestSyncResult struct { } type authoredRepositorySyncResult struct { - Key string `json:"key"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` - RetryAfterMS int `json:"retry_after_ms,omitempty"` - Updated int `json:"updated,omitempty"` - Requests int `json:"requests,omitempty"` + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + RetryAfterMS int `json:"retry_after_ms,omitempty"` + Updated int `json:"updated,omitempty"` + Requests int `json:"requests,omitempty"` } diff --git a/internal/app/mcp_code_search.go b/internal/app/mcp_code_search.go index 56db61e0..67edd24b 100644 --- a/internal/app/mcp_code_search.go +++ b/internal/app/mcp_code_search.go @@ -11,63 +11,123 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +type mcpCodeSearchRequest struct { + query string + repository domain.RepoRef + page corpus.SearchPage + 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() + } + return in +} + +type mcpCodeSearchBatchRequest struct { + repository domain.RepoRef + queries []string + page corpus.SearchPage + snapshotToken string +} + +func parseMCPCodeSearchBatchInput(in mcpcontract.SearchCodeBatchInput) (mcpCodeSearchBatchRequest, error) { + if len(in.Queries) < 1 || len(in.Queries) > 20 { + return mcpCodeSearchBatchRequest{}, errors.New("queries must contain 1 to 20 items") + } + if in.Limit == 0 { + in.Limit = 20 + } + 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 err != nil { + return mcpCodeSearchBatchRequest{}, err + } + if in.Owner == "" || in.Repo == "" { + return mcpCodeSearchBatchRequest{}, errors.New("owner and repo are required") + } + repository, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { + return mcpCodeSearchBatchRequest{}, err + } + queries := make([]string, len(in.Queries)) + for i, query := range in.Queries { + queries[i] = strings.TrimSpace(query) + if queries[i] == "" { + return mcpCodeSearchBatchRequest{}, fmt.Errorf("queries[%d] is required", i) + } + } + return mcpCodeSearchBatchRequest{repository: repository, queries: queries, page: page, snapshotToken: in.SnapshotToken}, nil +} + +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) { - in.Query = strings.TrimSpace(in.Query) - ref, err := validateSearchCodeInput(&in) + request, err := parseMCPCodeSearchInput(in) if err != nil { return mcpcontract.SearchCodeOutput{}, err } + canonical := request.canonical() c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.SearchCodeOutput{}, err } - revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + revision, err := beginCorpusRead(ctx, c, request.snapshotToken) if err != nil { return mcpcontract.SearchCodeOutput{}, err } - out, coverage, page, truncated, unknownCoverage, err := r.searchCodeAtRevision(ctx, c, in, ref) + 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, in, !truncated && !unknownCoverage, truncated, unknownCoverage) + 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(in, coverage, truncated, unknownCoverage) + recovery = codeSearchRecovery(canonical, coverage, truncated, unknownCoverage) } else if page.NextCursor != "" { - recovery = codeSearchPageRecovery(in, page.NextCursor, in.SnapshotToken) + recovery = codeSearchPageRecovery(canonical, page.NextCursor, request.snapshotToken) } provenance.Recovery = recovery - return mcpcontract.SearchCodeOutput{Query: in.Query, Total: page.Total, Matches: out, Coverage: coverage, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Recovery: recovery, Provenance: provenance}, nil -} - -func validateSearchCodeInput(in *mcpcontract.SearchCodeInput) (domain.RepoRef, error) { - if in.Query == "" { - return domain.RepoRef{}, errors.New("query is required") - } - if in.Limit == 0 { - in.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { - return domain.RepoRef{}, errors.New("limit must be between 1 and 100") - } - ref, err := optionalRepoRef(in.Owner, in.Repo) - if err != nil { - return domain.RepoRef{}, err - } - return ref, nil + 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, in mcpcontract.SearchCodeInput, ref domain.RepoRef) ( +func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, request mcpCodeSearchRequest) ( []mcpcontract.CodeMatchOutput, []mcpcontract.CodeIndexCoverageOutput, corpus.CodeSearchPage, bool, bool, error, ) { - page, err := c.SearchCodeWithOptions(ctx, in.Query, corpus.CodeSearchOptions{Ref: ref, Limit: in.Limit, Cursor: in.Cursor}) + canonical := request.canonical() + page, err := c.SearchCodeWithOptions(ctx, request.query, corpus.CodeSearchOptions{Ref: request.repository, Page: request.page}) if err != nil { return nil, nil, corpus.CodeSearchPage{}, false, false, fmt.Errorf("search code: %w", err) } @@ -85,13 +145,13 @@ func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, entry.SkippedNonText = manifest.SkippedNonText entry.SkippedFiles = entry.SkippedPolicy + entry.SkippedLimits + entry.SkippedNonText if entry.Status != "indexed" || entry.Truncated { - entry.Recovery = codeIndexRecovery(in, []mcpcontract.CodeIndexCoverageOutput{entry}, entry.Truncated, entry.Status != "indexed") + entry.Recovery = codeIndexRecovery(canonical, []mcpcontract.CodeIndexCoverageOutput{entry}, entry.Truncated, entry.Status != "indexed") } coverage = append(coverage, entry) } - if ref != (domain.RepoRef{}) && len(page.Snapshots) == 0 { - entry := mcpcontract.CodeIndexCoverageOutput{Repo: ref.String(), Status: "missing"} - entry.Recovery = codeIndexRecovery(in, []mcpcontract.CodeIndexCoverageOutput{entry}, false, true) + if request.repository.IsValid() && len(page.Snapshots) == 0 { + entry := mcpcontract.CodeIndexCoverageOutput{Repo: request.repository.String(), Status: "missing"} + entry.Recovery = codeIndexRecovery(canonical, []mcpcontract.CodeIndexCoverageOutput{entry}, false, true) coverage = append(coverage, entry) } for i, match := range page.Matches { @@ -103,7 +163,7 @@ func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, } } truncated := page.NextCursor != "" - unknownCoverage := ref == (domain.RepoRef{}) || len(coverage) == 0 + unknownCoverage := !request.repository.IsValid() || len(coverage) == 0 for _, entry := range coverage { truncated = truncated || entry.Truncated unknownCoverage = unknownCoverage || entry.Status != "indexed" @@ -157,43 +217,25 @@ func codeIndexRecovery(in mcpcontract.SearchCodeInput, coverage []mcpcontract.Co // corpus revision. Each query keeps the single-query coverage and truncation // semantics; the tool only removes the model-side fan-out loop. func (r *MCPReader) SearchCodeBatch(ctx context.Context, in mcpcontract.SearchCodeBatchInput) (mcpcontract.SearchCodeBatchOutput, error) { - if len(in.Queries) < 1 || len(in.Queries) > 20 { - return mcpcontract.SearchCodeBatchOutput{}, errors.New("queries must contain 1 to 20 items") - } - if in.Limit == 0 { - in.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.SearchCodeBatchOutput{}, errors.New("limit must be between 1 and 100") - } - if in.Owner == "" || in.Repo == "" { - return mcpcontract.SearchCodeBatchOutput{}, errors.New("owner and repo are required") - } - ref, err := domain.NewRepoRef(in.Owner, in.Repo) + request, err := parseMCPCodeSearchBatchInput(in) if err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } - queries := make([]string, len(in.Queries)) - for i, query := range in.Queries { - queries[i] = strings.TrimSpace(query) - if queries[i] == "" { - return mcpcontract.SearchCodeBatchOutput{}, fmt.Errorf("queries[%d] is required", i) - } - } - in.Queries = queries + canonical := request.canonical() c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } - revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + revision, err := beginCorpusRead(ctx, c, request.snapshotToken) if err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } - out := mcpcontract.SearchCodeBatchOutput{Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: in.Owner, Repo: in.Repo}, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Items: make([]mcpcontract.BatchItem[mcpcontract.SearchCodeOutput], len(in.Queries))} + out := mcpcontract.SearchCodeBatchOutput{Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: request.repository.Owner(), Repo: request.repository.Repo()}, SnapshotToken: snapshotIdentity(request.snapshotToken, revision), Items: make([]mcpcontract.BatchItem[mcpcontract.SearchCodeOutput], len(request.queries))} allTruncated, allUnknown, allIndexGap := false, false, false - for i, query := range in.Queries { - searchIn := mcpcontract.SearchCodeInput{Owner: in.Owner, Repo: in.Repo, Query: query, Limit: in.Limit, SnapshotToken: in.SnapshotToken} - matches, coverage, page, truncated, unknown, searchErr := r.searchCodeAtRevision(ctx, c, searchIn, ref) + for i, query := range request.queries { + searchRequest := mcpCodeSearchRequest{query: query, repository: request.repository, page: request.page, snapshotToken: request.snapshotToken} + searchIn := searchRequest.canonical() + matches, coverage, page, truncated, unknown, searchErr := r.searchCodeAtRevision(ctx, c, searchRequest) item := mcpcontract.BatchItem[mcpcontract.SearchCodeOutput]{Key: query, Status: "complete"} if searchErr != nil { item.Status, item.Reason, item.Message = "failed", "code_search_failed", searchErr.Error() @@ -203,7 +245,7 @@ func (r *MCPReader) SearchCodeBatch(ctx context.Context, in mcpcontract.SearchCo out.Items[i] = item continue } - provenance, provenanceErr := offlineReadProvenance("code_search_batch", revision, searchIn, !truncated && !unknown, truncated, unknown) + provenance, provenanceErr := offlineReadProvenance("code_search_batch", revision, searchIn, truncated, unknown) if provenanceErr != nil { return mcpcontract.SearchCodeBatchOutput{}, provenanceErr } @@ -226,18 +268,18 @@ func (r *MCPReader) SearchCodeBatch(ctx context.Context, in mcpcontract.SearchCo if err := finishCorpusRead(ctx, c, revision); err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } - provenance, err := offlineReadProvenance("code_search_batch", revision, in, !allTruncated && !allUnknown, allTruncated, allUnknown) + provenance, err := offlineReadProvenance("code_search_batch", revision, canonical, allTruncated, allUnknown) if err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } if allUnknown || allIndexGap { - recovery := codeSearchRecovery(mcpcontract.SearchCodeInput{Owner: in.Owner, Repo: in.Repo, Query: in.Queries[0], Limit: in.Limit}, nil, allTruncated, allUnknown) + recovery := codeSearchRecovery(mcpCodeSearchRequest{query: request.queries[0], repository: request.repository, page: request.page}.canonical(), nil, allTruncated, allUnknown) out.Recovery = recovery provenance.Recovery = recovery } else if allTruncated { for _, item := range out.Items { if item.Value != nil && item.Value.NextCursor != "" { - recovery := codeSearchPageRecovery(mcpcontract.SearchCodeInput{Owner: in.Owner, Repo: in.Repo, Query: item.Value.Query, Limit: in.Limit}, item.Value.NextCursor, in.SnapshotToken) + recovery := codeSearchPageRecovery(mcpCodeSearchRequest{query: item.Value.Query, repository: request.repository, page: request.page}.canonical(), item.Value.NextCursor, request.snapshotToken) out.Recovery = recovery provenance.Recovery = recovery break diff --git a/internal/app/mcp_commitplan.go b/internal/app/mcp_commitplan.go index 7274897d..945ce21c 100644 --- a/internal/app/mcp_commitplan.go +++ b/internal/app/mcp_commitplan.go @@ -41,7 +41,7 @@ func commitInventoryToMCP(inventory commitplan.Inventory) mcpcontract.CommitInve out := mcpcontract.CommitInventoryOutput{SourcePatchSHA256: inventory.SourcePatchSHA256, InventorySHA256: inventory.InventorySHA256} for _, unit := range inventory.Units { out.Units = append(out.Units, mcpcontract.CommitUnitOutput{ - ID: unit.ID, Kind: unit.Kind, Path: unit.Path, OldPath: unit.OldPath, Operation: unit.Operation, + ID: unit.ID, Kind: string(unit.Kind), Path: unit.Path, OldPath: unit.OldPath, Operation: string(unit.Operation), OldStart: unit.OldStart, OldLines: unit.OldLines, NewStart: unit.NewStart, NewLines: unit.NewLines, Patch: unit.Patch, ContentSHA256: unit.ContentHash, Generated: unit.Generated, WhitespaceOnly: unit.WhitespaceOnly, }) diff --git a/internal/app/mcp_concerns.go b/internal/app/mcp_concerns.go index c4cda818..bf361d09 100644 --- a/internal/app/mcp_concerns.go +++ b/internal/app/mcp_concerns.go @@ -123,11 +123,16 @@ func concernSourceProvenance(values []mcpcontract.EvidenceSourceRevision) ([]evi if err != nil { return nil, fmt.Errorf("source_provenance[%d].observed_at: %w", index, err) } + subject, err := evidence.ParseSourceSubject( + value.Subject.Kind, value.Subject.Owner, value.Subject.Repo, + value.Subject.ThreadKind, value.Subject.Number, value.Subject.Facet, + ) + if err != nil { + return nil, fmt.Errorf("source_provenance[%d].subject: %w", index, err) + } out[index] = evidence.SourceRevision{ - Subject: evidence.SourceSubject{ - Kind: evidence.SourceSubjectKind(value.Subject.Kind), Owner: value.Subject.Owner, Repo: value.Subject.Repo, - ThreadKind: value.Subject.ThreadKind, Number: value.Subject.Number, Facet: value.Subject.Facet, - }, SourceUpdatedAt: sourceUpdatedAt, ObservationSequence: value.ObservationSequence, ObservedAt: observedAt, + Subject: subject, SourceUpdatedAt: sourceUpdatedAt, + ObservationSequence: value.ObservationSequence, ObservedAt: observedAt, } } return out, nil diff --git a/internal/app/mcp_contribution_artifacts.go b/internal/app/mcp_contribution_artifacts.go index 89cf42e1..40c19214 100644 --- a/internal/app/mcp_contribution_artifacts.go +++ b/internal/app/mcp_contribution_artifacts.go @@ -26,14 +26,30 @@ func (r *MCPReader) ImportExternalEvidenceManifest(ctx context.Context, in mcpco if err := decoder.Decode(&trailing); err != io.EOF { return mcpcontract.ImportExternalEvidenceManifestOutput{}, errors.New("external evidence manifest must contain one JSON value") } + completeness, err := evidence.ParseExternalEvidenceCompleteness(input.Completeness) + if err != nil { + return mcpcontract.ImportExternalEvidenceManifestOutput{}, err + } + integrity, err := evidence.ParseExternalEvidenceIntegrity(input.Integrity) + if err != nil { + return mcpcontract.ImportExternalEvidenceManifestOutput{}, err + } claims := make([]evidence.ExternalEvidenceClaim, 0, len(input.Claims)) for _, claim := range input.Claims { - claims = append(claims, evidence.ExternalEvidenceClaim{ID: claim.ID, Type: evidence.EvidenceType(claim.Type), Relation: evidence.Relation(claim.Relation), Description: claim.Description, SourceRefs: append([]string(nil), claim.SourceRefs...), Measurements: claim.Measurements}) + evidenceType, err := evidence.ParseEvidenceType(claim.Type) + if err != nil { + return mcpcontract.ImportExternalEvidenceManifestOutput{}, err + } + relation, err := evidence.ParseRelation(claim.Relation) + if err != nil { + return mcpcontract.ImportExternalEvidenceManifestOutput{}, err + } + claims = append(claims, evidence.ExternalEvidenceClaim{ID: claim.ID, Type: evidenceType, Relation: relation, Description: claim.Description, SourceRefs: append([]string(nil), claim.SourceRefs...), Measurements: claim.Measurements}) } result, err := r.application().importExternalEvidenceManifest(ctx, evidence.ExternalEvidenceManifest{ SchemaVersion: input.SchemaVersion, Producer: input.Producer, InvestigationID: input.InvestigationID, HypothesisID: input.HypothesisID, OpportunityID: input.OpportunityID, Repository: input.Repository, Revision: input.Revision, ArtifactSHA256: input.ArtifactSHA256, ObservedAt: input.ObservedAt, Environment: input.Environment, - Completeness: input.Completeness, Integrity: input.Integrity, Limitations: input.Limitations, Claims: claims, ManifestSHA256: input.ManifestSHA256, + Completeness: completeness, Integrity: integrity, Limitations: input.Limitations, Claims: claims, ManifestSHA256: input.ManifestSHA256, }) if err != nil { return mcpcontract.ImportExternalEvidenceManifestOutput{}, err diff --git a/internal/app/mcp_contribution_fork.go b/internal/app/mcp_contribution_fork.go index fbd9cafc..b1e7bfa3 100644 --- a/internal/app/mcp_contribution_fork.go +++ b/internal/app/mcp_contribution_fork.go @@ -27,7 +27,7 @@ func checkPreflightForkFreshness( upstream mcpcontract.RepositoryRef, fork *mcpcontract.RepositoryRef, identity string, - candidate mcpcontract.ContributionPreflightCandidate, + candidate preflightCandidate, worktrees []workspace.LocalWorktree, existing *preflightExisting, maxRequests int, @@ -128,12 +128,12 @@ func resolvePreflightFork( upstream mcpcontract.RepositoryRef, explicit *mcpcontract.RepositoryRef, identity string, - candidate mcpcontract.ContributionPreflightCandidate, + candidate preflightCandidate, worktrees []workspace.LocalWorktree, existing *preflightExisting, ) (*preflightForkContext, bool, string) { if explicit != nil { - return &preflightForkContext{ref: *explicit, branch: strings.TrimSpace(candidate.HeadRef), sha: strings.TrimSpace(candidate.HeadSHA)}, true, "" + 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{ @@ -162,11 +162,11 @@ func resolvePreflightFork( } seen[key] = struct{}{} branch, sha := worktree.Branch, worktree.HeadSHA - if candidate.HeadRef != "" { - branch = candidate.HeadRef + if candidate.headRef != "" { + branch = candidate.headRef } - if candidate.HeadSHA != "" { - sha = candidate.HeadSHA + if candidate.headSHA != "" { + sha = candidate.headSHA } candidates = append(candidates, preflightForkContext{ref: ref, branch: branch, sha: sha}) } diff --git a/internal/app/mcp_contribution_preflight.go b/internal/app/mcp_contribution_preflight.go index fe20e2c3..25a43d08 100644 --- a/internal/app/mcp_contribution_preflight.go +++ b/internal/app/mcp_contribution_preflight.go @@ -20,6 +20,52 @@ const ( 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. @@ -41,6 +87,10 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co 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 } @@ -53,10 +103,7 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co if in.MaxRequests < 2 || in.MaxRequests > 1000 { return mcpcontract.ContributionPreflightOutput{}, errors.New("max_requests must be between 2 and 1000") } - if in.Candidate.IssueNumber < 0 { - return mcpcontract.ContributionPreflightOutput{}, errors.New("candidate issue_number must be positive when provided") - } - if !preflightHasInputContext(in.Candidate, in.WorkspacePaths) { + if !candidate.hasInputContext(len(in.WorkspacePaths)) { return mcpcontract.ContributionPreflightOutput{}, errors.New("candidate or workspace_paths must provide contribution context") } @@ -93,7 +140,7 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co return out, nil } out.Identity = identity.Login - if !preflightHasComparableIdentity(in.Candidate, worktrees) { + if !candidate.hasComparableIdentity(worktrees) { out.CoverageReasons = append(out.CoverageReasons, "candidate has no comparable title, branch, commit, issue, or inspected worktree identity") } @@ -124,7 +171,7 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co } threadSearcher, hasThreadSearch := reader.(github.ThreadSearcher) - query := preflightSearchQuery(in.Candidate) + query := candidate.searchQuery() if !hasThreadSearch { out.CoverageReasons = append(out.CoverageReasons, "configured GitHub reader does not support related-thread search") } else if requests >= in.MaxRequests { @@ -151,7 +198,7 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co detailsReader := reader existing := make([]preflightExisting, 0) for _, marker := range authored.Items { - if !sameRepository(marker.RepositoryOwner, marker.RepositoryName, in.Repository) || marker.Kind != github.ThreadKindPullRequest { + if !sameRepository(marker.RepositoryOwner, marker.RepositoryName, in.Repository) || marker.Kind != domain.PullRequestKind { continue } if requests >= in.MaxRequests { @@ -167,16 +214,16 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co out.CoverageReasons = append(out.CoverageReasons, fmt.Sprintf("pull-request #%d details could not be inspected", marker.Number)) continue } - if preflightMatchesCandidate(in.Candidate, marker, details, worktrees) { + 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 := in.Candidate.IssueNumber + issue := candidate.issueNumber if issue == 0 { - issue = relatedIssueNumber(out.Related, in.Candidate) + issue = relatedIssueNumber(out.Related, candidate) } headOwner := match.details.HeadOwner if headOwner == "" { @@ -194,7 +241,7 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co out.Status = "existing_pr" out.NextAction = "review_or_follow_through" } - forkFreshness, forkChecked, forkErr := checkPreflightForkFreshness(ctx, reader, in.Repository, in.Fork, identity.Login, in.Candidate, worktrees, existingMatch(existing), in.MaxRequests, requests) + 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 } @@ -253,35 +300,6 @@ func inspectPreflightWorktrees(ctx context.Context, paths []string) ([]workspace return inspected, reasons } -func preflightSearchQuery(candidate mcpcontract.ContributionPreflightCandidate) string { - if query := strings.TrimSpace(candidate.Query); query != "" { - return query - } - if title := strings.TrimSpace(candidate.Title); title != "" { - return title - } - if branch := strings.TrimSpace(candidate.HeadRef); branch != "" { - return branch - } - if body := strings.TrimSpace(candidate.Body); body != "" { - if len(body) > 200 { - return body[:200] - } - return body - } - return "" -} - -func preflightHasComparableIdentity(candidate mcpcontract.ContributionPreflightCandidate, worktrees []workspace.LocalWorktree) bool { - return strings.TrimSpace(candidate.Title) != "" || strings.TrimSpace(candidate.Query) != "" || strings.TrimSpace(candidate.Body) != "" || - candidate.IssueNumber > 0 || strings.TrimSpace(candidate.HeadRef) != "" || strings.TrimSpace(candidate.HeadSHA) != "" || len(worktrees) > 0 -} - -func preflightHasInputContext(candidate mcpcontract.ContributionPreflightCandidate, paths []string) bool { - return strings.TrimSpace(candidate.Title) != "" || strings.TrimSpace(candidate.Query) != "" || strings.TrimSpace(candidate.Body) != "" || - candidate.IssueNumber > 0 || strings.TrimSpace(candidate.HeadRef) != "" || strings.TrimSpace(candidate.HeadSHA) != "" || len(candidate.ChangedFiles) > 0 || len(paths) > 0 -} - func relatedThreadOutputs(items []github.Issue) []mcpcontract.RelatedContributionThread { out := make([]mcpcontract.RelatedContributionThread, 0, len(items)) for _, item := range items { @@ -290,23 +308,23 @@ func relatedThreadOutputs(items []github.Issue) []mcpcontract.RelatedContributio return out } -func relatedIssueNumber(items []mcpcontract.RelatedContributionThread, candidate mcpcontract.ContributionPreflightCandidate) int { +func relatedIssueNumber(items []mcpcontract.RelatedContributionThread, candidate preflightCandidate) int { for _, item := range items { - if item.Kind == string(github.ThreadKindIssue) && textSimilarity(candidate, item.Title) >= 0.5 { + if item.Kind == string(domain.IssueKind) && textSimilarity(candidate, item.Title) >= 0.5 { return item.Number } } return 0 } -func preflightMatchesCandidate(candidate mcpcontract.ContributionPreflightCandidate, marker github.Issue, details github.PullRequestDetails, worktrees []workspace.LocalWorktree) bool { - if candidate.IssueNumber > 0 && marker.Number == candidate.IssueNumber { +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(strings.TrimSpace(candidate.HeadSHA), strings.TrimSpace(details.HeadSHA)) { + if candidate.headSHA != "" && strings.EqualFold(candidate.headSHA, strings.TrimSpace(details.HeadSHA)) { return true } - if candidate.HeadRef != "" && strings.EqualFold(strings.TrimSpace(candidate.HeadRef), strings.TrimSpace(details.HeadRef)) { + if candidate.headRef != "" && strings.EqualFold(candidate.headRef, strings.TrimSpace(details.HeadRef)) { return true } for _, worktree := range worktrees { @@ -345,13 +363,13 @@ func localMatchOutputs(worktrees []workspace.LocalWorktree, existing []preflight return out } -func textSimilarity(candidate mcpcontract.ContributionPreflightCandidate, other string) float64 { - left := candidate.Query - if strings.TrimSpace(left) == "" { - left = candidate.Title +func textSimilarity(candidate preflightCandidate, other string) float64 { + left := candidate.query + if left == "" { + left = candidate.title } - if strings.TrimSpace(left) == "" { - left = candidate.Body + if left == "" { + left = candidate.body } leftTokens := preflightTokens(left) rightTokens := preflightTokens(other) diff --git a/internal/app/mcp_contribution_preflight_test.go b/internal/app/mcp_contribution_preflight_test.go index 8ad3969e..58efe3dc 100644 --- a/internal/app/mcp_contribution_preflight_test.go +++ b/internal/app/mcp_contribution_preflight_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -20,6 +21,7 @@ type preflightReader struct { 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) { @@ -65,7 +67,8 @@ func (r *preflightReader) SearchAuthoredPullRequests(_ context.Context, opts git return r.authored, nil } -func (r *preflightReader) SearchThreads(context.Context, github.ThreadSearchOptions) (github.ThreadSearchResult, error) { +func (r *preflightReader) SearchThreads(_ context.Context, opts github.ThreadSearchOptions) (github.ThreadSearchResult, error) { + r.threadSearch = opts return r.related, nil } @@ -95,12 +98,12 @@ func TestPreflightContributionRoutesExistingAuthoredPRAndLocalWorktree(t *testin reader := &preflightReader{ authored: github.AuthoredPullRequestSearchResult{Items: []github.Issue{{ - RepositoryOwner: "fla-org", RepositoryName: "flash-linear-attention", Kind: github.ThreadKindPullRequest, + 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: github.ThreadKindIssue, Number: 1086, Title: "Fix n01 mask cu seqlens v2"}, - {RepositoryOwner: "fla-org", RepositoryName: "flash-linear-attention", Kind: github.ThreadKindPullRequest, Number: 1088, Title: "Fix n01 mask cu seqlens v2"}, + {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, @@ -192,6 +195,29 @@ func TestPreflightContributionReturnsNewWorkOnlyAfterLiveNegativeChecks(t *testi } } +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 { diff --git a/internal/app/mcp_deepwiki.go b/internal/app/mcp_deepwiki.go new file mode 100644 index 00000000..cc8f9e40 --- /dev/null +++ b/internal/app/mcp_deepwiki.go @@ -0,0 +1,85 @@ +package app + +import ( + "context" + "errors" + "unicode/utf8" + + "github.com/morluto/gitcontribute/internal/deepwiki" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type deepWikiQuery struct { + request deepwiki.Request + maxOutputBytes int +} + +func parseDeepWikiQuery(in mcpcontract.DeepWikiInput) (deepWikiQuery, error) { + request, err := deepwiki.ParseRequest(in.Action, in.Repository, in.Repositories, in.Question) + if err != nil { + return deepWikiQuery{}, err + } + maxBytes := in.MaxOutputBytes + if maxBytes == 0 { + maxBytes = mcpcontract.DeepWikiDefaultOutputBytes + } + if maxBytes < mcpcontract.DeepWikiMinOutputBytes || maxBytes > mcpcontract.DeepWikiMaxOutputBytes { + return deepWikiQuery{}, errors.New("max_output_bytes must be between 1024 and 1048576") + } + return deepWikiQuery{request: request, maxOutputBytes: maxBytes}, nil +} + +// DeepWiki performs one external derived-knowledge read and does not persist +// its response. +func (r *MCPReader) DeepWiki(ctx context.Context, in mcpcontract.DeepWikiInput) (mcpcontract.DeepWikiOutput, error) { + query, err := parseDeepWikiQuery(in) + if err != nil { + return mcpcontract.DeepWikiOutput{}, err + } + res, err := r.deepWiki().Read(ctx, query.request) + if err != nil { + return mcpcontract.DeepWikiOutput{}, err + } + repositories := query.request.Repositories() + out := mcpcontract.DeepWikiOutput{ + Status: "complete", + Provider: "deepwiki", + Action: query.request.Action().String(), + Repositories: repositories, + Question: query.request.Question(), + Result: res.Text(), + SourceURL: res.SourceURL(), + RetrievedAt: formatTime(r.now()), + Provenance: "derived_external", + } + if !res.Available() { + out.Status, out.Reason = "unavailable", "blocked" + out.Recovery = recoveryPlan("blocked", "Use GitHub metadata, stored corpus data, or explicit code acquisition instead.") + return out, nil + } + if len(out.Result) > query.maxOutputBytes { + out.Result = validUTF8Prefix(out.Result, query.maxOutputBytes) + out.Truncated = true + out.Reason = "output_limit" + if query.request.Action() == deepwiki.Contents { + out.Recovery = recoveryPlan( + "blocked", + "Call structure, then ask a focused question about the relevant section. Increase max_output_bytes only when the focused read is still incomplete.", + mcpcontract.RecoveryAction(mcpcontract.DeepWikiInput{Action: "structure", Repository: repositories[0]}), + ) + } else { + out.Recovery = recoveryPlan("blocked", "Narrow the question or repository set. Increase max_output_bytes only when the focused read is still incomplete.") + } + } + return out, nil +} + +func validUTF8Prefix(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + for maxBytes > 0 && !utf8.ValidString(value[:maxBytes]) { + maxBytes-- + } + return value[:maxBytes] +} diff --git a/internal/app/mcp_deepwiki_test.go b/internal/app/mcp_deepwiki_test.go index 8631a06e..5f5dd742 100644 --- a/internal/app/mcp_deepwiki_test.go +++ b/internal/app/mcp_deepwiki_test.go @@ -61,14 +61,36 @@ func TestDeepWikiUsesNormalizedRepositoriesForRequestAndOutput(t *testing.T) { fake := &fakeDeepWikiReader{response: deepwiki.AvailableResponse("ok", "")} svc.SetDeepWikiReader(fake) out, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpcontract.DeepWikiInput{ - Action: "question", Repository: "acme/rocket", Repositories: []string{"wrong/one", "wrong/two"}, Question: "architecture?", MaxOutputBytes: 1024, + Action: " question ", Repositories: []string{" acme/rocket ", "acme/booster"}, Question: " architecture? ", MaxOutputBytes: 1024, }) if err != nil { t.Fatal(err) } - want := []string{"acme/rocket"} - if !reflect.DeepEqual(fake.request.Repositories, want) || !reflect.DeepEqual(out.Repositories, want) { - t.Fatalf("request repositories = %v, output repositories = %v", fake.request.Repositories, out.Repositories) + want := []string{"acme/rocket", "acme/booster"} + if !reflect.DeepEqual(fake.request.Repositories(), want) || !reflect.DeepEqual(out.Repositories, want) { + t.Fatalf("request repositories = %v, output repositories = %v", fake.request.Repositories(), out.Repositories) + } + if fake.request.Question() != "architecture?" || out.Question != "architecture?" { + t.Fatalf("request question = %q, output question = %q", fake.request.Question(), out.Question) + } +} + +func TestDeepWikiRejectsContradictoryModesBeforeProviderRead(t *testing.T) { + t.Parallel() + svc := newSearchTestService(t) + deepWiki := &fakeDeepWikiReader{} + svc.SetDeepWikiReader(deepWiki) + _, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpcontract.DeepWikiInput{ + Action: "question", + Repository: "acme/rocket", + Repositories: []string{"acme/booster"}, + Question: "architecture?", + }) + if err == nil { + t.Fatal("DeepWiki accepted both repository representations") + } + if deepWiki.calls != 0 { + t.Fatalf("DeepWiki provider called %d times for contradictory input", deepWiki.calls) } } diff --git a/internal/app/mcp_ensure_coverage.go b/internal/app/mcp_ensure_coverage.go index d313aadc..c483102c 100644 --- a/internal/app/mcp_ensure_coverage.go +++ b/internal/app/mcp_ensure_coverage.go @@ -2,12 +2,12 @@ package app import ( "context" - "encoding/json" "errors" "fmt" "time" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/facets" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/repositorycontext" @@ -15,6 +15,20 @@ import ( const jobKindEnsureCoverage = "ensure_coverage" +type coverageSnapshotVersions struct { + Coverage string `json:"coverage"` +} + +type coverageSnapshotCompleteness struct { + Unknown bool `json:"unknown"` + Incomplete bool `json:"incomplete"` +} + +type coverageSnapshotProvenance struct { + Producer string `json:"producer"` + Workflow string `json:"workflow"` +} + // EnsureCoverage submits one durable workflow that owns repository bootstrap, // header synchronization, selected facet hydration, verification, and an // immutable offline handoff. @@ -78,39 +92,12 @@ func (r *MCPReader) ReadSnapshot(ctx context.Context, token string) (mcpcontract if err != nil { return mcpcontract.CorpusSnapshotArtifact{}, err } - decode := func(raw json.RawMessage) (any, error) { - var out any - if err := json.Unmarshal(raw, &out); err != nil { - return nil, err - } - return out, nil - } - scope, err := decode(value.Scope) - if err != nil { - return mcpcontract.CorpusSnapshotArtifact{}, fmt.Errorf("decode snapshot scope: %w", err) - } - derived, err := decode(value.DerivedVersions) - if err != nil { - return mcpcontract.CorpusSnapshotArtifact{}, fmt.Errorf("decode snapshot derived versions: %w", err) - } - completeness, err := decode(value.Completeness) - if err != nil { - return mcpcontract.CorpusSnapshotArtifact{}, fmt.Errorf("decode snapshot completeness: %w", err) - } - provenance, err := decode(value.Provenance) - if err != nil { - return mcpcontract.CorpusSnapshotArtifact{}, fmt.Errorf("decode snapshot provenance: %w", err) - } - payload, err := decode(value.Payload) - if err != nil { - return mcpcontract.CorpusSnapshotArtifact{}, fmt.Errorf("decode snapshot payload: %w", err) - } return mcpcontract.CorpusSnapshotArtifact{ SnapshotToken: value.Token, ContractVersion: value.ContractVersion, - ObservationWatermark: value.ObservationWatermark, Scope: scope, - SourceManifestSHA256: value.SourceManifestSHA256, DerivedVersions: derived, - Completeness: completeness, Provenance: provenance, ArtifactKind: value.ArtifactKind, - ArtifactDigest: value.ArtifactDigest, Payload: payload, CreatedAt: value.CreatedAt.Format(time.RFC3339Nano), + ObservationWatermark: value.ObservationWatermark, Scope: value.Scope, + SourceManifestSHA256: value.SourceManifestSHA256, DerivedVersions: value.DerivedVersions, + Completeness: value.Completeness, Provenance: value.Provenance, ArtifactKind: value.ArtifactKind, + ArtifactDigest: value.ArtifactDigest, Payload: value.Payload, CreatedAt: value.CreatedAt.Format(time.RFC3339Nano), }, nil } @@ -142,7 +129,11 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov if remaining < cost { return result, fmt.Errorf("max_requests is too small for repository bootstrap: need at least %d", cost) } - if _, err := r.syncRepositoryContext(ctx, mcpcontract.SyncRepositoryContextInput{Repositories: []mcpcontract.RepositoryRef{repo}, MaxRequests: cost}, report); err != nil { + request, err := newRepositoryContextSyncRequest([]domain.RepoRef{repoRef}, cost) + if err != nil { + return result, err + } + if _, err := r.syncRepositoryContext(ctx, request, report); err != nil { return result, err } remaining -= cost @@ -174,8 +165,8 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov return result, err } remaining -= headerRequests - threadStatus, _ := threadResult["status"].(string) - if threadStatus == "partial" { + threadStatus := string(threadResult.Status) + if threadResult.Status == batchOperationPartial { result.Status, result.Incomplete = "partial", true } stage("thread_headers", threadStatus, "thread headers synchronized after repository bootstrap") @@ -191,8 +182,8 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov if err != nil { return result, err } - facetStatus, _ := facetResult["status"].(string) - if facetStatus == "partial" { + facetStatus := string(facetResult.Status) + if facetResult.Status == batchOperationPartial { result.Status, result.Incomplete = "partial", true } stage("selected_facets", facetStatus, "selected exact-thread facets synchronized") @@ -213,7 +204,15 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov result.Status = "partial" } stage("coverage_verification", result.Status, afterReason) - snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{Kind: "coverage", Scope: target.wire(), SourceManifest: after, DerivedVersions: map[string]string{"coverage": "v1"}, Completeness: map[string]bool{"unknown": result.Unknown, "incomplete": result.Incomplete}, Provenance: map[string]any{"producer": "gitcontribute", "workflow": jobKindEnsureCoverage}, Payload: after}) + materialization, err := corpus.NewSnapshotMaterialization( + "coverage", target.wire(), after, coverageSnapshotVersions{Coverage: "v1"}, + coverageSnapshotCompleteness{Unknown: result.Unknown, Incomplete: result.Incomplete}, + coverageSnapshotProvenance{Producer: "gitcontribute", Workflow: jobKindEnsureCoverage}, after, + ) + if err != nil { + return result, err + } + snapshot, err := c.MaterializeReadSnapshot(ctx, materialization) if err != nil { return result, err } diff --git a/internal/app/mcp_explain_evidence_test.go b/internal/app/mcp_explain_evidence_test.go index 8a870eb0..68b3fcf3 100644 --- a/internal/app/mcp_explain_evidence_test.go +++ b/internal/app/mcp_explain_evidence_test.go @@ -25,7 +25,7 @@ func TestMCPReaderExplainMatchReturnsMatchingExcerpt(t *testing.T) { t.Fatal(err) } thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "ordinary title", Body: strings.Repeat("padding ", 400) + "deepthreadneedle", SourceUpdatedAt: time.Unix(2, 0).UTC(), }, `{}`) @@ -80,7 +80,7 @@ func TestMCPReaderExplainMatchTreatsWhitespaceQueryAsOmitted(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", Title: "exact item", SourceUpdatedAt: time.Unix(1, 0).UTC()}, `{}`); err != nil { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "exact item", SourceUpdatedAt: time.Unix(1, 0).UTC()}, `{}`); err != nil { t.Fatal(err) } diff --git a/internal/app/mcp_fix_patterns.go b/internal/app/mcp_fix_patterns.go index ddfa04ea..901ac435 100644 --- a/internal/app/mcp_fix_patterns.go +++ b/internal/app/mcp_fix_patterns.go @@ -39,23 +39,78 @@ type fixPatternClassification struct { superseded bool } -type fixPatternOperation string +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 = "preview" - fixPatternWorkflow fixPatternOperation = "workflow" + 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) { - normalized, err := normalizeFixPatternInput(in) + 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, normalized, report, fixPatternWorkflow) + return r.runFixPatternOperation(ctx, request, report, fixPatternWorkflow) }) if err != nil { return mcpcontract.JobReference{}, err @@ -68,19 +123,11 @@ func (r *MCPReader) MineRepositoryFixPatterns(ctx context.Context, in mcpcontrac // 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) { - normalized, err := normalizeFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput(in)) + request, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput(in)) if err != nil { return mcpcontract.FixPatternReport{}, err } - zero := 0 - normalized.HydrationLimit = &zero - return r.runFixPatternOperation(ctx, normalized, nil, fixPatternPreview) -} - -// mineRepositoryFixPatterns remains the executor-local entry point used by -// focused tests and the durable job adapter. -func (r *MCPReader) mineRepositoryFixPatterns(ctx context.Context, in mcpcontract.MineRepositoryFixPatternsInput, progress func(string, string) error) (mcpcontract.FixPatternReport, error) { - return r.runFixPatternOperation(ctx, in, progress, fixPatternWorkflow) + return r.runFixPatternOperation(ctx, request.withHydrationLimit(0), nil, fixPatternPreview) } // GetFixPatternReport reads the typed terminal result of a pattern-mining job. @@ -92,7 +139,7 @@ func (r *MCPReader) GetFixPatternReport(ctx context.Context, id string) (mcpcont 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 { + 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 @@ -109,8 +156,8 @@ func (r *MCPReader) GetFixPatternReport(ctx context.Context, id string) (mcpcont var request mcpcontract.MineRepositoryFixPatternsInput var actions []mcpcontract.ToolCall if err := json.Unmarshal([]byte(job.Request), &request); err == nil { - if request, err = normalizeFixPatternInput(request); err == nil { - actions = append(actions, mcpcontract.RecoveryAction(request)) + if _, canonical, parseErr := parseFixPatternInput(request); parseErr == nil { + actions = append(actions, mcpcontract.RecoveryAction(canonical)) } } return mcpcontract.FixPatternReport{}, mcpcontract.Unavailable( @@ -119,120 +166,143 @@ func (r *MCPReader) GetFixPatternReport(ctx context.Context, id string) (mcpcont 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 normalizeFixPatternInput(in mcpcontract.MineRepositoryFixPatternsInput) (mcpcontract.MineRepositoryFixPatternsInput, error) { +func parseFixPatternInput(in mcpcontract.MineRepositoryFixPatternsInput) (fixPatternRequest, mcpcontract.MineRepositoryFixPatternsInput, error) { ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) if err != nil { - return in, err + 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 in, errors.New("time_window.updated_after must be RFC 3339") + return fixPatternRequest{}, in, 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) + parsed, err := time.Parse(time.RFC3339, in.TimeWindow.UpdatedBefore) if err != nil { - return in, errors.New("time_window.updated_before must be RFC 3339") + return fixPatternRequest{}, in, errors.New("time_window.updated_before must be RFC 3339") } - if before.Before(after) { - return in, errors.New("time_window.updated_before must not be earlier than updated_after") + 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 in, errors.New("symptom_taxonomy must contain 1 to 12 categories") + 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 := in.SymptomTaxonomy[i] symptom.Name = strings.TrimSpace(symptom.Name) if symptom.Name == "" { - return in, fmt.Errorf("symptom_taxonomy[%d].name is required", i) + return fixPatternRequest{}, in, fmt.Errorf("symptom_taxonomy[%d].name is required", i) } key := strings.ToLower(symptom.Name) if _, exists := seenNames[key]; exists { - return in, fmt.Errorf("symptom_taxonomy[%d].name duplicates %q", i, symptom.Name) + 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 in, fmt.Errorf("symptom_taxonomy[%d].terms must contain 1 to 12 values", i) + 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 := range symptom.Terms { - symptom.Terms[j] = strings.TrimSpace(symptom.Terms[j]) - if symptom.Terms[j] == "" { - return in, fmt.Errorf("symptom_taxonomy[%d].terms[%d] must not be empty", i, j) + 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(symptom.Terms[j]) + key := strings.ToLower(terms[j]) if _, exists := seenTerms[key]; exists { - return in, fmt.Errorf("symptom_taxonomy[%d].terms contains duplicate %q", i, symptom.Terms[j]) + 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 in, errors.New("candidate_limit must be between 1 and 100") + return fixPatternRequest{}, in, errors.New("candidate_limit must be between 1 and 100") } - if in.HydrationLimit == nil { - value := mcpcontract.DefaultFixPatternHydrationLimit - in.HydrationLimit = &value + hydrationLimit := mcpcontract.DefaultFixPatternHydrationLimit + if in.HydrationLimit != nil { + hydrationLimit = *in.HydrationLimit } - if *in.HydrationLimit < 0 || *in.HydrationLimit > 100 { - return in, errors.New("hydration_limit must be between 0 and 100") + 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 in, errors.New("representative_limit must be between 1 and 20") + return fixPatternRequest{}, in, errors.New("representative_limit must be between 1 and 20") } if len(in.MergeOutcomes) == 0 { - in.MergeOutcomes = []mcpcontract.FixPatternOutcome{"merged"} + in.MergeOutcomes = []mcpcontract.FixPatternOutcome{mcpcontract.FixPatternMerged} } seenOutcomes := make(map[mcpcontract.FixPatternOutcome]struct{}, len(in.MergeOutcomes)) for _, outcome := range in.MergeOutcomes { switch outcome { - case "merged", "closed_unmerged", "superseded", "open", "unknown": + case mcpcontract.FixPatternMerged, mcpcontract.FixPatternClosedUnmerged, mcpcontract.FixPatternSuperseded, mcpcontract.FixPatternOpen, mcpcontract.FixPatternUnknown: default: - return in, fmt.Errorf("unsupported merge outcome %q", outcome) + return fixPatternRequest{}, in, fmt.Errorf("unsupported merge outcome %q", outcome) } if _, exists := seenOutcomes[outcome]; exists { - return in, fmt.Errorf("duplicate merge outcome %q", outcome) + return fixPatternRequest{}, in, fmt.Errorf("duplicate merge outcome %q", outcome) } seenOutcomes[outcome] = struct{}{} } - return in, nil + 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, in mcpcontract.MineRepositoryFixPatternsInput, progress func(string, string) error) (fixPatternAnalysis, error) { +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(in.SymptomTaxonomy)), + clusters: make([][]int64, len(request.symptoms)), candidates: make(map[int64]*fixPatternCandidate), orderedIDs: make([]int64, 0), } - after, _ := time.Parse(time.RFC3339, in.TimeWindow.UpdatedAfter) - var before time.Time - if in.TimeWindow.UpdatedBefore != "" { - before, _ = time.Parse(time.RFC3339, in.TimeWindow.UpdatedBefore) + ref, err := domain.NewRepoRef(repo.Owner, repo.Name) + if err != nil { + return fixPatternAnalysis{}, fmt.Errorf("parse stored repository: %w", err) } - if err := progress("candidate_search", jobProgressCounts(0, len(in.SymptomTaxonomy))); err != nil { + repositoryScope, err := corpus.NewThreadRepositoryScope(ref, repo.ID) + if err != nil { return fixPatternAnalysis{}, err } - for symptomIndex, symptom := range in.SymptomTaxonomy { - page, err := c.SearchThreadsPage(ctx, strings.Join(symptom.Terms, " "), corpus.SearchFilter{ - RepoID: repo.ID, Repo: in.Repository.Owner + "/" + in.Repository.Repo, - Kind: corpus.ThreadKindPullRequest, UpdatedAfter: after, UpdatedBefore: before, - Limit: in.CandidateLimit, Sort: "relevance", MatchMode: "any", + 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) + return fixPatternAnalysis{}, fmt.Errorf("search symptom %q: %w", symptom.name, err) } a.candidateMatches += page.Total a.candidateTruncated = a.candidateTruncated || page.Total > len(page.Threads) @@ -248,33 +318,32 @@ func collectFixPatternCandidates(ctx context.Context, c *corpus.Corpus, repo *co a.clusters[symptomIndex] = append(a.clusters[symptomIndex], thread.ID) } } - if err := progress("candidate_search", jobProgressCounts(symptomIndex+1, len(in.SymptomTaxonomy))); err != nil { + if err := progress("candidate_search", jobProgressCounts(symptomIndex+1, len(request.symptoms))); err != nil { return fixPatternAnalysis{}, err } } return a, nil } -func selectFixPatternHydration(a fixPatternAnalysis, in mcpcontract.MineRepositoryFixPatternsInput) []mcpcontract.ThreadRef { +func selectFixPatternHydration(a fixPatternAnalysis, request fixPatternRequest) []mcpcontract.ThreadRef { unknown := countUnknownCandidates(a.candidates) - refs := make([]mcpcontract.ThreadRef, 0, min(*in.HydrationLimit, unknown)) + refs := make([]mcpcontract.ThreadRef, 0, min(request.hydrationLimit, unknown)) for _, id := range a.orderedIDs { candidate := a.candidates[id] - if !needsMergeHydration(candidate.thread) || len(refs) >= *in.HydrationLimit { + if !needsMergeHydration(candidate.thread) || len(refs) >= request.hydrationLimit { continue } refs = append(refs, mcpcontract.ThreadRef{ - Owner: in.Repository.Owner, Repo: in.Repository.Repo, Kind: "pull_request", Number: candidate.thread.Number, + Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: "pull_request", Number: candidate.thread.Number, }) } return refs } -func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.MineRepositoryFixPatternsInput, progress func(string, string) error, operation fixPatternOperation) (mcpcontract.FixPatternReport, error) { - repoRef, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) - if err != nil { - return mcpcontract.FixPatternReport{}, err - } +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) @@ -291,14 +360,14 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M if progress == nil { progress = func(string, string) error { return nil } } - repo, err := c.GetRepository(ctx, in.Repository.Owner, in.Repository.Repo) + 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/%s has not been synced", in.Repository.Owner, in.Repository.Repo) + return mcpcontract.FixPatternReport{}, fmt.Errorf("repository %s has not been synced", repoRef) } - analysis, err := collectFixPatternCandidates(ctx, c, repo, in, progress) + analysis, err := collectFixPatternCandidates(ctx, c, repo, request, progress) if err != nil { return mcpcontract.FixPatternReport{}, err } @@ -307,7 +376,7 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M for id, candidate := range analysis.candidates { unknownBeforeByID[id] = candidate.unknownBefore } - hydrationRefs := selectFixPatternHydration(analysis, in) + hydrationRefs := selectFixPatternHydration(analysis, request) hydrated, failures := 0, make([]mcpcontract.FixPatternHydrationFailure, 0) if operation == fixPatternWorkflow && len(hydrationRefs) > 0 { @@ -324,17 +393,17 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M if err != nil { return mcpcontract.FixPatternReport{}, err } - items, _ := raw["items"].([]map[string]any) + items := raw.Items for i, ref := range hydrationRefs { - if i < len(items) && items[i]["status"] == "complete" { + 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"].(string) - message, _ = items[i]["message"].(string) - retryable = items[i]["status"] == "retryable" + 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, @@ -345,7 +414,7 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M if err != nil { return mcpcontract.FixPatternReport{}, err } - analysis, err = collectFixPatternCandidates(ctx, c, repo, in, progress) + analysis, err = collectFixPatternCandidates(ctx, c, repo, request, progress) if err != nil { return mcpcontract.FixPatternReport{}, err } @@ -357,14 +426,10 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M } candidates := analysis.candidates - wantedOutcomes := make(map[mcpcontract.FixPatternOutcome]struct{}, len(in.MergeOutcomes)) - for _, outcome := range in.MergeOutcomes { - wantedOutcomes[outcome] = struct{}{} - } - reportClusters := make([]mcpcontract.FixPatternCluster, len(in.SymptomTaxonomy)) - for i, symptom := range in.SymptomTaxonomy { + 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...), + Name: symptom.name, Terms: append([]string(nil), symptom.terms...), CandidateCount: mcpcontract.NonNegativeInt(len(analysis.clusters[i])), } for _, id := range analysis.clusters[i] { @@ -378,10 +443,10 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M if outcome == "unknown" { cluster.UnknownAfter++ } - if _, wanted := wantedOutcomes[outcome]; !wanted { + if _, wanted := request.wantedOutcomes[outcome]; !wanted { continue } - if len(cluster.Examples) >= in.RepresentativeLimit { + if len(cluster.Examples) >= request.representativeLimit { cluster.ExamplesTruncated = true continue } @@ -390,9 +455,9 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M reportClusters[i] = cluster } - status := mcpcontract.FixPatternReportStatus("complete") + status := mcpcontract.FixPatternReportComplete if len(failures) > 0 || analysis.candidateTruncated || countUnknownCandidates(candidates) > 0 { - status = "partial" + status = mcpcontract.FixPatternReportPartial } limitations := []string{ "Similarity-only examples are candidates, not proof that a pull request fixed a related issue.", @@ -433,10 +498,23 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M }, Clusters: reportClusters, Failures: failures, Limitations: limitations, Persisted: operation == fixPatternWorkflow, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), ObservationWatermark: revision, QueryDigestSHA256: hex.EncodeToString(queryHash[:]), - Complete: status == "complete", Truncated: analysis.candidateTruncated, UnknownCoverage: countUnknownCandidates(candidates) > 0, Recovery: recovery, + 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 { - snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{Kind: "fix_pattern_report", Scope: in.Repository, SourceManifest: map[string]any{"observation_watermark": revision, "query_digest": report.QueryDigestSHA256}, DerivedVersions: map[string]string{"fix_patterns": "v1"}, Completeness: map[string]bool{"complete": report.Complete, "truncated": report.Truncated, "unknown_coverage": report.UnknownCoverage}, Provenance: map[string]string{"producer": "gitcontribute", "operation": "workflow.mine_repository_fix_patterns"}, Payload: report}) + 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 } @@ -466,29 +544,29 @@ func needsMergeHydration(thread corpus.Thread) bool { func fixPatternOutcome(thread corpus.Thread, superseded bool) mcpcontract.FixPatternOutcome { switch { case thread.Merge.IsMerged(): - return "merged" + return mcpcontract.FixPatternMerged case thread.State != "closed": - return "open" + return mcpcontract.FixPatternOpen case !thread.Merge.Known(): - return "unknown" + return mcpcontract.FixPatternUnknown case superseded: - return "superseded" + return mcpcontract.FixPatternSuperseded default: - return "closed_unmerged" + return mcpcontract.FixPatternClosedUnmerged } } func incrementFixPatternOutcome(counts *mcpcontract.FixPatternOutcomeCounts, outcome mcpcontract.FixPatternOutcome) { switch outcome { - case "merged": + case mcpcontract.FixPatternMerged: counts.Merged++ - case "closed_unmerged": + case mcpcontract.FixPatternClosedUnmerged: counts.ClosedUnmerged++ - case "superseded": + case mcpcontract.FixPatternSuperseded: counts.Superseded++ - case "open": + case mcpcontract.FixPatternOpen: counts.Open++ - case "unknown": + case mcpcontract.FixPatternUnknown: counts.Unknown++ } } @@ -497,7 +575,7 @@ func buildFixPatternExample(ctx context.Context, c *corpus.Corpus, repoID int64, 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 == "merged" && classification.relationship == "closes", + AcceptedFix: outcome == mcpcontract.FixPatternMerged && classification.relationship == mcpcontract.FixPatternCloses, ProofStyles: detectProofStyles(thread.Body), UpdatedAt: thread.SourceUpdatedAt.Format(time.RFC3339), } if classification.related != nil { @@ -514,7 +592,7 @@ func buildFixPatternExample(ctx context.Context, c *corpus.Corpus, repoID int64, related, err := c.GetThreadByNumber(ctx, relatedRepoID, classification.related.Number) if err == nil && related != nil { example.RelatedKind = mcpcontract.FixPatternRelatedKind(related.Kind) - example.RelatedThread.Kind = related.Kind + example.RelatedThread.Kind = string(related.Kind) } } return example @@ -522,7 +600,7 @@ func buildFixPatternExample(ctx context.Context, c *corpus.Corpus, repoID int64, func classifyFixPattern(thread corpus.Thread, repository domain.RepoRef) fixPatternClassification { refs := relatedwork.Extract(thread.Body, repository) - classification := fixPatternClassification{relationship: "similarity_only"} + classification := fixPatternClassification{relationship: mcpcontract.FixPatternSimilarityOnly} bestPriority := 0 for _, ref := range refs { if ref.Relation == relatedwork.RelationSupersededBy { @@ -539,11 +617,11 @@ func classifyFixPattern(thread corpus.Thread, repository domain.RepoRef) fixPatt classification.evidence = ref.Evidence switch ref.Relation { case relatedwork.RelationClaimsToClose: - classification.relationship = "closes" + classification.relationship = mcpcontract.FixPatternCloses case relatedwork.RelationReplaces, relatedwork.RelationSupersededBy: - classification.relationship = "explicit_replacement" + classification.relationship = mcpcontract.FixPatternExplicitReplacement default: - classification.relationship = "references" + classification.relationship = mcpcontract.FixPatternReferences } } return classification @@ -556,11 +634,11 @@ func detectProofStyles(body string) []mcpcontract.FixPatternProofStyle { name mcpcontract.FixPatternProofStyle terms []string }{ - {name: "regression_test", terms: []string{"regression test", "unit test", "test coverage"}}, - {name: "reproduction", terms: []string{"reproducer", "reproduction", "repro case"}}, - {name: "benchmark", terms: []string{"benchmark", "throughput", "latency"}}, - {name: "before_after", terms: []string{"before and after", "before/after"}}, - {name: "screenshot", terms: []string{"screenshot"}}, + {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) diff --git a/internal/app/mcp_fix_patterns_test.go b/internal/app/mcp_fix_patterns_test.go index 91047ad1..ce36bf07 100644 --- a/internal/app/mcp_fix_patterns_test.go +++ b/internal/app/mcp_fix_patterns_test.go @@ -24,19 +24,19 @@ func TestMineRepositoryFixPatternsSeparatesAcceptedFixesFromSimilarity(t *testin t.Fatal(err) } for _, thread := range []corpus.Thread{ - {RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", Title: "Numeric drift on RDNA", Body: "split cumsum produces the wrong result", SourceUpdatedAt: now}, - {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, 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: corpus.ThreadKindPullRequest, 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: corpus.ThreadKindPullRequest, Number: 4, State: "closed", Title: "Investigate numeric drift", Body: "Numeric drift investigation.", SourceUpdatedAt: now.Add(3 * time.Hour)}, - {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, 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: corpus.ThreadKindPullRequest, Number: 6, State: "open", Title: "New numeric drift approach", Body: "Numeric drift work remains open.", SourceUpdatedAt: now.Add(3 * time.Hour)}, + {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 - input, err := normalizeFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ + request, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, TimeWindow: mcpcontract.FixPatternTimeWindow{ UpdatedAfter: now.Add(-time.Hour).Format(time.RFC3339), @@ -50,7 +50,7 @@ func TestMineRepositoryFixPatternsSeparatesAcceptedFixesFromSimilarity(t *testin t.Fatal(err) } - report, err := (&MCPReader{Service: svc}).mineRepositoryFixPatterns(ctx, input, func(string, string) error { return nil }) + report, err := (&MCPReader{Service: svc}).runFixPatternOperation(ctx, request, func(string, string) error { return nil }, fixPatternWorkflow) if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestMineRepositoryFixPatternsHydratesOnlyUnknownFinalists(t *testing.T) { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", + 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) @@ -106,14 +106,14 @@ func TestMineRepositoryFixPatternsHydratesOnlyUnknownFinalists(t *testing.T) { Merged: true, MergedAt: &mergedAt, UpdatedAt: mergedAt, }}, header: github.Issue{ - RepositoryOwner: "owner", RepositoryName: "repo", Number: 2, Kind: github.ThreadKindPullRequest, + 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 - input, err := normalizeFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ + 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"}}}, @@ -123,7 +123,7 @@ func TestMineRepositoryFixPatternsHydratesOnlyUnknownFinalists(t *testing.T) { t.Fatal(err) } - report, err := (&MCPReader{Service: svc}).mineRepositoryFixPatterns(ctx, input, func(string, string) error { return nil }) + report, err := (&MCPReader{Service: svc}).runFixPatternOperation(ctx, request, func(string, string) error { return nil }, fixPatternWorkflow) if err != nil { t.Fatal(err) } @@ -145,7 +145,7 @@ func TestPreviewRepositoryFixPatternsIsReadOnlyAndNeverHydrates(t *testing.T) { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", + 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) @@ -154,7 +154,7 @@ func TestPreviewRepositoryFixPatternsIsReadOnlyAndNeverHydrates(t *testing.T) { if err != nil { t.Fatal(err) } - beforeJobs, err := svc.corpus.ListJobs(ctx, "", 100) + beforeJobs, err := svc.corpus.ListJobs(ctx, corpus.JobStatus(""), 100) if err != nil { t.Fatal(err) } @@ -173,7 +173,7 @@ func TestPreviewRepositoryFixPatternsIsReadOnlyAndNeverHydrates(t *testing.T) { if err != nil { t.Fatal(err) } - afterJobs, err := svc.corpus.ListJobs(ctx, "", 100) + afterJobs, err := svc.corpus.ListJobs(ctx, corpus.JobStatus(""), 100) if err != nil { t.Fatal(err) } @@ -202,7 +202,7 @@ func TestGetFixPatternReportRejectsLegacyUnboundArtifact(t *testing.T) { if err := svc.corpus.StartJob(ctx, job.ID); err != nil { t.Fatal(err) } - if err := svc.corpus.TransitionJob(ctx, job.ID, corpus.JobStatusRunning, corpus.JobStatusSucceeded, `{"status":"complete","persisted":true}`, ""); err != nil { + 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) @@ -225,7 +225,7 @@ func TestNormalizeFixPatternInputRejectsInvalidWindow(t *testing.T) { {UpdatedAfter: "not-a-date"}, {UpdatedAfter: "2026-07-02T00:00:00Z", UpdatedBefore: "2026-07-01T00:00:00Z"}, } { - _, err := normalizeFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ + _, _, err := parseFixPatternInput(mcpcontract.MineRepositoryFixPatternsInput{ Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}, TimeWindow: window, SymptomTaxonomy: []mcpcontract.FixPatternSymptom{{Name: "drift", Terms: []string{"drift"}}}, diff --git a/internal/app/mcp_github_acquisition.go b/internal/app/mcp_github_acquisition.go index ab4154b4..883d1846 100644 --- a/internal/app/mcp_github_acquisition.go +++ b/internal/app/mcp_github_acquisition.go @@ -25,13 +25,140 @@ const ( maxSourceTotalBytes = 4 * 1024 * 1024 ) +type githubThreadSearchSort uint8 + +const ( + githubThreadSearchBestMatch githubThreadSearchSort = iota + githubThreadSearchComments + githubThreadSearchCreated + githubThreadSearchUpdated + githubThreadSearchReactions +) + +func parseGitHubThreadSearchSort(value string) (githubThreadSearchSort, error) { + switch strings.TrimSpace(value) { + case "": + return githubThreadSearchBestMatch, nil + case "comments": + return githubThreadSearchComments, nil + case "created": + return githubThreadSearchCreated, nil + case "updated": + return githubThreadSearchUpdated, nil + case "reactions": + return githubThreadSearchReactions, nil + default: + return 0, errors.New("sort must be comments, created, updated, or reactions") + } +} + +func (s githubThreadSearchSort) String() string { + return [...]string{"", "comments", "created", "updated", "reactions"}[s] +} + +type githubThreadSearchRequest struct { + repository domain.RepoRef + query string + kind corpus.ThreadKindFilter + state corpus.ThreadStateFilter + sort githubThreadSearchSort + order githubSearchOrder + page githubSearchPage +} + +type repositoryRelativePath string + +func parseRepositoryRelativePath(value string) (repositoryRelativePath, error) { + clean := strings.TrimSpace(value) + if clean == "" || strings.HasPrefix(clean, "/") || strings.Contains(clean, "\\") || clean != path.Clean(clean) || clean == "." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return "", errors.New("must be a repository-relative path without traversal") + } + return repositoryRelativePath(clean), nil +} + +type sourceFileSelection struct { + path repositoryRelativePath + startLine, endLine int +} + +func (s sourceFileSelection) githubRequest() github.SourceFileRequest { + return github.SourceFileRequest{Path: string(s.path), StartLine: s.startLine, EndLine: s.endLine} +} + +func (s sourceFileSelection) canonical() mcpcontract.SourceFileRequest { + return mcpcontract.SourceFileRequest{Path: string(s.path), StartLine: s.startLine, EndLine: s.endLine} +} + +type sourceFilesRequest struct { + repository domain.RepoRef + ref string + files []sourceFileSelection + perFileBytes int + totalBytes int +} + +type githubThreadSearchSnapshotScope struct { + Repository string `json:"repository"` + Query string `json:"query"` + Page int `json:"page"` +} + +type githubThreadSearchSnapshotSource struct { + ProviderQuery string `json:"provider_query"` + ItemIDs []int64 `json:"item_ids"` +} + +type githubThreadSearchSnapshotVersions struct { + GitHubThreadSearch string `json:"github_thread_search"` +} + +type sourceBundleSnapshotScope struct { + Repository string `json:"repository"` + RequestedRef string `json:"requested_ref"` + Paths []string `json:"paths"` +} + +type sourceBundleSnapshotSource struct { + CommitSHA string `json:"commit_sha"` + ItemStatuses []string `json:"item_statuses"` +} + +type sourceBundleSnapshotVersions struct { + SourceBundle string `json:"source_bundle"` +} + +func (r sourceFilesRequest) canonical() mcpcontract.ReadSourceFilesInput { + files := make([]mcpcontract.SourceFileRequest, len(r.files)) + for i, file := range r.files { + files[i] = file.canonical() + } + return mcpcontract.ReadSourceFilesInput{ + Repository: mcpcontract.RepositoryRef{Owner: r.repository.Owner(), Repo: r.repository.Repo()}, + Ref: r.ref, Files: files, PerFileBytes: r.perFileBytes, TotalBytes: r.totalBytes, + } +} + +func (r githubThreadSearchRequest) canonical() mcpcontract.SearchGitHubThreadsInput { + state := r.state.String() + if r.state.IsAny() { + state = "all" + } + return mcpcontract.SearchGitHubThreadsInput{ + Repository: mcpcontract.RepositoryRef{Owner: r.repository.Owner(), Repo: r.repository.Repo()}, + Query: r.query, Kind: r.kind.String(), State: state, Sort: r.sort.String(), Order: r.order.String(), + Page: r.page.number, Limit: r.page.limit, + } +} + // SearchGitHubThreads performs one bounded live issue-search request, records // returned thread observations, and creates an immutable query-result // artifact. It never claims repository-wide thread coverage. func (r *MCPReader) SearchGitHubThreads(ctx context.Context, in mcpcontract.SearchGitHubThreadsInput) (mcpcontract.SearchGitHubThreadsOutput, error) { - if err := validateGitHubThreadSearchInput(&in); err != nil { + request, err := parseGitHubThreadSearchInput(in) + if err != nil { return mcpcontract.SearchGitHubThreadsOutput{}, err } + canonical := request.canonical() reader, err := r.githubReader() //nolint:contextcheck // construction does not perform a request if err != nil { return mcpcontract.SearchGitHubThreadsOutput{}, err @@ -41,54 +168,51 @@ func (r *MCPReader) SearchGitHubThreads(ctx context.Context, in mcpcontract.Sear return mcpcontract.SearchGitHubThreadsOutput{}, errors.New("configured GitHub reader does not support thread search") } result, err := searcher.SearchThreads(ctx, github.ThreadSearchOptions{ - Owner: in.Repository.Owner, Repo: in.Repository.Repo, Query: in.Query, Kind: github.ThreadKind(in.Kind), State: in.State, - Sort: in.Sort, Order: in.Order, PageOptions: github.PageOptions{Page: in.Page, PerPage: in.Limit}, + Owner: request.repository.Owner(), Repo: request.repository.Repo(), Query: request.query, Kind: domain.ThreadKind(request.kind.String()), State: canonical.State, + Sort: request.sort.String(), Order: request.order.String(), PageOptions: github.PageOptions{Page: request.page.number, PerPage: request.page.limit}, }) if err != nil { return mcpcontract.SearchGitHubThreadsOutput{}, err } - return r.persistGitHubThreadSearch(ctx, in, result) + return r.persistGitHubThreadSearch(ctx, canonical, result) } -func validateGitHubThreadSearchInput(in *mcpcontract.SearchGitHubThreadsInput) error { - if _, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo); err != nil { - return err - } - in.Query = strings.TrimSpace(in.Query) - if in.Query == "" { - return errors.New("query is required") - } - if in.Kind != "" && in.Kind != "issue" && in.Kind != "pull_request" { - return fmt.Errorf("kind must be issue or pull_request") - } - if in.State == "" { - in.State = "all" - } - if in.State != "open" && in.State != "closed" && in.State != "all" { - return fmt.Errorf("state must be open, closed, or all") +func parseGitHubThreadSearchInput(in mcpcontract.SearchGitHubThreadsInput) (githubThreadSearchRequest, error) { + repository, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { + return githubThreadSearchRequest{}, err } - if in.Sort != "" && in.Sort != "comments" && in.Sort != "created" && in.Sort != "updated" && in.Sort != "reactions" { - return fmt.Errorf("sort must be comments, created, updated, or reactions") + query := strings.TrimSpace(in.Query) + if query == "" { + return githubThreadSearchRequest{}, errors.New("query is required") } - if in.Order == "" { - in.Order = "desc" + kind, err := corpus.ParseThreadKindFilter(in.Kind) + if err != nil { + return githubThreadSearchRequest{}, errors.New("kind must be issue or pull_request") } - if in.Order != "asc" && in.Order != "desc" { - return fmt.Errorf("order must be asc or desc") + state, err := corpus.ParseThreadStateFilter(in.State) + if err != nil { + return githubThreadSearchRequest{}, errors.New("state must be open, closed, or all") } - if in.Limit == 0 { - in.Limit = 20 + sortMode, err := parseGitHubThreadSearchSort(in.Sort) + if err != nil { + return githubThreadSearchRequest{}, err } - if in.Limit < 1 || in.Limit > 100 { - return fmt.Errorf("limit must be between 1 and 100") + order, err := parseGitHubSearchOrder(in.Order, githubSearchDescending) + if err != nil { + return githubThreadSearchRequest{}, err } - if in.Page == 0 { - in.Page = 1 + page, pageProblem := parseGitHubSearchPage(in.Limit, in.Page) + if pageProblem == githubSearchLimitInvalid { + return githubThreadSearchRequest{}, errors.New("limit must be between 1 and 100") } - if in.Page < 1 || in.Page > 1000 || (in.Page-1)*in.Limit >= 1000 { - return fmt.Errorf("page must keep the requested result offset below GitHub's 1,000-result cap") + if pageProblem == githubSearchPageInvalid { + return githubThreadSearchRequest{}, errors.New("page must keep the requested result offset below GitHub's 1,000-result cap") } - return nil + return githubThreadSearchRequest{ + repository: repository, query: query, kind: kind, state: state, + sort: sortMode, order: order, page: page, + }, nil } func (r *MCPReader) persistGitHubThreadSearch(ctx context.Context, in mcpcontract.SearchGitHubThreadsInput, result github.ThreadSearchResult) (mcpcontract.SearchGitHubThreadsOutput, error) { @@ -181,15 +305,16 @@ func (r *MCPReader) persistGitHubThreadSearch(ctx context.Context, in mcpcontrac artifact.Items[index] = githubThreadSearchArtifactItem(issue, index, in.Repository.Owner, in.Repository.Repo) } - snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{ - Kind: githubThreadSearchArtifactKind, - Scope: map[string]any{"repository": in.Repository.Owner + "/" + in.Repository.Repo, "query": in.Query, "page": in.Page}, - SourceManifest: map[string]any{"provider_query": result.Query, "item_ids": artifactItemIDs(artifact.Items)}, - DerivedVersions: map[string]string{"github_thread_search": "v1"}, - Completeness: artifact.Completeness, - Provenance: artifact.Provenance, - Payload: artifact, - }) + materialization, err := corpus.NewSnapshotMaterialization( + githubThreadSearchArtifactKind, + githubThreadSearchSnapshotScope{Repository: in.Repository.Owner + "/" + in.Repository.Repo, Query: in.Query, Page: in.Page}, + githubThreadSearchSnapshotSource{ProviderQuery: result.Query, ItemIDs: artifactItemIDs(artifact.Items)}, + githubThreadSearchSnapshotVersions{GitHubThreadSearch: "v1"}, artifact.Completeness, artifact.Provenance, artifact, + ) + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, fmt.Errorf("prepare github thread search artifact: %w", err) + } + snapshot, err := c.MaterializeReadSnapshot(ctx, materialization) if err != nil { return mcpcontract.SearchGitHubThreadsOutput{}, fmt.Errorf("store github thread search artifact: %w", err) } @@ -225,9 +350,11 @@ func (r *MCPReader) ReadGitHubThreadSearchArtifact(ctx context.Context, digest s // resulting immutable source bundle. It does not touch thread facets or code // index projections. func (r *MCPReader) ReadSourceFiles(ctx context.Context, in mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) { - if err := validateReadSourceFilesInput(&in); err != nil { + request, err := parseReadSourceFilesInput(in) + if err != nil { return mcpcontract.ReadSourceFilesOutput{}, err } + canonical := request.canonical() reader, err := r.githubReader() //nolint:contextcheck // construction does not perform a request if err != nil { return mcpcontract.ReadSourceFilesOutput{}, err @@ -236,27 +363,28 @@ func (r *MCPReader) ReadSourceFiles(ctx context.Context, in mcpcontract.ReadSour if !ok { return mcpcontract.ReadSourceFilesOutput{}, errors.New("configured GitHub reader does not support bounded source reads") } - requests := make([]github.SourceFileRequest, len(in.Files)) - for i, file := range in.Files { - requests[i] = github.SourceFileRequest{Path: file.Path, StartLine: file.StartLine, EndLine: file.EndLine} + requests := make([]github.SourceFileRequest, len(request.files)) + for i, file := range request.files { + requests[i] = file.githubRequest() } - result, err := fileReader.ReadSourceFiles(ctx, in.Repository.Owner, in.Repository.Repo, in.Ref, requests, github.SourceFileReadOptions{PerFileBytes: in.PerFileBytes, TotalBytes: in.TotalBytes}) + result, err := fileReader.ReadSourceFiles(ctx, request.repository.Owner(), request.repository.Repo(), request.ref, requests, github.SourceFileReadOptions{PerFileBytes: request.perFileBytes, TotalBytes: request.totalBytes}) if err != nil { return mcpcontract.ReadSourceFilesOutput{}, err } - return r.persistSourceBundle(ctx, in, result) + return r.persistSourceBundle(ctx, canonical, result) } -func validateReadSourceFilesInput(in *mcpcontract.ReadSourceFilesInput) error { - if _, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo); err != nil { - return err +func parseReadSourceFilesInput(in mcpcontract.ReadSourceFilesInput) (sourceFilesRequest, error) { + repository, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { + return sourceFilesRequest{}, err } - in.Ref = strings.TrimSpace(in.Ref) - if in.Ref == "" { - return errors.New("ref is required") + ref := strings.TrimSpace(in.Ref) + if ref == "" { + return sourceFilesRequest{}, errors.New("ref is required") } if len(in.Files) < 1 || len(in.Files) > maxSourceFileRequests { - return fmt.Errorf("files must contain 1 to %d items", maxSourceFileRequests) + return sourceFilesRequest{}, fmt.Errorf("files must contain 1 to %d items", maxSourceFileRequests) } if in.PerFileBytes == 0 { in.PerFileBytes = defaultSourcePerFileBytes @@ -265,27 +393,31 @@ func validateReadSourceFilesInput(in *mcpcontract.ReadSourceFilesInput) error { in.TotalBytes = defaultSourceTotalBytes } if in.PerFileBytes < 1 || in.PerFileBytes > maxSourcePerFileBytes { - return fmt.Errorf("per_file_bytes must be between 1 and %d", maxSourcePerFileBytes) + return sourceFilesRequest{}, fmt.Errorf("per_file_bytes must be between 1 and %d", maxSourcePerFileBytes) } if in.TotalBytes < 1 || in.TotalBytes > maxSourceTotalBytes { - return fmt.Errorf("total_bytes must be between 1 and %d", maxSourceTotalBytes) + return sourceFilesRequest{}, fmt.Errorf("total_bytes must be between 1 and %d", maxSourceTotalBytes) } seen := make(map[string]struct{}, len(in.Files)) + files := make([]sourceFileSelection, len(in.Files)) for i, file := range in.Files { - clean := strings.TrimSpace(file.Path) - if clean == "" || strings.HasPrefix(clean, "/") || strings.Contains(clean, "\\") || clean != path.Clean(clean) || clean == "." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { - return fmt.Errorf("files[%d].path must be a repository-relative path without traversal", i) + parsedPath, err := parseRepositoryRelativePath(file.Path) + if err != nil { + return sourceFilesRequest{}, fmt.Errorf("files[%d].path %w", i, err) } if file.StartLine < 0 || file.EndLine < 0 || (file.StartLine > 0 && file.EndLine > 0 && file.EndLine < file.StartLine) { - return fmt.Errorf("files[%d] line range must be inclusive and ordered", i) + return sourceFilesRequest{}, fmt.Errorf("files[%d] line range must be inclusive and ordered", i) } - if _, ok := seen[clean]; ok { - return fmt.Errorf("files[%d].path is duplicated", i) + if _, ok := seen[string(parsedPath)]; ok { + return sourceFilesRequest{}, fmt.Errorf("files[%d].path is duplicated", i) } - seen[clean] = struct{}{} - in.Files[i].Path = clean + seen[string(parsedPath)] = struct{}{} + files[i] = sourceFileSelection{path: parsedPath, startLine: file.StartLine, endLine: file.EndLine} } - return nil + return sourceFilesRequest{ + repository: repository, ref: ref, files: files, + perFileBytes: in.PerFileBytes, totalBytes: in.TotalBytes, + }, nil } func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.ReadSourceFilesInput, result github.SourceFileReadResult) (mcpcontract.ReadSourceFilesOutput, error) { @@ -309,12 +441,16 @@ func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.Read Provenance: mcpcontract.GitHubAcquisitionProvenance{Provider: "github", Endpoint: "repos/contents", ObservedAt: formatTime(now)}, CreatedAt: formatTime(now), } for i, item := range result.Items { + status, err := sourceFileStatus(item.Status) + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, err + } value := sourceFileOutput(item, result.Resolution, now) - artifactItem := mcpcontract.SourceFileBatchItem{Key: item.Request.Path, Status: mcpcontract.SourceFileStatus(item.Status), Value: &value, Message: item.Message} + artifactItem := mcpcontract.SourceFileBatchItem{Key: item.Request.Path, Status: status, Value: &value, Message: item.Message} if item.RetryAfter > 0 { artifactItem.RetryAfterMS = mcpcontract.NonNegativeInt(item.RetryAfter.Milliseconds()) } - if item.Status == "too_large" || item.Status == "retryable" { + if item.Status == github.SourceFileReadTooLarge || item.Status == github.SourceFileReadRetryable { artifactItem.Recovery = sourceFileRecovery(in, item.Request, item.Status) } artifact.Items[i] = artifactItem @@ -325,10 +461,10 @@ func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.Read compact.Value = ©Value } out.Items[i] = compact - if item.Status != "complete" { + if item.Status != github.SourceFileReadComplete { out.Status = "partial" } - if item.Status == "complete" { + if item.Status == github.SourceFileReadComplete { artifact.Completeness.CompleteItems++ } else { artifact.Completeness.FailedItems++ @@ -337,13 +473,16 @@ func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.Read artifact.Completeness.RequestedItems = len(result.Items) artifact.Completeness.Status = out.Status artifact.Completeness.ContentsBounded = true - snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{ - Kind: sourceBundleArtifactKind, - Scope: map[string]any{"repository": in.Repository.Owner + "/" + in.Repository.Repo, "requested_ref": in.Ref, "paths": sourceBundlePaths(in.Files)}, - SourceManifest: map[string]any{"commit_sha": result.Resolution.CommitSHA, "item_statuses": sourceBundleStatuses(result.Items)}, - DerivedVersions: map[string]string{"source_bundle": "v1"}, Completeness: artifact.Completeness, - Provenance: artifact.Provenance, Payload: artifact, - }) + materialization, err := corpus.NewSnapshotMaterialization( + sourceBundleArtifactKind, + sourceBundleSnapshotScope{Repository: in.Repository.Owner + "/" + in.Repository.Repo, RequestedRef: in.Ref, Paths: sourceBundlePaths(in.Files)}, + sourceBundleSnapshotSource{CommitSHA: result.Resolution.CommitSHA, ItemStatuses: sourceBundleStatuses(result.Items)}, + sourceBundleSnapshotVersions{SourceBundle: "v1"}, artifact.Completeness, artifact.Provenance, artifact, + ) + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, fmt.Errorf("prepare source bundle artifact: %w", err) + } + snapshot, err := c.MaterializeReadSnapshot(ctx, materialization) if err != nil { return mcpcontract.ReadSourceFilesOutput{}, fmt.Errorf("store source bundle artifact: %w", err) } @@ -352,10 +491,10 @@ func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.Read return out, nil } -func sourceFileRecovery(in mcpcontract.ReadSourceFilesInput, request github.SourceFileRequest, status string) *mcpcontract.RecoveryPlan { +func sourceFileRecovery(in mcpcontract.ReadSourceFilesInput, request github.SourceFileRequest, status github.SourceFileReadStatus) *mcpcontract.RecoveryPlan { next := in next.Files = []mcpcontract.SourceFileRequest{{Path: request.Path, StartLine: request.StartLine, EndLine: request.EndLine}} - if status == "too_large" { + if status == github.SourceFileReadTooLarge { next.PerFileBytes = min(1024*1024, max(in.PerFileBytes*2, in.PerFileBytes+1)) next.TotalBytes = min(4*1024*1024, max(in.TotalBytes*2, in.TotalBytes+1)) return recoveryPlan("source_file_too_large", "The selected file exceeded the current byte bound. Retry this exact file with the returned larger bounds or narrow its line range.", mcpcontract.RecoveryAction(next)) @@ -363,6 +502,25 @@ func sourceFileRecovery(in mcpcontract.ReadSourceFilesInput, request github.Sour return recoveryPlan("source_file_retryable", "The provider returned a retryable source-file outcome. Replay this exact file request after the returned retry delay.", mcpcontract.RecoveryAction(next)) } +func sourceFileStatus(status github.SourceFileReadStatus) (mcpcontract.SourceFileStatus, error) { + switch status { + case github.SourceFileReadComplete: + return mcpcontract.SourceFileComplete, nil + case github.SourceFileReadNotFound: + return mcpcontract.SourceFileNotFound, nil + case github.SourceFileReadTooLarge: + return mcpcontract.SourceFileTooLarge, nil + case github.SourceFileReadRetryable: + return mcpcontract.SourceFileRetryable, nil + case github.SourceFileReadUnavailable: + return mcpcontract.SourceFileUnavailable, nil + case github.SourceFileReadFailed: + return mcpcontract.SourceFileFailed, nil + default: + return "", fmt.Errorf("unsupported source-file read status %q", status) + } +} + // ReadSourceBundleArtifact is a local-only typed resource reader. func (r *MCPReader) ReadSourceBundleArtifact(ctx context.Context, digest string) (mcpcontract.SourceBundleArtifact, error) { c, err := r.openReadOnlyCorpus(ctx) @@ -472,7 +630,7 @@ func sourceBundlePaths(files []mcpcontract.SourceFileRequest) []string { func sourceBundleStatuses(items []github.SourceFileReadItem) []string { statuses := make([]string, len(items)) for i, item := range items { - statuses[i] = item.Status + statuses[i] = string(item.Status) } return statuses } diff --git a/internal/app/mcp_github_acquisition_test.go b/internal/app/mcp_github_acquisition_test.go index d460ba46..68d0b47f 100644 --- a/internal/app/mcp_github_acquisition_test.go +++ b/internal/app/mcp_github_acquisition_test.go @@ -67,7 +67,7 @@ func TestMCPReaderSearchGitHubThreadsPersistsArtifactWithoutFullCoverage(t *test now := time.Date(2026, 8, 1, 1, 2, 3, 0, time.UTC) svc.SetClock(func() time.Time { return now }) reader := &MCPReader{svc} - out, err := reader.SearchGitHubThreads(context.Background(), mcpcontract.SearchGitHubThreadsInput{Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Query: "persist", Kind: "issue", Limit: 2}) + out, err := reader.SearchGitHubThreads(context.Background(), mcpcontract.SearchGitHubThreadsInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Query: " persist ", Kind: " issue ", Limit: 2}) if err != nil { t.Fatal(err) } @@ -168,15 +168,18 @@ func TestMCPReaderReadSourceFilesStoresCommitAndBlobProvenanceAndReadsLocally(t } } -func TestValidateReadSourceFilesInputTrimsRef(t *testing.T) { - in := mcpcontract.ReadSourceFilesInput{ - Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Ref: " main ", Files: []mcpcontract.SourceFileRequest{{Path: "README.md"}}, - } - if err := validateReadSourceFilesInput(&in); err != nil { +func TestParseReadSourceFilesInputOwnsCanonicalBoundary(t *testing.T) { + files := []mcpcontract.SourceFileRequest{{Path: " README.md "}} + request, err := parseReadSourceFilesInput(mcpcontract.ReadSourceFilesInput{ + Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Ref: " main ", Files: files, + }) + if err != nil { t.Fatal(err) } - if in.Ref != "main" { - t.Fatalf("ref = %q, want canonical main", in.Ref) + files[0].Path = "changed.md" + canonical := request.canonical() + if canonical.Repository.Owner != "acme" || canonical.Repository.Repo != "rocket" || canonical.Ref != "main" || canonical.Files[0].Path != "README.md" { + t.Fatalf("canonical source request = %+v", canonical) } } diff --git a/internal/app/mcp_issue_set.go b/internal/app/mcp_issue_set.go index f20e8688..d8073bc2 100644 --- a/internal/app/mcp_issue_set.go +++ b/internal/app/mcp_issue_set.go @@ -12,6 +12,7 @@ import ( "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 ( @@ -19,29 +20,35 @@ const ( 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) { - if err := normalizePrepareIssueSetInput(&in); err != nil { - return mcpcontract.PrepareIssueSetOutput{}, err - } - ref, err := domain.NewRepoRef(in.Owner, in.Repo) + 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, in.SnapshotToken) + 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: in.ResponseFormat, - Items: make([]mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence], len(in.IssueNumbers)), + 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(in.SnapshotToken, revision), + SnapshotToken: snapshotIdentity(request.snapshotToken, revision), } stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { @@ -51,7 +58,7 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI if err := finishCorpusRead(ctx, c, revision); err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } - return unavailableIssueSet(in, out, ref), nil + return unavailableIssueSet(request, out), nil } threadsCoverage, err := c.GetCoverage(ctx, stored.ID, nil, "threads") if err != nil { @@ -84,10 +91,10 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI out.SourceAsOf = formatTime(threadsCoverage.SourceUpdatedAt) } - issues := make([]corpus.Thread, 0, len(in.IssueNumbers)) - issuesByNumber := make(map[int]corpus.Thread, len(in.IssueNumbers)) - for _, number := range in.IssueNumbers { - issue, err := c.GetThread(ctx, stored.ID, corpus.ThreadKindIssue, number) + 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 } @@ -97,11 +104,11 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI } } - pullRequests, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.ThreadKindPullRequest, "all", radarPullRequestPopulation) + 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.ThreadKindPullRequest, "all") + pullRequestTotal, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) if err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } @@ -112,7 +119,7 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI if out.Truncated { out.Status = "partial" } - relatedByIssue, projectedCapped, err := radarPullRequestRelatedWork(ctx, c, stored, ref, issues, pullRequests, "all") + relatedByIssue, projectedCapped, err := radarPullRequestRelatedWork(ctx, c, stored, ref, issues, pullRequests, corpus.AnyThreadState()) if err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } @@ -128,11 +135,11 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI out.Status, out.Truncated = "partial", true } - precedentInput := issueSetPrecedentInput(in) + 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 = in.SnapshotToken + precedentInput.SnapshotToken = request.snapshotToken precedents, err := r.FindPrecedents(ctx, precedentInput) if err != nil { return mcpcontract.PrepareIssueSetOutput{}, err @@ -147,8 +154,8 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI } evaluatedAt := r.now() - for i, number := range in.IssueNumbers { - key := threadRefKey(mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: corpus.ThreadKindIssue, Number: number}) + 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 { @@ -162,7 +169,7 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI } value, actions, partial, err := prepareOneIssue( ctx, c, stored, ref, issue, relatedByIssue[number], pullRequestsByNumber, duplicatesByIssue[number], - precedents.Items[i], in.ResponseFormat, in.PrecedentLimit, + precedents.Items[i], request.format, request.precedentLimit, threadsCoverage != nil && threadsCoverage.Complete && !relationshipScanCapped && pullRequestBodiesAvailable, threadsCoverage == nil || !threadsCoverage.SourceUpdatedAt.Before(issue.SourceUpdatedAt), evaluatedAt, ) @@ -191,17 +198,17 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI return out, nil } -func normalizePrepareIssueSetInput(in *mcpcontract.PrepareIssueSetInput) error { +func parsePrepareIssueSetInput(in mcpcontract.PrepareIssueSetInput) (prepareIssueSetRequest, error) { if len(in.IssueNumbers) < 1 || len(in.IssueNumbers) > 20 { - return errors.New("issue_numbers must contain 1 to 20 items") + 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 errors.New("issue_numbers must contain only positive numbers") + return prepareIssueSetRequest{}, errors.New("issue_numbers must contain only positive numbers") } if _, ok := seen[number]; ok { - return fmt.Errorf("issue_numbers contains duplicate #%d", number) + return prepareIssueSetRequest{}, fmt.Errorf("issue_numbers contains duplicate #%d", number) } seen[number] = struct{}{} } @@ -209,36 +216,41 @@ func normalizePrepareIssueSetInput(in *mcpcontract.PrepareIssueSetInput) error { in.PrecedentLimit = defaultIssueSetPrecedentLimit } if in.PrecedentLimit < 1 || in.PrecedentLimit > 10 { - return errors.New("precedent_limit must be between 1 and 10") + return prepareIssueSetRequest{}, errors.New("precedent_limit must be between 1 and 10") } - if in.ResponseFormat == "" { - in.ResponseFormat = "concise" + format, err := parseResponseFormat(in.ResponseFormat) + if err != nil { + return prepareIssueSetRequest{}, err } - if in.ResponseFormat != "concise" && in.ResponseFormat != "detailed" { - return errors.New("response_format must be concise or detailed") + repository, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { + return prepareIssueSetRequest{}, err } - return nil + return prepareIssueSetRequest{ + repository: repository, issueNumbers: append([]int(nil), in.IssueNumbers...), + precedentLimit: in.PrecedentLimit, format: format, snapshotToken: in.SnapshotToken, + }, nil } -func unavailableIssueSet(in mcpcontract.PrepareIssueSetInput, out mcpcontract.PrepareIssueSetOutput, ref domain.RepoRef) mcpcontract.PrepareIssueSetOutput { +func unavailableIssueSet(request prepareIssueSetRequest, out mcpcontract.PrepareIssueSetOutput) mcpcontract.PrepareIssueSetOutput { out.Status = "partial" - for i, number := range in.IssueNumbers { + for i, number := range request.issueNumbers { out.Items[i] = mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence]{ - Key: threadRefKey(mcpcontract.ThreadRef{Owner: in.Owner, Repo: in.Repo, Kind: corpus.ThreadKindIssue, Number: number}), Status: "unavailable", + 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(in.Owner, in.Repo), issueSyncAction(ref, number)), + 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(in.Owner, in.Repo), issueSyncAction(ref, 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(in mcpcontract.PrepareIssueSetInput) mcpcontract.FindPrecedentsInput { - threads := make([]mcpcontract.ThreadRef, len(in.IssueNumbers)) - for i, number := range in.IssueNumbers { - threads[i] = mcpcontract.ThreadRef{Owner: in.Owner, Repo: in.Repo, Kind: corpus.ThreadKindIssue, Number: number} +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: in.SnapshotToken} + return mcpcontract.FindPrecedentsInput{Threads: threads, Limit: 100, SnapshotToken: request.snapshotToken} } func prepareOneIssue( @@ -251,14 +263,14 @@ func prepareOneIssue( pullRequests map[int]corpus.Thread, duplicate *radar.DuplicateCluster, precedents mcpcontract.BatchItem[mcpcontract.PrecedentSet], - responseFormat string, + 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: issue.State, StateReason: issue.StateReason, + 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{}, @@ -281,7 +293,7 @@ func prepareOneIssue( relationshipEvidenceComplete := issue.Body != "" if issue.Body != "" { value.BodyStatus = "available" - if responseFormat == "detailed" { + if format.includesDetails() { value.Body = issue.Body } } else { @@ -337,15 +349,15 @@ func prepareOneIssue( value.RelatedWorkTotal = relatedTotal value.RelatedWorkTotalKnown = relationshipPopulationComplete && relationshipEvidenceComplete && !relatedCapped limit := len(combined) - if responseFormat == "concise" && limit > conciseIssueSetRelatedLimit { + if !format.includesDetails() && limit > conciseIssueSetRelatedLimit { limit = conciseIssueSetRelatedLimit } conciseEvidenceOmitted := false for _, work := range combined[:limit] { - if responseFormat == "concise" && len(work.Evidence) > 0 { + if !format.includesDetails() && len(work.Evidence) > 0 { conciseEvidenceOmitted = true } - value.RelatedWork = append(value.RelatedWork, issueSetRelatedWork(work, ref, pullRequests, responseFormat)) + value.RelatedWork = append(value.RelatedWork, issueSetRelatedWork(work, ref, pullRequests, format)) } value.RelatedWorkTruncated = relatedCapped || recordsCapped || evidenceCapped || conciseEvidenceOmitted || limit < len(combined) if relatedCapped || recordsCapped || evidenceCapped { @@ -365,7 +377,7 @@ func prepareOneIssue( 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 == corpus.ThreadKindPullRequest && match.MergedAt != "" { + if match.Kind == string(domain.PullRequestKind) && match.MergedAt != "" { value.AcceptedExamples = append(value.AcceptedExamples, match) if len(value.AcceptedExamples) == precedentLimit { break @@ -397,7 +409,7 @@ func issueContributionDisposition(issue mcpcontract.PreparedIssueEvidence) mcpco var mergedClosing, openClosing, closedUnmerged []mcpcontract.IssueSetRelatedWork var missingMerge []string for _, work := range issue.RelatedWork { - if work.Kind != corpus.ThreadKindPullRequest || work.Relation != "claims_to_close" || work.Direction != "inbound" { + if work.Kind != string(domain.PullRequestKind) || work.Relation != string(relatedwork.RelationClaimsToClose) || work.Direction != string(radar.RelatedWorkInbound) { continue } switch { @@ -462,21 +474,21 @@ func hasCompleteIssueFacet(values []mcpcontract.FacetCoverageOutput, facet strin }) } -func issueSetRelatedWork(work radar.RelatedWork, ref domain.RepoRef, pullRequests map[int]corpus.Thread, responseFormat string) mcpcontract.IssueSetRelatedWork { +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: work.Relation, Direction: work.Direction, URL: work.URL, + 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 == corpus.ThreadKindPullRequest && work.Ref == localPullRequestRef { + 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 responseFormat == "detailed" { + if format.includesDetails() { seen := map[string]struct{}{} for _, evidence := range work.Evidence { if _, ok := seen[evidence.Kind]; ok { @@ -507,13 +519,13 @@ func preparedIssueSourceAsOf(value mcpcontract.PreparedIssueEvidence) string { 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: corpus.ThreadKindIssue, Number: number}}, + 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: corpus.ThreadKindIssue, Number: number}}, + Threads: []mcpcontract.ThreadRef{{Owner: ref.Owner(), Repo: ref.Repo(), Kind: string(domain.IssueKind), Number: number}}, Facets: []string{facet}, }) } @@ -522,7 +534,7 @@ func repositoryPullRequestSyncAction(ref domain.RepoRef) mcpcontract.ToolCall { return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, - Kind: corpus.ThreadKindPullRequest, + Kind: string(domain.PullRequestKind), State: "all", }) } diff --git a/internal/app/mcp_issue_set_test.go b/internal/app/mcp_issue_set_test.go index bd96f27f..8e02fe9a 100644 --- a/internal/app/mcp_issue_set_test.go +++ b/internal/app/mcp_issue_set_test.go @@ -25,7 +25,7 @@ func TestPrepareIssueSetComposesStoredEvidenceWithoutClaimingClosure(t *testing. t.Fatal(err) } issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", + 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), }, `{}`) @@ -33,14 +33,14 @@ func TestPrepareIssueSetComposesStoredEvidenceWithoutClaimingClosure(t *testing. t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 21, State: "closed", + 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: corpus.ThreadKindPullRequest, Number: 22, State: "closed", + 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 { @@ -108,7 +108,7 @@ func TestPrepareIssueSetPreservesUnknownAndExactRecovery(t *testing.T) { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", Title: "Body not captured", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Body not captured", }, `{}`); err != nil { t.Fatal(err) } @@ -172,14 +172,14 @@ func TestPrepareIssueSetKeepsRelatedTotalUnknownForAmbiguousPullRequestBody(t *t t.Fatal(err) } issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", + 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: corpus.ThreadKindPullRequest, Number: 21, State: "open", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 21, State: "open", Title: "Body may not have been captured", SourceUpdatedAt: now, }, `{}`); err != nil { t.Fatal(err) @@ -211,10 +211,10 @@ func TestPrepareIssueSetKeepsRelatedTotalUnknownForAmbiguousPullRequestBody(t *t func TestIssueSetRelatedWorkDoesNotBorrowMergeStateAcrossRepositories(t *testing.T) { t.Parallel() out := issueSetRelatedWork( - radar.RelatedWork{Ref: "pull_request:other/repo#21", Kind: corpus.ThreadKindPullRequest, Number: 21}, + 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{})}}, - "concise", + 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 309f676d..1d6fe5bd 100644 --- a/internal/app/mcp_job_artifacts.go +++ b/internal/app/mcp_job_artifacts.go @@ -102,40 +102,72 @@ func validationJobArtifact(job *contracts.JobResult, kind string) ([]mcpcontract return []mcpcontract.JobArtifactReference{{Kind: kind, ID: result.ID}}, nil } +func failureBatchItemStatus(s mcpcontract.BatchItemStatus) (mcpcontract.BatchItemStatus, bool) { + switch s { + case mcpcontract.BatchItemRetryable: + return mcpcontract.BatchItemRetryable, true + case mcpcontract.BatchItemUnavailable: + return mcpcontract.BatchItemUnavailable, true + case mcpcontract.BatchItemFailed: + return mcpcontract.BatchItemFailed, true + default: + return "", false + } +} + type syncBatchItem struct { - Key string `json:"key"` - Status string `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfter int `json:"retry_after_ms"` - Threads []mcpcontract.ThreadRef `json:"threads"` + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfter int `json:"retry_after_ms"` + Threads []mcpcontract.ThreadRef `json:"threads"` } type syncBatchResult struct { Items []syncBatchItem `json:"items"` } +type syncReferenceShape uint8 + +const ( + repositorySyncReferences syncReferenceShape = iota + threadSyncReferences +) + func decodeSyncBatchResult(job *contracts.JobResult, total int) (syncBatchResult, int) { var result syncBatchResult count := total - if json.Unmarshal([]byte(job.Result), &result) == nil && result.Items != nil { - count = len(result.Items) + if err := json.Unmarshal([]byte(job.Result), &result); err != nil || result.Items == nil { + return syncBatchResult{}, count + } + for _, item := range result.Items { + if _, err := mcpcontract.ParseBatchItemStatus(string(item.Status)); err != nil { + return syncBatchResult{}, count + } } + count = len(result.Items) return result, count } -func syncBatchReferences(result syncBatchResult, includeThreads bool) ([]string, []mcpcontract.ThreadRef, []mcpcontract.JobArtifactFailure, bool, bool) { +func syncBatchReferences(result syncBatchResult, shape syncReferenceShape) ([]string, []mcpcontract.ThreadRef, []mcpcontract.JobArtifactFailure, bool, bool) { references := make([]string, 0, min(len(result.Items), maxJobArtifactItems)) threadRefs := make([]mcpcontract.ThreadRef, 0, min(len(result.Items), maxJobArtifactItems)) failures := make([]mcpcontract.JobArtifactFailure, 0, min(len(result.Items), maxJobArtifactItems)) referencesTruncated := false failuresTruncated := false for _, item := range result.Items { - partialThreadBatch := includeThreads && item.Status == "partial" - if item.Status != "complete" && !partialThreadBatch { + partialThreadBatch := shape == threadSyncReferences && item.Status == mcpcontract.BatchItemPartial + if item.Status != mcpcontract.BatchItemComplete && !partialThreadBatch { if len(failures) < maxJobArtifactItems { + status, ok := failureBatchItemStatus(item.Status) + if !ok { + status = mcpcontract.BatchItemFailed + item.Reason = "invalid_stored_status" + item.Message = fmt.Sprintf("stored batch item has unsupported status %q", item.Status) + } failures = append(failures, mcpcontract.JobArtifactFailure{ - Reference: item.Key, Status: mcpcontract.BatchItemStatus(item.Status), Reason: item.Reason, + Reference: item.Key, Status: status, Reason: item.Reason, Message: item.Message, RetryAfterMS: mcpcontract.NonNegativeInt(item.RetryAfter), }) } else { @@ -143,7 +175,7 @@ func syncBatchReferences(result syncBatchResult, includeThreads bool) ([]string, } continue } - if includeThreads && len(item.Threads) > 0 { + if shape == threadSyncReferences && len(item.Threads) > 0 { for _, ref := range item.Threads { if len(threadRefs) >= maxJobArtifactItems { referencesTruncated = true @@ -167,7 +199,7 @@ func syncBatchReferences(result syncBatchResult, includeThreads bool) ([]string, func repositoryBatchJobArtifact(job *contracts.JobResult, total int) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { result, count := decodeSyncBatchResult(job, total) - references, _, failures, referencesTruncated, failuresTruncated := syncBatchReferences(result, false) + references, _, failures, referencesTruncated, failuresTruncated := syncBatchReferences(result, repositorySyncReferences) value := mcpcontract.NonNegativeInt(count) var request mcpcontract.SyncRepositoryContextInput var follow *mcpcontract.JobFollowUp @@ -185,7 +217,7 @@ func repositoryBatchJobArtifact(job *contracts.JobResult, total int) ([]mcpcontr func threadBatchJobArtifact(job *contracts.JobResult, total int) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { result, count := decodeSyncBatchResult(job, total) - references, threadRefs, failures, referencesTruncated, failuresTruncated := syncBatchReferences(result, true) + references, threadRefs, failures, referencesTruncated, failuresTruncated := syncBatchReferences(result, threadSyncReferences) value := mcpcontract.NonNegativeInt(count) var follow *mcpcontract.JobFollowUp if len(threadRefs) > 0 { @@ -218,16 +250,21 @@ func portfolioJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactRe SearchIncomplete bool `json:"search_incomplete"` RequestCapped bool `json:"request_capped"` Failures []struct { - Reference string `json:"reference"` - Status string `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS int `json:"retry_after_ms"` + Reference string `json:"reference"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` } `json:"failures"` } if json.Unmarshal([]byte(job.Result), &result) != nil { return nil, nil } + for _, failure := range result.Failures { + if _, err := mcpcontract.ParseBatchItemStatus(string(failure.Status)); err != nil { + return nil, nil + } + } value := mcpcontract.NonNegativeInt(result.Refreshed) references, referencesTruncated := boundedArtifactReferences(result.PullRequests) failures := make([]mcpcontract.JobArtifactFailure, 0, min(len(result.Failures), maxJobArtifactItems)) @@ -238,7 +275,7 @@ func portfolioJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactRe continue } failures = append(failures, mcpcontract.JobArtifactFailure{ - Reference: failure.Reference, Status: mcpcontract.BatchItemStatus(failure.Status), Reason: failure.Reason, + Reference: failure.Reference, Status: failure.Status, Reason: failure.Reason, Message: failure.Message, RetryAfterMS: mcpcontract.NonNegativeInt(failure.RetryAfterMS), }) } @@ -316,6 +353,11 @@ func pullRequestWorkflowJobArtifact(job *contracts.JobResult) ([]mcpcontract.Job if json.Unmarshal([]byte(job.Result), &result) != nil { return nil, nil } + for _, item := range result.Items { + if _, err := mcpcontract.ParseBatchItemStatus(string(item.Status)); err != nil { + return nil, nil + } + } kind, reason, resourceKind := "pull_request_feedback", "Read the persisted feedback snapshots through their resource links.", "pull-request-feedback" if job.Kind == "sync_ci_failures" { kind, reason, resourceKind = "ci_failure_report", "Read the persisted CI reports and bounded job logs through their resource links.", "ci-failure-report" @@ -383,6 +425,11 @@ func pullRequestFeedbackIndexJobArtifact(job *contracts.JobResult) ([]mcpcontrac if json.Unmarshal([]byte(job.Request), &request) != nil { return nil, nil } + for _, item := range result.Items { + if _, err := mcpcontract.ParseBatchItemStatus(string(item.Status)); err != nil { + return nil, nil + } + } refs := make([]string, 0, min(len(result.Items), maxJobArtifactItems)) failures := make([]mcpcontract.JobArtifactFailure, 0, min(len(result.Items), maxJobArtifactItems)) completed := 0 @@ -404,7 +451,7 @@ func pullRequestFeedbackIndexJobArtifact(job *contracts.JobResult) ([]mcpcontrac failuresTruncated = true } } - artifact := mcpcontract.JobArtifactReference{Kind: "pull_request_feedback_index", Count: ptrNonNegative(completed), References: refs, ReferencesTruncated: referencesTruncated, Failures: failures, FailuresTruncated: failuresTruncated, Status: result.Status, DiscoveryStatus: result.DiscoveryStatus, Recovery: result.Recovery} + artifact := mcpcontract.JobArtifactReference{Kind: "pull_request_feedback_index", Count: ptrNonNegative(completed), References: refs, ReferencesTruncated: referencesTruncated, Failures: failures, FailuresTruncated: failuresTruncated, Status: string(result.Status), DiscoveryStatus: string(result.DiscoveryStatus), Recovery: result.Recovery} follow := &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.SearchPullRequestFeedbackInput{Repository: request.Repository}), Reason: "Search the indexed pull-request feedback through the offline corpus."} return []mcpcontract.JobArtifactReference{artifact}, follow } @@ -415,16 +462,16 @@ func ptrNonNegative(value int) *mcpcontract.NonNegativeInt { } type indexJobItem struct { - Key string `json:"key"` - Status string `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS int `json:"retry_after_ms"` - CommitSHA string `json:"commit_sha"` - IndexManifest codeindex.Manifest `json:"index_manifest"` - ArtifactDigest string `json:"artifact_digest"` - ManifestDigest string `json:"manifest_digest"` - SnapshotToken string `json:"snapshot_token"` + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + CommitSHA string `json:"commit_sha"` + IndexManifest codeindex.Manifest `json:"index_manifest"` + ArtifactDigest string `json:"artifact_digest"` + ManifestDigest string `json:"manifest_digest"` + SnapshotToken string `json:"snapshot_token"` } type indexJobResult struct { @@ -437,6 +484,11 @@ func indexRepositoriesJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobAr if json.Unmarshal([]byte(job.Result), &result) != nil { return nil, nil } + for _, item := range result.Items { + if _, err := mcpcontract.ParseBatchItemStatus(string(item.Status)); err != nil { + return nil, nil + } + } artifacts := make([]mcpcontract.JobArtifactReference, 0, len(result.Items)) completedRefs := make([]string, 0, min(len(result.Items), maxJobArtifactItems)) failures := make([]mcpcontract.JobArtifactFailure, 0, min(len(result.Items), maxJobArtifactItems)) @@ -444,10 +496,10 @@ func indexRepositoriesJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobAr referencesTruncated := false failuresTruncated := false for _, item := range result.Items { - if item.Status != "complete" { + if item.Status != mcpcontract.BatchItemComplete { if len(failures) < maxJobArtifactItems { failures = append(failures, mcpcontract.JobArtifactFailure{ - Reference: item.Key, Status: mcpcontract.BatchItemStatus(item.Status), Reason: item.Reason, + Reference: item.Key, Status: item.Status, Reason: item.Reason, Message: item.Message, RetryAfterMS: mcpcontract.NonNegativeInt(item.RetryAfterMS), }) } else { diff --git a/internal/app/mcp_jobs.go b/internal/app/mcp_jobs.go index 5de9f42e..002a4c72 100644 --- a/internal/app/mcp_jobs.go +++ b/internal/app/mcp_jobs.go @@ -23,7 +23,7 @@ func (r *MCPReader) GetJob(ctx context.Context, in mcpcontract.GetJobInput) (mcp if err != nil { return mcpcontract.GetJobOutput{}, err } - return jobResultToMCP(job, true), nil + return jobResultToMCP(job, detailedResponse), nil } // CancelJobs requests bounded cancellation in input order. Missing and terminal @@ -86,7 +86,7 @@ func (r *MCPReader) cancelJobItem(ctx context.Context, inputID string) mcpcontra } func jobResultItem(item mcpcontract.BatchItem[mcpcontract.GetJobOutput], job *contracts.JobResult) mcpcontract.BatchItem[mcpcontract.GetJobOutput] { - value := jobResultToMCP(job, true) + value := jobResultToMCP(job, detailedResponse) item.Value = &value if value.Status == "running" { item.Recovery = recoveryPlan("blocked", "Poll jobs.get until this job reaches a terminal state.", mcpcontract.RecoveryAction(mcpcontract.GetJobsInput{IDs: []string{value.ID}})) @@ -94,7 +94,7 @@ func jobResultItem(item mcpcontract.BatchItem[mcpcontract.GetJobOutput], job *co return item } -func jobResultToMCP(job *contracts.JobResult, includeDetails bool) mcpcontract.GetJobOutput { +func jobResultToMCP(job *contracts.JobResult, format responseFormat) mcpcontract.GetJobOutput { phase, completed, total := decodeJobProgress(job) percent := 0 if total > 0 { @@ -116,7 +116,7 @@ func jobResultToMCP(job *contracts.JobResult, includeDetails bool) mcpcontract.G } out.ExecutionState, out.Outcome = jobExecution(job) out.Summary = jobSummary(job, completed, total) - if includeDetails { + if format.includesDetails() { switch job.Status { case "succeeded": out.Artifacts, out.FollowUp = jobArtifactsAndFollowUp(job, total) @@ -132,23 +132,23 @@ func jobResultToMCP(job *contracts.JobResult, includeDetails bool) mcpcontract.G func jobExecution(job *contracts.JobResult) (mcpcontract.JobExecutionState, mcpcontract.JobOutcome) { switch job.Status { case "queued": - return "queued", "" + return mcpcontract.JobExecutionQueued, "" case "running": - return "running", "" + return mcpcontract.JobExecutionRunning, "" case "succeeded": switch jobResultStatus(job) { case "failed": - return "terminal", "failed" + return mcpcontract.JobExecutionTerminal, mcpcontract.JobOutcomeFailed case "partial": - return "terminal", "partial" + return mcpcontract.JobExecutionTerminal, mcpcontract.JobOutcomePartial } - return "terminal", "succeeded" + return mcpcontract.JobExecutionTerminal, mcpcontract.JobOutcomeSucceeded case "failed": - return "terminal", "failed" + return mcpcontract.JobExecutionTerminal, mcpcontract.JobOutcomeFailed case "cancelled": - return "terminal", "cancelled" + return mcpcontract.JobExecutionTerminal, mcpcontract.JobOutcomeCancelled default: - return "running", "" + return mcpcontract.JobExecutionRunning, "" } } diff --git a/internal/app/mcp_jobs_test.go b/internal/app/mcp_jobs_test.go index 7781c2af..79482dad 100644 --- a/internal/app/mcp_jobs_test.go +++ b/internal/app/mcp_jobs_test.go @@ -247,7 +247,7 @@ func TestRemovedJobKindsDoNotExposeCompatibilityArtifacts(t *testing.T) { t.Run(kind, func(t *testing.T) { out := jobResultToMCP(&contracts.JobResult{ Kind: kind, Status: "succeeded", Result: `{"status":"complete","items":[]}`, - }, true) + }, detailedResponse) if len(out.Artifacts) != 0 || out.FollowUp != nil { t.Fatalf("removed job kind exposed compatibility output: artifacts=%+v follow_up=%+v", out.Artifacts, out.FollowUp) } @@ -447,3 +447,28 @@ func TestPullRequestFeedbackIndexJobOffersOfflineSearchFollowUp(t *testing.T) { t.Fatalf("feedback index follow-up = %+v", follow) } } + +func TestJobArtifactReadersRejectUnsupportedStoredItemStatuses(t *testing.T) { + t.Parallel() + + decoded, count := decodeSyncBatchResult(&contracts.JobResult{Result: `{"items":[{"key":"acme/rocket","status":"impossible"}]}`}, 7) + if len(decoded.Items) != 0 || count != 7 { + t.Fatalf("corrupt sync result = %+v count=%d", decoded, count) + } + + if artifacts, _ := portfolioJobArtifact(&contracts.JobResult{Result: `{"failures":[{"reference":"acme/rocket","status":"impossible"}]}`}); len(artifacts) != 0 { + t.Fatalf("corrupt portfolio artifacts = %+v", artifacts) + } + if artifacts, _ := indexRepositoriesJobArtifact(&contracts.JobResult{Result: `{"items":[{"key":"acme/rocket","status":"impossible"}]}`}); len(artifacts) != 0 { + t.Fatalf("corrupt index artifacts = %+v", artifacts) + } + if artifacts, _ := pullRequestWorkflowJobArtifact(&contracts.JobResult{Result: `{"items":[{"key":"acme/rocket/pull_request#1","item_status":"impossible"}]}`}); len(artifacts) != 0 { + t.Fatalf("corrupt workflow artifacts = %+v", artifacts) + } + if artifacts, _ := pullRequestFeedbackIndexJobArtifact(&contracts.JobResult{ + Request: `{"repository":{"owner":"acme","repo":"rocket"}}`, + Result: `{"items":[{"key":"acme/rocket/pull_request#1","item_status":"impossible"}]}`, + }); len(artifacts) != 0 { + t.Fatalf("corrupt feedback index artifacts = %+v", artifacts) + } +} diff --git a/internal/app/mcp_local_repository_search.go b/internal/app/mcp_local_repository_search.go index a19dc9da..d33b706a 100644 --- a/internal/app/mcp_local_repository_search.go +++ b/internal/app/mcp_local_repository_search.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "strings" "github.com/morluto/gitcontribute/internal/contracts" @@ -10,29 +11,33 @@ import ( // SearchRepositories performs a local-only repository search. func (r *MCPReader) SearchRepositories(ctx context.Context, in mcpcontract.SearchRepositoriesInput) (mcpcontract.SearchRepositoriesOutput, error) { - in.Query = strings.TrimSpace(in.Query) repoRef, err := optionalRepoRef(in.Owner, in.Repo) if err != nil { return mcpcontract.SearchRepositoriesOutput{}, err } - repoFilter := "" - if repoRef.IsValid() { - repoFilter = repoRef.String() - } - - res, err := r.searchCorpus(ctx, in.Query, contracts.SearchOptions{ + request, err := parseSearchRequest(in.Query, contracts.SearchOptions{ Kind: "repos", - Repo: repoFilter, Limit: in.Limit, Cursor: in.Cursor, Sort: in.Sort, SnapshotToken: in.SnapshotToken, - }) + }, repoRef) + if err != nil { + return mcpcontract.SearchRepositoriesOutput{}, err + } + repositoryRequest, ok := request.(repositorySearchRequest) + if !ok { + return mcpcontract.SearchRepositoriesOutput{}, errors.New("repository search parser returned another operation") + } + in.Query = request.read().query + in.Limit = request.read().page.Limit() + in.Sort = repositoryRequest.order.String() + res, err := r.searchCorpus(ctx, request) if err != nil { return mcpcontract.SearchRepositoriesOutput{}, err } if len(res.Matches) == 0 { - provenance, err := offlineReadProvenance("repository_search", res.ObservationWatermark, in, res.NextCursor == "", res.NextCursor != "", true) + provenance, err := offlineReadProvenance("repository_search", res.ObservationWatermark, in, res.NextCursor != "", true) if err != nil { return mcpcontract.SearchRepositoriesOutput{}, err } @@ -61,11 +66,11 @@ func (r *MCPReader) SearchRepositories(ctx context.Context, in mcpcontract.Searc } } } - provenance, err := offlineReadProvenance("repository_search", res.ObservationWatermark, in, res.NextCursor == "", res.NextCursor != "", true) + provenance, err := offlineReadProvenance("repository_search", res.ObservationWatermark, in, res.NextCursor != "", true) if err != nil { return mcpcontract.SearchRepositoriesOutput{}, err } - incomplete := len(missing) > 0 || batch.Status != "complete" || provenance.UnknownCoverage + incomplete := len(missing) > 0 || batch.Status != "complete" || provenance.UnknownCoverage() var recovery *mcpcontract.RecoveryPlan if len(missing) > 0 { calls := make([]mcpcontract.ToolCall, 0, len(missing)) diff --git a/internal/app/mcp_portfolio_reads.go b/internal/app/mcp_portfolio_reads.go index 3e0dc89c..850e99f9 100644 --- a/internal/app/mcp_portfolio_reads.go +++ b/internal/app/mcp_portfolio_reads.go @@ -40,9 +40,15 @@ func (r *MCPReader) ListPullRequestPortfolio(ctx context.Context, in mcpcontract if in.State == "" { in.State = "open" } - if in.State != "open" && in.State != "closed" && in.State != "all" { + state, err := corpus.ParseThreadStateFilter(in.State) + if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, errors.New("state must be open, closed, or all") } + if state.IsAny() { + in.State = "all" + } else { + in.State = state.String() + } if in.View == "" { in.View = "compact" } @@ -63,11 +69,14 @@ func (r *MCPReader) ListPullRequestPortfolio(ctx context.Context, in mcpcontract if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, err } - page, unavailable, err := portfolioPage(ctx, c, in) + page, unavailable, err := portfolioPage(ctx, c, in, state) if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, err } - format := portfolioResponseFormat(map[string]string{"compact": "concise", "full": "detailed"}[in.View]) + format := conciseResponse + if in.View == "full" { + format = detailedResponse + } readSet, err := loadPortfolioReadSet(ctx, c, page.PullRequests, format) if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, err @@ -99,7 +108,7 @@ func (r *MCPReader) ListPullRequestPortfolio(ctx context.Context, in mcpcontract return out, nil } -func portfolioPage(ctx context.Context, c *corpus.Corpus, in mcpcontract.ListPullRequestPortfolioInput) (corpus.PortfolioPage, []mcpcontract.ThreadRef, error) { +func portfolioPage(ctx context.Context, c *corpus.Corpus, in mcpcontract.ListPullRequestPortfolioInput, state corpus.ThreadStateFilter) (corpus.PortfolioPage, []mcpcontract.ThreadRef, error) { if len(in.PullRequests) == 0 { author := "" if len(in.Authors) > 0 { @@ -109,7 +118,7 @@ func portfolioPage(ctx context.Context, c *corpus.Corpus, in mcpcontract.ListPul if in.Repository != nil { repository = &corpus.RepositoryKey{Owner: in.Repository.Owner, Name: in.Repository.Repo} } - page, err := c.ListPullRequestPortfolioPage(ctx, author, in.State, repository, in.Limit) + page, err := c.ListPullRequestPortfolioPage(ctx, author, state, repository, in.Limit) return page, nil, err } repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.PullRequests)) @@ -123,7 +132,7 @@ func portfolioPage(ctx context.Context, c *corpus.Corpus, in mcpcontract.ListPul threadKeys := make([]corpus.ThreadKey, 0, len(in.PullRequests)) for _, ref := range in.PullRequests { if repository := repositories[corpus.RepositoryKey{Owner: ref.Owner, Name: ref.Repo}]; repository != nil { - threadKeys = append(threadKeys, corpus.ThreadKey{RepositoryID: repository.ID, Kind: corpus.ThreadKindPullRequest, Number: ref.Number}) + threadKeys = append(threadKeys, corpus.ThreadKey{RepositoryID: repository.ID, Kind: corpus.PullRequestThreadKind(), Number: ref.Number}) } } threads, err := c.GetThreadsBatch(ctx, threadKeys) @@ -138,7 +147,7 @@ func portfolioPage(ctx context.Context, c *corpus.Corpus, in mcpcontract.ListPul unavailable = append(unavailable, ref) continue } - thread := threads[corpus.ThreadKey{RepositoryID: repository.ID, Kind: corpus.ThreadKindPullRequest, Number: ref.Number}] + thread := threads[corpus.ThreadKey{RepositoryID: repository.ID, Kind: corpus.PullRequestThreadKind(), Number: ref.Number}] if thread == nil { unavailable = append(unavailable, ref) continue diff --git a/internal/app/mcp_portfolio_refs.go b/internal/app/mcp_portfolio_refs.go index 0f3b732c..2d239f98 100644 --- a/internal/app/mcp_portfolio_refs.go +++ b/internal/app/mcp_portfolio_refs.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -25,9 +24,9 @@ func parsePullRequestRefs(inputs []mcpcontract.ThreadRef, path string) ([]mcpcon } kind := strings.TrimSpace(refs[i].Kind) if kind == "" { - kind = corpus.ThreadKindPullRequest + kind = string(domain.PullRequestKind) } - if kind != corpus.ThreadKindPullRequest { + if kind != string(domain.PullRequestKind) { return nil, mcpcontract.InvalidArgument(itemPath+".kind", "must be pull_request when provided", nil) } refs[i] = mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: kind, Number: refs[i].Number} diff --git a/internal/app/mcp_portfolio_relationships.go b/internal/app/mcp_portfolio_relationships.go index c3d65fc3..6c03cc91 100644 --- a/internal/app/mcp_portfolio_relationships.go +++ b/internal/app/mcp_portfolio_relationships.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -34,7 +35,7 @@ func (r *MCPReader) FindPortfolioOverlaps(ctx context.Context, in mcpcontract.Fi pullRequests := append([]mcpcontract.ThreadRef(nil), in.PullRequests...) for i := range pullRequests { if pullRequests[i].Kind == "" { - pullRequests[i].Kind = corpus.ThreadKindPullRequest + pullRequests[i].Kind = string(domain.PullRequestKind) } } prIDs, missingPullRequests, err := resolvePortfolioPullRequests(ctx, c, pullRequests) @@ -64,11 +65,11 @@ func (r *MCPReader) FindPortfolioOverlaps(ctx context.Context, in mcpcontract.Fi for resultIndex, result := range results { i := candidateIndexes[resultIndex] value := portfolioOverlapOutput(result) - batch := mcpcontract.BatchItem[mcpcontract.PortfolioOverlapOutput]{Key: result.Candidate.Kind + ":" + result.Candidate.Ref, Status: "complete", Value: &value} + batch := mcpcontract.BatchItem[mcpcontract.PortfolioOverlapOutput]{Key: result.Candidate.Kind() + ":" + result.Candidate.Ref(), Status: "complete", Value: &value} if missingPullRequests { out.Status = "partial" batch.Status, batch.Reason, batch.Recovery = "retryable", "thread_not_indexed", recoveryPlan("thread_not_indexed", "Sync the missing pull requests, then retry this comparison.", syncPullRequestCalls(pullRequests)...) - } else if result.Status == "unknown" { + } else if result.Unknown() { out.Status = "partial" batch.Status, batch.Reason, batch.Recovery = "unavailable", "candidate_signal_unavailable", recoveryPlan("candidate_signal_unavailable", "Sync pull-request status and record candidate overlap signals before retrying.", syncPullRequestCalls(pullRequests)...) } @@ -85,12 +86,13 @@ func collectPortfolioCandidates(inputs []mcpcontract.PortfolioSubjectInput, out var indexes []int for i, candidate := range inputs { item := mcpcontract.BatchItem[mcpcontract.PortfolioOverlapOutput]{Key: candidate.Kind + ":" + candidate.Ref} - if !validPortfolioSubjectInput(candidate) { + parsed, err := corpus.ParsePortfolioSubject(candidate.Kind, candidate.Ref) + if err != nil { item.Status, item.Reason, item.Message = "failed", "invalid_candidate", "kind must be opportunity, workspace, or pull_request and ref must be a valid local ID" out.Status, out.Items[i] = "partial", item continue } - candidates = append(candidates, corpus.PortfolioSubject{Kind: candidate.Kind, Ref: strings.TrimSpace(candidate.Ref)}) + candidates = append(candidates, parsed) indexes = append(indexes, i) } return candidates, indexes @@ -108,7 +110,7 @@ func resolvePortfolioPullRequests(ctx context.Context, c *corpus.Corpus, refs [] missing = true continue } - thread, err := c.GetThread(ctx, repo.ID, ref.Kind, ref.Number) + thread, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil { return nil, false, err } @@ -122,13 +124,13 @@ func resolvePortfolioPullRequests(ctx context.Context, c *corpus.Corpus, refs [] } func portfolioOverlapOutput(result corpus.PortfolioOverlapResult) mcpcontract.PortfolioOverlapOutput { - value := mcpcontract.PortfolioOverlapOutput{Candidate: mcpcontract.PortfolioSubjectInput{Kind: result.Candidate.Kind, Ref: result.Candidate.Ref}, Status: result.Status, Coverage: result.Coverage} + value := mcpcontract.PortfolioOverlapOutput{Candidate: mcpcontract.PortfolioSubjectInput{Kind: result.Candidate.Kind(), Ref: result.Candidate.Ref()}, Status: result.Status(), Coverage: result.Coverage()} for _, match := range result.Matches { converted := mcpcontract.PortfolioOverlapMatchOutput{PullRequestThreadID: match.PullRequestThreadID} for _, evidence := range match.Evidence { item := mcpcontract.PortfolioOverlapEvidenceOutput{Kind: evidence.Kind, Value: evidence.Value, Score: mcpcontract.SimilarityScore(evidence.Score)} for _, ref := range evidence.SourceObservationRefs { - item.SourceRefs = append(item.SourceRefs, ref.Kind+":"+strconv.FormatInt(ref.ID, 10)) + item.SourceRefs = append(item.SourceRefs, ref.Kind()+":"+strconv.FormatInt(ref.ID(), 10)) } converted.Evidence = append(converted.Evidence, item) } @@ -137,22 +139,6 @@ func portfolioOverlapOutput(result corpus.PortfolioOverlapResult) mcpcontract.Po return value } -func validPortfolioSubjectInput(candidate mcpcontract.PortfolioSubjectInput) bool { - ref := strings.TrimSpace(candidate.Ref) - if ref == "" { - return false - } - switch candidate.Kind { - case corpus.PortfolioSubjectOpportunity, corpus.PortfolioSubjectWorkspace: - return true - case corpus.PortfolioSubjectPullRequest: - id, err := strconv.ParseInt(ref, 10, 64) - return err == nil && id > 0 - default: - return false - } -} - // LinkPullRequest records an explicit local relationship without mutating GitHub. func (r *MCPReader) LinkPullRequest(ctx context.Context, in mcpcontract.LinkPullRequestInput) (mcpcontract.LinkPullRequestOutput, error) { c, err := r.openCorpus(ctx) @@ -179,9 +165,9 @@ func resolveStoredPullRequest(ctx context.Context, c *corpus.Corpus, ref mcpcont return nil, fmt.Errorf("repository %s/%s is not stored", ref.Owner, ref.Repo) } if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } - thread, err := c.GetThread(ctx, repo.ID, ref.Kind, ref.Number) + thread, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil { return nil, err } diff --git a/internal/app/mcp_portfolio_relationships_test.go b/internal/app/mcp_portfolio_relationships_test.go index 5dc5ce43..7f1500c1 100644 --- a/internal/app/mcp_portfolio_relationships_test.go +++ b/internal/app/mcp_portfolio_relationships_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -17,7 +18,7 @@ func TestFindPortfolioOverlapsIsolatesInvalidCandidatesAndMissingPullRequests(t if err != nil { t.Fatal(err) } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "open", SourceUpdatedAt: time.Unix(10, 0).UTC()}, `{}`); err != nil { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", SourceUpdatedAt: time.Unix(10, 0).UTC()}, `{}`); err != nil { t.Fatal(err) } out, err := (&MCPReader{svc}).FindPortfolioOverlaps(ctx, mcpcontract.FindPortfolioOverlapsInput{ diff --git a/internal/app/mcp_portfolio_sync.go b/internal/app/mcp_portfolio_sync.go index 3406d9be..51b9125d 100644 --- a/internal/app/mcp_portfolio_sync.go +++ b/internal/app/mcp_portfolio_sync.go @@ -43,7 +43,7 @@ func (explicitPortfolioSelection) isSyncPortfolioSelection() {} type authoredPortfolioSelection struct { repository *mcpcontract.RepositoryRef - state string + state syncThreadState updatedAfter time.Time limit int maxRequests int @@ -110,8 +110,9 @@ func parseAuthoredPortfolioSelection(in mcpcontract.SyncPortfolioInput) (authore if in.State == "" { in.State = "open" } - if in.State != "open" && in.State != "closed" && in.State != "all" { - return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("state must be open, closed, or all") + state, err := parseSyncThreadState(in.State) + if err != nil { + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, err } var updatedAfter time.Time if in.UpdatedAfter != "" { @@ -138,7 +139,7 @@ func parseAuthoredPortfolioSelection(in mcpcontract.SyncPortfolioInput) (authore copy := *in.Repository repository = © } - return authoredPortfolioSelection{repository: repository, state: in.State, updatedAfter: updatedAfter, limit: in.Limit, maxRequests: in.DiscoveryMaxRequests}, in, nil + return authoredPortfolioSelection{repository: repository, state: state, updatedAfter: updatedAfter, limit: in.Limit, maxRequests: in.DiscoveryMaxRequests}, in, nil } func (r *MCPReader) runPortfolioSync(ctx context.Context, request syncPortfolioRequest, report func(string, string) error) (syncPortfolioResult, error) { @@ -157,7 +158,7 @@ func (r *MCPReader) syncExplicitPortfolio(ctx context.Context, selection explici if err != nil { return syncPortfolioResult{}, err } - return syncPortfolioResult{Status: status, Discovered: len(selection.pullRequests), Refreshed: refreshed, PullRequests: threadRefKeys(selection.pullRequests), Failures: failures, DiscoveryStatus: "complete"}, nil + return syncPortfolioResult{Status: status, Discovered: len(selection.pullRequests), Refreshed: refreshed, PullRequests: threadRefKeys(selection.pullRequests), Failures: failures, DiscoveryStatus: batchOperationComplete}, nil } func (r *MCPReader) syncAuthoredPortfolio(ctx context.Context, selection authoredPortfolioSelection, statusMaxPages int, report func(string, string) error) (syncPortfolioResult, error) { @@ -171,16 +172,16 @@ func (r *MCPReader) syncAuthoredPortfolio(ctx context.Context, selection authore if err != nil { return syncPortfolioResult{}, err } - if discovery.Status != "complete" || discovery.SearchIncomplete || discovery.RequestCapped { - status = "partial" + if discovery.Status != batchOperationComplete || discovery.SearchIncomplete || discovery.RequestCapped { + status = batchOperationPartial } return syncPortfolioResult{Status: status, Login: discovery.Login, Discovered: discovery.PullRequests, Refreshed: refreshed, PullRequests: append([]string(nil), discovery.PullRequestRefs...), Failures: failures, DiscoveryStatus: discovery.Status, SearchIncomplete: discovery.SearchIncomplete, RequestCapped: discovery.RequestCapped}, nil } -func (r *MCPReader) syncPortfolioStatusBatches(ctx context.Context, refs []mcpcontract.ThreadRef, maxPages int, report func(string, string) error) (int, []pullRequestStatusFailure, string, error) { +func (r *MCPReader) syncPortfolioStatusBatches(ctx context.Context, refs []mcpcontract.ThreadRef, maxPages int, report func(string, string) error) (int, []pullRequestStatusFailure, batchOperationStatus, error) { refreshed := 0 failures := make([]pullRequestStatusFailure, 0) - status := "complete" + status := batchOperationComplete for start := 0; start < len(refs); start += 50 { end := min(start+50, len(refs)) batch, err := r.syncPullRequestStatusBatch(ctx, pullRequestStatusBatchInput{PullRequests: refs[start:end], MaxPages: maxPages}, report) @@ -189,21 +190,21 @@ func (r *MCPReader) syncPortfolioStatusBatches(ctx context.Context, refs []mcpco } refreshed += batch.Completed failures = append(failures, batch.Failures...) - if batch.Status != "complete" { - status = "partial" + if batch.Status != batchOperationComplete { + status = batchOperationPartial } } return refreshed, failures, status, nil } type syncPortfolioResult struct { - Status string `json:"status"` + Status batchOperationStatus `json:"status"` Login string `json:"login"` Discovered int `json:"discovered"` Refreshed int `json:"refreshed"` PullRequests []string `json:"pull_requests"` Failures []pullRequestStatusFailure `json:"failures,omitempty"` - DiscoveryStatus string `json:"discovery_status"` + DiscoveryStatus batchOperationStatus `json:"discovery_status"` SearchIncomplete bool `json:"search_incomplete"` RequestCapped bool `json:"request_capped"` } diff --git a/internal/app/mcp_portfolio_test.go b/internal/app/mcp_portfolio_test.go index 073251b4..89d6f0ec 100644 --- a/internal/app/mcp_portfolio_test.go +++ b/internal/app/mcp_portfolio_test.go @@ -23,11 +23,11 @@ func TestPullRequestPortfolioDerivesConflictAndPreservesUnknownCoverage(t *testi if err != nil { t.Fatal(err) } - conflicted, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "open", Title: "fix cache", Author: "alice", SourceUpdatedAt: now}, `{}`) + conflicted, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", Title: "fix cache", Author: "alice", SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } - unknown, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "open", Title: "fix parser", Author: "alice", SourceUpdatedAt: now}, `{}`) + unknown, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "fix parser", Author: "alice", SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -86,11 +86,11 @@ func TestPullRequestPortfolioClassifiesClosedUnmerged(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 9, State: "closed", Title: "abandoned change", Author: "alice", Merge: domain.UnmergedStatus(), SourceUpdatedAt: now}, `{}`) + thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 9, State: "closed", Title: "abandoned change", Author: "alice", Merge: domain.UnmergedStatus(), SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } - unknown, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 10, State: "closed", Title: "header only", Author: "alice", SourceUpdatedAt: now}, `{}`) + unknown, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 10, State: "closed", Title: "header only", Author: "alice", SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -120,7 +120,7 @@ func TestPullRequestPortfolioKeepsComputingMergeabilityUnknown(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 10, State: "open", Title: "computing", Author: "alice", SourceUpdatedAt: now}, `{}`) + thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 10, State: "open", Title: "computing", Author: "alice", SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -159,10 +159,10 @@ func TestPullRequestPortfolioExactSelectionDoesNotSubstituteNewerPullRequests(t if err != nil { t.Fatal(err) } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "open", Title: "selected", Author: "alice", SourceUpdatedAt: now}, `{}`); err != nil { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", Title: "selected", Author: "alice", SourceUpdatedAt: now}, `{}`); err != nil { t.Fatal(err) } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "open", Title: "newer unrelated", Author: "alice", SourceUpdatedAt: now.Add(time.Second)}, `{}`); err != nil { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "newer unrelated", Author: "alice", SourceUpdatedAt: now.Add(time.Second)}, `{}`); err != nil { t.Fatal(err) } out, err := (&MCPReader{svc}).ListPullRequestPortfolio(ctx, mcpcontract.ListPullRequestPortfolioInput{ @@ -190,9 +190,9 @@ func TestPullRequestPortfolioRepositoryScopePreservesTotalAndTruncationRecovery( t.Fatal(err) } for _, thread := range []corpus.Thread{ - {RepositoryID: selected.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "open", Author: "alice", Title: "older selected", SourceUpdatedAt: now}, - {RepositoryID: selected.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "open", Author: "alice", Title: "newer selected", SourceUpdatedAt: now.Add(time.Second)}, - {RepositoryID: other.ID, Kind: corpus.ThreadKindPullRequest, Number: 3, State: "open", Author: "alice", Title: "newest other repository", SourceUpdatedAt: now.Add(2 * time.Second)}, + {RepositoryID: selected.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", Author: "alice", Title: "older selected", SourceUpdatedAt: now}, + {RepositoryID: selected.ID, Kind: domain.PullRequestKind, Number: 2, State: "open", Author: "alice", Title: "newer selected", SourceUpdatedAt: now.Add(time.Second)}, + {RepositoryID: other.ID, Kind: domain.PullRequestKind, Number: 3, State: "open", Author: "alice", Title: "newest other repository", SourceUpdatedAt: now.Add(2 * time.Second)}, } { if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) diff --git a/internal/app/mcp_pr_check_wait.go b/internal/app/mcp_pr_check_wait.go index dc4fd8a3..492f0f54 100644 --- a/internal/app/mcp_pr_check_wait.go +++ b/internal/app/mcp_pr_check_wait.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" @@ -23,16 +22,72 @@ const ( maxPullRequestCheckTransitions = 64 ) +type gitCommitSHA [20]byte + +func parseGitCommitSHA(value string) (gitCommitSHA, error) { + decoded, err := hex.DecodeString(strings.TrimSpace(value)) + if err != nil || len(decoded) != len(gitCommitSHA{}) { + return gitCommitSHA{}, errors.New("expected_head_sha must be a full 40-character hexadecimal commit SHA") + } + var sha gitCommitSHA + copy(sha[:], decoded) + return sha, nil +} + +func (s gitCommitSHA) String() string { + return hex.EncodeToString(s[:]) +} + +func (s gitCommitSHA) matches(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), s.String()) +} + +type pullRequestCheckCompletion uint8 + +const ( + pullRequestCheckCompletionAll pullRequestCheckCompletion = iota + 1 + pullRequestCheckCompletionFailFast +) + +func (c pullRequestCheckCompletion) failFast() bool { + return c == pullRequestCheckCompletionFailFast +} + +type pullRequestCheckWaitRequest struct { + repository domain.RepoRef + number int + expectedHead gitCommitSHA + timeout time.Duration + pollInterval time.Duration + maxPages int + completion pullRequestCheckCompletion +} + +func (r pullRequestCheckWaitRequest) canonical() mcpcontract.WaitPullRequestChecksInput { + return mcpcontract.WaitPullRequestChecksInput{ + Owner: r.repository.Owner(), + Repo: r.repository.Repo(), + Number: r.number, + ExpectedHeadSHA: r.expectedHead.String(), + Timeout: r.timeout.String(), + PollInterval: r.pollInterval.String(), + MaxPages: r.maxPages, + FailFast: r.completion.failFast(), + } +} + // WaitPullRequestChecks owns the unchanged-state interval inside a durable // job. It watches one exact head and only replaces the stored health snapshot // after a complete terminal observation, so timeout/cancellation cannot erase // the last usable corpus projection. func (r *MCPReader) WaitPullRequestChecks(ctx context.Context, in mcpcontract.WaitPullRequestChecksInput) (mcpcontract.JobReference, error) { - if err := validatePullRequestCheckWaitInput(&in); err != nil { + request, err := parsePullRequestCheckWaitInput(in) + if err != nil { return mcpcontract.JobReference{}, err } - id, err := r.submitJob(ctx, "wait_pull_request_checks", in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.waitPullRequestChecks(ctx, in, report) + canonical := request.canonical() + id, err := r.submitJob(ctx, "wait_pull_request_checks", canonical, func(ctx context.Context, report func(string, string) error) (any, error) { + return r.waitPullRequestChecks(ctx, request, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -40,50 +95,53 @@ func (r *MCPReader) WaitPullRequestChecks(ctx context.Context, in mcpcontract.Wa return queuedJobReference(id, "wait_pull_request_checks", "pull-request check watch started"), nil } -func validatePullRequestCheckWaitInput(in *mcpcontract.WaitPullRequestChecksInput) error { - if _, err := domain.NewRepoRef(in.Owner, in.Repo); err != nil { - return err +func parsePullRequestCheckWaitInput(in mcpcontract.WaitPullRequestChecksInput) (pullRequestCheckWaitRequest, error) { + repository, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { + return pullRequestCheckWaitRequest{}, err } if in.Number < 1 { - return errors.New("number must be positive") + return pullRequestCheckWaitRequest{}, errors.New("number must be positive") } - if len(strings.TrimSpace(in.ExpectedHeadSHA)) != 40 { - return errors.New("expected_head_sha must be a full 40-character hexadecimal commit SHA") - } - if _, err := hex.DecodeString(strings.TrimSpace(in.ExpectedHeadSHA)); err != nil { - return errors.New("expected_head_sha must be a full 40-character hexadecimal commit SHA") + expectedHead, err := parseGitCommitSHA(in.ExpectedHeadSHA) + if err != nil { + return pullRequestCheckWaitRequest{}, err } - in.ExpectedHeadSHA = strings.ToLower(strings.TrimSpace(in.ExpectedHeadSHA)) if in.Timeout == "" { in.Timeout = defaultPullRequestCheckWaitTimeout.String() } timeout, err := time.ParseDuration(in.Timeout) if err != nil || timeout <= 0 || timeout > maxPullRequestCheckWaitTimeout { - return fmt.Errorf("timeout must be between 1s and %s", maxPullRequestCheckWaitTimeout) + return pullRequestCheckWaitRequest{}, fmt.Errorf("timeout must be between 1s and %s", maxPullRequestCheckWaitTimeout) } if in.PollInterval == "" { in.PollInterval = defaultPullRequestCheckPoll.String() } interval, err := time.ParseDuration(in.PollInterval) if err != nil || interval < time.Second || interval > 5*time.Minute { - return errors.New("poll_interval must be between 1s and 5m") + return pullRequestCheckWaitRequest{}, errors.New("poll_interval must be between 1s and 5m") } if timeout/interval > maxPullRequestCheckPolls { - return fmt.Errorf("timeout and poll_interval allow at most %d status polls", maxPullRequestCheckPolls) + return pullRequestCheckWaitRequest{}, fmt.Errorf("timeout and poll_interval allow at most %d status polls", maxPullRequestCheckPolls) } if in.MaxPages == 0 { in.MaxPages = 10 } if in.MaxPages < 1 || in.MaxPages > 100 { - return errors.New("max_pages must be between 1 and 100") + return pullRequestCheckWaitRequest{}, errors.New("max_pages must be between 1 and 100") + } + completion := pullRequestCheckCompletionAll + if in.FailFast { + completion = pullRequestCheckCompletionFailFast } - return nil + return pullRequestCheckWaitRequest{ + repository: repository, number: in.Number, expectedHead: expectedHead, + timeout: timeout, pollInterval: interval, maxPages: in.MaxPages, completion: completion, + }, nil } -func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.WaitPullRequestChecksInput, report func(string, string) error) (mcpcontract.WaitPullRequestChecksOutput, error) { //nolint:gocognit // The bounded watcher owns polling, coalescing, exact-head checks, and terminal persistence. - timeout, _ := time.ParseDuration(in.Timeout) - interval, _ := time.ParseDuration(in.PollInterval) - watchCtx, cancel := context.WithTimeout(ctx, timeout) +func (r *MCPReader) waitPullRequestChecks(ctx context.Context, request pullRequestCheckWaitRequest, report func(string, string) error) (mcpcontract.WaitPullRequestChecksOutput, error) { //nolint:gocognit // The bounded watcher owns polling, coalescing, exact-head checks, and terminal persistence. + watchCtx, cancel := context.WithTimeout(ctx, request.timeout) defer cancel() reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { @@ -95,22 +153,22 @@ func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.Wa } result := mcpcontract.WaitPullRequestChecksOutput{ - Status: "waiting", Owner: in.Owner, Repo: in.Repo, Number: in.Number, - ExpectedHeadSHA: in.ExpectedHeadSHA, Transitions: make([]mcpcontract.PullRequestCheckTransition, 0, 8), + Status: mcpcontract.PullRequestCheckWaiting, Owner: request.repository.Owner(), Repo: request.repository.Repo(), Number: request.number, + ExpectedHeadSHA: request.expectedHead.String(), Transitions: make([]mcpcontract.PullRequestCheckTransition, 0, 8), } var lastSignature string for poll := 1; poll <= maxPullRequestCheckPolls; poll++ { if err := watchCtx.Err(); err != nil { if errors.Is(err, context.DeadlineExceeded) { - result.Status, result.Reason = "timed_out", "watch timeout elapsed before a complete terminal check set was observed" + result.Status, result.Reason = mcpcontract.PullRequestCheckTimedOut, "watch timeout elapsed before a complete terminal check set was observed" return result, nil } return result, err } - remote, err := statusReader.GetPullRequestStatus(watchCtx, in.Owner, in.Repo, in.Number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: in.MaxPages}) + remote, err := statusReader.GetPullRequestStatus(watchCtx, request.repository.Owner(), request.repository.Repo(), request.number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: request.maxPages}) if err != nil { if errors.Is(watchCtx.Err(), context.DeadlineExceeded) { - result.Status, result.Reason = "timed_out", "watch timeout elapsed before a complete terminal check set was observed" + result.Status, result.Reason = mcpcontract.PullRequestCheckTimedOut, "watch timeout elapsed before a complete terminal check set was observed" return result, nil } return mcpcontract.WaitPullRequestChecksOutput{}, err @@ -118,19 +176,19 @@ func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.Wa result.Polls = poll result.ObservedHeadSHA = remote.HeadSHA result.ObservedAt = remote.SourceUpdatedAt.UTC().Format(time.RFC3339Nano) - if !strings.EqualFold(remote.HeadSHA, in.ExpectedHeadSHA) { - result.Status, result.Reason = "superseded", "pull-request head changed while waiting; start a new watch for the new revision" + if !request.expectedHead.matches(remote.HeadSHA) { + result.Status, result.Reason = mcpcontract.PullRequestCheckSuperseded, "pull-request head changed while waiting; start a new watch for the new revision" return result, nil } - if in.FailFast && pullRequestChecksHasFailed(remote.Checks.Items) && (!remote.Checks.Coverage.Complete || !pullRequestChecksAllTerminal(remote.Checks.Items)) { + if request.completion.failFast() && pullRequestChecksHasFailed(remote.Checks.Items) && (!remote.Checks.Coverage.Complete || !pullRequestChecksAllTerminal(remote.Checks.Items)) { result.Checks = pullRequestChecksToOutput(remote.Checks.Items) - result.Status, result.Reason = "failed", "one or more observed checks concluded unsuccessfully; fail-fast returned without replacing incomplete coverage" + result.Status, result.Reason = mcpcontract.PullRequestCheckFailed, "one or more observed checks concluded unsuccessfully; fail-fast returned without replacing incomplete coverage" return result, nil } if !remote.Checks.Coverage.Complete { // A truncated rollup cannot prove terminality. Keep the prior complete // snapshot intact and let the bounded wait return timed_out if needed. - result.Status = "incomplete" + result.Status = mcpcontract.PullRequestCheckIncomplete } else { signature := pullRequestCheckSignature(remote.Checks.Items) if signature != lastSignature && len(result.Transitions) < maxPullRequestCheckTransitions { @@ -142,71 +200,71 @@ func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.Wa result.TransitionsTruncated = true lastSignature = signature } - terminal, failed := pullRequestChecksTerminal(remote.Checks.Items, in.FailFast) + terminal, failed := pullRequestChecksTerminal(remote.Checks.Items, request.completion) if terminal { result.Checks = pullRequestChecksToOutput(remote.Checks.Items) - if failed && in.FailFast && !pullRequestChecksAllTerminal(remote.Checks.Items) { - result.Status, result.Reason = "failed", "one or more checks concluded unsuccessfully; fail-fast returned before other checks completed" + if failed && request.completion.failFast() && !pullRequestChecksAllTerminal(remote.Checks.Items) { + result.Status, result.Reason = mcpcontract.PullRequestCheckFailed, "one or more checks concluded unsuccessfully; fail-fast returned before other checks completed" return result, nil } - latest, latestErr := statusReader.GetPullRequestStatus(watchCtx, in.Owner, in.Repo, in.Number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: in.MaxPages}) + latest, latestErr := statusReader.GetPullRequestStatus(watchCtx, request.repository.Owner(), request.repository.Repo(), request.number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: request.maxPages}) if latestErr != nil { if errors.Is(watchCtx.Err(), context.DeadlineExceeded) { - result.Status, result.Reason = "timed_out", "watch timeout elapsed before the terminal health projection could be replaced" + result.Status, result.Reason = mcpcontract.PullRequestCheckTimedOut, "watch timeout elapsed before the terminal health projection could be replaced" return result, nil } return result, latestErr } - if !strings.EqualFold(latest.HeadSHA, in.ExpectedHeadSHA) { - result.Status, result.Reason = "superseded", "pull-request head changed before the terminal health projection could be replaced" + if !request.expectedHead.matches(latest.HeadSHA) { + result.Status, result.Reason = mcpcontract.PullRequestCheckSuperseded, "pull-request head changed before the terminal health projection could be replaced" return result, nil } if !latest.Checks.Coverage.Complete { - result.Status, result.Reason = "incomplete", "terminal observation became incomplete before local replacement" + result.Status, result.Reason = mcpcontract.PullRequestCheckIncomplete, "terminal observation became incomplete before local replacement" return result, nil } - latestTerminal, _ := pullRequestChecksTerminal(latest.Checks.Items, false) + latestTerminal, _ := pullRequestChecksTerminal(latest.Checks.Items, pullRequestCheckCompletionAll) if !latestTerminal || !pullRequestChecksAllTerminal(latest.Checks.Items) { - result.Status, result.Reason = "incomplete", "check state changed before local replacement and is no longer a complete terminal set" + result.Status, result.Reason = mcpcontract.PullRequestCheckIncomplete, "check state changed before local replacement and is no longer a complete terminal set" return result, nil } remote = latest result.Checks = pullRequestChecksToOutput(remote.Checks.Items) - baselines, baselineErr := r.pullRequestHealthBaselines(watchCtx, mcpcontract.ThreadRef{Owner: in.Owner, Repo: in.Repo, Kind: corpus.ThreadKindPullRequest, Number: in.Number}) + baselines, baselineErr := r.pullRequestHealthBaselines(watchCtx, mcpcontract.ThreadRef{Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: string(domain.PullRequestKind), Number: request.number}) if baselineErr == nil { - final, finalErr := statusReader.GetPullRequestStatus(watchCtx, in.Owner, in.Repo, in.Number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: in.MaxPages}) + final, finalErr := statusReader.GetPullRequestStatus(watchCtx, request.repository.Owner(), request.repository.Repo(), request.number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: request.maxPages}) if finalErr != nil { if errors.Is(watchCtx.Err(), context.DeadlineExceeded) { - result.Status, result.Reason = "timed_out", "watch timeout elapsed before the terminal health projection could be replaced" + result.Status, result.Reason = mcpcontract.PullRequestCheckTimedOut, "watch timeout elapsed before the terminal health projection could be replaced" return result, nil } return result, finalErr } - if !strings.EqualFold(final.HeadSHA, in.ExpectedHeadSHA) { - result.Status, result.Reason = "superseded", "pull-request head changed immediately before the local health projection could be replaced" + if !request.expectedHead.matches(final.HeadSHA) { + result.Status, result.Reason = mcpcontract.PullRequestCheckSuperseded, "pull-request head changed immediately before the local health projection could be replaced" return result, nil } if !final.Checks.Coverage.Complete || !pullRequestChecksAllTerminal(final.Checks.Items) { - result.Status, result.Reason = "incomplete", "check state changed immediately before the local health projection could be replaced" + result.Status, result.Reason = mcpcontract.PullRequestCheckIncomplete, "check state changed immediately before the local health projection could be replaced" return result, nil } remote = final failed = pullRequestChecksHasFailed(final.Checks.Items) result.Checks = pullRequestChecksToOutput(final.Checks.Items) - _, persistErr := r.persistPullRequestHealth(watchCtx, mcpcontract.ThreadRef{Owner: in.Owner, Repo: in.Repo, Kind: corpus.ThreadKindPullRequest, Number: in.Number}, remote, nil, baselines) + _, persistErr := r.persistPullRequestHealth(watchCtx, mcpcontract.ThreadRef{Owner: request.repository.Owner(), Repo: request.repository.Repo(), Kind: string(domain.PullRequestKind), Number: request.number}, remote, nil, baselines) result.Persisted = persistErr == nil if persistErr != nil { - result.Status, result.Reason = "incomplete", "terminal checks observed but the local health projection could not be replaced" + result.Status, result.Reason = mcpcontract.PullRequestCheckIncomplete, "terminal checks observed but the local health projection could not be replaced" return result, nil } } else { - result.Status, result.Reason = "incomplete", "terminal checks observed but the local health baseline is unavailable" + result.Status, result.Reason = mcpcontract.PullRequestCheckIncomplete, "terminal checks observed but the local health baseline is unavailable" return result, nil } if failed { - result.Status, result.Reason = "failed", "one or more checks concluded unsuccessfully" + result.Status, result.Reason = mcpcontract.PullRequestCheckFailed, "one or more checks concluded unsuccessfully" } else { - result.Status = "succeeded" + result.Status = mcpcontract.PullRequestCheckSucceeded } return result, nil } @@ -214,7 +272,7 @@ func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.Wa if err := report("waiting_for_checks", fmt.Sprintf(`{"polls":%d,"transitions":%d}`, result.Polls, len(result.Transitions))); err != nil { return result, err } - timer := time.NewTimer(interval) + timer := time.NewTimer(request.pollInterval) select { case <-watchCtx.Done(): if !timer.Stop() { @@ -226,7 +284,7 @@ func (r *MCPReader) waitPullRequestChecks(ctx context.Context, in mcpcontract.Wa case <-timer.C: } } - result.Status, result.Reason = "timed_out", "maximum poll count reached before a complete terminal check set was observed" + result.Status, result.Reason = mcpcontract.PullRequestCheckTimedOut, "maximum poll count reached before a complete terminal check set was observed" return result, nil } @@ -260,7 +318,7 @@ func pullRequestCheckSignature(items []github.PullRequestCheck) string { return hex.EncodeToString(digest[:]) } -func pullRequestChecksTerminal(items []github.PullRequestCheck, failFast bool) (terminal, failed bool) { +func pullRequestChecksTerminal(items []github.PullRequestCheck, completion pullRequestCheckCompletion) (terminal, failed bool) { if len(items) == 0 { return false, false } @@ -274,7 +332,7 @@ func pullRequestChecksTerminal(items []github.PullRequestCheck, failFast bool) ( failed = true } } - if failFast && failed { + if completion.failFast() && failed { return true, true } if pending { diff --git a/internal/app/mcp_pr_check_wait_test.go b/internal/app/mcp_pr_check_wait_test.go index c40f3bdc..9bbbc4cc 100644 --- a/internal/app/mcp_pr_check_wait_test.go +++ b/internal/app/mcp_pr_check_wait_test.go @@ -2,16 +2,42 @@ package app import ( "testing" + "time" "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" ) +func TestParsePullRequestCheckWaitInputCanonicalizesBoundary(t *testing.T) { + request, err := parsePullRequestCheckWaitInput(mcpcontract.WaitPullRequestChecksInput{ + Owner: " Acme ", Repo: " Rocket ", Number: 7, + ExpectedHeadSHA: " ABCDEF0123456789ABCDEF0123456789ABCDEF01 ", + FailFast: true, + }) + if err != nil { + t.Fatalf("parse wait request: %v", err) + } + if request.timeout != 30*time.Minute || request.pollInterval != 10*time.Second { + t.Fatalf("wait bounds = %s/%s, want 30m/10s", request.timeout, request.pollInterval) + } + canonical := request.canonical() + if canonical.Owner != "Acme" || canonical.Repo != "Rocket" || canonical.MaxPages != 10 { + t.Fatalf("canonical repository/page = %s/%s/%d", canonical.Owner, canonical.Repo, canonical.MaxPages) + } + if canonical.ExpectedHeadSHA != "abcdef0123456789abcdef0123456789abcdef01" { + t.Fatalf("canonical head = %q", canonical.ExpectedHeadSHA) + } + if !canonical.FailFast || !request.expectedHead.matches("ABCDEF0123456789ABCDEF0123456789ABCDEF01") { + t.Fatal("parsed fail-fast policy or exact-head identity was lost") + } +} + func TestPullRequestChecksTerminalRequiresEveryCheck(t *testing.T) { checks := []github.PullRequestCheck{ {Name: "build", Status: "COMPLETED", Conclusion: "SUCCESS"}, {Name: "test", Status: "IN_PROGRESS"}, } - if terminal, failed := pullRequestChecksTerminal(checks, false); terminal || failed { + if terminal, failed := pullRequestChecksTerminal(checks, pullRequestCheckCompletionAll); terminal || failed { t.Fatalf("terminal = %v, failed = %v; pending check must keep the watch open", terminal, failed) } } @@ -21,7 +47,7 @@ func TestPullRequestChecksFailFastReturnsFailure(t *testing.T) { {Name: "long-test", Status: "IN_PROGRESS"}, {Name: "lint", Status: "COMPLETED", Conclusion: "FAILURE"}, } - terminal, failed := pullRequestChecksTerminal(checks, true) + terminal, failed := pullRequestChecksTerminal(checks, pullRequestCheckCompletionFailFast) if !terminal || !failed { t.Fatalf("terminal = %v, failed = %v; fail-fast should finish on a failed check", terminal, failed) } @@ -35,7 +61,7 @@ func TestPullRequestChecksExpectedAndCancelledAreNotPassing(t *testing.T) { {Name: "queued", Status: "EXPECTED"}, {Name: "cancelled", Status: "CANCELLED"}, } { - terminal, failed := pullRequestChecksTerminal([]github.PullRequestCheck{check}, false) + terminal, failed := pullRequestChecksTerminal([]github.PullRequestCheck{check}, pullRequestCheckCompletionAll) if check.Status == "EXPECTED" { if terminal || failed { t.Fatalf("EXPECTED check was treated as terminal/failing: terminal=%v failed=%v", terminal, failed) @@ -47,7 +73,7 @@ func TestPullRequestChecksExpectedAndCancelledAreNotPassing(t *testing.T) { } func TestPullRequestChecksEmptyRollupIsNotTerminal(t *testing.T) { - if terminal, failed := pullRequestChecksTerminal(nil, false); terminal || failed { + if terminal, failed := pullRequestChecksTerminal(nil, pullRequestCheckCompletionAll); terminal || failed { t.Fatalf("terminal = %v, failed = %v; an empty rollup must wait for late registration", terminal, failed) } } diff --git a/internal/app/mcp_pr_health.go b/internal/app/mcp_pr_health.go index 2ac2b5a9..2283f7b1 100644 --- a/internal/app/mcp_pr_health.go +++ b/internal/app/mcp_pr_health.go @@ -5,12 +5,12 @@ import ( "encoding/json" "errors" "fmt" - "strconv" "sync" "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" ) @@ -26,7 +26,7 @@ type pullRequestStatusBatchInput struct { // It uses the existing REST hydration path for details and reviews, then one // typed GraphQL read for health facets unavailable from REST. func (s *Service) syncPullRequestStatusBatch(ctx context.Context, in pullRequestStatusBatchInput, report func(string, string) error) (pullRequestStatusBatchResult, error) { - results := make([]map[string]any, len(in.PullRequests)) + results := make([]pullRequestStatusItem, len(in.PullRequests)) work := make(chan int) workers := min(pullRequestStatusWorkers, len(in.PullRequests)) var wg sync.WaitGroup @@ -53,25 +53,25 @@ func (s *Service) syncPullRequestStatusBatch(ctx context.Context, in pullRequest wg.Wait() completed := 0 - status := "complete" + status := batchOperationComplete failures := make([]pullRequestStatusFailure, 0) for index, item := range results { - if item["status"] == "complete" { + if item.Status() == mcpcontract.BatchItemComplete { completed++ } else { - status = "partial" + status = batchOperationPartial ref := in.PullRequests[index] if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } failure := pullRequestStatusFailure{ Reference: threadRefKey(ref), - Status: stringMapValue(item, "status"), - Reason: stringMapValue(item, "reason"), - Message: stringMapValue(item, "message"), + Status: item.Status(), + Reason: item.Reason(), + Message: item.Message(), } - if retry, ok := item["retry_after_ms"].(int); ok && retry > 0 { - failure.RetryAfterMS = retry + if retryAfter := item.RetryAfterMS(); retryAfter != nil && *retryAfter > 0 { + failure.RetryAfterMS = *retryAfter } failures = append(failures, failure) } @@ -83,76 +83,168 @@ func (s *Service) syncPullRequestStatusBatch(ctx context.Context, in pullRequest } type pullRequestStatusBatchResult struct { - Status string `json:"status"` - Items []map[string]any `json:"items"` + Status batchOperationStatus `json:"status"` + Items []pullRequestStatusItem `json:"items"` Failures []pullRequestStatusFailure `json:"failures,omitempty"` Completed int `json:"completed"` Total int `json:"total"` } -type pullRequestStatusFailure struct { - Reference string `json:"reference"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` - RetryAfterMS int `json:"retry_after_ms,omitempty"` +type pullRequestStatusItem struct { + key string + status mcpcontract.BatchItemStatus + snapshot *pullRequestStatusSnapshot + failure *pullRequestStatusItemFailure +} + +type pullRequestStatusSnapshot struct { + reason string + recovery *mcpcontract.RecoveryPlan + facets []pullRequestHealthFacet + headSHA string +} + +type pullRequestStatusItemFailure struct { + reason string + message string + retryAfterMS *int + recovery *mcpcontract.RecoveryPlan +} + +func pullRequestStatusSnapshotItem(key string, status mcpcontract.BatchItemStatus, reason string, recovery *mcpcontract.RecoveryPlan, facets []pullRequestHealthFacet, headSHA string) pullRequestStatusItem { + return pullRequestStatusItem{ + key: key, status: status, + snapshot: &pullRequestStatusSnapshot{reason: reason, recovery: recovery, facets: facets, headSHA: headSHA}, + } +} + +func pullRequestStatusFailureItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS *int, recovery *mcpcontract.RecoveryPlan) pullRequestStatusItem { + return pullRequestStatusItem{ + key: key, status: status, + failure: &pullRequestStatusItemFailure{reason: reason, message: message, retryAfterMS: retryAfterMS, recovery: recovery}, + } +} + +func (i pullRequestStatusItem) Status() mcpcontract.BatchItemStatus { return i.status } + +func (i pullRequestStatusItem) Reason() string { + if i.snapshot != nil { + return i.snapshot.reason + } + if i.failure != nil { + return i.failure.reason + } + return "" +} + +func (i pullRequestStatusItem) Message() string { + if i.failure == nil { + return "" + } + return i.failure.message +} + +func (i pullRequestStatusItem) RetryAfterMS() *int { + if i.failure == nil { + return nil + } + return i.failure.retryAfterMS } -func stringMapValue(item map[string]any, key string) string { - value, _ := item[key].(string) - return value +func (i pullRequestStatusItem) MarshalJSON() ([]byte, error) { + if i.snapshot != nil && i.failure == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason,omitempty"` + Recovery *mcpcontract.RecoveryPlan `json:"recovery,omitempty"` + Facets []pullRequestHealthFacet `json:"facets"` + HeadSHA string `json:"head_sha"` + }{i.key, i.status, i.snapshot.reason, i.snapshot.recovery, i.snapshot.facets, i.snapshot.headSHA}) + } + if i.failure != nil && i.snapshot == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + RetryAfterMS *int `json:"retry_after_ms,omitempty"` + Recovery *mcpcontract.RecoveryPlan `json:"recovery,omitempty"` + }{i.key, i.status, i.failure.reason, i.failure.message, i.failure.retryAfterMS, i.failure.recovery}) + } + return nil, errors.New("pull request status item has no single outcome") } -func (s *Service) syncOnePullRequestStatus(ctx context.Context, ref mcpcontract.ThreadRef, maxPages int) map[string]any { +type pullRequestHealthFacet struct { + Facet string `json:"facet"` + Status mcpcontract.BatchItemStatus `json:"status"` + Complete bool `json:"complete"` + Fetched int `json:"fetched"` + Total *int `json:"total,omitempty"` + Pages *int `json:"pages,omitempty"` + Recovery *mcpcontract.RecoveryPlan `json:"recovery,omitempty"` +} + +type pullRequestStatusFailure struct { + Reference string `json:"reference"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + RetryAfterMS int `json:"retry_after_ms,omitempty"` +} + +func (s *Service) syncOnePullRequestStatus(ctx context.Context, ref mcpcontract.ThreadRef, maxPages int) pullRequestStatusItem { if ref.Number <= 0 { - return map[string]any{"key": threadRefKey(ref), "status": "failed", "reason": "invalid_reference", "message": "pull request number must be positive"} + return pullRequestStatusFailureItem(threadRefKey(ref), mcpcontract.BatchItemFailed, "invalid_reference", "pull request number must be positive", nil, nil) } if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } key := threadRefKey(ref) - hydrated, err := s.HydrateThread(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, ref.Number, HydrateOptions{Kind: ref.Kind, Facets: []string{FacetPRDetails, FacetPRReviews}, MaxPages: maxPages}) + hydrated, err := s.hydrateStoredThread(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, ref.Number, hydrateThreadInput{Kind: ref.Kind, Facets: []string{FacetPRDetails, FacetPRReviews}, MaxPages: maxPages}) if err != nil { status, reason, message, retry := githubBatchError(err) - return map[string]any{"key": key, "status": status, "reason": reason, "message": message, "retry_after_ms": retry} + return pullRequestStatusFailureItem(key, status, reason, message, intPointer(retry), nil) } reader, err := s.githubReader() //nolint:contextcheck // The typed read below receives ctx. if err != nil { - return map[string]any{"key": key, "status": "failed", "reason": "github_unavailable", "message": err.Error()} + return pullRequestStatusFailureItem(key, mcpcontract.BatchItemFailed, "github_unavailable", err.Error(), nil, nil) } statusReader, ok := reader.(github.PullRequestStatusReader) if !ok { - return map[string]any{"key": key, "status": "unavailable", "reason": "blocked", "message": "Configure a GitHub reader with pull-request status support.", "recovery": recoveryPlan("blocked", "Configure a GitHub reader with pull-request status support.")} + message := "Configure a GitHub reader with pull-request status support." + return pullRequestStatusFailureItem(key, mcpcontract.BatchItemUnavailable, "blocked", message, nil, recoveryPlan("blocked", message)) } baselines, err := s.pullRequestHealthBaselines(ctx, ref) if err != nil { - return map[string]any{"key": key, "status": "failed", "reason": "read_status_baseline_failed", "message": err.Error()} + return pullRequestStatusFailureItem(key, mcpcontract.BatchItemFailed, "read_status_baseline_failed", err.Error(), nil, nil) } remote, err := statusReader.GetPullRequestStatus(ctx, ref.Owner, ref.Repo, ref.Number, github.PullRequestStatusOptions{PageSize: 100, MaxPages: maxPages}) if err != nil { itemStatus, reason, message, retry := githubBatchError(err) - return map[string]any{"key": key, "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} + return pullRequestStatusFailureItem(key, itemStatus, reason, message, intPointer(retry), nil) } facets, err := s.persistPullRequestHealth(ctx, ref, remote, hydrated.Facets, baselines) if err != nil { - return map[string]any{"key": key, "status": "failed", "reason": "persist_status_failed", "message": err.Error()} + return pullRequestStatusFailureItem(key, mcpcontract.BatchItemFailed, "persist_status_failed", err.Error(), nil, nil) } - itemStatus := "complete" + itemStatus := mcpcontract.BatchItemComplete for _, facet := range facets { - if facet["status"] != "complete" { - itemStatus = "retryable" + if facet.Status != mcpcontract.BatchItemComplete { + itemStatus = mcpcontract.BatchItemRetryable break } } - item := map[string]any{"key": key, "status": itemStatus, "facets": facets, "head_sha": remote.HeadSHA} - if itemStatus == "retryable" { - item["reason"] = "facet_incomplete" - item["recovery"] = recoveryPlan("facet_incomplete", "Retry this pull request in explicit mode to complete its facets.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) + var reason string + var recovery *mcpcontract.RecoveryPlan + if itemStatus == mcpcontract.BatchItemRetryable { + reason = "facet_incomplete" + recovery = recoveryPlan("facet_incomplete", "Retry this pull request in explicit mode to complete its facets.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) } - return item + return pullRequestStatusSnapshotItem(key, itemStatus, reason, recovery, facets, remote.HeadSHA) } -func (s *Service) persistPullRequestHealth(ctx context.Context, ref mcpcontract.ThreadRef, remote github.PullRequestStatus, hydrated []HydratedFacet, baselines map[string]int64) ([]map[string]any, error) { +func (s *Service) persistPullRequestHealth(ctx context.Context, ref mcpcontract.ThreadRef, remote github.PullRequestStatus, hydrated []contracts.HydratedFacet, baselines map[string]int64) ([]pullRequestHealthFacet, error) { c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -164,7 +256,7 @@ func (s *Service) persistPullRequestHealth(ctx context.Context, ref mcpcontract. } return nil, err } - thread, err := c.GetThread(ctx, repo.ID, ref.Kind, ref.Number) + thread, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil || thread == nil { if err == nil { err = errors.New("pull request is not stored") @@ -175,15 +267,11 @@ func (s *Service) persistPullRequestHealth(ctx context.Context, ref mcpcontract. if sourceUpdatedAt.IsZero() { sourceUpdatedAt = thread.SourceUpdatedAt } - targets := []healthFacet{ - {name: FacetPRMergeState, value: remote.MergeState, coverage: remote.MergeStateCoverage}, - {name: FacetPRMergeQueue, value: remote.MergeQueue, coverage: remote.MergeQueueCoverage}, - {name: FacetPRChecks, value: remote.Checks.Items, coverage: remote.Checks.Coverage}, - {name: FacetPRReviewThreads, value: remote.ReviewThreads.Items, coverage: remote.ReviewThreads.Coverage}, - {name: FacetPRClosingIssues, value: remote.ClosingIssues.Items, coverage: remote.ClosingIssues.Coverage}, - {name: FacetPRFiles, value: remote.Files.Items, coverage: remote.Files.Coverage}, + targets, err := parseHealthFacets(remote) + if err != nil { + return nil, err } - results := hydratedHealthResults(hydrated, mcpcontract.ThreadRef{Owner: repo.Owner, Repo: repo.Name, Kind: thread.Kind, Number: thread.Number}) + results := hydratedHealthResults(hydrated, mcpcontract.ThreadRef{Owner: repo.Owner, Repo: repo.Name, Kind: string(thread.Kind), Number: thread.Number}) for _, target := range targets { result, err := persistOneHealthFacet(ctx, c, *repo, *thread, ref, sourceUpdatedAt, target, baselines[target.name], remote) if err != nil { @@ -194,91 +282,160 @@ func (s *Service) persistPullRequestHealth(ctx context.Context, ref mcpcontract. return results, nil } -func hydratedHealthResults(facets []HydratedFacet, ref mcpcontract.ThreadRef) []map[string]any { - results := make([]map[string]any, 0, len(facets)) +func hydratedHealthResults(facets []contracts.HydratedFacet, ref mcpcontract.ThreadRef) []pullRequestHealthFacet { + results := make([]pullRequestHealthFacet, 0, len(facets)) for _, facet := range facets { - result := map[string]any{"facet": facet.Facet, "status": "complete", "complete": facet.Complete, "fetched": facet.Count, "pages": facet.Pages} + result := pullRequestHealthFacet{Facet: facet.Facet, Status: mcpcontract.BatchItemComplete, Complete: facet.Complete, Fetched: facet.Count, Pages: intPointer(facet.Pages)} if !facet.Complete { - result["status"] = "retryable" - result["recovery"] = recoveryPlan("facet_incomplete", "Retry this pull request with a larger max_pages bound.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) + result.Status = mcpcontract.BatchItemRetryable + result.Recovery = recoveryPlan("facet_incomplete", "Retry this pull request with a larger max_pages bound.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) } results = append(results, result) } return results } -func persistOneHealthFacet(ctx context.Context, c *corpus.Corpus, repo corpus.Repository, thread corpus.Thread, ref mcpcontract.ThreadRef, sourceUpdatedAt time.Time, target healthFacet, baseline int64, remote github.PullRequestStatus) (map[string]any, error) { +func persistOneHealthFacet(ctx context.Context, c *corpus.Corpus, repo corpus.Repository, thread corpus.Thread, ref mcpcontract.ThreadRef, sourceUpdatedAt time.Time, target healthFacet, baseline int64, remote github.PullRequestStatus) (pullRequestHealthFacet, error) { applied, err := persistHealthFacet(ctx, c, repo.ID, thread.ID, sourceUpdatedAt, target, baseline) if err != nil { - return nil, err + return pullRequestHealthFacet{}, err } - if applied && target.coverage.Complete { - if err := persistPortfolioSignals(ctx, c, thread, sourceUpdatedAt, target); err != nil { - return nil, err + if applied && target.portfolio != nil { + if err := persistPortfolioSignals(ctx, c, thread, sourceUpdatedAt, target.name, *target.portfolio); err != nil { + return pullRequestHealthFacet{}, err } } - result := map[string]any{"facet": target.name, "complete": target.coverage.Complete, "fetched": target.coverage.Fetched, "total": target.coverage.Total, "status": "complete"} + result := pullRequestHealthFacet{Facet: target.name, Status: mcpcontract.BatchItemComplete, Complete: target.coverage.Complete, Fetched: target.coverage.Fetched, Total: intPointer(target.coverage.Total)} if !applied { - result["status"], result["complete"] = "retryable", false - result["recovery"] = recoveryPlan("coverage_stale", "A concurrent refresh advanced this facet; retry for a coherent snapshot.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) + result.Status, result.Complete = mcpcontract.BatchItemRetryable, false + result.Recovery = recoveryPlan("coverage_stale", "A concurrent refresh advanced this facet; retry for a coherent snapshot.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) } if _, known := remote.MergeState.Mergeability(); target.name == FacetPRMergeState && !known { - result["status"] = "retryable" - result["recovery"] = recoveryPlan("facet_incomplete", "Retry after GitHub finishes computing mergeability.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) + result.Status = mcpcontract.BatchItemRetryable + result.Recovery = recoveryPlan("facet_incomplete", "Retry after GitHub finishes computing mergeability.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) } if !target.coverage.Complete { - result["status"] = "retryable" - result["recovery"] = recoveryPlan("facet_incomplete", "Retry this pull request to complete the facet after the current cursor.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) + result.Status = mcpcontract.BatchItemRetryable + result.Recovery = recoveryPlan("facet_incomplete", "Retry this pull request to complete the facet after the current cursor.", syncPullRequestCalls([]mcpcontract.ThreadRef{ref})...) } return result, nil } -func persistPortfolioSignals(ctx context.Context, c *corpus.Corpus, thread corpus.Thread, sourceUpdatedAt time.Time, facet healthFacet) error { - portfolioFacet := "" - var signals []corpus.PortfolioSignal - switch facet.name { - case FacetPRFiles: - portfolioFacet = corpus.PortfolioFacetChangedFiles - files, ok := facet.value.([]github.PullRequestFile) - if !ok { - return fmt.Errorf("%s facet has unexpected value type %T", facet.name, facet.value) - } - for _, file := range files { - signals = append(signals, corpus.PortfolioSignal{Kind: corpus.PortfolioSignalFilePath, Value: file.Path}) - } - case FacetPRClosingIssues: - portfolioFacet = corpus.PortfolioFacetLinkedIssues - issues, ok := facet.value.([]github.PullRequestClosingIssue) - if !ok { - return fmt.Errorf("%s facet has unexpected value type %T", facet.name, facet.value) - } - for _, issue := range issues { - signals = append(signals, corpus.PortfolioSignal{Kind: corpus.PortfolioSignalLinkedIssue, Value: fmt.Sprintf("%s#%d", issue.RepositoryFullName, issue.Number)}) - } - default: - return nil - } - observations, _, err := c.ListFacetObservationsBounded(ctx, thread.RepositoryID, &thread.ID, facet.name, 1) +type portfolioSignalProjection struct { + facet string + signals []corpus.PortfolioSignal +} + +func persistPortfolioSignals(ctx context.Context, c *corpus.Corpus, thread corpus.Thread, sourceUpdatedAt time.Time, sourceFacet string, projection portfolioSignalProjection) error { + observations, _, err := c.ListFacetObservationsBounded(ctx, thread.RepositoryID, &thread.ID, sourceFacet, 1) if err != nil { return err } if len(observations) == 0 { - return fmt.Errorf("complete %s facet has no source observation", facet.name) + return fmt.Errorf("complete %s facet has no source observation", sourceFacet) + } + subject, err := corpus.NewPullRequestPortfolioSubject(thread.ID) + if err != nil { + return err + } + sourceRef, err := corpus.NewFacetObservationRef(observations[0].ID) + if err != nil { + return err } _, err = c.ReplacePortfolioSignals(ctx, corpus.PortfolioSignalSnapshot{ - Subject: corpus.PortfolioSubject{Kind: corpus.PortfolioSubjectPullRequest, Ref: strconv.FormatInt(thread.ID, 10)}, - Facet: portfolioFacet, - Signals: signals, + Subject: subject, + Facet: projection.facet, + Signals: projection.signals, SourceUpdatedAt: sourceUpdatedAt, - SourceObservationRefs: []corpus.ObservationRef{{Kind: "facet", ID: observations[0].ID}}, + SourceObservationRefs: []corpus.ObservationRef{sourceRef}, }) return err } type healthFacet struct { - name string - value any - coverage github.FacetCoverage + name string + payload json.RawMessage + coverage github.FacetCoverage + portfolio *portfolioSignalProjection +} + +func newHealthFacet[T any](name string, value T, coverage github.FacetCoverage, portfolio *portfolioSignalProjection) (healthFacet, error) { + payload, err := json.Marshal(value) + if err != nil { + return healthFacet{}, fmt.Errorf("marshal %s: %w", name, err) + } + return healthFacet{name: name, payload: payload, coverage: coverage, portfolio: portfolio}, nil +} + +func appendHealthFacet[T any](targets []healthFacet, name string, value T, coverage github.FacetCoverage, portfolio *portfolioSignalProjection) ([]healthFacet, error) { + target, err := newHealthFacet(name, value, coverage, portfolio) + if err != nil { + return nil, err + } + return append(targets, target), nil +} + +func parseHealthFacets(remote github.PullRequestStatus) ([]healthFacet, error) { + var files, issues *portfolioSignalProjection + if remote.Files.Coverage.Complete { + parsed, err := parseFilePortfolioSignals(remote.Files.Items) + if err != nil { + return nil, err + } + files = &parsed + } + if remote.ClosingIssues.Coverage.Complete { + parsed, err := parseLinkedIssuePortfolioSignals(remote.ClosingIssues.Items) + if err != nil { + return nil, err + } + issues = &parsed + } + targets := make([]healthFacet, 0, 6) + var err error + if targets, err = appendHealthFacet(targets, FacetPRMergeState, remote.MergeState, remote.MergeStateCoverage, nil); err != nil { + return nil, err + } + if targets, err = appendHealthFacet(targets, FacetPRMergeQueue, remote.MergeQueue, remote.MergeQueueCoverage, nil); err != nil { + return nil, err + } + if targets, err = appendHealthFacet(targets, FacetPRChecks, remote.Checks.Items, remote.Checks.Coverage, nil); err != nil { + return nil, err + } + if targets, err = appendHealthFacet(targets, FacetPRReviewThreads, remote.ReviewThreads.Items, remote.ReviewThreads.Coverage, nil); err != nil { + return nil, err + } + if targets, err = appendHealthFacet(targets, FacetPRClosingIssues, remote.ClosingIssues.Items, remote.ClosingIssues.Coverage, issues); err != nil { + return nil, err + } + if targets, err = appendHealthFacet(targets, FacetPRFiles, remote.Files.Items, remote.Files.Coverage, files); err != nil { + return nil, err + } + return targets, nil +} + +func parseFilePortfolioSignals(files []github.PullRequestFile) (portfolioSignalProjection, error) { + projection := portfolioSignalProjection{facet: corpus.PortfolioFacetChangedFiles, signals: make([]corpus.PortfolioSignal, 0, len(files))} + for _, file := range files { + signal, err := corpus.NewPortfolioFilePathSignal(file.Path) + if err != nil { + return portfolioSignalProjection{}, fmt.Errorf("parse changed file signal: %w", err) + } + projection.signals = append(projection.signals, signal) + } + return projection, nil +} + +func parseLinkedIssuePortfolioSignals(issues []github.PullRequestClosingIssue) (portfolioSignalProjection, error) { + projection := portfolioSignalProjection{facet: corpus.PortfolioFacetLinkedIssues, signals: make([]corpus.PortfolioSignal, 0, len(issues))} + for _, issue := range issues { + signal, err := corpus.NewPortfolioLinkedIssueSignal(fmt.Sprintf("%s#%d", issue.RepositoryFullName, issue.Number)) + if err != nil { + return portfolioSignalProjection{}, fmt.Errorf("parse linked issue signal: %w", err) + } + projection.signals = append(projection.signals, signal) + } + return projection, nil } func persistHealthFacet(ctx context.Context, c *corpus.Corpus, repoID, threadID int64, sourceUpdatedAt time.Time, facet healthFacet, expectedSequence int64) (bool, error) { @@ -287,11 +444,7 @@ func persistHealthFacet(ctx context.Context, c *corpus.Corpus, repoID, threadID // incomplete coverage for this newer source revision. return c.AdvanceFacetCAS(ctx, repoID, &threadID, facet.name, sourceUpdatedAt, false, 0, expectedSequence) } - payload, err := json.Marshal(facet.value) - if err != nil { - return false, fmt.Errorf("marshal %s: %w", facet.name, err) - } - pages := []corpus.FacetObservationInput{{SourceUpdatedAt: sourceUpdatedAt, Payload: string(payload)}} + pages := []corpus.FacetObservationInput{{SourceUpdatedAt: sourceUpdatedAt, Payload: string(facet.payload)}} return c.ApplyFacetObservationSetCAS(ctx, repoID, &threadID, facet.name, sourceUpdatedAt, pages, true, 0, expectedSequence) } @@ -308,9 +461,9 @@ func (s *Service) pullRequestHealthBaselines(ctx context.Context, ref mcpcontrac return nil, err } if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } - thread, err := c.GetThread(ctx, repo.ID, ref.Kind, ref.Number) + thread, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil || thread == nil { if err == nil { err = errors.New("pull request is not stored") diff --git a/internal/app/mcp_pr_workflows.go b/internal/app/mcp_pr_workflows.go index ff9b7152..0f8d6c6b 100644 --- a/internal/app/mcp_pr_workflows.go +++ b/internal/app/mcp_pr_workflows.go @@ -8,17 +8,14 @@ import ( "time" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" ) const ( - facetPRFeedbackIssueComments = "pr_feedback_issue_comments" - facetPRFeedbackReviews = "pr_feedback_reviews" - facetPRFeedbackInlineComments = "pr_feedback_inline_comments" - facetPRFeedbackReviewThreads = "pr_feedback_review_threads" - facetPRCIReport = "pr_ci_report" - maxFeedbackItemsPerChannel = 1000 + facetPRCIReport = "pr_ci_report" + maxFeedbackItemsPerChannel = 1000 ) var ( @@ -38,7 +35,7 @@ type pullRequestWorkflowItem struct { } type pullRequestWorkflowResult struct { - BatchStatus string `json:"batch_status"` + BatchStatus batchOperationStatus `json:"batch_status"` Items []pullRequestWorkflowItem `json:"items"` Requests int `json:"requests"` } @@ -55,12 +52,11 @@ func (r *MCPReader) SyncPullRequestFeedback(ctx context.Context, in mcpcontract. if in.ThreadState == "" { in.ThreadState = "unresolved" } - if in.ThreadState != "unresolved" && in.ThreadState != "all" { - return mcpcontract.JobReference{}, errors.New("thread_state must be unresolved or all") - } - if err := validateFeedbackChannels(in.Channels); err != nil { + selection, err := corpus.ParseFeedbackSelection(in.Channels, in.ThreadState) + if err != nil { return mcpcontract.JobReference{}, err } + in.Channels, in.ThreadState = selection.Channels(), selection.ThreadState() if in.MaxItemsPerChannel == 0 { in.MaxItemsPerChannel = 300 } @@ -74,7 +70,7 @@ func (r *MCPReader) SyncPullRequestFeedback(ctx context.Context, in mcpcontract. return mcpcontract.JobReference{}, errors.New("max_requests must be between 1 and 1000") } id, err := r.submitJob(ctx, "sync_pull_request_feedback", in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.syncPullRequestFeedback(ctx, in, report) + return r.syncPullRequestFeedback(ctx, in, selection, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -82,26 +78,8 @@ func (r *MCPReader) SyncPullRequestFeedback(ctx context.Context, in mcpcontract. return queuedJobReference(id, "sync_pull_request_feedback", "pull-request feedback synchronization job started"), nil } -func validateFeedbackChannels(channels []string) error { - if len(channels) < 1 || len(channels) > 4 { - return errors.New("channels must contain 1 to 4 items") - } - seen := make(map[string]struct{}, len(channels)) - for _, channel := range channels { - switch channel { - case "issue_comments", "submitted_reviews", "inline_comments", "review_threads": - default: - return fmt.Errorf("unsupported feedback channel %q", channel) - } - if _, ok := seen[channel]; ok { - return fmt.Errorf("duplicate feedback channel %q", channel) - } - seen[channel] = struct{}{} - } - return nil -} - -func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract.SyncPullRequestFeedbackInput, report func(string, string) error) (pullRequestWorkflowResult, error) { +func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract.SyncPullRequestFeedbackInput, selection corpus.FeedbackSelection, report func(string, string) error) (pullRequestWorkflowResult, error) { + in.Channels, in.ThreadState = selection.Channels(), selection.ThreadState() reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return pullRequestWorkflowResult{}, err @@ -111,16 +89,17 @@ func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract. return pullRequestWorkflowResult{}, errors.New("GitHub reader does not support pull-request feedback") } budget := github.NewRequestBudget(in.MaxRequests) - out := pullRequestWorkflowResult{BatchStatus: "complete", Items: make([]pullRequestWorkflowItem, len(in.PullRequests))} + channels, providerChannels, threadState := selection.ChannelValues(), selection.Channels(), selection.ThreadState() + out := pullRequestWorkflowResult{BatchStatus: batchOperationComplete, Items: make([]pullRequestWorkflowItem, len(in.PullRequests))} for index, ref := range in.PullRequests { if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } - item := pullRequestWorkflowItem{Key: pullRequestKey(ref), Status: "complete"} + item := pullRequestWorkflowItem{Key: pullRequestKey(ref), Status: mcpcontract.BatchItemComplete} snapshot, readErr := feedbackReader.GetPullRequestFeedback(ctx, ref.Owner, ref.Repo, ref.Number, github.PullRequestFeedbackOptions{ - Channels: in.Channels, ThreadState: in.ThreadState, MaxItemsPerChannel: in.MaxItemsPerChannel, + Channels: providerChannels, ThreadState: threadState, MaxItemsPerChannel: in.MaxItemsPerChannel, }, budget) - snapshot.ThreadState = in.ThreadState + snapshot.ThreadState = threadState item.HeadSHA = snapshot.HeadSHA if snapshot.Header.Number > 0 { code, persistErr := r.persistPullRequestIdentity(ctx, ref, snapshot.Header, snapshot.SourceUpdatedAt) @@ -128,7 +107,7 @@ func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract. failure := feedbackPersistenceFailure(ref, code, persistErr.Error()) failure.HeadSHA = item.HeadSHA item = failure - out.BatchStatus = "partial" + out.BatchStatus = batchOperationPartial out.Items[index] = item if err := report("pull_request_feedback", jobProgressCounts(index+1, len(in.PullRequests))); err != nil { return pullRequestWorkflowResult{}, err @@ -139,24 +118,24 @@ func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract. if readErr != nil { var persistErr error if len(snapshot.Coverage) > 0 { - persistErr = r.persistPullRequestFeedback(ctx, ref, snapshot, coveredFeedbackChannels(in.Channels, snapshot.Coverage)) + persistErr = r.persistPullRequestFeedback(ctx, ref, snapshot, coveredFeedbackChannels(channels, snapshot.Coverage)) } if persistErr != nil { item.Status, item.Code, item.Message = "failed", "persist_partial_feedback_failed", persistErr.Error() } else { item = workflowFailure(ref, readErr, mcpcontract.ToolSyncPullRequestFeedback) } - out.BatchStatus = "partial" - } else if err := r.persistPullRequestFeedback(ctx, ref, snapshot, in.Channels); err != nil { + out.BatchStatus = batchOperationPartial + } else if err := r.persistPullRequestFeedback(ctx, ref, snapshot, channels); err != nil { item = feedbackPersistenceFailure(ref, feedbackPersistenceFailureCode(err), err.Error()) - out.BatchStatus = "partial" - } else if !feedbackSnapshotComplete(snapshot, in.Channels) { + out.BatchStatus = batchOperationPartial + } else if !feedbackSnapshotComplete(snapshot, channels) { item.Status = "retryable" item.Code = "feedback_coverage_incomplete" item.Message = "one or more feedback channels reached max_items_per_channel" item.Recovery = feedbackCoverageRecovery(ref, in, item.Message) item.HeadSHA = snapshot.HeadSHA - out.BatchStatus = "partial" + out.BatchStatus = batchOperationPartial } else { item.HeadSHA = snapshot.HeadSHA item.ResourceURI = fmt.Sprintf("gitcontribute://pull-request-feedback/%s/%s/%d", ref.Owner, ref.Repo, ref.Number) @@ -174,8 +153,8 @@ func (r *MCPReader) syncPullRequestFeedback(ctx context.Context, in mcpcontract. return pullRequestWorkflowResult{}, fmt.Errorf("rebuild pull-request feedback projection: %w", err) } out.Requests = budget.Completed() - if out.BatchStatus == "partial" && allWorkflowItemsFailed(out.Items) { - out.BatchStatus = "failed" + if out.BatchStatus == batchOperationPartial && allWorkflowItemsFailed(out.Items) { + out.BatchStatus = batchOperationFailed } return out, nil } @@ -228,7 +207,7 @@ func (r *MCPReader) persistPullRequestIdentity(ctx context.Context, ref mcpcontr if err != nil { return "pull_request_header_unavailable", err } - existing, err := c.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, ref.Number) + existing, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil { return "persistence_retryable", fmt.Errorf("get pull request identity: %w", err) } @@ -257,11 +236,15 @@ func threadFromPullRequestDetails(header github.PullRequestDetails, repositoryID if err != nil { return corpus.Thread{}, fmt.Errorf("parse pull-request merge status: %w", err) } + state, err := domain.ParseThreadState(header.State) + if err != nil { + return corpus.Thread{}, fmt.Errorf("parse pull-request state: %w", err) + } thread := corpus.Thread{ RepositoryID: repositoryID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: header.Number, - State: header.State, + State: state, Title: header.Title, Body: header.Body, Author: header.Author, @@ -281,53 +264,84 @@ func threadFromPullRequestDetails(header github.PullRequestDetails, repositoryID return thread, nil } -func coveredFeedbackChannels(requested []string, coverage map[string]github.FeedbackCoverage) []string { - channels := make([]string, 0, len(requested)) +func coveredFeedbackChannels(requested []corpus.FeedbackChannel, coverage map[string]github.FeedbackCoverage) []corpus.FeedbackChannel { + channels := make([]corpus.FeedbackChannel, 0, len(requested)) for _, channel := range requested { - if _, ok := coverage[channel]; ok { + if _, ok := coverage[channel.String()]; ok { channels = append(channels, channel) } } return channels } -func feedbackSnapshotComplete(snapshot github.PullRequestFeedback, channels []string) bool { +func feedbackSnapshotComplete(snapshot github.PullRequestFeedback, channels []corpus.FeedbackChannel) bool { for _, channel := range channels { - if !snapshot.Coverage[channel].Complete { + if !snapshot.Coverage[channel.String()].Complete { return false } } return true } -func (r *MCPReader) persistPullRequestFeedback(ctx context.Context, ref mcpcontract.ThreadRef, snapshot github.PullRequestFeedback, channels []string) error { - values := map[string]any{ - "issue_comments": snapshot.IssueComments, - "submitted_reviews": snapshot.Reviews, - "inline_comments": snapshot.InlineComments, - "review_threads": snapshot.ReviewThreads, - } - facets := map[string]string{ - "issue_comments": facetPRFeedbackIssueComments, "submitted_reviews": facetPRFeedbackReviews, - "inline_comments": facetPRFeedbackInlineComments, "review_threads": facetPRFeedbackReviewThreads, - } +func (r *MCPReader) persistPullRequestFeedback(ctx context.Context, ref mcpcontract.ThreadRef, snapshot github.PullRequestFeedback, channels []corpus.FeedbackChannel) error { for _, channel := range channels { - payload := struct { - HeadSHA string `json:"head_sha"` - Coverage github.FeedbackCoverage `json:"coverage"` - Selection string `json:"selection,omitempty"` - Items any `json:"items"` - }{HeadSHA: snapshot.HeadSHA, Coverage: snapshot.Coverage[channel], Items: values[channel]} - if channel == "review_threads" { - payload.Selection = snapshot.ThreadState + facet, update, err := feedbackWorkflowFacet(snapshot, channel) + if err != nil { + return err } - if err := r.persistPullRequestWorkflowFacet(ctx, ref, facets[channel], snapshot.SourceUpdatedAt, payload, snapshot.Coverage[channel].Complete); err != nil { + if err := r.persistPullRequestWorkflowFacet(ctx, ref, facet, snapshot.SourceUpdatedAt, update); err != nil { return err } } return nil } +type workflowFacetUpdate struct { + payload json.RawMessage +} + +func incompleteWorkflowFacetUpdate() workflowFacetUpdate { return workflowFacetUpdate{} } + +func completeWorkflowFacetUpdate[T any](value T) (workflowFacetUpdate, error) { + payload, err := json.Marshal(value) + if err != nil { + return workflowFacetUpdate{}, err + } + return workflowFacetUpdate{payload: payload}, nil +} + +func feedbackWorkflowFacetUpdate[T any](snapshot github.PullRequestFeedback, coverage github.FeedbackCoverage, selection string, items []T) (workflowFacetUpdate, error) { + if !coverage.Complete { + return incompleteWorkflowFacetUpdate(), nil + } + return completeWorkflowFacetUpdate(struct { + HeadSHA string `json:"head_sha"` + Coverage github.FeedbackCoverage `json:"coverage"` + Selection string `json:"selection,omitempty"` + Items []T `json:"items"` + }{HeadSHA: snapshot.HeadSHA, Coverage: coverage, Selection: selection, Items: items}) +} + +func feedbackWorkflowFacet(snapshot github.PullRequestFeedback, channel corpus.FeedbackChannel) (string, workflowFacetUpdate, error) { + coverage := snapshot.Coverage[channel.String()] + switch channel { + case corpus.FeedbackIssueComments: + update, err := feedbackWorkflowFacetUpdate(snapshot, coverage, "", snapshot.IssueComments) + return channel.Facet(), update, err + case corpus.FeedbackSubmittedReviews: + update, err := feedbackWorkflowFacetUpdate(snapshot, coverage, "", snapshot.Reviews) + return channel.Facet(), update, err + case corpus.FeedbackInlineComments: + update, err := feedbackWorkflowFacetUpdate(snapshot, coverage, "", snapshot.InlineComments) + return channel.Facet(), update, err + case corpus.FeedbackReviewThreads: + update, err := feedbackWorkflowFacetUpdate(snapshot, coverage, snapshot.ThreadState, snapshot.ReviewThreads) + return channel.Facet(), update, err + default: + return "", workflowFacetUpdate{}, fmt.Errorf("unsupported parsed feedback channel %d", channel) + } +} + func (r *MCPReader) SyncCIFailures(ctx context.Context, in mcpcontract.SyncCIFailuresInput) (mcpcontract.JobReference, error) { refs, err := parsePullRequestRefs(in.PullRequests, "pull_requests") if err != nil { @@ -383,12 +397,12 @@ func (r *MCPReader) syncCIFailures(ctx context.Context, in mcpcontract.SyncCIFai return pullRequestWorkflowResult{}, errors.New("GitHub reader does not support CI diagnostics") } budget := github.NewRequestBudget(in.MaxRequests) - out := pullRequestWorkflowResult{BatchStatus: "complete", Items: make([]pullRequestWorkflowItem, len(in.PullRequests))} + out := pullRequestWorkflowResult{BatchStatus: batchOperationComplete, Items: make([]pullRequestWorkflowItem, len(in.PullRequests))} for index, ref := range in.PullRequests { if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } - item := pullRequestWorkflowItem{Key: pullRequestKey(ref), Status: "complete"} + item := pullRequestWorkflowItem{Key: pullRequestKey(ref), Status: mcpcontract.BatchItemComplete} snapshot, readErr := ciReader.GetPullRequestCI(ctx, ref.Owner, ref.Repo, ref.Number, github.CIFailureOptions{ MaxRuns: in.MaxRunsPerPR, MaxJobsPerRun: in.MaxJobsPerRun, MaxLogBytes: in.MaxLogBytesPerJob, Logs: in.Logs, }, budget) @@ -399,26 +413,34 @@ func (r *MCPReader) syncCIFailures(ctx context.Context, in mcpcontract.SyncCIFai // facet in that case so an older complete report is not presented as // current after an unsuccessful refresh. if snapshot.HeadSHA != "" { - persistErr = r.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, snapshot.SourceUpdatedAt, snapshot, false) + persistErr = r.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, snapshot.SourceUpdatedAt, incompleteWorkflowFacetUpdate()) } if persistErr != nil { item.Status, item.Code, item.Message = "failed", "persist_partial_ci_failed", persistErr.Error() } else { item = workflowFailure(ref, readErr, mcpcontract.ToolSyncCIFailures) } - out.BatchStatus = "partial" + out.BatchStatus = batchOperationPartial } else { complete := ciSnapshotComplete(snapshot) - if err := r.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, snapshot.SourceUpdatedAt, snapshot, complete); err != nil { - item.Status, item.Code, item.Message = "failed", "persist_ci_failed", err.Error() - out.BatchStatus = "partial" + update := incompleteWorkflowFacetUpdate() + var persistErr error + if complete { + update, persistErr = completeWorkflowFacetUpdate(snapshot) + } + if persistErr == nil { + persistErr = r.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, snapshot.SourceUpdatedAt, update) + } + if persistErr != nil { + item.Status, item.Code, item.Message = "failed", "persist_ci_failed", persistErr.Error() + out.BatchStatus = batchOperationPartial } else if !complete { item.Status = "retryable" item.Code = "ci_coverage_incomplete" item.Message = "one or more CI collections reached a configured item bound" item.Recovery = recoveryPlan("facet_incomplete", item.Message, mcpcontract.RecoveryAction(mcpcontract.SyncCIFailuresInput{PullRequests: []mcpcontract.ThreadRef{ref}, Logs: in.Logs, MaxRunsPerPR: in.MaxRunsPerPR, MaxJobsPerRun: in.MaxJobsPerRun, MaxLogBytesPerJob: in.MaxLogBytesPerJob, MaxRequests: in.MaxRequests})) item.HeadSHA = snapshot.HeadSHA - out.BatchStatus = "partial" + out.BatchStatus = batchOperationPartial } else { item.HeadSHA = snapshot.HeadSHA item.ResourceURI = fmt.Sprintf("gitcontribute://ci-failure-report/%s/%s/%d", ref.Owner, ref.Repo, ref.Number) @@ -430,8 +452,8 @@ func (r *MCPReader) syncCIFailures(ctx context.Context, in mcpcontract.SyncCIFai } } out.Requests = budget.Completed() - if out.BatchStatus == "partial" && allWorkflowItemsFailed(out.Items) { - out.BatchStatus = "failed" + if out.BatchStatus == batchOperationPartial && allWorkflowItemsFailed(out.Items) { + out.BatchStatus = batchOperationFailed } return out, nil } @@ -450,7 +472,7 @@ func ciSnapshotComplete(snapshot github.PullRequestCI) bool { return true } -func (r *MCPReader) persistPullRequestWorkflowFacet(ctx context.Context, ref mcpcontract.ThreadRef, facet string, sourceUpdatedAt time.Time, value any, complete bool) error { +func (r *MCPReader) persistPullRequestWorkflowFacet(ctx context.Context, ref mcpcontract.ThreadRef, facet string, sourceUpdatedAt time.Time, update workflowFacetUpdate) error { c, err := r.openCorpus(ctx) if err != nil { return err @@ -463,9 +485,9 @@ func (r *MCPReader) persistPullRequestWorkflowFacet(ctx context.Context, ref mcp return err } if ref.Kind == "" { - ref.Kind = corpus.ThreadKindPullRequest + ref.Kind = string(domain.PullRequestKind) } - thread, err := c.GetThread(ctx, repo.ID, ref.Kind, ref.Number) + thread, err := c.GetThread(ctx, repo.ID, domain.PullRequestKind, ref.Number) if err != nil || thread == nil { if err == nil { err = errFeedbackPullRequestNotStored @@ -475,14 +497,10 @@ func (r *MCPReader) persistPullRequestWorkflowFacet(ctx context.Context, ref mcp if sourceUpdatedAt.IsZero() { sourceUpdatedAt = thread.SourceUpdatedAt } - if !complete { + if update.payload == nil { return c.AdvanceFacet(ctx, repo.ID, &thread.ID, facet, sourceUpdatedAt, false, 0) } - payload, err := json.Marshal(value) - if err != nil { - return err - } - return c.ApplyFacetObservationSet(ctx, repo.ID, &thread.ID, facet, sourceUpdatedAt, []corpus.FacetObservationInput{{SourceUpdatedAt: sourceUpdatedAt, Payload: string(payload)}}, complete, 0) + return c.ApplyFacetObservationSet(ctx, repo.ID, &thread.ID, facet, sourceUpdatedAt, []corpus.FacetObservationInput{{SourceUpdatedAt: sourceUpdatedAt, Payload: string(update.payload)}}, true, 0) } func feedbackPersistenceFailureCode(err error) string { @@ -515,10 +533,10 @@ func workflowFailure(ref mcpcontract.ThreadRef, err error, tool string) pullRequ } status, code, message, retryAfterMS := githubBatchError(err) item := pullRequestWorkflowItem{ - Key: pullRequestKey(ref), Status: mcpcontract.BatchItemStatus(status), Code: code, + Key: pullRequestKey(ref), Status: status, Code: code, Message: message, RetryAfterMS: retryAfterMS, } - if status == "retryable" { + if status == mcpcontract.BatchItemRetryable { item.Recovery = recoveryPlan(code, message, workflowRetryCall(tool, ref)) } return item @@ -537,7 +555,7 @@ func pullRequestKey(ref mcpcontract.ThreadRef) string { func allWorkflowItemsFailed(items []pullRequestWorkflowItem) bool { for _, item := range items { - if item.Status == "complete" || item.Status == "retryable" { + if item.Status == mcpcontract.BatchItemComplete || item.Status == mcpcontract.BatchItemRetryable { return false } } diff --git a/internal/app/mcp_pr_workflows_test.go b/internal/app/mcp_pr_workflows_test.go index 7b7997e2..dc6760df 100644 --- a/internal/app/mcp_pr_workflows_test.go +++ b/internal/app/mcp_pr_workflows_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/json" "errors" "testing" "time" @@ -12,6 +13,15 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +func mustFeedbackSelection(t *testing.T, channels []string, threadState string) corpus.FeedbackSelection { + t.Helper() + selection, err := corpus.ParseFeedbackSelection(channels, threadState) + if err != nil { + t.Fatalf("parse feedback selection: %v", err) + } + return selection +} + func TestIncompletePullRequestFacetPreservesLastCompleteObservation(t *testing.T) { ctx := context.Background() svc := newLocalService(t) @@ -26,7 +36,7 @@ func TestIncompletePullRequestFacetPreservesLastCompleteObservation(t *testing.T t.Fatal(err) } thread, err := stored.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", SourceUpdatedAt: completeAt, }, `{}`) if err != nil { @@ -34,10 +44,14 @@ func TestIncompletePullRequestFacetPreservesLastCompleteObservation(t *testing.T } reader := &MCPReader{Service: svc} ref := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7} - if err := reader.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, completeAt, map[string]string{"head_sha": "complete"}, true); err != nil { + completeUpdate, err := completeWorkflowFacetUpdate(map[string]string{"head_sha": "complete"}) + if err != nil { t.Fatal(err) } - if err := reader.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, incompleteAt, map[string]string{"head_sha": "partial"}, false); err != nil { + if err := reader.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, completeAt, completeUpdate); err != nil { + t.Fatal(err) + } + if err := reader.persistPullRequestWorkflowFacet(ctx, ref, facetPRCIReport, incompleteAt, incompleteWorkflowFacetUpdate()); err != nil { t.Fatal(err) } @@ -59,9 +73,19 @@ func TestIncompletePullRequestFacetPreservesLastCompleteObservation(t *testing.T if err != nil { t.Fatal(err) } - effective, _ := resource["effective_coverage"].(map[string]any) - if complete, _ := effective["complete"].(bool); complete { - t.Fatalf("resource effective coverage = %+v, want incomplete", effective) + payload, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + var envelope struct { + HeadSHA string `json:"head_sha"` + EffectiveCoverage *mcpcontract.ResourceCoverage `json:"effective_coverage"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatal(err) + } + if envelope.HeadSHA != "complete" || envelope.EffectiveCoverage == nil || envelope.EffectiveCoverage.Complete { + t.Fatalf("resource = %s, want preserved payload with incomplete effective coverage", payload) } } @@ -88,7 +112,7 @@ func TestFeedbackSyncSeedsMissingRepositoryAndPullRequest(t *testing.T) { ref := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7} result, err := reader.syncPullRequestFeedback(ctx, mcpcontract.SyncPullRequestFeedbackInput{ PullRequests: []mcpcontract.ThreadRef{ref}, Channels: []string{"issue_comments"}, MaxRequests: 10, - }, func(string, string) error { return nil }) + }, mustFeedbackSelection(t, []string{"issue_comments"}, "unresolved"), func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -100,7 +124,7 @@ func TestFeedbackSyncSeedsMissingRepositoryAndPullRequest(t *testing.T) { if err != nil || repo == nil { t.Fatalf("stored repository = %v, %+v", err, repo) } - thread, err := svc.corpus.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, 7) + thread, err := svc.corpus.GetThread(ctx, repo.ID, domain.PullRequestKind, 7) if err != nil || thread == nil { t.Fatalf("stored pull request = %v, %+v", err, thread) } @@ -111,7 +135,7 @@ func TestFeedbackSyncSeedsMissingRepositoryAndPullRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if resource["number"] != 7 || result.Items[0].ResourceURI == "" { + if resource.Number != 7 || result.Items[0].ResourceURI == "" { t.Fatalf("feedback resource/result = %+v / %+v", resource, result) } } @@ -128,7 +152,7 @@ func TestFeedbackSyncPreservesExistingRepositoryAndPullRequestFields(t *testing. t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", StateReason: "completed", Title: "old title", SourceUpdatedAt: now, }, `{}`); err != nil { t.Fatal(err) @@ -149,7 +173,7 @@ func TestFeedbackSyncPreservesExistingRepositoryAndPullRequestFields(t *testing. ref := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7} result, err := reader.syncPullRequestFeedback(ctx, mcpcontract.SyncPullRequestFeedbackInput{ PullRequests: []mcpcontract.ThreadRef{ref}, Channels: []string{"issue_comments"}, MaxRequests: 10, - }, func(string, string) error { return nil }) + }, mustFeedbackSelection(t, []string{"issue_comments"}, "unresolved"), func(string, string) error { return nil }) if err != nil || result.BatchStatus != "complete" { t.Fatalf("feedback result = %v, %+v", err, result) } @@ -158,7 +182,7 @@ func TestFeedbackSyncPreservesExistingRepositoryAndPullRequestFields(t *testing. if err != nil { t.Fatal(err) } - gotThread, err := svc.corpus.GetThread(ctx, gotRepo.ID, corpus.ThreadKindPullRequest, 7) + gotThread, err := svc.corpus.GetThread(ctx, gotRepo.ID, domain.PullRequestKind, 7) if err != nil { t.Fatal(err) } @@ -182,7 +206,7 @@ func TestFeedbackSyncReportsStructuredPersistenceFailure(t *testing.T) { ref := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7} result, err := reader.syncPullRequestFeedback(ctx, mcpcontract.SyncPullRequestFeedbackInput{ PullRequests: []mcpcontract.ThreadRef{ref}, Channels: []string{"issue_comments"}, MaxRequests: 10, - }, func(string, string) error { return nil }) + }, mustFeedbackSelection(t, []string{"issue_comments"}, "unresolved"), func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -201,7 +225,7 @@ func TestBoundedWorkflowSnapshotsReturnRetryablePartialItems(t *testing.T) { t.Fatal(err) } thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", SourceUpdatedAt: now, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", SourceUpdatedAt: now, }, `{}`) if err != nil { t.Fatal(err) @@ -227,7 +251,7 @@ func TestBoundedWorkflowSnapshotsReturnRetryablePartialItems(t *testing.T) { feedback, err := reader.syncPullRequestFeedback(ctx, mcpcontract.SyncPullRequestFeedbackInput{ PullRequests: []mcpcontract.ThreadRef{ref}, Channels: []string{"issue_comments", "review_threads"}, MaxItemsPerChannel: 10, MaxRequests: 10, - }, report) + }, mustFeedbackSelection(t, []string{"issue_comments", "review_threads"}, "unresolved"), report) if err != nil { t.Fatal(err) } @@ -316,7 +340,7 @@ func TestFeedbackResourceUsesPublicChannelsAndPreservesThreadSelection(t *testin t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", SourceUpdatedAt: now, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", SourceUpdatedAt: now, }, `{}`); err != nil { t.Fatal(err) } @@ -329,20 +353,29 @@ func TestFeedbackResourceUsesPublicChannelsAndPreservesThreadSelection(t *testin "review_threads": {Complete: true}, }, } - if err := reader.persistPullRequestFeedback(ctx, ref, snapshot, []string{"issue_comments", "review_threads"}); err != nil { + selection := mustFeedbackSelection(t, []string{"issue_comments", "review_threads"}, "unresolved") + if err := reader.persistPullRequestFeedback(ctx, ref, snapshot, selection.ChannelValues()); err != nil { t.Fatal(err) } resource, err := reader.PullRequestFeedbackResource(ctx, "acme", "rocket", 7) if err != nil { t.Fatal(err) } - channels := resource["channels"].(map[string]any) - if channels["issue_comments"] == nil || channels["review_threads"] == nil || channels[facetPRFeedbackIssueComments] != nil { - t.Fatalf("channels = %+v", channels) + if resource.Channels.IssueComments == nil || resource.Channels.ReviewThreads == nil || resource.Channels.SubmittedReviews != nil { + t.Fatalf("channels = %+v", resource.Channels) + } + payload, err := json.Marshal(resource.Channels.ReviewThreads) + if err != nil { + t.Fatal(err) + } + var reviewThreads struct { + Selection string `json:"selection"` + } + if err := json.Unmarshal(payload, &reviewThreads); err != nil { + t.Fatal(err) } - reviewThreads := channels["review_threads"].(map[string]any) - if reviewThreads["selection"] != "unresolved" { - t.Fatalf("review-thread selection = %v", reviewThreads["selection"]) + if reviewThreads.Selection != "unresolved" { + t.Fatalf("review-thread selection = %v", reviewThreads.Selection) } } @@ -363,15 +396,15 @@ func TestFeedbackSearchRecoveryRefreshesAllThreadState(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", SourceUpdatedAt: now}, `{}`); err != nil { + if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", SourceUpdatedAt: now}, `{}`); err != nil { t.Fatal(err) } - if err := svc.corpus.UpsertFeedbackDiscovery(ctx, corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, Complete: true, Channels: []string{"issue_comments"}, ThreadState: "all", SourceUpdatedAt: now}); err != nil { + if err := svc.corpus.UpsertFeedbackDiscovery(ctx, corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, State: corpus.FeedbackDiscoveryComplete, Selection: mustFeedbackSelection(t, []string{"issue_comments"}, "all"), SourceUpdatedAt: now}); err != nil { t.Fatal(err) } plan := feedbackSearchRecovery(ctx, svc.corpus, repo.ID, domain.MustRepoRef("acme", "rocket"), mcpcontract.SearchPullRequestFeedbackInput{ Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Channel: "issue_comments", ThreadState: "resolved", - }, corpus.FeedbackSearchPage{Coverage: corpus.FeedbackCoverageSummary{Status: "partial", DiscoveryComplete: true, IncompletePRs: 1}}) + }, corpus.FeedbackSearchPage{Coverage: corpus.FeedbackCoverageSummary{State: corpus.FeedbackCoveragePartialFacets, IncompletePRs: 1}}) if plan == nil || len(plan.Then) != 1 { t.Fatalf("feedback recovery plan = %+v", plan) } @@ -391,7 +424,7 @@ func TestFeedbackSearchRecoveryBoundsMergeStateHydration(t *testing.T) { items = append(items, corpus.PullRequestFeedbackProjection{PullRequestNumber: number}) } plan := feedbackSearchRecovery(context.Background(), nil, 0, domain.MustRepoRef("acme", "rocket"), mcpcontract.SearchPullRequestFeedbackInput{}, corpus.FeedbackSearchPage{ - Coverage: corpus.FeedbackCoverageSummary{Status: "complete", DiscoveryComplete: true}, + Coverage: corpus.FeedbackCoverageSummary{State: corpus.FeedbackCoverageComplete}, UnknownMergePullRequests: unknown, Items: items, }) diff --git a/internal/app/mcp_precedent_reads.go b/internal/app/mcp_precedent_reads.go index ef52d6db..b86fe167 100644 --- a/internal/app/mcp_precedent_reads.go +++ b/internal/app/mcp_precedent_reads.go @@ -132,7 +132,7 @@ func (r *MCPReader) FindPrecedents(ctx context.Context, in mcpcontract.FindPrece } truncated = truncated || item.Value.Truncated } - out.Provenance, err = offlineReadProvenance("precedent_search", revision, in, !truncated && !unknownCoverage, truncated, unknownCoverage) + out.Provenance, err = offlineReadProvenance("precedent_search", revision, in, truncated, unknownCoverage) if err != nil { return mcpcontract.FindPrecedentsOutput{}, err } @@ -179,5 +179,5 @@ func precedentToMCP(source, owner, repo string, t precedent.Thread, score float6 reasons = append(reasons, "label: "+label) } } - return mcpcontract.PrecedentOutput{Source: source, Ref: fmt.Sprintf("%s/%s#%d", owner, repo, t.Number), Kind: t.Kind, State: t.State, StateReason: t.StateReason, Title: t.Title, Score: mcpcontract.SimilarityScore(score), RuleVersion: similarity.PrecedentV1, Reasons: reasons, ClosedAt: formatTime(t.ClosedAt), MergedAt: formatTime(t.Merge.MergedAt())} + return mcpcontract.PrecedentOutput{Source: source, Ref: fmt.Sprintf("%s/%s#%d", owner, repo, t.Number), Kind: string(t.Kind), State: string(t.State), StateReason: t.StateReason, Title: t.Title, Score: mcpcontract.SimilarityScore(score), RuleVersion: similarity.PrecedentV1, Reasons: reasons, ClosedAt: formatTime(t.ClosedAt), MergedAt: formatTime(t.Merge.MergedAt())} } diff --git a/internal/app/mcp_pull_request_feedback_index.go b/internal/app/mcp_pull_request_feedback_index.go index 1a0ce689..074751ee 100644 --- a/internal/app/mcp_pull_request_feedback_index.go +++ b/internal/app/mcp_pull_request_feedback_index.go @@ -30,8 +30,8 @@ type pullRequestFeedbackIndexItem struct { } type pullRequestFeedbackIndexResult struct { - Status string `json:"status"` - DiscoveryStatus string `json:"discovery_status"` + Status batchOperationStatus `json:"status"` + DiscoveryStatus batchOperationStatus `json:"discovery_status"` NextPage int `json:"next_page,omitempty"` PullRequests int `json:"pull_requests"` FeedbackItems int `json:"feedback_items"` @@ -51,17 +51,16 @@ func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract in.Repository.Owner = ref.Owner() in.Repository.Repo = ref.Repo() if len(in.Channels) == 0 { - in.Channels = []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"} - } - if err := validateFeedbackChannels(in.Channels); err != nil { - return mcpcontract.JobReference{}, err + in.Channels = corpus.AllFeedbackSelection().Channels() } if in.ThreadState == "" { in.ThreadState = "all" } - if in.ThreadState != "all" && in.ThreadState != "unresolved" { - return mcpcontract.JobReference{}, errors.New("thread_state must be unresolved or all") + selection, err := corpus.ParseFeedbackSelection(in.Channels, in.ThreadState) + if err != nil { + return mcpcontract.JobReference{}, err } + in.Channels, in.ThreadState = selection.Channels(), selection.ThreadState() if in.MaxPullRequests == 0 { in.MaxPullRequests = 1000 } @@ -87,7 +86,7 @@ func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract return mcpcontract.JobReference{}, errors.New("max_requests must be between 1 and 1000") } id, err := r.submitJob(ctx, jobKindIndexPullRequestFeedback, in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.indexPullRequestFeedback(ctx, in, report) + return r.indexPullRequestFeedback(ctx, in, selection, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -95,7 +94,8 @@ func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract return queuedJobReference(id, jobKindIndexPullRequestFeedback, "repository pull-request feedback indexing job started"), nil } -func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract.IndexPullRequestFeedbackInput, report func(string, string) error) (pullRequestFeedbackIndexResult, error) { +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() reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return pullRequestFeedbackIndexResult{}, err @@ -130,21 +130,20 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract if err != nil { return pullRequestFeedbackIndexResult{}, err } - if discovery == nil || discovery.Complete || !sameFeedbackSelection(discovery.Channels, in.Channels) || discovery.ThreadState != in.ThreadState { + if discovery == nil || discovery.IsComplete() || !discovery.Selection.Equal(selection) { generation := int64(1) if discovery != nil { generation = discovery.Generation + 1 } - discovery = &corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: generation, State: "all", NextPage: 1, Channels: append([]string(nil), in.Channels...), ThreadState: in.ThreadState} + discovery = &corpus.FeedbackDiscovery{RepositoryID: repo.ID, Generation: generation, NextPage: 1, Selection: selection} } else { - discovery.Channels = append([]string(nil), in.Channels...) - discovery.ThreadState = in.ThreadState + discovery.Selection = selection } if discovery.NextPage < 1 { discovery.NextPage = 1 } budget := github.NewRequestBudget(in.MaxRequests) - result := pullRequestFeedbackIndexResult{Status: "complete", DiscoveryStatus: "complete", Items: make([]pullRequestFeedbackIndexItem, 0, in.MaxPullRequests)} + result := pullRequestFeedbackIndexResult{Status: batchOperationComplete, DiscoveryStatus: batchOperationComplete, Items: make([]pullRequestFeedbackIndexItem, 0, in.MaxPullRequests)} page := discovery.NextPage initialRequests := discovery.Requests pages := 0 @@ -166,17 +165,17 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract stopReason = "pull_request_item_bound" break } - if issue.Kind != github.ThreadKindPullRequest || issue.Number < 1 { + if issue.Kind != domain.PullRequestKind || issue.Number < 1 { continue } processed++ ref := mcpcontract.ThreadRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo, Kind: "pull_request", Number: issue.Number} - item := r.indexOnePullRequestFeedback(ctx, feedbackReader, ref, in, budget) + item := r.indexOnePullRequestFeedback(ctx, feedbackReader, ref, in, selection, budget) result.Items = append(result.Items, item) result.PullRequests++ result.FeedbackItems += item.FeedbackItems - if item.Status != "complete" { - result.Status = "partial" + if item.Status != mcpcontract.BatchItemComplete { + result.Status = batchOperationPartial } if err := report("pull_request_feedback", jobProgressCounts(result.PullRequests, in.MaxPullRequests)); err != nil { return pullRequestFeedbackIndexResult{}, err @@ -190,16 +189,16 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract break } if !listed.Page.HasNext { - discovery.NextPage, discovery.Complete, discovery.Truncated = page, true, false + discovery.NextPage, discovery.State = page, corpus.FeedbackDiscoveryComplete break } page = listed.Page.NextPage - discovery.NextPage, discovery.Complete, discovery.Truncated = page, false, true + discovery.NextPage, discovery.State = page, corpus.FeedbackDiscoveryTruncated if pages >= in.MaxPages { stopReason = "discovery_page_bound" break } - discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.ThreadKindPullRequest, "") + discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) if err != nil { return pullRequestFeedbackIndexResult{}, err } @@ -211,12 +210,12 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract } } if stopReason != "" { - result.Status, result.DiscoveryStatus = "partial", "partial" - discovery.Complete, discovery.Truncated, discovery.LastError = false, true, stopReason + result.Status, result.DiscoveryStatus = batchOperationPartial, batchOperationPartial + discovery.State, discovery.LastError = corpus.FeedbackDiscoveryTruncated, stopReason result.NextPage = discovery.NextPage result.Recovery = mcpcontractRecoveryIndex(in) - } else if !discovery.Complete { - result.Status, result.DiscoveryStatus = "partial", "partial" + } else if !discovery.IsComplete() { + result.Status, result.DiscoveryStatus = batchOperationPartial, batchOperationPartial result.NextPage = discovery.NextPage result.Recovery = mcpcontractRecoveryIndex(in) } @@ -228,12 +227,12 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract } } } - discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.ThreadKindPullRequest, "") + discovery.DiscoveredPullRequests, err = c.CountThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), corpus.AnyThreadState()) if err != nil { return pullRequestFeedbackIndexResult{}, err } discovery.Requests = initialRequests + budget.Completed() - if result.Status == "complete" { + if result.Status == batchOperationComplete { discovery.LastError = "" } discovery.SourceUpdatedAt = time.Now().UTC() @@ -248,8 +247,9 @@ func (r *MCPReader) indexPullRequestFeedback(ctx context.Context, in mcpcontract return result, nil } -func (r *MCPReader) indexOnePullRequestFeedback(ctx context.Context, feedbackReader github.PullRequestFeedbackReader, ref mcpcontract.ThreadRef, in mcpcontract.IndexPullRequestFeedbackInput, budget *github.RequestBudget) pullRequestFeedbackIndexItem { - item := pullRequestFeedbackIndexItem{Key: pullRequestKey(ref), Status: "complete"} +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() snapshot, readErr := feedbackReader.GetPullRequestFeedback(ctx, ref.Owner, ref.Repo, ref.Number, github.PullRequestFeedbackOptions{Channels: in.Channels, ThreadState: in.ThreadState, MaxItemsPerChannel: in.MaxItemsPerChannel}, budget) item.FeedbackItems = len(snapshot.IssueComments) + len(snapshot.Reviews) + len(snapshot.InlineComments) for _, thread := range snapshot.ReviewThreads { @@ -264,7 +264,7 @@ func (r *MCPReader) indexOnePullRequestFeedback(ctx context.Context, feedbackRea } if readErr != nil { if len(snapshot.Coverage) > 0 { - if persistErr := r.persistPullRequestFeedback(ctx, ref, snapshot, coveredFeedbackChannels(in.Channels, snapshot.Coverage)); persistErr != nil { + if persistErr := r.persistPullRequestFeedback(ctx, ref, snapshot, coveredFeedbackChannels(channels, snapshot.Coverage)); persistErr != nil { item.Status, item.Code, item.Message = "failed", "feedback_persistence_failed", persistErr.Error() return item } @@ -277,11 +277,11 @@ func (r *MCPReader) indexOnePullRequestFeedback(ctx context.Context, feedbackRea } return item } - if err := r.persistPullRequestFeedback(ctx, ref, snapshot, in.Channels); err != nil { + if err := r.persistPullRequestFeedback(ctx, ref, snapshot, channels); err != nil { item.Status, item.Code, item.Message = "failed", "feedback_persistence_failed", err.Error() return item } - if !feedbackSnapshotComplete(snapshot, in.Channels) { + if !feedbackSnapshotComplete(snapshot, channels) { item.Status, item.Code, item.Message = "retryable", "feedback_coverage_incomplete", "one or more feedback channels reached an item bound" item.Recovery = mcpcontractRecoveryExact(ref, in) return item @@ -298,22 +298,6 @@ func pullRequestFeedbackIndexFailure(ref mcpcontract.ThreadRef, in mcpcontract.I return item } -func sameFeedbackSelection(left, right []string) bool { - if len(left) != len(right) { - return false - } - seen := make(map[string]struct{}, len(left)) - for _, value := range left { - seen[value] = struct{}{} - } - for _, value := range right { - if _, ok := seen[value]; !ok { - return false - } - } - return true -} - func isRetryableGitHubError(err error) bool { var transient *github.TransientError var primary *github.PrimaryRateLimitError diff --git a/internal/app/mcp_pull_request_feedback_index_test.go b/internal/app/mcp_pull_request_feedback_index_test.go index 48bd2a88..2180716e 100644 --- a/internal/app/mcp_pull_request_feedback_index_test.go +++ b/internal/app/mcp_pull_request_feedback_index_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -62,8 +63,8 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t svc := newLocalService(t) t.Cleanup(func() { _ = svc.Close() }) githubReader := &feedbackIndexTestReader{pages: map[int]github.ListResult[github.Issue]{ - 1: {Items: []github.Issue{{Number: 1, Kind: github.ThreadKindPullRequest}}, Page: github.PageInfo{Page: 1, NextPage: 2, HasNext: true}}, - 2: {Items: []github.Issue{{Number: 2, Kind: github.ThreadKindPullRequest}}, Page: github.PageInfo{Page: 2, HasNext: false}}, + 1: {Items: []github.Issue{{Number: 1, Kind: domain.PullRequestKind}}, Page: github.PageInfo{Page: 1, NextPage: 2, HasNext: true}}, + 2: {Items: []github.Issue{{Number: 2, Kind: domain.PullRequestKind}}, Page: github.PageInfo{Page: 2, HasNext: false}}, }} svc.SetGitHubReader(githubReader) reader := &MCPReader{Service: svc} @@ -77,7 +78,8 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t MaxRequests: 20, } report := func(string, string) error { return nil } - first, err := reader.indexPullRequestFeedback(ctx, in, report) + selection := mustFeedbackSelection(t, in.Channels, in.ThreadState) + first, err := reader.indexPullRequestFeedback(ctx, in, selection, report) if err != nil { t.Fatal(err) } @@ -88,12 +90,12 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t if err != nil { t.Fatal(err) } - if discovery == nil || discovery.Complete || discovery.NextPage != 2 || discovery.DiscoveredPullRequests != 1 { + if discovery == nil || discovery.IsComplete() || discovery.NextPage != 2 || discovery.DiscoveredPullRequests != 1 { t.Fatalf("bounded discovery = %+v", discovery) } in.MaxPages = 1 - second, err := reader.indexPullRequestFeedback(ctx, in, report) + second, err := reader.indexPullRequestFeedback(ctx, in, selection, report) if err != nil { t.Fatal(err) } @@ -107,7 +109,7 @@ func TestPullRequestFeedbackIndexResumesDiscoveryAndBuildsOfflineProjection(t *t if err != nil { t.Fatal(err) } - if discovery == nil || !discovery.Complete || discovery.DiscoveredPullRequests != 2 { + if discovery == nil || !discovery.IsComplete() || discovery.DiscoveredPullRequests != 2 { t.Fatalf("completed discovery = %+v", discovery) } @@ -127,14 +129,15 @@ func TestPullRequestFeedbackSearchKeepsThreadResourceReadable(t *testing.T) { svc := newLocalService(t) t.Cleanup(func() { _ = svc.Close() }) reader := &feedbackIndexTestReader{withThread: true, pages: map[int]github.ListResult[github.Issue]{ - 1: {Items: []github.Issue{{Number: 2, Kind: github.ThreadKindPullRequest}}, Page: github.PageInfo{Page: 1, HasNext: false}}, + 1: {Items: []github.Issue{{Number: 2, Kind: domain.PullRequestKind}}, Page: github.PageInfo{Page: 1, HasNext: false}}, }} svc.SetGitHubReader(reader) appReader := &MCPReader{Service: svc} - if _, err := appReader.indexPullRequestFeedback(ctx, mcpcontract.IndexPullRequestFeedbackInput{ + indexInput := mcpcontract.IndexPullRequestFeedbackInput{ Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Channels: []string{"review_threads"}, ThreadState: "all", MaxPullRequests: 10, MaxItemsPerChannel: 10, MaxPages: 10, MaxRequests: 20, - }, func(string, string) error { return nil }); err != nil { + } + if _, err := appReader.indexPullRequestFeedback(ctx, indexInput, mustFeedbackSelection(t, indexInput.Channels, indexInput.ThreadState), func(string, string) error { return nil }); err != nil { t.Fatal(err) } result, err := appReader.SearchPullRequestFeedback(ctx, mcpcontract.SearchPullRequestFeedbackInput{ @@ -150,7 +153,7 @@ func TestPullRequestFeedbackSearchKeepsThreadResourceReadable(t *testing.T) { if err != nil { t.Fatal(err) } - if item["schema_version"] != "gitcontribute.pull-request-feedback-item.v1" || item["feedback_id"] != "202" || item["thread_id"] != "thread-2" || item["resolved"] != false || item["resolution_state"] != "unresolved" { + if item.SchemaVersion != "gitcontribute.pull-request-feedback-item.v1" || item.FeedbackID != "202" || item.ThreadID != "thread-2" || item.Resolved == nil || *item.Resolved || item.ResolutionState != "unresolved" { t.Fatalf("exact feedback resource = %+v", item) } } @@ -161,7 +164,8 @@ func TestFeedbackIndexReportsPartialSnapshotPersistenceFailure(t *testing.T) { svc := newLocalService(t) t.Cleanup(func() { _ = svc.Close() }) reader := &MCPReader{Service: svc} - item := reader.indexOnePullRequestFeedback(ctx, &cancelledPartialFeedbackReader{cancel: cancel}, mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7}, mcpcontract.IndexPullRequestFeedbackInput{Channels: []string{"issue_comments"}}, github.NewRequestBudget(10)) + selection := mustFeedbackSelection(t, []string{"issue_comments"}, "all") + item := reader.indexOnePullRequestFeedback(ctx, &cancelledPartialFeedbackReader{cancel: cancel}, mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7}, mcpcontract.IndexPullRequestFeedbackInput{Channels: []string{"issue_comments"}}, selection, github.NewRequestBudget(10)) if item.Code != "feedback_persistence_failed" { t.Fatalf("partial snapshot persistence failure was hidden: %+v", item) } diff --git a/internal/app/mcp_pull_request_feedback_search.go b/internal/app/mcp_pull_request_feedback_search.go index 2c525ccc..ed1d9b01 100644 --- a/internal/app/mcp_pull_request_feedback_search.go +++ b/internal/app/mcp_pull_request_feedback_search.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/url" - "time" "unicode/utf8" "github.com/morluto/gitcontribute/internal/corpus" @@ -23,28 +22,16 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err } - createdAfter, err := parseFeedbackDate("created_after", in.CreatedAfter) - if err != nil { - return mcpcontract.SearchPullRequestFeedbackOutput{}, err - } - createdBefore, err := parseFeedbackDate("created_before", in.CreatedBefore) - if err != nil { - return mcpcontract.SearchPullRequestFeedbackOutput{}, err - } - updatedAfter, err := parseFeedbackDate("updated_after", in.UpdatedAfter) - if err != nil { - return mcpcontract.SearchPullRequestFeedbackOutput{}, err - } - updatedBefore, err := parseFeedbackDate("updated_before", in.UpdatedBefore) + query, err := corpus.ParseFeedbackSearchQuery(corpus.FeedbackSearchInput{ + FeedbackAuthor: in.FeedbackAuthor, PullRequestAuthor: in.PullRequestAuthor, + State: in.State, Merged: in.Merged, ThreadState: in.ThreadState, Channel: in.Channel, Text: in.Text, + CreatedAfter: in.CreatedAfter, CreatedBefore: in.CreatedBefore, UpdatedAfter: in.UpdatedAfter, UpdatedBefore: in.UpdatedBefore, + Sort: in.Sort, Order: in.Order, Limit: in.Limit, Cursor: in.Cursor, + }) if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err } - if in.Limit == 0 { - in.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.SearchPullRequestFeedbackOutput{}, errors.New("limit must be between 1 and 100") - } + in.Limit = query.Limit() c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err @@ -60,12 +47,11 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac if repo == nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.Unavailable("repository_feedback_not_indexed", fmt.Sprintf("No pull-request feedback index exists for %s.", ref), mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}})) } - page, err := c.SearchPullRequestFeedback(ctx, corpus.FeedbackSearchFilter{ - RepositoryID: repo.ID, FeedbackAuthor: in.FeedbackAuthor, PullRequestAuthor: in.PullRequestAuthor, - State: in.State, Merged: in.Merged, ThreadState: in.ThreadState, Channel: in.Channel, Text: in.Text, - CreatedAfter: createdAfter, CreatedBefore: createdBefore, UpdatedAfter: updatedAfter, UpdatedBefore: updatedBefore, - Sort: in.Sort, Order: in.Order, Limit: in.Limit, Cursor: in.Cursor, - }) + request, err := query.InRepository(repo.ID) + if err != nil { + return mcpcontract.SearchPullRequestFeedbackOutput{}, err + } + 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"})) @@ -73,7 +59,7 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac return mcpcontract.SearchPullRequestFeedbackOutput{}, err } out := mcpcontract.SearchPullRequestFeedbackOutput{ - Status: "complete", Coverage: page.Coverage.Status, Total: page.Total, Truncated: page.Truncated, + Status: "complete", Coverage: page.Coverage.Status(), Total: page.Total, Truncated: page.Truncated, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Projection: corpus.ProjectionVersionPullRequestFeedbackFTS, IncompletePRs: page.Coverage.IncompletePRs, Matches: make([]mcpcontract.PullRequestFeedbackMatch, 0, len(page.Items)), @@ -117,7 +103,7 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac } } } - if page.Coverage.Status != "complete" || unknownMergeState { + if !page.Coverage.Complete() || unknownMergeState { out.Status = "partial" out.Recovery = feedbackSearchRecovery(ctx, c, repo.ID, ref, in, page) } @@ -127,17 +113,6 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac return out, nil } -func parseFeedbackDate(field, value string) (time.Time, error) { - if value == "" { - return time.Time{}, nil - } - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, fmt.Errorf("%s must be RFC3339: %w", field, err) - } - return parsed, nil -} - func compactFeedbackBody(value string) string { const max = 512 if len(value) <= max { @@ -151,7 +126,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 { + 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"})) } channels := []string{in.Channel} diff --git a/internal/app/mcp_radar_eligibility_test.go b/internal/app/mcp_radar_eligibility_test.go index f044580a..0b63e5a2 100644 --- a/internal/app/mcp_radar_eligibility_test.go +++ b/internal/app/mcp_radar_eligibility_test.go @@ -15,7 +15,7 @@ func TestMCPCrossRepositoryEligibilityOrderMatchesRadar(t *testing.T) { radar.EligibilityBlocked, } for i, eligibility := range ordered { - if got := eligibilityRank(string(eligibility)); got != i { + if got := eligibilityRank(eligibility); got != i { t.Fatalf("eligibilityRank(%q) = %d, want %d", eligibility, got, i) } } diff --git a/internal/app/mcp_read_provenance.go b/internal/app/mcp_read_provenance.go index c5bcb425..b3892a85 100644 --- a/internal/app/mcp_read_provenance.go +++ b/internal/app/mcp_read_provenance.go @@ -11,7 +11,7 @@ import ( const transactionBoundReadLimitation = "transaction-bound identity; request a durable snapshot before reusing this result across calls" -func offlineReadProvenance(kind string, revision int64, input any, complete, truncated, unknownCoverage bool) (mcpcontract.CorpusReadProvenance, error) { +func offlineReadProvenance(kind string, revision int64, input any, truncated, unknownCoverage bool) (mcpcontract.CorpusReadProvenance, error) { query, err := json.Marshal(input) if err != nil { return mcpcontract.CorpusReadProvenance{}, fmt.Errorf("encode %s provenance input: %w", kind, err) @@ -27,13 +27,10 @@ func offlineReadProvenance(kind string, revision int64, input any, complete, tru return mcpcontract.CorpusReadProvenance{}, fmt.Errorf("encode %s provenance identity: %w", kind, err) } identityDigest := sha256.Sum256(identity) - return mcpcontract.CorpusReadProvenance{ - SnapshotToken: "ephemeral:" + hex.EncodeToString(identityDigest[:]), - ObservationWatermark: revision, - QueryDigestSHA256: hex.EncodeToString(queryDigest[:]), - Complete: complete, - Truncated: truncated, - UnknownCoverage: unknownCoverage, - Limitations: []string{transactionBoundReadLimitation}, - }, nil + provenance := mcpcontract.NewCorpusReadProvenance( + "ephemeral:"+hex.EncodeToString(identityDigest[:]), false, revision, + hex.EncodeToString(queryDigest[:]), truncated, unknownCoverage, + ) + provenance.Limitations = []string{transactionBoundReadLimitation} + return provenance, nil } diff --git a/internal/app/mcp_read_provenance_test.go b/internal/app/mcp_read_provenance_test.go index 5589ecbc..d60f32a9 100644 --- a/internal/app/mcp_read_provenance_test.go +++ b/internal/app/mcp_read_provenance_test.go @@ -11,21 +11,21 @@ func TestOfflineReadProvenanceBindsQueryAndWatermark(t *testing.T) { Query string `json:"query"` }{Query: "immutable artifacts"} - first, err := offlineReadProvenance("search", 7, input, true, false, false) + first, err := offlineReadProvenance("search", 7, input, false, false) if err != nil { t.Fatal(err) } - repeated, err := offlineReadProvenance("search", 7, input, true, false, false) + repeated, err := offlineReadProvenance("search", 7, input, false, false) if err != nil { t.Fatal(err) } - newer, err := offlineReadProvenance("search", 8, input, true, false, false) + newer, err := offlineReadProvenance("search", 8, input, false, false) if err != nil { t.Fatal(err) } differentQuery, err := offlineReadProvenance("search", 7, struct { Query string `json:"query"` - }{Query: "coverage"}, true, false, false) + }{Query: "coverage"}, false, false) if err != nil { t.Fatal(err) } @@ -43,3 +43,25 @@ func TestOfflineReadProvenanceBindsQueryAndWatermark(t *testing.T) { t.Fatal("query did not affect provenance identity") } } + +func TestOfflineReadProvenanceDerivesCompletenessFromCoverageGaps(t *testing.T) { + t.Parallel() + for _, test := range []struct { + truncated bool + unknown bool + complete bool + }{ + {complete: true}, + {truncated: true}, + {unknown: true}, + {truncated: true, unknown: true}, + } { + provenance, err := offlineReadProvenance("search", 7, struct{}{}, test.truncated, test.unknown) + if err != nil { + t.Fatal(err) + } + if provenance.Complete() != test.complete || provenance.Truncated() != test.truncated || provenance.UnknownCoverage() != test.unknown { + t.Fatalf("provenance = %+v, want complete=%t truncated=%t unknown=%t", provenance, test.complete, test.truncated, test.unknown) + } + } +} diff --git a/internal/app/mcp_recovery_test.go b/internal/app/mcp_recovery_test.go index 69aaf096..37fd1fab 100644 --- a/internal/app/mcp_recovery_test.go +++ b/internal/app/mcp_recovery_test.go @@ -20,7 +20,7 @@ func TestMCPThreadAndRepositorySearchExposeCoverageRecovery(t *testing.T) { if err != nil { t.Fatalf("thread search: %v", err) } - if !threads.Provenance.UnknownCoverage || threads.Recovery == nil || len(threads.Recovery.Then) != 1 || threads.Recovery.Then[0].Type() != "ensure_coverage" { + if !threads.Provenance.UnknownCoverage() || threads.Provenance.Complete() || threads.Recovery == nil || len(threads.Recovery.Then) != 1 || threads.Recovery.Then[0].Type() != "ensure_coverage" { t.Fatalf("thread search recovery = %+v", threads) } if got, ok := mcpcontract.RecoveryInput[mcpcontract.EnsureCoverageInput](threads.Recovery.Then[0]); !ok || got.Target.Repository.Owner != "owner" || got.Target.Repository.Repo != "repo" { @@ -31,7 +31,7 @@ func TestMCPThreadAndRepositorySearchExposeCoverageRecovery(t *testing.T) { 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" { + 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) } @@ -39,7 +39,7 @@ func TestMCPThreadAndRepositorySearchExposeCoverageRecovery(t *testing.T) { if err != nil { t.Fatalf("repository search: %v", err) } - if !repositories.Incomplete || repositories.Recovery == nil || len(repositories.Recovery.Then) != 1 || repositories.Recovery.Then[0].Type() != "sync_repository_context" { + if !repositories.Incomplete || !repositories.Provenance.UnknownCoverage() || repositories.Provenance.Complete() || repositories.Recovery == nil || len(repositories.Recovery.Then) != 1 || repositories.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("repository search recovery = %+v", repositories) } } @@ -59,11 +59,14 @@ func TestMCPRelatedWorkDoesNotTreatAbsentRepositoryAsNoFindings(t *testing.T) { } reader := &MCPReader{svc} - duplicates, err := reader.CheckDuplicates(ctx, mcpcontract.CheckDuplicatesInput{Target: "hypothesis", ID: hypothesis.ID, Limit: 10}) + duplicates, err := reader.CheckDuplicates(ctx, mcpcontract.CheckDuplicatesInput{Target: " Hypothesis ", ID: " " + hypothesis.ID + " ", Limit: 10}) if err != nil { t.Fatalf("check duplicates: %v", err) } assertRelatedWorkRecovery(t, duplicates, "duplicate") + if duplicates.Target != "hypothesis" || duplicates.ID != hypothesis.ID { + t.Fatalf("canonical related-work identity = %+v", duplicates) + } collisions, err := reader.CheckCollisions(ctx, mcpcontract.CheckCollisionsInput{Target: "hypothesis", ID: hypothesis.ID, Limit: 10}) if err != nil { diff --git a/internal/app/mcp_related_work.go b/internal/app/mcp_related_work.go index 48d9200a..0e0aa7b9 100644 --- a/internal/app/mcp_related_work.go +++ b/internal/app/mcp_related_work.go @@ -13,69 +13,87 @@ import ( // CheckDuplicates finds duplicate-candidate threads for a hypothesis or opportunity. func (r *MCPReader) CheckDuplicates(ctx context.Context, in mcpcontract.CheckDuplicatesInput) (mcpcontract.CheckOutput, error) { - return r.checkRelatedWork(ctx, in.Target, in.ID, in.Limit, "duplicate", func() (mcpcontract.CheckOutput, error) { - var result *contracts.DuplicateCheckResult - var err error - switch in.Target { - case "hypothesis": - result, err = r.CheckHypothesisDuplicates(ctx, in.ID, in.Limit) - case "opportunity": - result, err = r.CheckOpportunityDuplicates(ctx, in.ID, in.Limit) - default: - return mcpcontract.CheckOutput{}, fmt.Errorf("unknown target %q", in.Target) - } - if err != nil { - return mcpcontract.CheckOutput{}, err - } - return duplicateCheckResultToMCP(in.Target, in.ID, result), nil - }) + return r.checkRelatedWorkInput(ctx, in.Target, in.ID, in.Limit, duplicateRelatedWork) } // CheckCollisions finds open pull request collisions for a hypothesis or opportunity. func (r *MCPReader) CheckCollisions(ctx context.Context, in mcpcontract.CheckCollisionsInput) (mcpcontract.CheckOutput, error) { - return r.checkRelatedWork(ctx, in.Target, in.ID, in.Limit, "collision", func() (mcpcontract.CheckOutput, error) { - var result *contracts.CollisionCheckResult - var err error - switch in.Target { - case "hypothesis": - result, err = r.CheckHypothesisCollisions(ctx, in.ID, in.Limit) - case "opportunity": - result, err = r.CheckOpportunityCollisions(ctx, in.ID, in.Limit) - default: - return mcpcontract.CheckOutput{}, fmt.Errorf("unknown target %q", in.Target) - } - if err != nil { - return mcpcontract.CheckOutput{}, err - } - return collisionCheckResultToMCP(in.Target, in.ID, result), nil - }) + return r.checkRelatedWorkInput(ctx, in.Target, in.ID, in.Limit, competingPullRequests) +} + +type relatedWorkCheckKind uint8 + +const ( + duplicateRelatedWork relatedWorkCheckKind = iota + 1 + competingPullRequests +) + +func (k relatedWorkCheckKind) resultName() string { + if k == competingPullRequests { + return "collision" + } + return "duplicate" +} + +func (k relatedWorkCheckKind) recoveryKind() string { + if k == competingPullRequests { + return "competing_pull_requests" + } + return "duplicates" } -func (r *MCPReader) checkRelatedWork(ctx context.Context, target, id string, limit int, kind string, run func() (mcpcontract.CheckOutput, error)) (mcpcontract.CheckOutput, error) { - repo, err := r.relatedWorkRepository(ctx, target, id) +func (r *MCPReader) checkRelatedWorkInput(ctx context.Context, rawTarget, rawID string, limit int, check relatedWorkCheckKind) (mcpcontract.CheckOutput, error) { + target, err := parseRelatedWorkSubjectKind(rawTarget) + if err != nil { + return mcpcontract.CheckOutput{}, err + } + subject, err := r.loadRelatedWorkSubject(ctx, target, rawID) if err != nil { return mcpcontract.CheckOutput{}, err } + return r.checkRelatedWork(ctx, subject, limit, check) +} + +func (r *MCPReader) checkRelatedWork(ctx context.Context, subject relatedWorkSubject, limit int, check relatedWorkCheckKind) (mcpcontract.CheckOutput, error) { + repo := subject.investigation.Repo + target := subject.kind.String() + id := subject.id indexed, err := r.relatedWorkRepositoryIndexed(ctx, repo) if err != nil { return mcpcontract.CheckOutput{}, err } - message := fmt.Sprintf("The repository is absent from the local corpus, so an empty %s result would not be evidence of absence.", kind) + message := fmt.Sprintf("The repository is absent from the local corpus, so an empty %s result would not be evidence of absence.", check.resultName()) if !indexed { return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner(), repo.Repo())), nil } - result, err := run() + var result mcpcontract.CheckOutput + switch check { + case duplicateRelatedWork: + value, runErr := r.duplicatesForRelatedWorkSubject(ctx, subject, limit) + err = runErr + if err == nil { + result = duplicateCheckResultToMCP(subject, value) + } + case competingPullRequests: + value, runErr := r.collisionsForRelatedWorkSubject(ctx, subject, limit) + err = runErr + if err == nil { + result = collisionCheckResultToMCP(subject, value) + } + default: + return mcpcontract.CheckOutput{}, errors.New("related-work check was not parsed") + } if errors.Is(err, errRepositoryNotFound) { return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner(), repo.Repo())), nil } return result, err } -func duplicateCheckResultToMCP(target, id string, result *contracts.DuplicateCheckResult) mcpcontract.CheckOutput { +func duplicateCheckResultToMCP(subject relatedWorkSubject, result *contracts.DuplicateCheckResult) mcpcontract.CheckOutput { truncated := result.Total >= result.Limit var recovery *mcpcontract.RecoveryPlan if truncated { - recovery = relatedWorkLimitRecovery(target, id, result.Limit, false) + recovery = relatedWorkLimitRecovery(subject, result.Limit, duplicateRelatedWork) } status := "complete" if truncated { @@ -83,8 +101,8 @@ func duplicateCheckResultToMCP(target, id string, result *contracts.DuplicateChe } return mcpcontract.CheckOutput{ Status: status, Coverage: "complete", Truncated: truncated, Recovery: recovery, - Target: target, - ID: id, + Target: subject.kind.String(), + ID: subject.id, Repo: result.Repo.String(), Query: result.Query, Total: result.Total, @@ -94,13 +112,13 @@ func duplicateCheckResultToMCP(target, id string, result *contracts.DuplicateChe } } -func collisionCheckResultToMCP(target, id string, result *contracts.CollisionCheckResult) mcpcontract.CheckOutput { +func collisionCheckResultToMCP(subject relatedWorkSubject, result *contracts.CollisionCheckResult) mcpcontract.CheckOutput { findings := make([]evidence.Evidence, len(result.Findings)) copy(findings, result.Findings) truncated := result.Total >= result.Limit var recovery *mcpcontract.RecoveryPlan if truncated { - recovery = relatedWorkLimitRecovery(target, id, result.Limit, true) + recovery = relatedWorkLimitRecovery(subject, result.Limit, competingPullRequests) } status := "complete" if truncated { @@ -108,8 +126,8 @@ func collisionCheckResultToMCP(target, id string, result *contracts.CollisionChe } return mcpcontract.CheckOutput{ Status: status, Coverage: "complete", Truncated: truncated, Recovery: recovery, - Target: target, - ID: id, + Target: subject.kind.String(), + ID: subject.id, Repo: result.Repo.String(), Query: result.Query, Total: result.Total, @@ -119,42 +137,9 @@ func collisionCheckResultToMCP(target, id string, result *contracts.CollisionChe } } -func relatedWorkLimitRecovery(target, id string, limit int, collision bool) *mcpcontract.RecoveryPlan { +func relatedWorkLimitRecovery(subject relatedWorkSubject, limit int, check relatedWorkCheckKind) *mcpcontract.RecoveryPlan { nextLimit := min(100, max(limit*2, limit+1)) - kind := "duplicates" - if collision { - kind = "competing_pull_requests" - } - 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: target, ID: id, Kinds: []string{kind}, Limit: nextLimit})) -} - -func (r *MCPReader) relatedWorkRepository(ctx context.Context, target, id string) (domain.RepoRef, error) { - invSvc, err := r.readInvestigationSvc(ctx) - if err != nil { - return domain.RepoRef{}, err - } - var investigationID string - switch target { - case "hypothesis": - hypothesis, err := invSvc.GetHypothesis(ctx, id) - if err != nil { - return domain.RepoRef{}, mapInvestigationError(err) - } - investigationID = hypothesis.InvestigationID - case "opportunity": - opportunity, err := invSvc.GetOpportunity(ctx, id) - if err != nil { - return domain.RepoRef{}, mapInvestigationError(err) - } - investigationID = opportunity.InvestigationID - default: - return domain.RepoRef{}, fmt.Errorf("unknown related-work target %q", target) - } - investigation, err := invSvc.GetInvestigation(ctx, investigationID) - if err != nil { - return domain.RepoRef{}, mapInvestigationError(err) - } - return investigation.Repo, nil + 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})) } func (r *MCPReader) relatedWorkRepositoryIndexed(ctx context.Context, repo domain.RepoRef) (bool, error) { diff --git a/internal/app/mcp_repository_search.go b/internal/app/mcp_repository_search.go index e00bf87d..e87a24dd 100644 --- a/internal/app/mcp_repository_search.go +++ b/internal/app/mcp_repository_search.go @@ -12,16 +12,88 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +type repositorySearchSort uint8 + +const ( + repositorySearchBestMatch repositorySearchSort = iota + repositorySearchStars + repositorySearchForks + repositorySearchHelpWanted + repositorySearchUpdated +) + +func parseRepositorySearchSort(value string) (repositorySearchSort, error) { + switch strings.TrimSpace(value) { + case "": + return repositorySearchBestMatch, nil + case "stars": + return repositorySearchStars, nil + case "forks": + return repositorySearchForks, nil + case "help-wanted-issues": + return repositorySearchHelpWanted, nil + case "updated": + return repositorySearchUpdated, nil + default: + return 0, errors.New("unsupported repository search sort") + } +} + +func (s repositorySearchSort) String() string { + return [...]string{"", "stars", "forks", "help-wanted-issues", "updated"}[s] +} + +type githubRepositorySearchRequest struct { + query string + interpretation string + warnings []mcpcontract.SearchWarning + sort repositorySearchSort + order githubSearchOrder + page githubSearchPage + format responseFormat +} + +func parseRepositorySearchInput(in mcpcontract.SearchGitHubRepositoriesInput) (githubRepositorySearchRequest, error) { + query, interpretation, warnings, err := compileRepositorySearch(in) + if err != nil { + return githubRepositorySearchRequest{}, err + } + page, pageProblem := parseGitHubSearchPage(in.Limit, in.Page) + if pageProblem == githubSearchLimitInvalid { + return githubRepositorySearchRequest{}, mcpcontract.InvalidArgument("limit", "must be between 1 and 100", map[string]any{"limit": 20}) + } + sortMode, err := parseRepositorySearchSort(in.Sort) + if err != nil { + return githubRepositorySearchRequest{}, mcpcontract.InvalidArgument("sort", "must be stars, forks, help-wanted-issues, or updated", map[string]any{"sort": "stars"}) + } + order, err := parseGitHubSearchOrder(in.Order, githubSearchOrderUnspecified) + if err != nil { + return githubRepositorySearchRequest{}, mcpcontract.InvalidArgument("order", "must be asc or desc", map[string]any{"order": "desc"}) + } + if pageProblem == githubSearchPageInvalid { + limit := in.Limit + if limit == 0 { + limit = 20 + } + return githubRepositorySearchRequest{}, mcpcontract.InvalidArgument("page", "must keep the requested result offset below GitHub's 1,000-result cap", map[string]any{"page": 1, "limit": limit}) + } + format, err := parseResponseFormat(in.ResponseFormat) + if err != nil { + return githubRepositorySearchRequest{}, mcpcontract.InvalidArgument("response_format", "must be concise or detailed; use concise for discovery and detailed for finalist inspection", map[string]any{"response_format": "concise"}) + } + return githubRepositorySearchRequest{ + query: query, interpretation: interpretation, warnings: append([]mcpcontract.SearchWarning(nil), warnings...), + sort: sortMode, order: order, page: page, format: format, + }, nil +} + // SearchGitHubRepositories performs one bounded live repository search and // persists the returned metadata observations without fetching thread data. func (r *MCPReader) SearchGitHubRepositories(ctx context.Context, in mcpcontract.SearchGitHubRepositoriesInput) (mcpcontract.SearchGitHubRepositoriesOutput, error) { - query, interpretation, warnings, err := compileRepositorySearch(in) + request, err := parseRepositorySearchInput(in) if err != nil { return mcpcontract.SearchGitHubRepositoriesOutput{}, err } - if err := normalizeRepositorySearchPage(&in); err != nil { - return mcpcontract.SearchGitHubRepositoriesOutput{}, err - } reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return mcpcontract.SearchGitHubRepositoriesOutput{}, err @@ -30,47 +102,22 @@ func (r *MCPReader) SearchGitHubRepositories(ctx context.Context, in mcpcontract if !ok { return mcpcontract.SearchGitHubRepositoriesOutput{}, errors.New("configured GitHub reader does not support repository search") } - result, err := searcher.SearchRepositories(ctx, github.RepositorySearchOptions{Query: query, Sort: in.Sort, Order: in.Order, PageOptions: github.PageOptions{Page: in.Page, PerPage: in.Limit}}) + result, err := searcher.SearchRepositories(ctx, github.RepositorySearchOptions{ + Query: request.query, Sort: request.sort.String(), Order: request.order.String(), + PageOptions: github.PageOptions{Page: request.page.number, PerPage: request.page.limit}, + }) if err != nil { return mcpcontract.SearchGitHubRepositoriesOutput{}, err } - return r.persistRepositorySearch(ctx, in, query, interpretation, warnings, result) -} - -func normalizeRepositorySearchPage(in *mcpcontract.SearchGitHubRepositoriesInput) error { - 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}) - } - if in.Sort != "" && in.Sort != "stars" && in.Sort != "forks" && in.Sort != "help-wanted-issues" && in.Sort != "updated" { - return mcpcontract.InvalidArgument("sort", "must be stars, forks, help-wanted-issues, or updated", map[string]any{"sort": "stars"}) - } - if in.Order != "" && in.Order != "asc" && in.Order != "desc" { - return mcpcontract.InvalidArgument("order", "must be asc or desc", map[string]any{"order": "desc"}) - } - if in.Page == 0 { - in.Page = 1 - } - if in.Page < 1 || in.Page > 1000 || (in.Page-1)*in.Limit >= 1000 { - return mcpcontract.InvalidArgument("page", "must keep the requested result offset below GitHub's 1,000-result cap", map[string]any{"page": 1, "limit": in.Limit}) - } - if in.ResponseFormat == "" { - in.ResponseFormat = "concise" - } - if in.ResponseFormat != "concise" && in.ResponseFormat != "detailed" { - return mcpcontract.InvalidArgument("response_format", "must be concise or detailed; use concise for discovery and detailed for finalist inspection", map[string]any{"response_format": "concise"}) - } - return nil + return r.persistRepositorySearch(ctx, request, result) } -func (r *MCPReader) persistRepositorySearch(ctx context.Context, in mcpcontract.SearchGitHubRepositoriesInput, query, interpretation string, warnings []mcpcontract.SearchWarning, result github.RepositorySearchResult) (mcpcontract.SearchGitHubRepositoriesOutput, error) { +func (r *MCPReader) persistRepositorySearch(ctx context.Context, request githubRepositorySearchRequest, result github.RepositorySearchResult) (mcpcontract.SearchGitHubRepositoriesOutput, error) { c, err := r.openCorpus(ctx) if err != nil { return mcpcontract.SearchGitHubRepositoriesOutput{}, err } - out := repositorySearchOutput(in, query, interpretation, warnings, result) + out := repositorySearchOutput(request, result) observedAt := r.now() repositoryIDs := make([]int64, 0, len(result.Items)) for i, remote := range result.Items { @@ -87,7 +134,7 @@ func (r *MCPReader) persistRepositorySearch(ctx context.Context, in mcpcontract. } repositoryIDs = append(repositoryIDs, stored.ID) metadata := mcpcontract.RepositoryMetadataOutput{Status: "complete", ObservedAt: formatTime(observedAt), SourceUpdatedAt: formatTime(remote.UpdatedAt)} - value := liveRepositorySearchMatch(remote, metadata, in.ResponseFormat) + value := liveRepositorySearchMatch(remote, metadata, request.format) value.DossierStatus = "missing" out.Items[i] = mcpcontract.BatchItem[mcpcontract.RepositorySearchMatch]{Key: remote.Owner + "/" + remote.Name, Status: "complete", Value: &value} } @@ -105,12 +152,12 @@ func (r *MCPReader) persistRepositorySearch(ctx context.Context, in mcpcontract. return out, nil } -func repositorySearchOutput(in mcpcontract.SearchGitHubRepositoriesInput, query, interpretation string, warnings []mcpcontract.SearchWarning, result github.RepositorySearchResult) mcpcontract.SearchGitHubRepositoriesOutput { - out := mcpcontract.SearchGitHubRepositoriesOutput{Status: "complete", Query: query, Interpretation: interpretation, ResponseFormat: in.ResponseFormat, Page: in.Page, Total: result.Total, Incomplete: result.Incomplete, Warnings: warnings, Items: make([]mcpcontract.BatchItem[mcpcontract.RepositorySearchMatch], len(result.Items))} +func repositorySearchOutput(request githubRepositorySearchRequest, result github.RepositorySearchResult) mcpcontract.SearchGitHubRepositoriesOutput { + out := mcpcontract.SearchGitHubRepositoriesOutput{Status: "complete", Query: request.query, Interpretation: request.interpretation, ResponseFormat: request.format.String(), Page: request.page.number, Total: result.Total, Incomplete: result.Incomplete, Warnings: append([]mcpcontract.SearchWarning(nil), request.warnings...), Items: make([]mcpcontract.BatchItem[mcpcontract.RepositorySearchMatch], len(result.Items))} if result.Page.HasNext { out.NextPage = result.Page.NextPage - } else if in.Page*in.Limit < result.Total && in.Page*in.Limit < 1000 { - out.NextPage = in.Page + 1 + } else if request.page.number*request.page.limit < result.Total && request.page.number*request.page.limit < 1000 { + out.NextPage = request.page.number + 1 } if result.Incomplete { out.Status = "partial" @@ -140,12 +187,12 @@ func addRepositorySearchAction(out *mcpcontract.SearchGitHubRepositoriesOutput, }} } -func liveRepositorySearchMatch(remote github.Repository, metadata mcpcontract.RepositoryMetadataOutput, format string) mcpcontract.RepositorySearchMatch { +func liveRepositorySearchMatch(remote github.Repository, metadata mcpcontract.RepositoryMetadataOutput, format responseFormat) mcpcontract.RepositorySearchMatch { match := mcpcontract.RepositorySearchMatch{Ref: "repository:" + remote.Owner + "/" + remote.Name, Owner: remote.Owner, Repo: remote.Name, Description: ptr(remote.Description), Language: ptr(remote.Language), Stars: ptr(remote.Stars), Metadata: metadata} if remote.PushedAt != nil { match.PushedAt = formatTime(*remote.PushedAt) } - if format == "detailed" { + if format.includesDetails() { match.DefaultBranch = ptr(remote.DefaultBranch) match.License = ptr(remote.License) match.Topics = append([]string(nil), remote.Topics...) @@ -159,33 +206,23 @@ func liveRepositorySearchMatch(remote github.Repository, metadata mcpcontract.Re } func compileRepositorySearch(in mcpcontract.SearchGitHubRepositoriesInput) (string, string, []mcpcontract.SearchWarning, error) { - query, mode, warnings, structured, err := repositorySearchMode(in) - if err != nil { - return "", "", nil, err - } - if !structured { - if strings.Contains(strings.ToLower(query), "in:readme") { - warnings = append(warnings, readmeSearchWarning()) - } - return query, "Search using " + mode + ".", warnings, nil - } - query, structuredWarnings, err := compileStructuredRepositorySearch(in) - return query, "Search using structured repository filters.", append(warnings, structuredWarnings...), err -} - -func repositorySearchMode(in mcpcontract.SearchGitHubRepositoriesInput) (string, string, []mcpcontract.SearchWarning, bool, error) { raw := strings.TrimSpace(in.RawQuery) structured := hasStructuredRepositorySearch(in) if raw != "" && structured { - return "", "", nil, false, mcpcontract.InvalidArgument("raw_query", "cannot be combined with structured filters; choose one input mode", map[string]any{"raw_query": "is:public language:go stars:>=100"}) + return "", "", nil, mcpcontract.InvalidArgument("raw_query", "cannot be combined with structured filters; choose one input mode", map[string]any{"raw_query": "is:public language:go stars:>=100"}) } if raw == "" && !structured { - return "", "", nil, false, mcpcontract.InvalidArgument("text", "provide raw_query or at least one structured filter such as text, topics, language, or pushed_after", map[string]any{"text": "GitHub contribution research", "match_fields": []string{"name", "description"}}) + return "", "", nil, mcpcontract.InvalidArgument("text", "provide raw_query or at least one structured filter such as text, topics, language, or pushed_after", map[string]any{"text": "GitHub contribution research", "match_fields": []string{"name", "description"}}) } if raw != "" { - return raw, "advanced raw query", nil, false, nil + warnings := []mcpcontract.SearchWarning{} + if strings.Contains(strings.ToLower(raw), "in:readme") { + warnings = append(warnings, readmeSearchWarning()) + } + return raw, "Search using advanced raw query.", warnings, nil } - return "", "", nil, true, nil + query, warnings, err := compileStructuredRepositorySearch(in) + return query, "Search using structured repository filters.", warnings, err } func hasStructuredRepositorySearch(in mcpcontract.SearchGitHubRepositoriesInput) bool { diff --git a/internal/app/mcp_resource_reads.go b/internal/app/mcp_resource_reads.go index d25e9978..be1cfd74 100644 --- a/internal/app/mcp_resource_reads.go +++ b/internal/app/mcp_resource_reads.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/failure" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/workspace" @@ -28,38 +29,42 @@ func (r *MCPReader) Workspace(ctx context.Context, id string) (mcpcontract.Works return mcpcontract.WorkspaceResource{ SchemaVersion: "gitcontribute.workspace.v1", ID: ws.Name, InvestigationID: ws.InvestigationID, Owner: ws.RepoOwner, Repo: ws.RepoName, BaseSHA: ws.BaseSHA, HeadSHA: ws.CandidateSHA, - MergeBase: ws.MergeBase, Ownership: string(ws.Ownership), Dirty: ws.Dirty, - HasUntracked: ws.HasUntracked, CreatedAt: formatTime(ws.CreatedAt), + MergeBase: ws.MergeBase, Ownership: string(ws.Ownership), Dirty: ws.Dirty(), + HasUntracked: ws.HasUntracked(), CreatedAt: formatTime(ws.CreatedAt), }, nil } -func (r *MCPReader) PullRequestFeedbackResource(ctx context.Context, owner, repo string, number int) (map[string]any, error) { - facets := []struct { - channel string - facet string - }{ - {"issue_comments", facetPRFeedbackIssueComments}, - {"submitted_reviews", facetPRFeedbackReviews}, - {"inline_comments", facetPRFeedbackInlineComments}, - {"review_threads", facetPRFeedbackReviewThreads}, - } - channels := make(map[string]any, len(facets)) - out := map[string]any{ - "schema_version": "gitcontribute.pull-request-feedback.v1", - "owner": owner, "repo": repo, "number": number, "channels": channels, - } - for _, target := range facets { - value, err := r.pullRequestWorkflowFacet(ctx, owner, repo, number, target.facet) +func (r *MCPReader) PullRequestFeedbackResource(ctx context.Context, owner, repo string, number int) (mcpcontract.PullRequestFeedbackResource, error) { + out := mcpcontract.PullRequestFeedbackResource{ + SchemaVersion: "gitcontribute.pull-request-feedback.v1", Owner: owner, Repo: repo, Number: number, + } + count := 0 + for _, channel := range []corpus.FeedbackChannel{corpus.FeedbackIssueComments, corpus.FeedbackSubmittedReviews, corpus.FeedbackInlineComments, corpus.FeedbackReviewThreads} { + observation, err := r.readPullRequestWorkflowFacet(ctx, owner, repo, number, channel.Facet()) if err != nil { if isCorpusNotFound(err) { continue } - return nil, err + return mcpcontract.PullRequestFeedbackResource{}, err + } + value, err := mcpcontract.NewStoredFacetResource(observation.payload, observation.coverage) + if err != nil { + return mcpcontract.PullRequestFeedbackResource{}, fmt.Errorf("decode %s feedback facet: %w", channel, err) + } + switch channel { + case corpus.FeedbackIssueComments: + out.Channels.IssueComments = &value + case corpus.FeedbackSubmittedReviews: + out.Channels.SubmittedReviews = &value + case corpus.FeedbackInlineComments: + out.Channels.InlineComments = &value + case corpus.FeedbackReviewThreads: + out.Channels.ReviewThreads = &value } - channels[target.channel] = value + count++ } - if len(channels) == 0 { - return nil, failure.NotFound(errors.New("pull-request feedback is not stored")) + if count == 0 { + return mcpcontract.PullRequestFeedbackResource{}, failure.NotFound(errors.New("pull-request feedback is not stored")) } return out, nil } @@ -67,151 +72,158 @@ func (r *MCPReader) PullRequestFeedbackResource(ctx context.Context, owner, repo // PullRequestFeedbackItemResource returns the exact normalized record named by // a search match. The root feedback resource remains the canonical raw facet // view; this child resource is the compact, identity-preserving follow-up. -func (r *MCPReader) PullRequestFeedbackItemResource(ctx context.Context, owner, repo string, number int, channel, feedbackID string) (map[string]any, error) { +func (r *MCPReader) PullRequestFeedbackItemResource(ctx context.Context, owner, repo string, number int, channel, feedbackID string) (mcpcontract.PullRequestFeedbackItemResource, error) { + parsedChannel, err := corpus.ParseFeedbackChannel(channel) + if err != nil { + return mcpcontract.PullRequestFeedbackItemResource{}, failure.NotFound(err) + } + channel = parsedChannel.String() c, err := r.openReadOnlyCorpus(ctx) if err != nil { - return nil, err + return mcpcontract.PullRequestFeedbackItemResource{}, err } storedRepo, err := c.GetRepository(ctx, owner, repo) if err != nil || storedRepo == nil { if err == nil { err = failure.NotFound(errors.New("repository is not stored")) } - return nil, err + return mcpcontract.PullRequestFeedbackItemResource{}, err } - item, err := c.GetPullRequestFeedbackItem(ctx, storedRepo.ID, number, channel, feedbackID) + item, err := c.GetPullRequestFeedbackItem(ctx, storedRepo.ID, number, parsedChannel, feedbackID) if err != nil { - return nil, err + return mcpcontract.PullRequestFeedbackItemResource{}, err } if item == nil { - return nil, failure.NotFound(fmt.Errorf("pull-request feedback item %s is not stored", feedbackID)) + return mcpcontract.PullRequestFeedbackItemResource{}, failure.NotFound(fmt.Errorf("pull-request feedback item %s is not stored", feedbackID)) } - merged := any(nil) + var merged *bool if value, known := item.PullRequestMerge.IsMerged(), item.PullRequestMerge.Known(); known { - merged = value + merged = &value } - resolved := any(nil) + var resolved *bool resolutionState := "unknown" if value, known := item.Resolution.Value(); known { - resolved = value + resolved = &value if value { resolutionState = "resolved" } else { resolutionState = "unresolved" } } - facet := map[string]string{ - "issue_comments": facetPRFeedbackIssueComments, - "submitted_reviews": facetPRFeedbackReviews, - "inline_comments": facetPRFeedbackInlineComments, - "review_threads": facetPRFeedbackReviewThreads, - }[channel] - if facet == "" { - return nil, failure.NotFound(fmt.Errorf("unknown pull-request feedback channel %q", channel)) - } - coverage, err := c.GetCoverage(ctx, storedRepo.ID, &item.ThreadID, facet) + coverage, err := c.GetCoverage(ctx, storedRepo.ID, &item.ThreadID, parsedChannel.Facet()) if err != nil { - return nil, err - } - out := map[string]any{ - "schema_version": "gitcontribute.pull-request-feedback-item.v1", - "owner": owner, "repo": repo, "number": number, "channel": channel, - "feedback_id": item.FeedbackID, "feedback_node_id": item.FeedbackNodeID, - "thread_id": item.ThreadExternalID, "in_reply_to_id": item.InReplyToID, - "feedback_author": item.Author, "review_state": item.ReviewState, - "body": item.Body, "path": item.Path, "line": item.Line, "start_line": item.StartLine, - "side": item.Side, "start_side": item.StartSide, "commit_oid": item.CommitOID, - "outdated": item.Outdated, "resolved": resolved, "resolution_state": resolutionState, "resolved_by": item.ResolvedBy, - "created_at": formatTime(item.CreatedAt), "updated_at": formatTime(item.UpdatedAt), - "head_sha": item.HeadSHA, "source_observation_id": item.SourceObservationID, - "pull_request": map[string]any{ - "owner": owner, "repo": repo, "number": item.PullRequestNumber, - "author": item.PullRequestAuthor, "state": item.PullRequestState, "merged": merged, + return mcpcontract.PullRequestFeedbackItemResource{}, err + } + out := mcpcontract.PullRequestFeedbackItemResource{ + SchemaVersion: "gitcontribute.pull-request-feedback-item.v1", Owner: owner, Repo: repo, Number: number, Channel: channel, + FeedbackID: item.FeedbackID, FeedbackNodeID: item.FeedbackNodeID, ThreadID: item.ThreadExternalID, InReplyToID: item.InReplyToID, + FeedbackAuthor: item.Author, ReviewState: item.ReviewState, Body: item.Body, Path: item.Path, Line: item.Line, StartLine: item.StartLine, + Side: item.Side, StartSide: item.StartSide, CommitOID: item.CommitOID, Outdated: item.Outdated, Resolved: resolved, + ResolutionState: resolutionState, ResolvedBy: item.ResolvedBy, CreatedAt: formatTime(item.CreatedAt), UpdatedAt: formatTime(item.UpdatedAt), + HeadSHA: item.HeadSHA, SourceObservationID: item.SourceObservationID, + PullRequest: mcpcontract.PullRequestFeedbackPullRequestResource{ + Owner: owner, Repo: repo, Number: item.PullRequestNumber, Author: item.PullRequestAuthor, State: item.PullRequestState, Merged: merged, }, } if coverage != nil { - out["effective_coverage"] = map[string]any{"complete": coverage.Complete, "source_updated_at": formatTime(coverage.SourceUpdatedAt)} + out.EffectiveCoverage = &mcpcontract.ResourceCoverage{Complete: coverage.Complete, SourceUpdatedAt: formatTime(coverage.SourceUpdatedAt)} } return out, nil } -func (r *MCPReader) CIFailureResource(ctx context.Context, owner, repo string, number int) (map[string]any, error) { - value, err := r.pullRequestWorkflowFacet(ctx, owner, repo, number, facetPRCIReport) +func (r *MCPReader) CIFailureResource(ctx context.Context, owner, repo string, number int) (mcpcontract.CIFailureResource, error) { + observation, err := r.readPullRequestWorkflowFacet(ctx, owner, repo, number, facetPRCIReport) if err != nil { - return nil, err + return mcpcontract.CIFailureResource{}, err + } + value, err := mcpcontract.NewCIFailureResource(observation.payload, owner, repo, number, observation.coverage) + if err != nil { + return mcpcontract.CIFailureResource{}, fmt.Errorf("decode CI failure report: %w", err) } - value["schema_version"] = "gitcontribute.ci-failure-report.v1" - value["owner"], value["repo"], value["number"] = owner, repo, number return value, nil } -func (r *MCPReader) CIJobLogResource(ctx context.Context, owner, repo string, number int, jobID int64) (map[string]any, error) { - report, err := r.CIFailureResource(ctx, owner, repo, number) +func (r *MCPReader) CIJobLogResource(ctx context.Context, owner, repo string, number int, jobID int64) (mcpcontract.CIJobLogResource, error) { + observation, err := r.readPullRequestWorkflowFacet(ctx, owner, repo, number, facetPRCIReport) if err != nil { - return nil, err - } - runs, _ := report["workflow_runs"].([]any) - for _, rawRun := range runs { - run, _ := rawRun.(map[string]any) - jobs, _ := run["jobs"].([]any) - for _, rawJob := range jobs { - job, _ := rawJob.(map[string]any) - id, _ := job["id"].(float64) - if int64(id) != jobID { + return mcpcontract.CIJobLogResource{}, err + } + var report struct { + WorkflowRuns []struct { + Jobs []struct { + ID int64 `json:"id"` + Log *struct { + JobID int64 `json:"job_id"` + Body string `json:"body"` + Truncated bool `json:"truncated"` + } `json:"log"` + } `json:"jobs"` + } `json:"workflow_runs"` + } + if err := json.Unmarshal(observation.payload, &report); err != nil { + return mcpcontract.CIJobLogResource{}, fmt.Errorf("decode CI job log index: %w", err) + } + for _, run := range report.WorkflowRuns { + for _, job := range run.Jobs { + if job.ID != jobID { continue } - log, ok := job["log"].(map[string]any) - if !ok { - return nil, failure.NotFound(errors.New("CI job log is not stored")) + if job.Log == nil { + return mcpcontract.CIJobLogResource{}, failure.NotFound(errors.New("CI job log is not stored")) + } + if job.Log.JobID != job.ID { + return mcpcontract.CIJobLogResource{}, errors.New("stored CI job log identity does not match its job") } - log["schema_version"] = "gitcontribute.ci-job-log.v1" - return log, nil + return mcpcontract.NewCIJobLogResource(job.Log.JobID, job.Log.Body, job.Log.Truncated) } } - return nil, failure.NotFound(errors.New("CI job log is not stored")) + return mcpcontract.CIJobLogResource{}, failure.NotFound(errors.New("CI job log is not stored")) +} + +type storedWorkflowFacetObservation struct { + payload json.RawMessage + coverage *mcpcontract.ResourceCoverage } -func (r *MCPReader) pullRequestWorkflowFacet(ctx context.Context, owner, repo string, number int, facet string) (map[string]any, error) { +func (r *MCPReader) readPullRequestWorkflowFacet(ctx context.Context, owner, repo string, number int, facet string) (storedWorkflowFacetObservation, error) { c, err := r.openReadOnlyCorpus(ctx) if err != nil { - return nil, err + return storedWorkflowFacetObservation{}, err } storedRepo, err := c.GetRepository(ctx, owner, repo) if err != nil || storedRepo == nil { if err == nil { err = failure.NotFound(errors.New("repository is not stored")) } - return nil, err + return storedWorkflowFacetObservation{}, err } thread, err := c.GetThreadByNumber(ctx, storedRepo.ID, number) if err != nil || thread == nil { if err == nil { err = failure.NotFound(errors.New("pull request is not stored")) } - return nil, err + return storedWorkflowFacetObservation{}, err } observations, _, err := c.ListFacetObservationsBounded(ctx, storedRepo.ID, &thread.ID, facet, 1) if err != nil { - return nil, err + return storedWorkflowFacetObservation{}, err } if len(observations) == 0 { - return nil, failure.NotFound(errors.New("facet is not stored")) + return storedWorkflowFacetObservation{}, failure.NotFound(errors.New("facet is not stored")) } - var value map[string]any - if err := json.Unmarshal([]byte(observations[0].Payload), &value); err != nil { - return nil, err + payload := json.RawMessage(observations[0].Payload) + if !json.Valid(payload) { + return storedWorkflowFacetObservation{}, errors.New("stored workflow facet payload is invalid JSON") } coverage, err := c.GetCoverage(ctx, storedRepo.ID, &thread.ID, facet) if err != nil { - return nil, err + return storedWorkflowFacetObservation{}, err } + out := storedWorkflowFacetObservation{payload: payload} if coverage != nil { - value["effective_coverage"] = map[string]any{ - "complete": coverage.Complete, - "source_updated_at": formatTime(coverage.SourceUpdatedAt), - } + out.coverage = &mcpcontract.ResourceCoverage{Complete: coverage.Complete, SourceUpdatedAt: formatTime(coverage.SourceUpdatedAt)} } - return value, nil + return out, nil } func isCorpusNotFound(err error) bool { diff --git a/internal/app/mcp_scalable_inputs_test.go b/internal/app/mcp_scalable_inputs_test.go index 94b6ad52..faea4f7f 100644 --- a/internal/app/mcp_scalable_inputs_test.go +++ b/internal/app/mcp_scalable_inputs_test.go @@ -61,6 +61,14 @@ func TestSyncPortfolioRejectsDuplicateDefaultKindReferences(t *testing.T) { func TestParsedSyncInputsRejectDuplicatesAfterCanonicalization(t *testing.T) { t.Parallel() + if _, _, err := parseRepositoryContextSyncInput(mcpcontract.SyncRepositoryContextInput{ + Repositories: []mcpcontract.RepositoryRef{ + {Owner: "acme", Repo: "rocket"}, + {Owner: " ACME ", Repo: " rocket "}, + }, + }); err == nil { + t.Fatal("repository-context duplicates created by canonicalization were accepted") + } if _, _, err := parseSyncThreadsInput(mcpcontract.SyncThreadsInput{ Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{ @@ -117,7 +125,7 @@ func TestParsedSyncInputsOwnCanonicalCopies(t *testing.T) { } repositories[0] = mcpcontract.RepositoryRef{Owner: "changed", Repo: "changed"} selection, ok := request.selection.(repositoryThreadSelection) - if !ok || selection.repositories[0].Owner != "acme" || normalized.Repositories[0].Repo != "rocket" { + if !ok || selection.repositories[0].Owner() != "acme" || normalized.Repositories[0].Repo != "rocket" { t.Fatalf("parsed repository selection = %+v, normalized = %+v", request.selection, normalized) } diff --git a/internal/app/mcp_scalable_operations.go b/internal/app/mcp_scalable_operations.go index 490b6e0c..46d84722 100644 --- a/internal/app/mcp_scalable_operations.go +++ b/internal/app/mcp_scalable_operations.go @@ -5,46 +5,482 @@ import ( "encoding/json" "errors" "fmt" - "maps" "strings" "sync" "time" - "unicode/utf8" + "github.com/morluto/gitcontribute/internal/codeindex" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" - "github.com/morluto/gitcontribute/internal/deepwiki" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/repositorycontext" ) -// SyncRepositoryContext submits a durable metadata and contribution-guidance -// GitHub read. It does not fetch threads, comments, reviews, or code. -func (r *MCPReader) SyncRepositoryContext(ctx context.Context, in mcpcontract.SyncRepositoryContextInput) (mcpcontract.JobReference, error) { - if err := rejectDuplicateRepositoryRefs(in.Repositories); err != nil { - return mcpcontract.JobReference{}, err +type batchOperationStatus string + +const ( + batchOperationComplete batchOperationStatus = "complete" + batchOperationPartial batchOperationStatus = "partial" + batchOperationFailed batchOperationStatus = "failed" +) + +type batchOperationSummary[T any] struct { + Status batchOperationStatus `json:"status"` + Items []T `json:"items"` + Completed int `json:"completed"` + Total int `json:"total"` +} + +type threadSyncBatchResult struct { + batchOperationSummary[threadSyncItem] + Requests int `json:"requests"` + RequestBudget int `json:"request_budget"` + PlannedRequests int `json:"planned_requests"` +} + +type threadSyncOutcome interface { + threadSyncOutcome() + status() mcpcontract.BatchItemStatus + requestsUsed() int +} + +type threadSyncRepositorySuccess struct { + updated int + requests int + requestCapped bool + message string + threads []mcpcontract.ThreadRef +} + +func (threadSyncRepositorySuccess) threadSyncOutcome() {} +func (s threadSyncRepositorySuccess) status() mcpcontract.BatchItemStatus { + if s.requestCapped { + return mcpcontract.BatchItemPartial } - if len(in.Repositories) < 1 || len(in.Repositories) > 100 { - return mcpcontract.JobReference{}, errors.New("repositories must contain 1 to 100 items") + return mcpcontract.BatchItemComplete +} +func (s threadSyncRepositorySuccess) requestsUsed() int { return s.requests } + +type threadSyncExactSuccess struct { + requestCapped bool + message string + threads []mcpcontract.ThreadRef +} + +func (threadSyncExactSuccess) threadSyncOutcome() {} +func (s threadSyncExactSuccess) status() mcpcontract.BatchItemStatus { + if s.requestCapped { + return mcpcontract.BatchItemPartial } - for _, input := range in.Repositories { - if _, err := domain.NewRepoRef(input.Owner, input.Repo); err != nil { - return mcpcontract.JobReference{}, err - } + return mcpcontract.BatchItemComplete +} +func (threadSyncExactSuccess) requestsUsed() int { return 0 } + +type threadSyncFailure struct { + itemStatus mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS *int +} + +func (threadSyncFailure) threadSyncOutcome() {} +func (f threadSyncFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } +func (threadSyncFailure) requestsUsed() int { return 0 } + +type threadSyncExactFailure struct { + threadSyncFailure + threads []mcpcontract.ThreadRef +} + +type threadSyncItem struct { + key string + outcome threadSyncOutcome +} + +func successfulThreadSyncItem(key string, updated, requests int, requestCapped bool, message string, threads []mcpcontract.ThreadRef) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncRepositorySuccess{ + updated: updated, requests: requests, requestCapped: requestCapped, message: message, threads: threads, + }} +} + +func unavailableThreadSyncItem(key, reason, message string) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: mcpcontract.BatchItemUnavailable, reason: reason, message: message}} +} + +func failedThreadSyncItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: status, reason: reason, message: message, retryAfterMS: &retryAfterMS}} +} + +func (i threadSyncItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" } - if in.MaxRequests == 0 { - in.MaxRequests = defaultSyncBatchMaxRequests + return i.outcome.status() +} + +func (i threadSyncItem) RequestsUsed() int { + if i.outcome == nil { + return 0 } - if in.MaxRequests < repositorycontext.RequestCost() || in.MaxRequests > defaultSyncBatchMaxRequests { - return mcpcontract.JobReference{}, fmt.Errorf( - "max requests must be between %d and %d", - repositorycontext.RequestCost(), defaultSyncBatchMaxRequests, - ) + return i.outcome.requestsUsed() +} + +func (i threadSyncItem) forExactThread(key string, fallback mcpcontract.ThreadRef) (threadSyncItem, error) { + switch outcome := i.outcome.(type) { + case threadSyncRepositorySuccess: + return threadSyncItem{key: key, outcome: threadSyncExactSuccess{ + requestCapped: outcome.requestCapped, message: outcome.message, threads: outcome.threads, + }}, nil + case threadSyncFailure: + return threadSyncItem{key: key, outcome: threadSyncExactFailure{ + threadSyncFailure: outcome, threads: []mcpcontract.ThreadRef{fallback}, + }}, nil + default: + return threadSyncItem{}, errors.New("thread sync item cannot be projected to an exact thread") + } +} + +func (i threadSyncItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case threadSyncRepositorySuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Updated int `json:"updated"` + Requests int `json:"requests"` + RequestCapped bool `json:"request_capped"` + Message string `json:"message"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{i.key, outcome.status(), outcome.updated, outcome.requests, outcome.requestCapped, outcome.message, outcome.threads}) + case threadSyncExactSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + RequestCapped bool `json:"request_capped"` + Message string `json:"message"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{i.key, outcome.status(), outcome.requestCapped, outcome.message, outcome.threads}) + case threadSyncFailure: + return marshalThreadSyncFailure(i.key, outcome, nil) + case threadSyncExactFailure: + return marshalThreadSyncFailure(i.key, outcome.threadSyncFailure, outcome.threads) + default: + return nil, errors.New("thread sync item has no supported outcome") } - id, err := r.submitJob(ctx, "sync_repository_context", in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.syncRepositoryContext(ctx, in, report) +} + +func marshalThreadSyncFailure(key string, failure threadSyncFailure, threads []mcpcontract.ThreadRef) ([]byte, error) { + if failure.itemStatus == mcpcontract.BatchItemComplete || failure.itemStatus == mcpcontract.BatchItemPartial || failure.itemStatus == "" { + return nil, errors.New("thread sync failure has a non-failure status") + } + if threads != nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS *int `json:"retry_after_ms,omitempty"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS, threads}) + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS *int `json:"retry_after_ms,omitempty"` + }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS}) +} + +type threadHydrationBatchResult struct { + Status batchOperationStatus `json:"status"` + Items []threadHydrationItem `json:"items"` + Completed int `json:"completed"` + Total int `json:"total"` +} + +type threadHydrationSuccess struct { + kind string + requests int + facets []contracts.HydratedFacet +} + +type threadHydrationFailure struct { + reason string + message string + retryAfterMS int +} + +type threadHydrationItem struct { + key string + status mcpcontract.BatchItemStatus + success *threadHydrationSuccess + failure *threadHydrationFailure +} + +func completeThreadHydrationItem(key, kind string, requests int, facets []contracts.HydratedFacet) threadHydrationItem { + return threadHydrationItem{ + key: key, status: mcpcontract.BatchItemComplete, + success: &threadHydrationSuccess{kind: kind, requests: requests, facets: facets}, + } +} + +func failedThreadHydrationItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadHydrationItem { + return threadHydrationItem{ + key: key, status: status, + failure: &threadHydrationFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}, + } +} + +func (i threadHydrationItem) Status() mcpcontract.BatchItemStatus { return i.status } + +func (i threadHydrationItem) Reason() string { + if i.failure == nil { + return "" + } + return i.failure.reason +} + +func (i threadHydrationItem) Message() string { + if i.failure == nil { + return "" + } + return i.failure.message +} + +func (i threadHydrationItem) MarshalJSON() ([]byte, error) { + if i.success != nil && i.failure == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Kind string `json:"kind"` + HeaderRefreshed bool `json:"header_refreshed"` + Requests int `json:"requests"` + Facets []contracts.HydratedFacet `json:"facets"` + }{i.key, i.status, i.success.kind, true, i.success.requests, i.success.facets}) + } + if i.failure != nil && i.success == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, i.status, i.failure.reason, i.failure.message, i.failure.retryAfterMS}) + } + return nil, errors.New("thread hydration item has no single outcome") +} + +type repositoryIndexBatchResult struct { + batchOperationSummary[repositoryIndexItem] + SnapshotToken string `json:"snapshot_token"` +} + +type repositoryIndexOutcome interface { + repositoryIndexOutcome() + status() mcpcontract.BatchItemStatus +} + +type repositoryIndexSuccess struct{ result contracts.AcquisitionResult } + +func (repositoryIndexSuccess) repositoryIndexOutcome() {} +func (repositoryIndexSuccess) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemComplete +} + +type repositoryIndexFailure struct { + reason string + message string + retryAfterMS int +} + +func (repositoryIndexFailure) repositoryIndexOutcome() {} +func (repositoryIndexFailure) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemFailed +} + +type repositoryIndexItem struct { + key string + outcome repositoryIndexOutcome +} + +func successfulRepositoryIndexItem(key string, result contracts.AcquisitionResult) repositoryIndexItem { + return repositoryIndexItem{key: key, outcome: repositoryIndexSuccess{result: result}} +} + +func failedRepositoryIndexItem(key, reason, message string, retryAfterMS int) repositoryIndexItem { + return repositoryIndexItem{key: key, outcome: repositoryIndexFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}} +} + +func (i repositoryIndexItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" + } + return i.outcome.status() +} + +func (i repositoryIndexItem) SnapshotToken() string { + if outcome, ok := i.outcome.(repositoryIndexSuccess); ok { + return outcome.result.SnapshotToken + } + return "" +} + +func (i repositoryIndexItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case repositoryIndexSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + CommitSHA string `json:"commit_sha"` + Files int `json:"files"` + Bytes int `json:"bytes"` + Inserted bool `json:"inserted"` + SnapshotToken string `json:"snapshot_token"` + IndexManifest codeindex.Manifest `json:"index_manifest"` + ArtifactDigest string `json:"artifact_digest"` + ManifestDigest string `json:"manifest_digest"` + }{i.key, outcome.status(), outcome.result.CommitSHA, outcome.result.Files, outcome.result.Bytes, outcome.result.Inserted, + outcome.result.SnapshotToken, outcome.result.IndexManifest, outcome.result.ArtifactDigest, outcome.result.ManifestDigest}) + case repositoryIndexFailure: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS}) + default: + return nil, errors.New("repository index item has no supported outcome") + } +} + +type repositoryContextBatchResult struct { + batchOperationSummary[repositoryContextItem] + Requests int `json:"requests"` + RequestBudget int `json:"request_budget"` + PlannedRequests int `json:"planned_requests"` +} + +type repositoryContextOutcome interface { + repositoryContextOutcome() + status() mcpcontract.BatchItemStatus +} + +type repositoryContextSuccess struct { + requests int + repository mcpcontract.RepositoryOutput +} + +func (repositoryContextSuccess) repositoryContextOutcome() {} +func (repositoryContextSuccess) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemComplete +} + +type repositoryContextBudgetFailure struct { + reason string + message string +} + +func (repositoryContextBudgetFailure) repositoryContextOutcome() {} +func (repositoryContextBudgetFailure) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemUnavailable +} + +type repositoryContextRequestFailure struct { + itemStatus mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS int + requests int +} + +func (repositoryContextRequestFailure) repositoryContextOutcome() {} +func (f repositoryContextRequestFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } + +type repositoryContextItem struct { + key string + outcome repositoryContextOutcome +} + +func successfulRepositoryContextItem(key string, requests int, repository mcpcontract.RepositoryOutput) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextSuccess{requests: requests, repository: repository}} +} + +func unavailableRepositoryContextItem(key, reason, message string) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextBudgetFailure{reason: reason, message: message}} +} + +func failedRepositoryContextItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS, requests int) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextRequestFailure{ + itemStatus: status, reason: reason, message: message, retryAfterMS: retryAfterMS, requests: requests, + }} +} + +func (i repositoryContextItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" + } + return i.outcome.status() +} + +func (i repositoryContextItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case repositoryContextSuccess: + type facet struct { + Status mcpcontract.BatchItemStatus `json:"status"` + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Requests int `json:"requests"` + Repository mcpcontract.RepositoryOutput `json:"repository"` + Facets struct { + Metadata facet `json:"metadata"` + ContributionGuidance facet `json:"contribution_guidance"` + } `json:"facets"` + }{ + Key: i.key, Status: outcome.status(), Requests: outcome.requests, Repository: outcome.repository, + Facets: struct { + Metadata facet `json:"metadata"` + ContributionGuidance facet `json:"contribution_guidance"` + }{Metadata: facet{Status: mcpcontract.BatchItemComplete}, ContributionGuidance: facet{Status: mcpcontract.BatchItemComplete}}, + }) + case repositoryContextBudgetFailure: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + }{i.key, outcome.status(), outcome.reason, outcome.message}) + case repositoryContextRequestFailure: + if outcome.itemStatus == mcpcontract.BatchItemComplete || outcome.itemStatus == mcpcontract.BatchItemPartial || outcome.itemStatus == "" { + return nil, errors.New("repository context failure has a non-failure status") + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + Requests int `json:"requests"` + }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS, outcome.requests}) + default: + return nil, errors.New("repository context item has no supported outcome") + } +} + +// SyncRepositoryContext submits a durable metadata and contribution-guidance +// GitHub read. It does not fetch threads, comments, reviews, or code. +func (r *MCPReader) SyncRepositoryContext(ctx context.Context, in mcpcontract.SyncRepositoryContextInput) (mcpcontract.JobReference, error) { + request, canonical, err := parseRepositoryContextSyncInput(in) + if err != nil { + return mcpcontract.JobReference{}, err + } + id, err := r.submitJob(ctx, "sync_repository_context", canonical, func(ctx context.Context, report func(string, string) error) (any, error) { + return r.syncRepositoryContext(ctx, request, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -72,20 +508,20 @@ func (r *MCPReader) SyncThreads(ctx context.Context, in mcpcontract.SyncThreadsI // together so cancellation and per-item failures remain consistent. // //nolint:gocognit -func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsRequest, report func(string, string) error) (map[string]any, error) { +func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsRequest, report func(string, string) error) (*threadSyncBatchResult, error) { type task struct { key string - ref contracts.RepoRef - kind string + ref domain.RepoRef + kind syncThreadKind numbers []int inputIndexes []int maxRequests int } var ( tasks []task - exactThreads []mcpcontract.ThreadRef - kind = "both" - state = "all" + exactThreads []exactThreadTarget + kind = syncAllThreads + state = syncAllStates since time.Time limitPerRepository int ) @@ -93,24 +529,20 @@ func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsReque case repositoryThreadSelection: kind, state, since, limitPerRepository = selection.kind, selection.state, selection.updatedAfter, selection.limitPerRepository for _, ref := range selection.repositories { - tasks = append(tasks, task{key: ref.Owner + "/" + ref.Repo, ref: contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}}) + tasks = append(tasks, task{key: ref.String(), ref: ref}) } case exactThreadSelection: exactThreads = selection.threads grouped := make(map[string]int) for inputIndex, thread := range selection.threads { - kind := thread.Kind - if kind == "" { - kind = "both" - } - key := thread.Owner + "/" + thread.Repo + "\x00" + kind + key := thread.repository.String() + "\x00" + thread.kind.String() index, ok := grouped[key] if !ok { grouped[key] = len(tasks) - tasks = append(tasks, task{key: thread.Owner + "/" + thread.Repo + "/" + kind, kind: kind, ref: contracts.RepoRef{Owner: thread.Owner, Repo: thread.Repo}}) + tasks = append(tasks, task{key: thread.repository.String() + "/" + thread.kind.String(), kind: thread.kind, ref: thread.repository}) index = len(tasks) - 1 } - tasks[index].numbers = append(tasks[index].numbers, thread.Number) + tasks[index].numbers = append(tasks[index].numbers, thread.number) tasks[index].inputIndexes = append(tasks[index].inputIndexes, inputIndex) } default: @@ -127,7 +559,7 @@ func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsReque if limitPerRepository > 100 { maxPages = (limitPerRepository + 99) / 100 } - taskResults := make([]map[string]any, len(tasks)) + taskResults := make([]threadSyncItem, len(tasks)) c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -136,29 +568,26 @@ func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsReque plannedRequests := 0 runnable := make([]int, 0, len(tasks)) for index := range tasks { - stored, err := c.GetRepository(ctx, tasks[index].ref.Owner, tasks[index].ref.Repo) + stored, err := c.GetRepository(ctx, tasks[index].ref.Owner(), tasks[index].ref.Repo()) if err != nil { return nil, err } if stored == nil { - taskResults[index] = map[string]any{ - "key": tasks[index].key, "status": "unavailable", "reason": "repository_not_indexed", - "message": "repository is not stored; call github.sync_repository_context first", - } + taskResults[index] = unavailableThreadSyncItem(tasks[index].key, "repository_not_indexed", "repository is not stored; call github.sync_repository_context first") continue } threadRequests := maxPages if len(tasks[index].numbers) > 0 { threadRequests = len(tasks[index].numbers) if threadRequests > remainingRequests { - taskResults[index] = syncRequestBudgetUnavailable(tasks[index].key, threadRequests, remainingRequests) + taskResults[index] = unavailableThreadSyncItem(tasks[index].key, "request_budget_exceeded", syncRequestBudgetMessage(threadRequests, remainingRequests)) continue } } else if threadRequests > remainingRequests { threadRequests = remainingRequests } if threadRequests < 1 { - taskResults[index] = syncRequestBudgetUnavailable(tasks[index].key, 1, remainingRequests) + taskResults[index] = unavailableThreadSyncItem(tasks[index].key, "request_budget_exceeded", syncRequestBudgetMessage(1, remainingRequests)) continue } required := threadRequests @@ -180,25 +609,28 @@ func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsReque for index := range jobs { current := tasks[index] currentKind := kind + currentState := state + currentSince := since if exactThreads != nil { currentKind = current.kind } - opts := SyncOptions{Kind: currentKind, State: state, Since: since, Numbers: current.numbers, MaxItems: limitPerRepository, MaxPages: maxPages, MaxRequests: current.maxRequests} if len(current.numbers) > 0 { - opts.State = "all" - opts.Since = time.Time{} + currentState = syncAllStates + currentSince = time.Time{} } - res, err := s.syncThreadHeaders(ctx, current.ref, opts) + threadRequest, plan, err := newThreadSyncRequest(currentKind, currentState, currentSince, current.numbers, limitPerRepository, maxPages, current.maxRequests) if err != nil { status, reason, message, retry := githubBatchError(err) - taskResults[index] = map[string]any{"key": current.key, "status": status, "reason": reason, "message": message, "retry_after_ms": retry} + taskResults[index] = failedThreadSyncItem(current.key, status, reason, message, retry) continue } - status := "complete" - if res.Capped { - status = "partial" + res, err := s.executeThreadSync(ctx, current.ref, threadRequest, plan) + if err != nil { + status, reason, message, retry := githubBatchError(err) + taskResults[index] = failedThreadSyncItem(current.key, status, reason, message, retry) + continue } - taskResults[index] = map[string]any{"key": current.key, "status": status, "updated": res.Updated, "requests": res.Requests, "request_capped": res.Capped, "message": res.Message, "threads": syncThreadRefsToMCP(res.Threads)} + taskResults[index] = successfulThreadSyncItem(current.key, res.Updated, res.Requests, res.Capped, res.Message, syncThreadRefsToMCP(res.Threads)) } }() } @@ -215,44 +647,37 @@ func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsReque wg.Wait() results := taskResults if exactThreads != nil { - results = make([]map[string]any, len(exactThreads)) + results = make([]threadSyncItem, len(exactThreads)) for taskIndex, current := range tasks { for _, inputIndex := range current.inputIndexes { - item := maps.Clone(taskResults[taskIndex]) - delete(item, "requests") - delete(item, "updated") - thread := exactThreads[inputIndex] - item["key"] = threadRefKey(thread) - if resolved, ok := taskResults[taskIndex]["threads"].([]mcpcontract.ThreadRef); ok { - item["threads"] = resolved - } else { - item["threads"] = []mcpcontract.ThreadRef{thread} + thread := exactThreads[inputIndex].wire() + item, err := taskResults[taskIndex].forExactThread(threadRefKey(thread), thread) + if err != nil { + return nil, err } results[inputIndex] = item } } } - status := "complete" + status := batchOperationComplete completed := 0 requests := 0 for _, result := range taskResults { - if count, ok := result["requests"].(int); ok { - requests += count - } + requests += result.RequestsUsed() } for _, result := range results { - if result["status"] == "complete" { + if result.Status() == mcpcontract.BatchItemComplete { completed++ } else { - status = "partial" + status = batchOperationPartial } } if err := report("thread_headers", jobProgressCounts(resultCount, resultCount)); err != nil { return nil, err } - return map[string]any{ - "status": status, "items": results, "completed": completed, "total": resultCount, - "requests": requests, "request_budget": request.maxRequests, "planned_requests": plannedRequests, + return &threadSyncBatchResult{ + batchOperationSummary: batchOperationSummary[threadSyncItem]{Status: status, Items: results, Completed: completed, Total: resultCount}, + Requests: requests, RequestBudget: request.maxRequests, PlannedRequests: plannedRequests, }, nil } @@ -286,16 +711,20 @@ func (r *MCPReader) HydrateThreads(ctx context.Context, in mcpcontract.HydrateTh // IndexRepositories submits a durable Git acquisition and safe indexing job // with at most two repositories processed concurrently. func (r *MCPReader) IndexRepositories(ctx context.Context, in mcpcontract.IndexRepositoriesInput) (mcpcontract.JobReference, error) { - if err := rejectDuplicateIndexRepositoryInputs(in.Repositories); err != nil { - return mcpcontract.JobReference{}, err - } if len(in.Repositories) < 1 || len(in.Repositories) > 10 { return mcpcontract.JobReference{}, errors.New("repositories must contain 1 to 10 items") } - for _, input := range in.Repositories { - if _, err := domain.NewRepoRef(input.Owner, input.Repo); err != nil { + canonical := make([]mcpcontract.IndexRepositoryInput, len(in.Repositories)) + for i, input := range in.Repositories { + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { return mcpcontract.JobReference{}, err } + canonical[i] = mcpcontract.IndexRepositoryInput{Owner: ref.Owner(), Repo: ref.Repo(), Remote: strings.TrimSpace(input.Remote)} + } + in.Repositories = canonical + if err := rejectDuplicateIndexRepositoryInputs(in.Repositories); err != nil { + return mcpcontract.JobReference{}, err } id, err := r.submitJob(ctx, "index_repositories", in, func(ctx context.Context, report func(string, string) error) (any, error) { return r.indexRepositoriesBatch(ctx, in, report) @@ -397,11 +826,11 @@ func (r *MCPReader) CheckMergeConflicts(ctx context.Context, in mcpcontract.Chec return out, nil } -func (s *Service) indexRepositoriesBatch(ctx context.Context, in mcpcontract.IndexRepositoriesInput, report func(string, string) error) (map[string]any, error) { +func (s *Service) indexRepositoriesBatch(ctx context.Context, in mcpcontract.IndexRepositoriesInput, report func(string, string) error) (*repositoryIndexBatchResult, error) { if err := report("repository_indexing", jobProgressCounts(0, len(in.Repositories))); err != nil { return nil, err } - results := make([]map[string]any, len(in.Repositories)) + results := make([]repositoryIndexItem, len(in.Repositories)) jobs := make(chan int) var wg sync.WaitGroup workers := 2 @@ -417,10 +846,10 @@ func (s *Service) indexRepositoriesBatch(ctx context.Context, in mcpcontract.Ind key := current.Owner + "/" + current.Repo result, err := s.Acquire(ctx, contracts.RepoRef{Owner: current.Owner, Repo: current.Repo}, current.Remote) if err != nil { - results[index] = map[string]any{"key": key, "status": "failed", "reason": "acquisition_or_index_failed", "message": err.Error(), "retry_after_ms": 0} + results[index] = failedRepositoryIndexItem(key, "acquisition_or_index_failed", err.Error(), 0) continue } - results[index] = map[string]any{"key": key, "status": "complete", "commit_sha": result.CommitSHA, "files": result.Files, "bytes": result.Bytes, "inserted": result.Inserted, "snapshot_token": result.SnapshotToken, "index_manifest": result.IndexManifest, "artifact_digest": result.ArtifactDigest, "manifest_digest": result.ManifestDigest} + results[index] = successfulRepositoryIndexItem(key, *result) } }() } @@ -435,30 +864,30 @@ func (s *Service) indexRepositoriesBatch(ctx context.Context, in mcpcontract.Ind } close(jobs) wg.Wait() - status := "complete" + status := batchOperationComplete completed := 0 for _, result := range results { - if result["status"] == "complete" { + if result.Status() == mcpcontract.BatchItemComplete { completed++ } else { - status = "partial" + status = batchOperationPartial } } // Each completed acquisition owns its immutable artifact token. Do not // replace those scoped identities with a mutable database watermark. snapshotToken := "" for _, result := range results { - if result["status"] != "complete" { - continue - } - if token, ok := result["snapshot_token"].(string); ok && snapshotToken == "" { + if token := result.SnapshotToken(); token != "" && snapshotToken == "" { snapshotToken = token } } if err := report("repository_indexing", jobProgressCounts(len(in.Repositories), len(in.Repositories))); err != nil { return nil, err } - return map[string]any{"status": status, "items": results, "completed": completed, "total": len(in.Repositories), "snapshot_token": snapshotToken}, nil + return &repositoryIndexBatchResult{ + batchOperationSummary: batchOperationSummary[repositoryIndexItem]{Status: status, Items: results, Completed: completed, Total: len(in.Repositories)}, + SnapshotToken: snapshotToken, + }, nil } func syncThreadRefsToMCP(values []contracts.SyncThreadRef) []mcpcontract.ThreadRef { @@ -469,11 +898,11 @@ func syncThreadRefsToMCP(values []contracts.SyncThreadRef) []mcpcontract.ThreadR return refs } -func (s *Service) hydrateThreadsBatch(ctx context.Context, in mcpcontract.HydrateThreadsInput, report func(string, string) error) (map[string]any, error) { +func (s *Service) hydrateThreadsBatch(ctx context.Context, in mcpcontract.HydrateThreadsInput, report func(string, string) error) (*threadHydrationBatchResult, error) { if err := report("thread_hydration", jobProgressCounts(0, len(in.Threads))); err != nil { return nil, err } - results := make([]map[string]any, len(in.Threads)) + results := make([]threadHydrationItem, len(in.Threads)) jobs := make(chan int) var wg sync.WaitGroup workers := 4 @@ -490,13 +919,10 @@ func (s *Service) hydrateThreadsBatch(ctx context.Context, in mcpcontract.Hydrat res, err := s.Hydrate(ctx, contracts.RepoRef{Owner: current.Owner, Repo: current.Repo}, current.Number, contracts.HydrateOptions{Kind: current.Kind, Facets: in.Facets, MaxPages: in.MaxPages}) if err != nil { status, reason, message, retry := githubBatchError(err) - results[index] = map[string]any{"key": key, "status": status, "reason": reason, "message": message, "retry_after_ms": retry} + results[index] = failedThreadHydrationItem(key, status, reason, message, retry) continue } - results[index] = map[string]any{ - "key": key, "status": "complete", "kind": res.Kind, - "header_refreshed": true, "requests": res.Requests, "facets": res.Facets, - } + results[index] = completeThreadHydrationItem(key, res.Kind, res.Requests, res.Facets) } }() } @@ -511,27 +937,27 @@ func (s *Service) hydrateThreadsBatch(ctx context.Context, in mcpcontract.Hydrat } close(jobs) wg.Wait() - status := "complete" + status := batchOperationComplete completed := 0 for _, result := range results { - if result["status"] == "complete" { + if result.Status() == mcpcontract.BatchItemComplete { completed++ } else { - status = "partial" + status = batchOperationPartial } } if err := report("thread_hydration", jobProgressCounts(len(in.Threads), len(in.Threads))); err != nil { return nil, err } - return map[string]any{"status": status, "items": results, "completed": completed, "total": len(in.Threads)}, nil + return &threadHydrationBatchResult{Status: status, Items: results, Completed: completed, Total: len(in.Threads)}, nil } // This bounded worker loop keeps each repository's fetch, persistence, and // ordered result mapping in one place to preserve item-level failure semantics. // //nolint:gocognit -func (s *Service) syncRepositoryContext(ctx context.Context, in mcpcontract.SyncRepositoryContextInput, report func(string, string) error) (map[string]any, error) { - if err := report("repository_context", jobProgressCounts(0, len(in.Repositories))); err != nil { +func (s *Service) syncRepositoryContext(ctx context.Context, request repositoryContextSyncRequest, report func(string, string) error) (*repositoryContextBatchResult, error) { + if err := report("repository_context", jobProgressCounts(0, len(request.repositories))); err != nil { return nil, err } reader, err := s.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. @@ -542,59 +968,44 @@ func (s *Service) syncRepositoryContext(ctx context.Context, in mcpcontract.Sync if err != nil { return nil, err } - results := make([]map[string]any, len(in.Repositories)) - remaining := in.MaxRequests + results := make([]repositoryContextItem, len(request.repositories)) + remaining := request.maxRequests planned := 0 requests := 0 completed := 0 - for index, input := range in.Repositories { + for index, ref := range request.repositories { if err := ctx.Err(); err != nil { return nil, err } - key := input.Owner + "/" + input.Repo + key := ref.String() required := repositorycontext.RequestCost() if required > remaining { - results[index] = syncRequestBudgetUnavailable(key, required, remaining) + results[index] = unavailableRepositoryContextItem(key, "request_budget_exceeded", syncRequestBudgetMessage(required, remaining)) continue } remaining -= required planned += required budget := newSyncRequestBudget(required) - ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) - if parseErr != nil { - results[index] = map[string]any{"key": key, "status": "failed", "reason": "invalid_repository", "message": parseErr.Error()} - continue - } repo, syncErr := syncRepositoryContextItem(ctx, c, reader, ref, budget) requests += budget.used if syncErr != nil { status, reason, message, retry := githubBatchError(syncErr) - results[index] = map[string]any{ - "key": key, "status": status, "reason": reason, "message": message, - "retry_after_ms": retry, "requests": budget.used, - } + results[index] = failedRepositoryContextItem(key, status, reason, message, retry, budget.used) continue } - results[index] = map[string]any{ - "key": key, "status": "complete", "requests": budget.used, - "repository": typedRepository(&repo), - "facets": map[string]any{ - "metadata": map[string]any{"status": "complete"}, - "contribution_guidance": map[string]any{"status": "complete"}, - }, - } + results[index] = successfulRepositoryContextItem(key, budget.used, repositoryOutput(&repo)) completed++ } - status := "complete" + status := batchOperationComplete if completed != len(results) { - status = "partial" + status = batchOperationPartial } if err := report("repository_context", jobProgressCounts(len(results), len(results))); err != nil { return nil, err } - return map[string]any{ - "status": status, "items": results, "completed": completed, "total": len(results), - "requests": requests, "request_budget": in.MaxRequests, "planned_requests": planned, + return &repositoryContextBatchResult{ + batchOperationSummary: batchOperationSummary[repositoryContextItem]{Status: status, Items: results, Completed: completed, Total: len(results)}, + Requests: requests, RequestBudget: request.maxRequests, PlannedRequests: planned, }, nil } @@ -624,7 +1035,7 @@ func syncRepositoryContextItem( return repo, nil } -func githubBatchError(err error) (status, reason, message string, retryMS int) { +func githubBatchError(err error) (status mcpcontract.BatchItemStatus, reason, message string, retryMS int) { message = err.Error() var primary *github.PrimaryRateLimitError var secondary *github.SecondaryRateLimitError @@ -633,66 +1044,18 @@ func githubBatchError(err error) (status, reason, message string, retryMS int) { var denied *github.AccessDeniedError switch { case errors.As(err, &primary): - return "retryable", "rate_limited", message, int(primary.RetryAfter.Milliseconds()) + return mcpcontract.BatchItemRetryable, "rate_limited", message, int(primary.RetryAfter.Milliseconds()) case errors.As(err, &secondary): - return "retryable", "rate_limited", message, int(secondary.RetryAfter.Milliseconds()) + return mcpcontract.BatchItemRetryable, "rate_limited", message, int(secondary.RetryAfter.Milliseconds()) case errors.As(err, &transient): - return "retryable", "transient", message, 1000 + return mcpcontract.BatchItemRetryable, "transient", message, 1000 case errors.As(err, ¬Found): - return "unavailable", "not_found", message, 0 + return mcpcontract.BatchItemUnavailable, "not_found", message, 0 case errors.As(err, &denied): - return "unavailable", "access_denied", message, 0 + return mcpcontract.BatchItemUnavailable, "access_denied", message, 0 default: - return "failed", "request_failed", message, 0 - } -} - -// DeepWiki performs one external derived-knowledge read and does not persist its response. -func (r *MCPReader) DeepWiki(ctx context.Context, in mcpcontract.DeepWikiInput) (mcpcontract.DeepWikiOutput, error) { - if in.Action != "structure" && in.Action != "contents" && in.Action != "question" { - return mcpcontract.DeepWikiOutput{}, errors.New("action must be structure, contents, or question") - } - if (in.Action == "structure" || in.Action == "contents") && strings.TrimSpace(in.Repository) == "" { - return mcpcontract.DeepWikiOutput{}, errors.New("repository is required for structure or contents") - } - if in.Action == "question" && (len(in.Repositories) < 1 || strings.TrimSpace(in.Question) == "") { - return mcpcontract.DeepWikiOutput{}, errors.New("repositories and question are required for question") - } - repositories := append([]string(nil), in.Repositories...) - if in.Repository != "" { - repositories = []string{in.Repository} - } - if len(repositories) > 10 { - return mcpcontract.DeepWikiOutput{}, errors.New("DeepWiki supports at most 10 repositories") + return mcpcontract.BatchItemFailed, "request_failed", message, 0 } - maxBytes := in.MaxOutputBytes - if maxBytes == 0 { - maxBytes = mcpcontract.DeepWikiDefaultOutputBytes - } - if maxBytes < mcpcontract.DeepWikiMinOutputBytes || maxBytes > mcpcontract.DeepWikiMaxOutputBytes { - return mcpcontract.DeepWikiOutput{}, errors.New("max_output_bytes must be between 1024 and 1048576") - } - res, err := r.deepWiki().Read(ctx, deepwiki.Request{Action: in.Action, Repository: in.Repository, Repositories: repositories, Question: in.Question}) - if err != nil { - return mcpcontract.DeepWikiOutput{}, err - } - out := mcpcontract.DeepWikiOutput{Status: "complete", Provider: "deepwiki", Action: in.Action, Repositories: repositories, Question: in.Question, Result: res.Text(), SourceURL: res.SourceURL(), RetrievedAt: formatTime(r.now()), Provenance: "derived_external"} - if !res.Available() { - out.Status, out.Reason = "unavailable", "blocked" - out.Recovery = recoveryPlan("blocked", "Use GitHub metadata, stored corpus data, or explicit code acquisition instead.") - return out, nil - } - if len(out.Result) > maxBytes { - out.Result = validUTF8Prefix(out.Result, maxBytes) - out.Truncated = true - out.Reason = "output_limit" - if in.Action == "contents" { - out.Recovery = recoveryPlan("blocked", "Call structure, then ask a focused question about the relevant section. Increase max_output_bytes only when the focused read is still incomplete.", mcpcontract.RecoveryAction(mcpcontract.DeepWikiInput{Action: "structure", Repository: in.Repository})) - } else { - out.Recovery = recoveryPlan("blocked", "Narrow the question or repository set. Increase max_output_bytes only when the focused read is still incomplete.") - } - } - return out, nil } func rejectDuplicateRepositoryRefs(inputs []mcpcontract.RepositoryRef) error { @@ -730,13 +1093,3 @@ func rejectDuplicateIndexRepositoryInputs(inputs []mcpcontract.IndexRepositoryIn } return nil } - -func validUTF8Prefix(value string, maxBytes int) string { - if len(value) <= maxBytes { - return value - } - for maxBytes > 0 && !utf8.ValidString(value[:maxBytes]) { - maxBytes-- - } - return value[:maxBytes] -} diff --git a/internal/app/mcp_scalable_reads.go b/internal/app/mcp_scalable_reads.go index c0d990d8..77e3474e 100644 --- a/internal/app/mcp_scalable_reads.go +++ b/internal/app/mcp_scalable_reads.go @@ -31,7 +31,7 @@ func (r *MCPReader) GetRepositories(ctx context.Context, in mcpcontract.GetRepos if err != nil { return mcpcontract.GetRepositoriesOutput{}, err } - out := mcpcontract.GetRepositoriesOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.TypedRepositoryOutput], len(in.Repositories)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + out := mcpcontract.GetRepositoriesOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.RepositoryOutput], len(in.Repositories)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Repositories)) for _, input := range in.Repositories { if ref, err := domain.NewRepoRef(input.Owner, input.Repo); err == nil { @@ -56,7 +56,7 @@ func (r *MCPReader) GetRepositories(ctx context.Context, in mcpcontract.GetRepos } for i, input := range in.Repositories { key := input.Owner + "/" + input.Repo - item := mcpcontract.BatchItem[mcpcontract.TypedRepositoryOutput]{Key: key, Status: "complete"} + item := mcpcontract.BatchItem[mcpcontract.RepositoryOutput]{Key: key, Status: "complete"} ref, err := domain.NewRepoRef(input.Owner, input.Repo) if err != nil { item.Status, item.Reason, item.Message = "failed", "invalid_reference", err.Error() @@ -72,7 +72,7 @@ func (r *MCPReader) GetRepositories(ctx context.Context, in mcpcontract.GetRepos out.Status = "partial" continue } - value := typedRepository(repo) + value := repositoryOutput(repo) value.DossierStatus = "missing" if dossierMetadata, ok := dossiersByRepository[repo.ID]; ok { value.DossierStatus = "available" @@ -105,11 +105,11 @@ func (r *MCPReader) GetRepositories(ctx context.Context, in mcpcontract.GetRepos return out, nil } -func typedRepository(repo *corpus.Repository) mcpcontract.TypedRepositoryOutput { - return mcpcontract.TypedRepositoryOutput{Ref: "repository:" + repo.Owner + "/" + repo.Name, Owner: repo.Owner, Repo: repo.Name, UpdatedAt: formatTime(repo.SourceUpdatedAt), Description: ptr(repo.Description), DefaultBranch: ptr(repo.DefaultBranch), Language: ptr(repo.Language), License: ptr(repo.License), Topics: append([]string(nil), repo.Topics...), Stars: ptr(repo.Stars), Watchers: ptr(repo.Watchers), Forks: ptr(repo.Forks), OpenIssues: ptr(repo.OpenIssues), Archived: ptr(repo.Archived), Fork: ptr(repo.Fork)} +func repositoryOutput(repo *corpus.Repository) mcpcontract.RepositoryOutput { + return mcpcontract.RepositoryOutput{Ref: "repository:" + repo.Owner + "/" + repo.Name, Owner: repo.Owner, Repo: repo.Name, UpdatedAt: formatTime(repo.SourceUpdatedAt), Description: ptr(repo.Description), DefaultBranch: ptr(repo.DefaultBranch), Language: ptr(repo.Language), License: ptr(repo.License), Topics: append([]string(nil), repo.Topics...), Stars: ptr(repo.Stars), Watchers: ptr(repo.Watchers), Forks: ptr(repo.Forks), OpenIssues: ptr(repo.OpenIssues), Archived: ptr(repo.Archived), Fork: ptr(repo.Fork)} } -func clearRepositoryFacts(v *mcpcontract.TypedRepositoryOutput) { +func clearRepositoryFacts(v *mcpcontract.RepositoryOutput) { v.Description = nil v.DefaultBranch = nil v.Language = nil @@ -145,25 +145,28 @@ func (r *MCPReader) GetThreads(ctx context.Context, in mcpcontract.GetThreadsInp return mcpcontract.GetThreadsOutput{}, err } out := mcpcontract.GetThreadsOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.ThreadOutput], len(in.Threads)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + parsed := make([]*parsedThreadReference, len(in.Threads)) repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Threads)) - for _, input := range in.Threads { - if ref, err := domain.NewRepoRef(input.Owner, input.Repo); err == nil && input.Number > 0 { - repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}) + for i, input := range in.Threads { + ref, parseErr := parseThreadReference(input) + if parseErr != nil { + continue } + parsed[i] = &ref + repositoryKeys = append(repositoryKeys, ref.repositoryKey()) } repositories, err := c.GetRepositoriesBatch(ctx, repositoryKeys) if err != nil { return mcpcontract.GetThreadsOutput{}, err } threadKeys := make([]corpus.ThreadKey, 0, len(in.Threads)) - for _, input := range in.Threads { - ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) - if parseErr != nil { + for _, ref := range parsed { + if ref == nil { continue } - repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] - if repo != nil && input.Number > 0 { - threadKeys = append(threadKeys, corpus.ThreadKey{RepositoryID: repo.ID, Kind: input.Kind, Number: input.Number}) + repo := repositories[ref.repositoryKey()] + if repo != nil { + threadKeys = append(threadKeys, ref.threadKey(repo.ID)) } } threads, err := c.GetThreadsBatch(ctx, threadKeys) @@ -173,31 +176,33 @@ func (r *MCPReader) GetThreads(ctx context.Context, in mcpcontract.GetThreadsInp for i, input := range in.Threads { key := threadRefKey(input) item := mcpcontract.BatchItem[mcpcontract.ThreadOutput]{Key: key, Status: "complete"} - ref, err := domain.NewRepoRef(input.Owner, input.Repo) - if err != nil || input.Number < 1 { + if parsed[i] == nil { item.Status, item.Reason, item.Message = "failed", "invalid_reference", "invalid thread reference" out.Items[i] = item out.Status = "partial" continue } - repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] + ref := *parsed[i] + wire := ref.wire() + item.Key = threadRefKey(wire) + repo := repositories[ref.repositoryKey()] if repo == nil { item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", "repository is not present in the local corpus" - item.Recovery = recoveryPlan(item.Reason, item.Message, syncRepositoryContextCall(input.Owner, input.Repo)) + item.Recovery = recoveryPlan(item.Reason, item.Message, syncRepositoryContextCall(ref.repository.Owner(), ref.repository.Repo())) out.Items[i] = item out.Status = "partial" continue } - thread := threads[corpus.ThreadKey{RepositoryID: repo.ID, Kind: input.Kind, Number: input.Number}] + thread := threads[ref.threadKey(repo.ID)] if thread == nil { item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", "thread is not present in the local corpus" - item.Recovery = recoveryPlan(item.Reason, item.Message, syncThreadCall(input)) + item.Recovery = recoveryPlan(item.Reason, item.Message, syncThreadCall(wire)) out.Items[i] = item out.Status = "partial" continue } value := corpusThreadToMCPOutput(thread) - value.Owner, value.Repo = ref.Owner(), ref.Repo() + value.Owner, value.Repo = ref.repository.Owner(), ref.repository.Repo() value.SnapshotToken = snapshotIdentity(in.SnapshotToken, revision) if in.View == "compact" { value.Body = "" @@ -217,17 +222,20 @@ func (r *MCPReader) GetJobs(ctx context.Context, in mcpcontract.GetJobsInput) (m if len(ids) < 1 || len(ids) > 100 { return mcpcontract.GetJobsOutput{}, errors.New("ids must contain 1 to 100 items") } - if in.ResponseFormat == "" { - in.ResponseFormat = "concise" - } - if in.ResponseFormat != "concise" && in.ResponseFormat != "detailed" { - return mcpcontract.GetJobsOutput{}, errors.New("response_format must be concise or detailed") + format, err := parseResponseFormat(in.ResponseFormat) + if err != nil { + return mcpcontract.GetJobsOutput{}, err } c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.GetJobsOutput{}, err } - storedJobs, err := c.GetJobsBatch(ctx, ids, in.ResponseFormat == "detailed") + var storedJobs map[string]*corpus.Job + if format.includesDetails() { + storedJobs, err = c.GetJobsBatch(ctx, ids) + } else { + storedJobs, err = c.GetJobSummariesBatch(ctx, ids) + } if err != nil { return mcpcontract.GetJobsOutput{}, err } @@ -242,9 +250,9 @@ func (r *MCPReader) GetJobs(ctx context.Context, in mcpcontract.GetJobsInput) (m item.Status, item.Reason, item.Message = "unavailable", "not_found", "job is not present in the local corpus" out.Status = "partial" } else { - job := jobResultToMCP(ptr(jobResult(stored)), in.ResponseFormat == "detailed") - if in.ResponseFormat == "concise" && (job.Status == "succeeded" || job.Status == "failed" || job.Status == "cancelled") { - item.Recovery = recoveryPlan("blocked", "Read the detailed typed artifact and follow-up references.", mcpcontract.RecoveryAction(mcpcontract.GetJobsInput{IDs: []string{id}, ResponseFormat: "detailed"})) + job := jobResultToMCP(ptr(jobResult(stored)), format) + if !format.includesDetails() && (job.Status == "succeeded" || job.Status == "failed" || job.Status == "cancelled") { + item.Recovery = recoveryPlan("blocked", "Read the detailed typed artifact and follow-up references.", mcpcontract.RecoveryAction(mcpcontract.GetJobsInput{IDs: []string{id}, ResponseFormat: detailedResponse.String()})) } item.Value = &job } @@ -258,18 +266,7 @@ type portfolioReadSet struct { observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch } -type portfolioResponseFormat string - -const ( - portfolioConcise portfolioResponseFormat = "concise" - portfolioDetailed portfolioResponseFormat = "detailed" -) - -func (f portfolioResponseFormat) includesDetails() bool { - return f == portfolioDetailed -} - -func loadPortfolioReadSet(ctx context.Context, c *corpus.Corpus, pullRequests []corpus.PortfolioPullRequest, format portfolioResponseFormat) (portfolioReadSet, error) { +func loadPortfolioReadSet(ctx context.Context, c *corpus.Corpus, pullRequests []corpus.PortfolioPullRequest, format responseFormat) (portfolioReadSet, error) { threadIDs := make([]int64, 0, len(pullRequests)) for _, stored := range pullRequests { threadIDs = append(threadIDs, stored.Thread.ID) @@ -299,9 +296,9 @@ func loadPortfolioReadSet(ctx context.Context, c *corpus.Corpus, pullRequests [] return portfolioReadSet{coverage: coverage, observations: observations}, nil } -func portfolioItem(stored corpus.PortfolioPullRequest, now time.Time, readSet portfolioReadSet, format portfolioResponseFormat) (mcpcontract.PullRequestPortfolioItem, error) { +func portfolioItem(stored corpus.PortfolioPullRequest, now time.Time, readSet portfolioReadSet, format responseFormat) (mcpcontract.PullRequestPortfolioItem, error) { t := stored.Thread - out := mcpcontract.PullRequestPortfolioItem{Ref: fmt.Sprintf("%s/%s#%d", stored.Owner, stored.Repo, t.Number), Owner: stored.Owner, Repo: stored.Repo, Number: t.Number, Title: t.Title, State: t.State, Author: t.Author, Draft: t.Draft, SourceUpdatedAt: formatTime(t.SourceUpdatedAt), StatusCoverage: "missing"} + out := mcpcontract.PullRequestPortfolioItem{Ref: fmt.Sprintf("%s/%s#%d", stored.Owner, stored.Repo, t.Number), Owner: stored.Owner, Repo: stored.Repo, Number: t.Number, Title: t.Title, State: string(t.State), Author: t.Author, Draft: t.Draft, SourceUpdatedAt: formatTime(t.SourceUpdatedAt), StatusCoverage: "missing"} coverage := portfolioCoverage(&out, t.ID, readSet.coverage, format) details, err := applyPortfolioDetails(&out, t.ID, coverage[FacetPRDetails], readSet.observations, format) if err != nil { @@ -322,7 +319,7 @@ func portfolioItem(stored corpus.PortfolioPullRequest, now time.Time, readSet po return out, nil } -func portfolioCoverage(out *mcpcontract.PullRequestPortfolioItem, threadID int64, all map[corpus.ThreadFacetKey]*corpus.Coverage, format portfolioResponseFormat) map[string]*corpus.Coverage { +func portfolioCoverage(out *mcpcontract.PullRequestPortfolioItem, threadID int64, all map[corpus.ThreadFacetKey]*corpus.Coverage, format responseFormat) map[string]*corpus.Coverage { facets := portfolioFacets() coverage := make(map[string]*corpus.Coverage, len(facets)) complete, observed := true, 0 @@ -357,7 +354,7 @@ func portfolioCoverage(out *mcpcontract.PullRequestPortfolioItem, threadID int64 return coverage } -func applyPortfolioDetails(out *mcpcontract.PullRequestPortfolioItem, threadID int64, coverage *corpus.Coverage, observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, format portfolioResponseFormat) (github.PullRequestDetails, error) { +func applyPortfolioDetails(out *mcpcontract.PullRequestPortfolioItem, threadID int64, coverage *corpus.Coverage, observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, format responseFormat) (github.PullRequestDetails, error) { var details github.PullRequestDetails if coverage == nil || !coverage.Complete { return details, nil @@ -454,7 +451,7 @@ func applyPortfolioHealth(out *mcpcontract.PullRequestPortfolioItem, threadID in return mergeabilityKnown, nil } -func applyPortfolioSupplementalDetails(out *mcpcontract.PullRequestPortfolioItem, threadID int64, coverage map[string]*corpus.Coverage, observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, format portfolioResponseFormat) error { +func applyPortfolioSupplementalDetails(out *mcpcontract.PullRequestPortfolioItem, threadID int64, coverage map[string]*corpus.Coverage, observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, format responseFormat) error { if !format.includesDetails() { return nil } @@ -555,7 +552,7 @@ func portfolioFacets() []string { return []string{FacetPRDetails, FacetPRReviews, FacetPRChecks, FacetPRReviewThreads, FacetPRMergeState, FacetPRMergeQueue, FacetPRClosingIssues, FacetPRFiles} } -func decodeLatestFacet(observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, threadID int64, facet string, target any) (string, error) { +func decodeLatestFacet[T any](observations map[corpus.ThreadFacetKey]corpus.FacetObservationBatch, threadID int64, facet string, target *T) (string, error) { batch := observations[corpus.ThreadFacetKey{ThreadID: threadID, Facet: facet}] if len(batch.Observations) == 0 { return "", fmt.Errorf("complete %s coverage has no observation", facet) @@ -622,7 +619,7 @@ func (r *MCPReader) RankOpportunities(ctx context.Context, in mcpcontract.RankOp Repositories: make([]mcpcontract.BatchItem[mcpcontract.RepositoryOpportunitySummaryOutput], len(in.Repositories)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), } - var candidates []mcpcontract.OpportunityCandidateOutput + var candidates []radar.Candidate for i, input := range in.Repositories { key := input.Owner + "/" + input.Repo item := mcpcontract.BatchItem[mcpcontract.RepositoryOpportunitySummaryOutput]{Key: key, Status: "complete"} @@ -649,9 +646,7 @@ func (r *MCPReader) RankOpportunities(ctx context.Context, in mcpcontract.RankOp out.Truncated = out.Truncated || summary.Truncated || summary.PopulationCapped item.Value = &summary out.Repositories[i] = item - for _, candidate := range report.Candidates { - candidates = append(candidates, radarCandidateToMCP(candidate)) - } + candidates = append(candidates, report.Candidates...) } sort.SliceStable(candidates, func(i, j int) bool { if candidates[i].Eligibility != candidates[j].Eligibility { @@ -664,9 +659,10 @@ func (r *MCPReader) RankOpportunities(ctx context.Context, in mcpcontract.RankOp }) out.Truncated = out.Truncated || len(candidates) > in.Limit end := min(in.Limit, len(candidates)) - out.Candidates = append(out.Candidates, candidates[:end]...) - for i := range out.Candidates { - out.Candidates[i].Rank = i + 1 + 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)) @@ -697,13 +693,13 @@ func radarCandidateToMCP(c radar.Candidate) mcpcontract.OpportunityCandidateOutp } for _, work := range c.RelatedWork { out.RelatedWork = append(out.RelatedWork, mcpcontract.OpportunityRelatedWorkOutput{ - Ref: work.Ref, Relation: work.Relation, Direction: work.Direction, State: work.State, + Ref: work.Ref, Relation: string(work.Relation), Direction: string(work.Direction), State: work.State, }) } return out } -func eligibilityRank(v string) int { - switch radar.Eligibility(v) { +func eligibilityRank(v radar.Eligibility) int { + switch v { case radar.EligibilityReadyToCode: return 0 case radar.EligibilityNeedsDiagnosis: diff --git a/internal/app/mcp_scalable_test.go b/internal/app/mcp_scalable_test.go index 84200ef4..8f641b69 100644 --- a/internal/app/mcp_scalable_test.go +++ b/internal/app/mcp_scalable_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/morluto/gitcontribute/internal/codeindex" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" @@ -27,6 +28,135 @@ func TestRadarCandidateToMCPPreservesRelatedWorkSemantics(t *testing.T) { } } +func TestTypedBatchResultsPreserveDurableJSONShapes(t *testing.T) { + t.Parallel() + threadSync := threadSyncBatchResult{ + batchOperationSummary: batchOperationSummary[threadSyncItem]{ + Status: batchOperationPartial, + Items: []threadSyncItem{ + successfulThreadSyncItem("acme/rocket", 3, 2, true, "request budget reached", []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Kind: "issue", Number: 1}}), + failedThreadSyncItem("acme/missing", mcpcontract.BatchItemRetryable, "rate_limited", "wait", 0), + }, + Completed: 0, + Total: 2, + }, + Requests: 2, RequestBudget: 3, PlannedRequests: 3, + } + assertJSONDocumentEqual(t, threadSync, `{ + "status":"partial","items":[ + {"key":"acme/rocket","status":"partial","updated":3,"requests":2,"request_capped":true,"message":"request budget reached","threads":[{"owner":"acme","repo":"rocket","kind":"issue","number":1}]}, + {"key":"acme/missing","status":"retryable","reason":"rate_limited","message":"wait","retry_after_ms":0} + ],"completed":0,"total":2,"requests":2,"request_budget":3,"planned_requests":3 + }`) + + exactFailure, err := unavailableThreadSyncItem("acme/rocket/issue", "repository_not_indexed", "missing").forExactThread( + "acme/rocket/issue#1", mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "issue", Number: 1}, + ) + if err != nil { + t.Fatal(err) + } + assertJSONDocumentEqual(t, exactFailure, `{ + "key":"acme/rocket/issue#1","status":"unavailable","reason":"repository_not_indexed","message":"missing", + "threads":[{"owner":"acme","repo":"rocket","kind":"issue","number":1}] + }`) + + index := repositoryIndexBatchResult{ + batchOperationSummary: batchOperationSummary[repositoryIndexItem]{ + Status: batchOperationPartial, + Items: []repositoryIndexItem{ + successfulRepositoryIndexItem("acme/rocket", contracts.AcquisitionResult{ + CommitSHA: "abc", Files: 2, Bytes: 3, Inserted: true, SnapshotToken: "snapshot", + IndexManifest: codeindex.Manifest{FormatVersion: "v1", CoverageKnown: true, TrackedEntries: 2, IndexedFiles: 2}, + ArtifactDigest: "artifact", ManifestDigest: "manifest", + }), + failedRepositoryIndexItem("acme/missing", "acquisition_or_index_failed", "checkout failed", 0), + }, + Completed: 1, + Total: 2, + }, + SnapshotToken: "snapshot", + } + assertJSONDocumentEqual(t, index, `{ + "status":"partial","items":[ + {"key":"acme/rocket","status":"complete","commit_sha":"abc","files":2,"bytes":3,"inserted":true,"snapshot_token":"snapshot", + "index_manifest":{"format_version":"v1","coverage_known":true,"tracked_entries":2,"indexed_files":2,"skipped_invalid_path":0,"skipped_excluded":0,"skipped_non_regular":0,"skipped_oversize":0,"skipped_total_budget":0,"skipped_non_text":0,"skipped_file_limit":0,"truncated":false}, + "artifact_digest":"artifact","manifest_digest":"manifest"}, + {"key":"acme/missing","status":"failed","reason":"acquisition_or_index_failed","message":"checkout failed","retry_after_ms":0} + ],"completed":1,"total":2,"snapshot_token":"snapshot" + }`) + + contextResult := repositoryContextBatchResult{ + batchOperationSummary: batchOperationSummary[repositoryContextItem]{ + Status: batchOperationPartial, + Items: []repositoryContextItem{ + successfulRepositoryContextItem("acme/rocket", 2, mcpcontract.RepositoryOutput{Ref: "repository:acme/rocket", Owner: "acme", Repo: "rocket"}), + unavailableRepositoryContextItem("acme/missing", "request_budget_exceeded", "budget exhausted"), + }, + Completed: 1, + Total: 2, + }, + Requests: 2, RequestBudget: 2, PlannedRequests: 2, + } + assertJSONDocumentEqual(t, contextResult, `{ + "status":"partial","items":[ + {"key":"acme/rocket","status":"complete","requests":2,"repository":{"ref":"repository:acme/rocket","owner":"acme","repo":"rocket","metadata":{"status":""},"dossier_status":"","description":null,"default_branch":null,"language":null,"license":null,"stars":null,"watchers":null,"forks":null,"open_issues":null,"archived":null,"fork":null},"facets":{"metadata":{"status":"complete"},"contribution_guidance":{"status":"complete"}}}, + {"key":"acme/missing","status":"unavailable","reason":"request_budget_exceeded","message":"budget exhausted"} + ],"completed":1,"total":2,"requests":2,"request_budget":2,"planned_requests":2 + }`) + + hydration := threadHydrationBatchResult{ + Status: batchOperationPartial, + Items: []threadHydrationItem{ + completeThreadHydrationItem("acme/rocket/issue#1", "issue", 2, []contracts.HydratedFacet{{Facet: "comments", Count: 3, Pages: 1, Complete: true}}), + failedThreadHydrationItem("acme/rocket/issue#2", mcpcontract.BatchItemRetryable, "rate_limited", "wait", 0), + }, + Completed: 1, + Total: 2, + } + assertJSONDocumentEqual(t, hydration, `{ + "status":"partial","items":[ + {"key":"acme/rocket/issue#1","status":"complete","kind":"issue","header_refreshed":true,"requests":2,"facets":[{"facet":"comments","count":3,"pages":1,"complete":true}]}, + {"key":"acme/rocket/issue#2","status":"retryable","reason":"rate_limited","message":"wait","retry_after_ms":0} + ],"completed":1,"total":2 + }`) + + zero := 0 + pullRequests := pullRequestStatusBatchResult{ + Status: batchOperationPartial, + Items: []pullRequestStatusItem{ + pullRequestStatusSnapshotItem("acme/rocket/pull_request#1", mcpcontract.BatchItemComplete, "", nil, []pullRequestHealthFacet{}, ""), + pullRequestStatusFailureItem("acme/rocket/pull_request#2", mcpcontract.BatchItemRetryable, "rate_limited", "wait", &zero, nil), + }, + Failures: []pullRequestStatusFailure{{Reference: "acme/rocket/pull_request#2", Status: mcpcontract.BatchItemRetryable, Reason: "rate_limited", Message: "wait"}}, + Completed: 1, + Total: 2, + } + assertJSONDocumentEqual(t, pullRequests, `{ + "status":"partial","items":[ + {"key":"acme/rocket/pull_request#1","status":"complete","facets":[],"head_sha":""}, + {"key":"acme/rocket/pull_request#2","status":"retryable","reason":"rate_limited","message":"wait","retry_after_ms":0} + ],"failures":[{"reference":"acme/rocket/pull_request#2","status":"retryable","reason":"rate_limited","message":"wait"}],"completed":1,"total":2 + }`) +} + +func assertJSONDocumentEqual(t *testing.T, value any, expected string) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var gotDocument, expectedDocument any + if err := json.Unmarshal(encoded, &gotDocument); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(expected), &expectedDocument); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotDocument, expectedDocument) { + t.Fatalf("JSON = %s, want %s", encoded, expected) + } +} + func TestRankOpportunitiesReportsBoundedNonPaginatedTruncation(t *testing.T) { t.Parallel() ctx := context.Background() @@ -93,7 +223,7 @@ func seedRadarRepository(ctx context.Context, t *testing.T, svc *Service, name s } for number := 1; number <= candidates; number++ { if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: number, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: number, State: "open", Title: "same-score candidate", SourceUpdatedAt: now.Add(-time.Hour), }, `{}`); err != nil { t.Fatal(err) @@ -180,7 +310,7 @@ func TestGetCoveragePreservesTargetOrderAndMissingItems(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "bounded coverage", SourceUpdatedAt: time.Unix(20, 0).UTC()}, `{}`) + thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 7, State: "open", Title: "bounded coverage", SourceUpdatedAt: time.Unix(20, 0).UTC()}, `{}`) if err != nil { t.Fatal(err) } @@ -221,7 +351,7 @@ func TestGetCoveragePreservesTargetOrderAndMissingItems(t *testing.T) { if out.Items[3].Status != "unavailable" || out.Items[3].Reason != "invalid_reference" { t.Fatalf("invalid coverage target = %+v", out.Items[3]) } - if !out.Provenance.UnknownCoverage || out.Provenance.Complete || out.Provenance.QueryDigestSHA256 == "" { + if !out.Provenance.UnknownCoverage() || out.Provenance.Complete() || out.Provenance.QueryDigestSHA256 == "" { t.Fatalf("coverage provenance = %+v", out.Provenance) } } @@ -235,21 +365,21 @@ func TestGetThreadsPreservesUnknownAndObservedFalseMergeState(t *testing.T) { t.Fatal(err) } for _, thread := range []corpus.Thread{ - {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "closed", Title: "unknown", SourceUpdatedAt: time.Unix(1, 0).UTC()}, - {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", Title: "observed false", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(2, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "unknown", SourceUpdatedAt: time.Unix(1, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "observed false", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(2, 0).UTC()}, } { if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) } } out, err := (&MCPReader{svc}).GetThreads(ctx, mcpcontract.GetThreadsInput{View: "compact", Threads: []mcpcontract.ThreadRef{ - {Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 1}, + {Owner: " acme ", Repo: " rocket ", Kind: " pull_request ", Number: 1}, {Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 2}, }}) if err != nil { t.Fatal(err) } - if out.Items[0].Value == nil || out.Items[0].Value.Merged != nil { + if out.Items[0].Key != "acme/rocket/pull_request#1" || out.Items[0].Value == nil || out.Items[0].Value.Owner != "acme" || out.Items[0].Value.Repo != "rocket" || out.Items[0].Value.Kind != "pull_request" || out.Items[0].Value.Merged != nil { t.Fatalf("unknown merge output = %+v", out.Items[0]) } if out.Items[1].Value == nil || out.Items[1].Value.Merged == nil || *out.Items[1].Value.Merged { @@ -275,7 +405,7 @@ func TestCancelJobsPreservesOrderAndIsIdempotent(t *testing.T) { if err := svc.corpus.StartJob(ctx, terminal.ID); err != nil { t.Fatal(err) } - if err := svc.corpus.TransitionJob(ctx, terminal.ID, corpus.JobStatusRunning, corpus.JobStatusSucceeded, `{}`, ""); err != nil { + if err := svc.corpus.TransitionJob(ctx, terminal.ID, corpus.JobRunningToSucceeded, `{}`, ""); err != nil { t.Fatal(err) } running, err := svc.corpus.CreateJob(ctx, "sync", `{}`) @@ -312,7 +442,7 @@ func TestCancelJobsDoesNotExposeOrDependOnMalformedStoredPayload(t *testing.T) { if err := svc.corpus.StartJob(ctx, malformed.ID); err != nil { t.Fatal(err) } - if err := svc.corpus.TransitionJob(ctx, malformed.ID, corpus.JobStatusRunning, corpus.JobStatusCancelled, "not-json", ""); err != nil { + if err := svc.corpus.TransitionJob(ctx, malformed.ID, corpus.JobRunningToCancelled, "not-json", ""); err != nil { t.Fatal(err) } @@ -385,7 +515,7 @@ func assertCancelJobsOutput(t *testing.T, out mcpcontract.GetJobsOutput, queuedI func TestJobResultToMCPExposesStructuredDurableProgress(t *testing.T) { t.Parallel() - out := jobResultToMCP(&contracts.JobResult{ID: "job-1", Kind: "sync_threads", Status: "running", Request: `{}`, Progress: "thread_headers", Statistics: `{"completed_items":2,"total_items":5}`, CreatedAt: "2026-07-19T00:00:00Z"}, true) + out := jobResultToMCP(&contracts.JobResult{ID: "job-1", Kind: "sync_threads", Status: "running", Request: `{}`, Progress: "thread_headers", Statistics: `{"completed_items":2,"total_items":5}`, CreatedAt: "2026-07-19T00:00:00Z"}, detailedResponse) if out.Phase != "thread_headers" || out.CompletedItems != 2 || out.TotalItems != 5 || out.ProgressPercent != 40 || out.RetryAfterMS != 1000 { t.Fatalf("structured progress = %+v", out) } @@ -404,7 +534,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { empty := jobResultToMCP(&contracts.JobResult{ ID: "job-empty", Kind: "sync_threads", Status: "succeeded", Result: `{"status":"complete","items":[]}`, CreatedAt: "2026-07-19T00:00:00Z", - }, true) + }, detailedResponse) if len(empty.Artifacts) != 1 || empty.Artifacts[0].Count == nil || *empty.Artifacts[0].Count != 0 { t.Fatalf("known empty artifact count = %+v", empty.Artifacts) } @@ -413,7 +543,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { ID: "job-partial", Kind: "sync_pull_request_portfolio", Status: "succeeded", Result: `{"status":"partial","pull_requests":["acme/rocket#7"],"refreshed":0,"failures":[{"reference":"acme/rocket#7","status":"retryable","reason":"facet_incomplete"}]}`, CreatedAt: "2026-07-19T00:00:00Z", - }, true) + }, detailedResponse) if !strings.Contains(partial.Summary, "partial") || len(partial.Artifacts) != 1 || !reflect.DeepEqual(partial.Artifacts[0].References, []string{"acme/rocket#7"}) || len(partial.Artifacts[0].Failures) != 1 || partial.Artifacts[0].Failures[0].Reason != "facet_incomplete" || @@ -424,7 +554,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { 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", - }, true) + }, 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" { @@ -433,7 +563,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { runningPatterns := jobResultToMCP(&contracts.JobResult{ ID: "job-running-patterns", Kind: "mine_repository_fix_patterns", Status: "running", - }, true) + }, 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) @@ -451,7 +581,7 @@ func TestGetJobsDetailedReturnsTypedArtifactsWithoutStoredPayloads(t *testing.T) if err := svc.corpus.StartJob(ctx, job.ID); err != nil { t.Fatal(err) } - if err := svc.corpus.TransitionJob(ctx, job.ID, corpus.JobStatusRunning, corpus.JobStatusSucceeded, `{"status":"complete"}`, ""); err != nil { + if err := svc.corpus.TransitionJob(ctx, job.ID, corpus.JobRunningToSucceeded, `{"status":"complete"}`, ""); err != nil { t.Fatal(err) } reader := &MCPReader{svc} @@ -612,7 +742,7 @@ func TestRepositorySearchDetailedFormatPreservesSecondaryFacts(t *testing.T) { t.Parallel() archived := true remote := github.Repository{Owner: "acme", Name: "rocket", Description: "fast", Stars: 42, Watchers: 9, Forks: 3, OpenIssues: 7, Archived: archived, Topics: []string{"cuda"}} - match := liveRepositorySearchMatch(remote, mcpcontract.RepositoryMetadataOutput{Status: "complete"}, "detailed") + match := liveRepositorySearchMatch(remote, mcpcontract.RepositoryMetadataOutput{Status: "complete"}, detailedResponse) if match.Ref != "repository:acme/rocket" || match.Watchers == nil || *match.Watchers != 9 || match.Archived == nil || !*match.Archived || len(match.Topics) != 1 { t.Fatalf("detailed match = %+v", match) } @@ -627,9 +757,9 @@ func TestFindPrecedentsUsesClosedAndMergedHistory(t *testing.T) { t.Fatal(err) } threads := []corpus.Thread{ - {RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", Title: "cache path ignores configured root", Body: "compiled cache artifacts use tmp", SourceUpdatedAt: time.Unix(30, 0).UTC()}, - {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", Title: "honor configured cache root", Body: "move compiled cache artifacts out of tmp", Merge: domain.MergedStatus(time.Unix(20, 0).UTC()), ClosedAt: time.Unix(20, 0).UTC(), SourceUpdatedAt: time.Unix(20, 0).UTC()}, - {RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 3, State: "open", Title: "unrelated typo", Body: "docs", SourceUpdatedAt: time.Unix(10, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "cache path ignores configured root", Body: "compiled cache artifacts use tmp", SourceUpdatedAt: time.Unix(30, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "honor configured cache root", Body: "move compiled cache artifacts out of tmp", Merge: domain.MergedStatus(time.Unix(20, 0).UTC()), ClosedAt: time.Unix(20, 0).UTC(), SourceUpdatedAt: time.Unix(20, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 3, State: "open", Title: "unrelated typo", Body: "docs", SourceUpdatedAt: time.Unix(10, 0).UTC()}, } for _, thread := range threads { if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { diff --git a/internal/app/mcp_snapshot_token_test.go b/internal/app/mcp_snapshot_token_test.go index 955ae904..8a5a0232 100644 --- a/internal/app/mcp_snapshot_token_test.go +++ b/internal/app/mcp_snapshot_token_test.go @@ -17,11 +17,14 @@ func TestSnapshotTokenReadFailsClosedAfterCorpusMutation(t *testing.T) { if _, err := svc.corpus.ApplyRepositoryObservation(ctx, "acme", "rocket", "repo-1", time.Unix(1, 0).UTC(), `{}`); err != nil { t.Fatal(err) } - snapshot, err := svc.corpus.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{ - Kind: "thread_search", Scope: "acme/rocket", SourceManifest: map[string]int64{"observation_watermark": 1}, - DerivedVersions: map[string]string{"search": "v1"}, Completeness: map[string]bool{"complete": true}, - Provenance: map[string]string{"producer": "test"}, Payload: map[string]any{"query": "rocket"}, - }) + materialization, err := corpus.NewSnapshotMaterialization( + "thread_search", "acme/rocket", map[string]int64{"observation_watermark": 1}, map[string]string{"search": "v1"}, + map[string]bool{"complete": true}, map[string]string{"producer": "test"}, map[string]any{"query": "rocket"}, + ) + if err != nil { + t.Fatal(err) + } + snapshot, err := svc.corpus.MaterializeReadSnapshot(ctx, materialization) if err != nil { t.Fatal(err) } diff --git a/internal/app/mcp_stdio_e2e_test.go b/internal/app/mcp_stdio_e2e_test.go index 55d1a27c..bff88e12 100644 --- a/internal/app/mcp_stdio_e2e_test.go +++ b/internal/app/mcp_stdio_e2e_test.go @@ -408,9 +408,9 @@ func seedMCPStdioCorpus(ctx context.Context, t *testing.T, home string) { t.Fatal(err) } rows := []corpus.Thread{ - {RepositoryID: observed.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", Title: "cache root ignores configured path", Body: "compiled cache artifacts unexpectedly use tmp", Labels: []string{"bug", "help wanted"}, SourceUpdatedAt: now}, - {RepositoryID: observed.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", Title: "honor configured cache root", Body: "move compiled cache artifacts away from tmp", Merge: domain.MergedStatus(now.Add(-time.Hour)), ClosedAt: now.Add(-time.Hour), SourceUpdatedAt: now.Add(-time.Hour)}, - {RepositoryID: observed.ID, Kind: corpus.ThreadKindPullRequest, Number: 3, State: "open", Title: "current contributor work", Body: "portfolio entry", Author: "morluto", SourceUpdatedAt: now}, + {RepositoryID: observed.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "cache root ignores configured path", Body: "compiled cache artifacts unexpectedly use tmp", Labels: []string{"bug", "help wanted"}, SourceUpdatedAt: now}, + {RepositoryID: observed.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "honor configured cache root", Body: "move compiled cache artifacts away from tmp", Merge: domain.MergedStatus(now.Add(-time.Hour)), ClosedAt: now.Add(-time.Hour), SourceUpdatedAt: now.Add(-time.Hour)}, + {RepositoryID: observed.ID, Kind: domain.PullRequestKind, Number: 3, State: "open", Title: "current contributor work", Body: "portfolio entry", Author: "morluto", SourceUpdatedAt: now}, } for _, row := range rows { if _, err := svc.corpus.UpsertThread(ctx, row, `{}`); err != nil { diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index 6c540553..31c0f5d7 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -54,7 +54,7 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { if len(out.Coverage) != 1 || out.Coverage[0].Repo != "owner/repo" || out.Coverage[0].Status != "indexed" || !out.Coverage[0].Truncated || out.Coverage[0].IndexedFiles != 1 || out.Coverage[0].SkippedFiles != 2 { t.Fatalf("unexpected code coverage: %+v", out.Coverage) } - if !out.Provenance.Truncated || out.Provenance.Complete || out.Provenance.QueryDigestSHA256 == "" { + 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}) @@ -168,7 +168,7 @@ func TestMCPReaderExplainRejectsNonMatchingThreadAndRepository(t *testing.T) { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "parser panic", Body: "reproduction", SourceUpdatedAt: time.Now().UTC(), }, `{}`); err != nil { @@ -194,7 +194,7 @@ func TestMCPReaderSearchAndExplainUseFacetEvidence(t *testing.T) { t.Fatal(err) } thread, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "plain title", Body: "plain body", SourceUpdatedAt: time.Unix(1, 0).UTC(), }, `{}`) if err != nil { @@ -238,11 +238,11 @@ func TestMCPReaderSearchReportsUnknownMergeState(t *testing.T) { } for _, thread := range []corpus.Thread{ { - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "closed", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(10, 0).UTC(), }, { - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "shared term", SourceUpdatedAt: time.Unix(20, 0).UTC(), }, } { @@ -253,7 +253,7 @@ func TestMCPReaderSearchReportsUnknownMergeState(t *testing.T) { merged := true out, err := svc.MCPReader().Search(ctx, mcpcontract.SearchInput{ - Query: "term", Owner: "owner", Repo: "repo", Kind: corpus.ThreadKindPullRequest, + Query: "term", Owner: "owner", Repo: "repo", Kind: string(domain.PullRequestKind), State: "closed", Merged: &merged, }) if err != nil { @@ -408,7 +408,7 @@ func TestMCPReaderExplainThreadRejectsDifferentRequestedKind(t *testing.T) { t.Fatalf("store repository: %v", err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", Title: "searchable change", SourceUpdatedAt: time.Now().UTC(), }, `{}`); err != nil { t.Fatalf("store pull request: %v", err) diff --git a/internal/app/mcp_thread_facets.go b/internal/app/mcp_thread_facets.go index 2bffa2d0..8627464e 100644 --- a/internal/app/mcp_thread_facets.go +++ b/internal/app/mcp_thread_facets.go @@ -34,24 +34,27 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea return mcpcontract.GetThreadFacetsOutput{}, err } out := mcpcontract.GetThreadFacetsOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.ThreadFacetsOutput], len(in.Threads)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + parsed := make([]*parsedThreadReference, len(in.Threads)) repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Threads)) - for _, input := range in.Threads { - if ref, err := domain.NewRepoRef(input.Owner, input.Repo); err == nil && input.Number > 0 { - repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}) + for i, input := range in.Threads { + ref, parseErr := parseThreadReference(input) + if parseErr != nil { + continue } + parsed[i] = &ref + repositoryKeys = append(repositoryKeys, ref.repositoryKey()) } repositories, err := c.GetRepositoriesBatch(ctx, repositoryKeys) if err != nil { return mcpcontract.GetThreadFacetsOutput{}, err } threadKeys := make([]corpus.ThreadKey, 0, len(in.Threads)) - for _, input := range in.Threads { - ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) - if parseErr != nil { + for _, ref := range parsed { + if ref == nil { continue } - if repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}]; repo != nil && input.Number > 0 { - threadKeys = append(threadKeys, corpus.ThreadKey{RepositoryID: repo.ID, Kind: input.Kind, Number: input.Number}) + if repo := repositories[ref.repositoryKey()]; repo != nil { + threadKeys = append(threadKeys, ref.threadKey(repo.ID)) } } threads, err := c.GetThreadsBatch(ctx, threadKeys) @@ -72,33 +75,35 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea } for i, input := range in.Threads { item := mcpcontract.BatchItem[mcpcontract.ThreadFacetsOutput]{Key: threadRefKey(input), Status: "complete"} - ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) - if parseErr != nil || (input.Kind != corpus.ThreadKindIssue && input.Kind != corpus.ThreadKindPullRequest) || input.Number < 1 { + if parsed[i] == nil { item.Status, item.Reason, item.Message = "failed", "blocked", "invalid thread reference" out.Status = "partial" out.Items[i] = item continue } - repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] + ref := *parsed[i] + wire := ref.wire() + item.Key = threadRefKey(wire) + repo := repositories[ref.repositoryKey()] if repo == nil { item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", "repository is not present in the local corpus" - item.Recovery = recoveryPlan("repository_not_indexed", item.Message, syncRepositoryContextCall(input.Owner, input.Repo)) + item.Recovery = recoveryPlan("repository_not_indexed", item.Message, syncRepositoryContextCall(ref.repository.Owner(), ref.repository.Repo())) out.Status = "partial" out.Items[i] = item continue } - thread := threads[corpus.ThreadKey{RepositoryID: repo.ID, Kind: input.Kind, Number: input.Number}] + thread := threads[ref.threadKey(repo.ID)] if thread == nil { item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", "thread is not present in the local corpus" - item.Recovery = recoveryPlan("thread_not_indexed", item.Message, syncThreadCall(input)) + item.Recovery = recoveryPlan("thread_not_indexed", item.Message, syncThreadCall(wire)) out.Status = "partial" out.Items[i] = item continue } - value := mcpcontract.ThreadFacetsOutput{Owner: ref.Owner(), Repo: ref.Repo(), Kind: thread.Kind, Number: thread.Number, Facets: make([]mcpcontract.ThreadFacetOutput, 0, len(in.Facets))} + value := mcpcontract.ThreadFacetsOutput{Owner: ref.repository.Owner(), Repo: ref.repository.Repo(), Kind: string(thread.Kind), Number: thread.Number, Facets: make([]mcpcontract.ThreadFacetOutput, 0, len(in.Facets))} for _, facet := range in.Facets { key := corpus.ThreadFacetKey{ThreadID: thread.ID, Facet: facet} - entry := mcpcontract.ThreadFacetOutput{Facet: facet, Status: "not_observed", ResourceURI: threadFacetURI(ref.Owner(), ref.Repo(), thread.Kind, thread.Number, facet)} + entry := mcpcontract.ThreadFacetOutput{Facet: facet, Status: "not_observed", ResourceURI: threadFacetURI(ref.repository.Owner(), ref.repository.Repo(), string(thread.Kind), thread.Number, facet)} if cov := coverage[key]; cov != nil { entry.Complete, entry.SourceUpdatedAt = cov.Complete, formatTime(cov.SourceUpdatedAt) entry.Status = "complete" @@ -128,56 +133,55 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea // ThreadFacetResource is the canonical offline payload read for one stored // facet. Resource reads are intentionally separate from bounded tool output. -func (r *MCPReader) ThreadFacetResource(ctx context.Context, owner, repo, kind string, number int, facet string) (map[string]any, error) { +func (r *MCPReader) ThreadFacetResource(ctx context.Context, owner, repo, kind string, number int, facet string) (mcpcontract.ThreadFacetResource, error) { if err := validateFacetNames([]string{facet}); err != nil { - return nil, err + return mcpcontract.ThreadFacetResource{}, err } c, err := r.openReadOnlyCorpus(ctx) if err != nil { - return nil, err + return mcpcontract.ThreadFacetResource{}, err } storedRepo, err := c.GetRepository(ctx, owner, repo) if err != nil || storedRepo == nil { if err == nil { err = errors.New("repository is not stored") } - return nil, err + return mcpcontract.ThreadFacetResource{}, err + } + parsedKind, err := domain.ParseThreadKind(kind) + if err != nil { + return mcpcontract.ThreadFacetResource{}, err } - thread, err := c.GetThread(ctx, storedRepo.ID, kind, number) + thread, err := c.GetThread(ctx, storedRepo.ID, parsedKind, number) if err != nil || thread == nil { if err == nil { err = errors.New("thread is not stored") } - return nil, err + return mcpcontract.ThreadFacetResource{}, err } observations, _, err := c.ListFacetObservationsBounded(ctx, storedRepo.ID, &thread.ID, facet, 1000) if err != nil { - return nil, err + return mcpcontract.ThreadFacetResource{}, err } coverage, err := c.GetCoverage(ctx, storedRepo.ID, &thread.ID, facet) if err != nil { - return nil, err + return mcpcontract.ThreadFacetResource{}, err } - observationValues := make([]any, 0, len(observations)) - out := map[string]any{ - "schema_version": "gitcontribute.thread-facet.v1", - "owner": owner, "repo": repo, "kind": thread.Kind, "number": number, "facet": facet, - "observations": observationValues, + out := mcpcontract.ThreadFacetResource{ + SchemaVersion: "gitcontribute.thread-facet.v1", Owner: owner, Repo: repo, Kind: string(thread.Kind), Number: number, Facet: facet, + Observations: make([]mcpcontract.ThreadFacetObservationResource, 0, len(observations)), } for _, observation := range observations { - var payload any - if err := json.Unmarshal([]byte(observation.Payload), &payload); err != nil { - return nil, fmt.Errorf("decode %s observation: %w", facet, err) + payload := json.RawMessage(observation.Payload) + if !json.Valid(payload) { + return mcpcontract.ThreadFacetResource{}, fmt.Errorf("decode %s observation: invalid JSON", facet) } - observationValues = append(observationValues, map[string]any{ - "source_updated_at": formatTime(observation.SourceUpdatedAt), - "observation_sequence": observation.ObservationSequence, - "payload": payload, + out.Observations = append(out.Observations, mcpcontract.ThreadFacetObservationResource{ + SourceUpdatedAt: formatTime(observation.SourceUpdatedAt), ObservationSequence: observation.ObservationSequence, Payload: payload, }) } - out["observations"] = observationValues if coverage != nil { - out["coverage"] = map[string]any{"complete": coverage.Complete, "source_updated_at": formatTime(coverage.SourceUpdatedAt)} + out.Coverage = &mcpcontract.ResourceCoverage{Complete: coverage.Complete, SourceUpdatedAt: formatTime(coverage.SourceUpdatedAt)} } return out, nil } diff --git a/internal/app/mcp_thread_facets_test.go b/internal/app/mcp_thread_facets_test.go index 952ac46a..1729ca1c 100644 --- a/internal/app/mcp_thread_facets_test.go +++ b/internal/app/mcp_thread_facets_test.go @@ -8,6 +8,7 @@ import ( "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -21,11 +22,11 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { if err != nil { t.Fatal(err) } - issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "issue"}, `{}`) + issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 7, State: "open", Title: "issue"}, `{}`) if err != nil { t.Fatal(err) } - pullRequest, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", Title: "pull request"}, `{}`) + pullRequest, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "open", Title: "pull request"}, `{}`) if err != nil { t.Fatal(err) } @@ -39,8 +40,8 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { reader := &MCPReader{svc} out, err := reader.GetThreadFacets(ctx, mcpcontract.GetThreadFacetsInput{ Threads: []mcpcontract.ThreadRef{ - {Owner: "acme", Repo: "rocket", Kind: corpus.ThreadKindIssue, Number: 7}, - {Owner: "acme", Repo: "rocket", Kind: corpus.ThreadKindPullRequest, Number: 7}, + {Owner: " acme ", Repo: " rocket ", Kind: " issue ", Number: 7}, + {Owner: "acme", Repo: "rocket", Kind: string(domain.PullRequestKind), Number: 7}, }, Facets: []string{FacetIssueComments}, }) @@ -50,7 +51,10 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { if out.Status != "complete" || len(out.Items) != 2 || out.Items[0].Value == nil || out.Items[1].Value == nil { t.Fatalf("facet batch = %+v", out) } - if out.Items[0].Value.Kind != corpus.ThreadKindIssue || out.Items[1].Value.Kind != corpus.ThreadKindPullRequest { + if out.Items[0].Key != "acme/rocket/issue#7" || out.Items[0].Value.Owner != "acme" || out.Items[0].Value.Repo != "rocket" { + t.Fatalf("canonical issue reference = %+v", out.Items[0]) + } + if out.Items[0].Value.Kind != string(domain.IssueKind) || out.Items[1].Value.Kind != string(domain.PullRequestKind) { t.Fatalf("kind preservation = %+v", out.Items) } if out.Items[0].Value.Facets[0].ObservationCount != 1 || out.Items[0].Value.Facets[0].ResourceURI != "gitcontribute://thread/acme/rocket/issue/7/facet/issue_comments" { @@ -61,7 +65,7 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { } missing, err := reader.GetThreadFacets(ctx, mcpcontract.GetThreadFacetsInput{ - Threads: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Kind: corpus.ThreadKindIssue, Number: 7}}, + Threads: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Kind: string(domain.IssueKind), Number: 7}}, Facets: []string{FacetIssueTimeline}, }) if err != nil { @@ -71,7 +75,7 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { t.Fatalf("missing facet recovery = %+v", missing.Items[0]) } - resource, err := reader.ThreadFacetResource(ctx, "acme", "rocket", corpus.ThreadKindPullRequest, 7, FacetPRDetails) + resource, err := reader.ThreadFacetResource(ctx, "acme", "rocket", string(domain.PullRequestKind), 7, FacetPRDetails) if err != nil { t.Fatal(err) } @@ -79,7 +83,7 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { if err != nil { t.Fatal(err) } - if string(data) == "" || resource["schema_version"] != "gitcontribute.thread-facet.v1" || len(resource["observations"].([]any)) != 1 { + if string(data) == "" || resource.SchemaVersion != "gitcontribute.thread-facet.v1" || len(resource.Observations) != 1 { t.Fatalf("facet resource = %s", data) } } diff --git a/internal/app/mcp_thread_search.go b/internal/app/mcp_thread_search.go index a3ad1038..fa6f215c 100644 --- a/internal/app/mcp_thread_search.go +++ b/internal/app/mcp_thread_search.go @@ -10,28 +10,42 @@ import ( "github.com/morluto/gitcontribute/internal/mcpcontract" ) +type threadSearchView uint8 + +const ( + compactThreadSearch threadSearchView = iota + fullThreadSearch +) + +func parseThreadSearchView(value string) (threadSearchView, error) { + switch strings.TrimSpace(value) { + case "", "compact": + return compactThreadSearch, nil + case "full": + return fullThreadSearch, nil + default: + return 0, errors.New("view must be compact or full") + } +} + +func (v threadSearchView) String() string { + if v == fullThreadSearch { + return "full" + } + return "compact" +} + // Search performs a local-only corpus search through the MCP interface. func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcpcontract.SearchOutput, error) { if strings.TrimSpace(in.Query) == "" { return mcpcontract.SearchOutput{}, errors.New("query is required") } - if in.Limit == 0 { - in.Limit = 20 - } - if in.Limit < 1 || in.Limit > 100 { + if in.Limit < 0 { return mcpcontract.SearchOutput{}, errors.New("limit must be between 1 and 100") } - if in.MatchMode == "" { - in.MatchMode = "all" - } - if in.MatchMode != "all" && in.MatchMode != "any" { - return mcpcontract.SearchOutput{}, errors.New("match_mode must be all or any") - } - if in.View == "" { - in.View = "compact" - } - if in.View != "compact" && in.View != "full" { - return mcpcontract.SearchOutput{}, errors.New("view must be compact or full") + view, err := parseThreadSearchView(in.View) + if err != nil { + return mcpcontract.SearchOutput{}, err } var updatedAfter time.Time if strings.TrimSpace(in.UpdatedAfter) != "" { @@ -49,27 +63,45 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp return mcpcontract.SearchOutput{}, errors.New("updated_before must be RFC 3339") } } - if !updatedAfter.IsZero() && !updatedBefore.IsZero() && updatedBefore.Before(updatedAfter) { - return mcpcontract.SearchOutput{}, errors.New("updated_before must not be earlier than updated_after") - } - - repo := "" - if (in.Owner == "") != (in.Repo == "") { - return mcpcontract.SearchOutput{}, errors.New("owner and repo must be provided together") - } - if in.Owner != "" && in.Repo != "" { - repo = in.Owner + "/" + in.Repo + repo, err := optionalRepoRef(in.Owner, in.Repo) + if err != nil { + return mcpcontract.SearchOutput{}, err } - res, err := r.searchCorpus(ctx, in.Query, contracts.SearchOptions{ + request, err := parseSearchRequest(in.Query, contracts.SearchOptions{ Kind: in.Kind, - Repo: repo, State: in.State, StateReason: in.StateReason, Merged: in.Merged, Author: in.Author, Association: in.Association, Assignee: in.Assignee, Labels: in.Labels, UpdatedAfter: updatedAfter, UpdatedBefore: updatedBefore, Limit: in.Limit, Cursor: in.Cursor, Sort: in.Sort, MatchMode: in.MatchMode, SnapshotToken: in.SnapshotToken, - }) + }, repo) + if err != nil { + return mcpcontract.SearchOutput{}, err + } + threadRequest, ok := request.(threadSearchRequest) + if !ok { + return mcpcontract.SearchOutput{}, errors.New("kind must be issue or pull_request") + } + in.Query = request.read().query + in.Kind = threadRequest.criteria.kind.corpusThreadKind().String() + in.State = threadRequest.criteria.state.String() + in.StateReason = threadRequest.criteria.stateReason.String() + in.Author = threadRequest.criteria.author + in.Association = threadRequest.criteria.association + in.Assignee = threadRequest.criteria.assignee + in.Labels = append([]string(nil), threadRequest.criteria.labels...) + in.Limit = request.read().page.Limit() + in.Sort = threadRequest.criteria.order.String() + in.MatchMode = threadRequest.criteria.match.String() + in.View = view.String() + if !updatedAfter.IsZero() { + in.UpdatedAfter = updatedAfter.Format(time.RFC3339) + } + if !updatedBefore.IsZero() { + in.UpdatedBefore = updatedBefore.Format(time.RFC3339) + } + res, err := r.searchCorpus(ctx, request) if err != nil { return mcpcontract.SearchOutput{}, err } @@ -83,7 +115,7 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp matches[i] = mcpcontract.ThreadOutput{ Owner: m.Repo.Owner(), Repo: m.Repo.Repo(), - Kind: m.Kind, + Kind: m.Kind.String(), Number: m.Number, State: m.State, StateReason: m.StateReason, @@ -93,14 +125,14 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp AuthorAssociation: m.AuthorAssociation, Labels: m.Labels, Assignees: m.Assignees, - Draft: m.Draft, ClosedAt: formatTime(m.ClosedAt), MergedAt: formatTime(m.Merge.MergedAt()), Merged: knownMergePointer(m.Merge.IsMerged(), m.Merge.Known()), + Draft: m.Draft, ClosedAt: formatTime(m.ClosedAt), MergedAt: formatTime(m.Merge.MergedAt()), Merged: mergeStatusPointer(m.Merge), UpdatedAt: updatedAt, MatchSource: m.MatchSource, MatchExcerpt: m.MatchExcerpt, MatchTruncated: m.MatchTruncated, SnapshotToken: res.SnapshotToken, } - if in.View == "full" { + if view == fullThreadSearch { matches[i].Body = m.Body } if m.MatchSource != "" { @@ -108,7 +140,7 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp } } separator := " AND " - if in.MatchMode == "any" { + if threadRequest.criteria.match.IsAny() { separator = " OR " } out := mcpcontract.SearchOutput{ @@ -117,18 +149,18 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp UnknownMergeCount: res.UnknownMergeCount, SnapshotToken: res.SnapshotToken, } - provenance, err := offlineReadProvenance("thread_search", res.ObservationWatermark, in, res.NextCursor == "", res.NextCursor != "", true) + provenance, err := offlineReadProvenance("thread_search", res.ObservationWatermark, in, res.NextCursor != "", true) if err != nil { return mcpcontract.SearchOutput{}, err } out.Provenance = provenance - if provenance.UnknownCoverage { + if provenance.UnknownCoverage() { out.Recovery = localThreadSearchRecovery(in) out.Provenance.Recovery = out.Recovery } if out.UnknownMergeCount > 0 { out.Suggestion = "Some otherwise-matching pull requests have unknown merge state. Repeat without the merged filter to identify finalists, then hydrate pr_details before inferring absence." - } else if out.Total == 0 && in.MatchMode == "all" && len(strings.Fields(in.Query)) > 1 { + } else if out.Total == 0 && !threadRequest.criteria.match.IsAny() && len(strings.Fields(in.Query)) > 1 { out.Suggestion = "No all-term matches. Retry with match_mode=any or fewer terms; verify corpus coverage before inferring absence." } return out, nil diff --git a/internal/app/mcp_v1.go b/internal/app/mcp_v1.go index 93f63d71..41ad4f03 100644 --- a/internal/app/mcp_v1.go +++ b/internal/app/mcp_v1.go @@ -98,14 +98,14 @@ func (r *MCPReader) ExplainMatch(ctx context.Context, in mcpcontract.ExplainMatc if thread == nil { return mcpcontract.ExplainMatchOutput{}, failure.NotFound(nil) } - if in.Kind != "" && thread.Kind != in.Kind { + if in.Kind != "" && thread.Kind != domain.ThreadKind(in.Kind) { return mcpcontract.ExplainMatchOutput{}, failure.NotFound(nil) } - out.Kind = thread.Kind + out.Kind = string(thread.Kind) out.Number = thread.Number out.Title = thread.Title out.Snippet = boundedText(thread.Body, 2000) - out.State = thread.State + out.State = string(thread.State) sourceRevision := thread.SourceUpdatedAt if in.Query != "" { evidence, found, err := c.FindThreadSearchEvidence(ctx, thread.ID, in.Query) @@ -337,10 +337,14 @@ func (r *MCPReader) RecordHypothesis(ctx context.Context, in mcpcontract.RecordH if err != nil { return mcpcontract.HypothesisOutput{}, err } + category, err := investigation.ParseCategory(in.Category) + if err != nil { + return mcpcontract.HypothesisOutput{}, err + } input := investigation.CreateHypothesisInput{ Title: in.Title, Description: in.Description, - Category: investigation.Category(in.Category), + Category: category, ExpectedBehavior: in.ExpectedBehavior, ObservedBehavior: in.ObservedBehavior, PotentialImpact: in.PotentialImpact, @@ -616,7 +620,7 @@ func draftResultToMCP(d *contracts.DraftResult) mcpcontract.DraftOutput { func draftArtifactToMCP(d *contribution.DraftArtifact) mcpcontract.DraftOutput { out := mcpcontract.DraftOutput{ - ID: d.ID, Revision: d.Revision, OpportunityID: d.OpportunityID, Kind: d.Kind, + ID: d.ID, Revision: d.Revision, OpportunityID: d.OpportunityID, Kind: string(d.Kind), Repository: d.Repository, Title: d.Title, Body: d.Body, TitleBytes: d.TitleBytes, BodyBytes: d.BodyBytes, TitleSHA256: d.TitleSHA256, BodySHA256: d.BodySHA256, EvidenceIDs: append([]string(nil), d.EvidenceIDs...), RenderedAt: d.RenderedAt.UTC().Format(time.RFC3339Nano), @@ -624,7 +628,7 @@ func draftArtifactToMCP(d *contribution.DraftArtifact) mcpcontract.DraftOutput { } for _, warning := range d.Warnings { out.Warnings = append(out.Warnings, mcpcontract.DraftDiagnosticOutput{ - Code: warning.Code, Severity: warning.Severity, Message: warning.Message, ByteOffset: warning.ByteOffset, + Code: warning.Code, Severity: string(warning.Severity), Message: warning.Message, ByteOffset: warning.ByteOffset, }) } return out @@ -660,6 +664,6 @@ func manifestSnapshotRecovery(in mcpcontract.ExportManifestInput) mcpcontract.To func manifestStatementToMCP(statement *manifest.Statement, snapshotToken string) mcpcontract.ManifestOutput { return mcpcontract.ManifestOutput{ ManifestID: statement.Predicate.ManifestID, ContentSHA256: statement.Predicate.ContentSHA256, - SchemaVersion: statement.Predicate.SchemaVersion, Status: statement.Predicate.Status, SnapshotToken: snapshotToken, Statement: *statement, + SchemaVersion: statement.Predicate.SchemaVersion, Status: string(statement.Predicate.Status), SnapshotToken: snapshotToken, Statement: *statement, } } diff --git a/internal/app/neighbors.go b/internal/app/neighbors.go index 7030afa8..6bce86a8 100644 --- a/internal/app/neighbors.go +++ b/internal/app/neighbors.go @@ -32,11 +32,15 @@ const ( // query thread. Results include transparent scores, reasons, and the source // revision of the candidate population. No network access occurs. func (s *Service) Neighbors(ctx context.Context, repo contracts.RepoRef, kind string, number int, limit int) (*NeighborsResult, error) { - ref, dref, err := validateThreadQuery(repo, kind, number) + target, err := parseSimilarityThread(repo, kind, number) if err != nil { return nil, err } - limit, err = normalizeSimilarityLimit(limit) + return s.neighborsForThread(ctx, target, limit) +} + +func (s *Service) neighborsForThread(ctx context.Context, target parsedSimilarityThread, limit int) (*NeighborsResult, error) { + limit, err := normalizeSimilarityLimit(limit) if err != nil { return nil, err } @@ -46,34 +50,34 @@ func (s *Service) Neighbors(ctx context.Context, repo contracts.RepoRef, kind st return nil, err } - repository, err := c.GetRepository(ctx, dref.Owner(), dref.Repo()) + repository, err := c.GetRepository(ctx, target.repository.Owner(), target.repository.Repo()) if err != nil { return nil, err } if repository == nil { - return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, dref) + return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, target.repository) } - query, err := c.GetThread(ctx, repository.ID, ref.Kind, ref.Number) + query, err := c.GetThread(ctx, repository.ID, target.kind, target.number) if err != nil { return nil, err } if query == nil { - return nil, fmt.Errorf("%w: %s", errThreadNotFound, ref.String()) + return nil, fmt.Errorf("%w: %s", errThreadNotFound, target.member().String()) } - threads, err := c.ListThreads(ctx, repository.ID, "", similarityCandidateLimit(limit)) + threads, err := c.ListThreads(ctx, repository.ID, corpus.AnyThreadKind(), similarityCandidateLimit(limit)) if err != nil { return nil, err } - queryCand := candidateFromThread(dref, *query) + queryCand := candidateFromThread(target.repository, *query) candidates := make([]clustering.Candidate, 0, len(threads)) for _, t := range threads { if t.ID == query.ID { continue } - candidates = append(candidates, candidateFromThread(dref, t)) + candidates = append(candidates, candidateFromThread(target.repository, t)) } scored, err := clustering.Neighbors(ctx, queryCand, candidates, limit) @@ -91,9 +95,9 @@ func (s *Service) Neighbors(ctx context.Context, repo contracts.RepoRef, kind st } return &NeighborsResult{ - Repo: dref.String(), - Kind: ref.Kind, - Number: ref.Number, + Repo: target.repository.String(), + Kind: string(target.kind), + Number: target.number, Limit: limit, Total: len(neighbors), SourceRevision: clustering.SourceRevision(all), @@ -108,22 +112,23 @@ func (s *Service) Neighbors(ctx context.Context, repo contracts.RepoRef, kind st // member, and source revision. If the thread is not in a cluster, the result // is empty. func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRef, kind string, number int, limit int) (*DuplicateCandidatesResult, error) { - ref, dref, err := validateThreadQuery(repo, kind, number) + target, err := parseSimilarityThread(repo, kind, number) if err != nil { return nil, err } + ref := target.member() c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } - repository, err := c.GetRepository(ctx, dref.Owner(), dref.Repo()) + repository, err := c.GetRepository(ctx, target.repository.Owner(), target.repository.Repo()) if err != nil { return nil, err } if repository == nil { - return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, dref) + return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, target.repository) } query, err := c.GetThread(ctx, repository.ID, ref.Kind, ref.Number) @@ -147,8 +152,8 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe } result := &DuplicateCandidatesResult{ - Repo: dref.String(), - Kind: ref.Kind, + Repo: target.repository.String(), + Kind: string(ref.Kind), Number: ref.Number, Limit: limit, SourceRevision: "", @@ -163,7 +168,7 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe result.ClusterID = cluster.ID result.StableID = cluster.StableID result.Canonical = ThreadRef{ - Kind: cluster.Canonical.Kind, + Kind: string(cluster.Canonical.Kind), Owner: cluster.Canonical.Owner, Repo: cluster.Canonical.Repo, Number: cluster.Canonical.Number, @@ -175,12 +180,12 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe continue } result.Candidates = append(result.Candidates, Neighbor{ - Kind: m.Ref.Kind, + Kind: string(m.Ref.Kind), Owner: m.Ref.Owner, Repo: m.Ref.Repo, Number: m.Ref.Number, Title: m.Title, - State: m.State, + State: string(m.State), Score: m.Score, Reason: m.Reason, }) @@ -194,36 +199,42 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe return result, nil } -func validateThreadQuery(repo contracts.RepoRef, kind string, number int) (clustering.MemberRef, domain.RepoRef, error) { - dref, err := domain.NewRepoRef(repo.Owner, repo.Repo) +type parsedSimilarityThread struct { + repository domain.RepoRef + kind domain.ThreadKind + number int +} + +func parseSimilarityThread(repo contracts.RepoRef, kind string, number int) (parsedSimilarityThread, error) { + repository, err := domain.NewRepoRef(repo.Owner, repo.Repo) if err != nil { - return clustering.MemberRef{}, dref, err + return parsedSimilarityThread{}, err } normalized, err := normalizeThreadKind(kind) if err != nil { - return clustering.MemberRef{}, dref, err + return parsedSimilarityThread{}, err } if number <= 0 { - return clustering.MemberRef{}, dref, errors.New("thread number must be positive") + return parsedSimilarityThread{}, errors.New("thread number must be positive") } + return parsedSimilarityThread{repository: repository, kind: normalized, number: number}, nil +} +func (t parsedSimilarityThread) member() clustering.MemberRef { return clustering.MemberRef{ - Owner: dref.Owner(), - Repo: dref.Repo(), - Kind: normalized, - Number: number, - }, dref, nil + Owner: t.repository.Owner(), Repo: t.repository.Repo(), Kind: t.kind, Number: t.number, + } } -func normalizeThreadKind(kind string) (string, error) { - switch strings.ToLower(kind) { +func normalizeThreadKind(kind string) (domain.ThreadKind, error) { + switch strings.ToLower(strings.TrimSpace(kind)) { case "issue", "issues": - return corpus.ThreadKindIssue, nil + return domain.IssueKind, nil case "pull_request", "pullrequest", "pr", "pull": - return corpus.ThreadKindPullRequest, nil + return domain.PullRequestKind, nil } - return "", fmt.Errorf("unsupported thread kind %q", kind) + return domain.ThreadKind(""), fmt.Errorf("unsupported thread kind %q", kind) } func candidateFromThread(repo domain.RepoRef, t corpus.Thread) clustering.Candidate { @@ -244,12 +255,12 @@ func candidateFromThread(repo domain.RepoRef, t corpus.Thread) clustering.Candid func neighborFromClustering(n clustering.Neighbor) Neighbor { return Neighbor{ - Kind: n.Ref.Kind, + Kind: string(n.Ref.Kind), Owner: n.Ref.Owner, Repo: n.Ref.Repo, Number: n.Ref.Number, Title: n.Title, - State: n.State, + State: string(n.State), Score: n.Score, Reason: n.Reason, } @@ -278,7 +289,7 @@ func sortNeighborsByScore(n []Neighbor) { func sameRef(a, b clustering.MemberRef) bool { return a.Number == b.Number && - strings.EqualFold(a.Kind, b.Kind) && + a.Kind == b.Kind && strings.EqualFold(a.Owner, b.Owner) && strings.EqualFold(a.Repo, b.Repo) } @@ -333,7 +344,7 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, dref) } - query, err := c.GetThread(ctx, repository.ID, corpus.ThreadKindPullRequest, number) + query, err := c.GetThread(ctx, repository.ID, domain.PullRequestKind, number) if err != nil { return nil, err } @@ -348,11 +359,11 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo queryBase := parsePRBaseRef(queryPayload) queryRefs := clustering.ExtractMemberRefs(query.Title+"\n"+query.Body, dref) - population, err := c.CountThreadsFiltered(ctx, repository.ID, corpus.ThreadKindPullRequest, "open") + population, err := c.CountThreadsFiltered(ctx, repository.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState()) if err != nil { return nil, err } - prs, err := c.ListThreadsFiltered(ctx, repository.ID, corpus.ThreadKindPullRequest, "open", maxCandidateLimit) + prs, err := c.ListThreadsFiltered(ctx, repository.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState(), maxCandidateLimit) if err != nil { return nil, err } @@ -461,7 +472,7 @@ func referencesThread(refs []clustering.MemberRef, target clustering.MemberRef) if !strings.EqualFold(r.Owner, target.Owner) || !strings.EqualFold(r.Repo, target.Repo) { continue } - if r.Kind != "" && !strings.EqualFold(r.Kind, target.Kind) { + if r.Kind != "" && r.Kind != target.Kind { continue } return true @@ -508,34 +519,98 @@ func sortPRCollisions(c []PullRequestCollision) { }) } -// CheckHypothesisDuplicates searches the local corpus for threads similar to -// a hypothesis, returning each finding as evidence. -func (s *Service) CheckHypothesisDuplicates(ctx context.Context, hypothesisID string, limit int) (*contracts.DuplicateCheckResult, error) { - invSvc, err := s.readInvestigationSvc(ctx) - if err != nil { - return nil, err +type relatedWorkSubjectKind uint8 + +const ( + relatedWorkHypothesis relatedWorkSubjectKind = iota + 1 + relatedWorkOpportunity +) + +func parseRelatedWorkSubjectKind(value string) (relatedWorkSubjectKind, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "hypothesis": + return relatedWorkHypothesis, nil + case "opportunity": + return relatedWorkOpportunity, nil + default: + return 0, fmt.Errorf("unknown related-work target %q", value) } - h, err := invSvc.GetHypothesis(ctx, hypothesisID) - if err != nil { - return nil, mapInvestigationError(err) +} + +func (k relatedWorkSubjectKind) String() string { + if k == relatedWorkOpportunity { + return "opportunity" } - inv, err := invSvc.GetInvestigation(ctx, h.InvestigationID) + return "hypothesis" +} + +type relatedWorkSubject struct { + kind relatedWorkSubjectKind + id string + investigation *investigation.Investigation + query clustering.Candidate + hypothesisID string + opportunityID string +} + +func (s *Service) loadRelatedWorkSubject(ctx context.Context, kind relatedWorkSubjectKind, id string) (relatedWorkSubject, error) { + invSvc, err := s.readInvestigationSvc(ctx) if err != nil { - return nil, mapInvestigationError(err) + return relatedWorkSubject{}, err } - query := candidateFromHypothesis(h, inv.Repo) - neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, inv.Repo, query, "", false, limit) + id = strings.TrimSpace(id) + var subject relatedWorkSubject + subject.kind = kind + switch kind { + case relatedWorkHypothesis: + hypothesis, err := invSvc.GetHypothesis(ctx, id) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.id = hypothesis.ID + subject.hypothesisID = hypothesis.ID + subject.investigation, err = invSvc.GetInvestigation(ctx, hypothesis.InvestigationID) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.query = candidateFromHypothesis(hypothesis, subject.investigation.Repo) + case relatedWorkOpportunity: + opportunity, err := invSvc.GetOpportunity(ctx, id) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.id = opportunity.ID + subject.hypothesisID = opportunity.HypothesisID + subject.opportunityID = opportunity.ID + subject.investigation, err = invSvc.GetInvestigation(ctx, opportunity.InvestigationID) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.query = candidateFromOpportunity(opportunity, subject.investigation.Repo) + default: + return relatedWorkSubject{}, errors.New("related-work subject was not parsed") + } + return subject, nil +} + +func (s *Service) duplicatesForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.DuplicateCheckResult, error) { + neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, allSimilarThreads, limit) if err != nil { return nil, err } findings := make([]evidence.Evidence, 0, len(neighbors)) for _, n := range neighbors { - findings = append(findings, evidenceFromNeighbor(n, inv.Repo, inv.ID, h.ID, "", evidence.RelationInconclusive)) + findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationInconclusive)) + } + hypothesisID := "" + if subject.kind == relatedWorkHypothesis { + hypothesisID = subject.hypothesisID } return &contracts.DuplicateCheckResult{ - HypothesisID: h.ID, - Repo: inv.Repo, - Query: query.Title, + HypothesisID: hypothesisID, + OpportunityID: subject.opportunityID, + Repo: subject.investigation.Repo, + Query: subject.query.Title, Findings: findings, SourceRevision: revision, Limit: effectiveLimit, @@ -543,93 +618,60 @@ func (s *Service) CheckHypothesisDuplicates(ctx context.Context, hypothesisID st }, nil } -// CheckOpportunityDuplicates searches the local corpus for threads similar to -// an opportunity. -func (s *Service) CheckOpportunityDuplicates(ctx context.Context, opportunityID string, limit int) (*contracts.DuplicateCheckResult, error) { - invSvc, err := s.readInvestigationSvc(ctx) +// CheckHypothesisDuplicates searches the local corpus for threads similar to +// a hypothesis, returning each finding as evidence. +func (s *Service) CheckHypothesisDuplicates(ctx context.Context, hypothesisID string, limit int) (*contracts.DuplicateCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) if err != nil { return nil, err } - o, err := invSvc.GetOpportunity(ctx, opportunityID) - if err != nil { - return nil, mapInvestigationError(err) - } - inv, err := invSvc.GetInvestigation(ctx, o.InvestigationID) - if err != nil { - return nil, mapInvestigationError(err) - } - query := candidateFromOpportunity(o, inv.Repo) - neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, inv.Repo, query, "", false, limit) + return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) +} + +// CheckOpportunityDuplicates searches the local corpus for threads similar to +// an opportunity. +func (s *Service) CheckOpportunityDuplicates(ctx context.Context, opportunityID string, limit int) (*contracts.DuplicateCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) if err != nil { return nil, err } - findings := make([]evidence.Evidence, 0, len(neighbors)) - for _, n := range neighbors { - findings = append(findings, evidenceFromNeighbor(n, inv.Repo, inv.ID, o.HypothesisID, o.ID, evidence.RelationInconclusive)) - } - return &contracts.DuplicateCheckResult{ - OpportunityID: o.ID, - Repo: inv.Repo, - Query: query.Title, - Findings: findings, - SourceRevision: revision, - Limit: effectiveLimit, - Total: len(findings), - }, nil + return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) } // CheckHypothesisCollisions searches the local corpus for open pull requests // that may collide with a hypothesis. func (s *Service) CheckHypothesisCollisions(ctx context.Context, hypothesisID string, limit int) (*contracts.CollisionCheckResult, error) { - invSvc, err := s.readInvestigationSvc(ctx) + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) if err != nil { return nil, err } - h, err := invSvc.GetHypothesis(ctx, hypothesisID) - if err != nil { - return nil, mapInvestigationError(err) - } - inv, err := invSvc.GetInvestigation(ctx, h.InvestigationID) - if err != nil { - return nil, mapInvestigationError(err) - } - query := candidateFromHypothesis(h, inv.Repo) - return s.collisionsForQuery(ctx, inv, h.ID, "", query, limit) + return s.collisionsForRelatedWorkSubject(ctx, subject, limit) } // CheckOpportunityCollisions searches the local corpus for open pull requests // that may collide with an opportunity. func (s *Service) CheckOpportunityCollisions(ctx context.Context, opportunityID string, limit int) (*contracts.CollisionCheckResult, error) { - invSvc, err := s.readInvestigationSvc(ctx) + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) if err != nil { return nil, err } - o, err := invSvc.GetOpportunity(ctx, opportunityID) - if err != nil { - return nil, mapInvestigationError(err) - } - inv, err := invSvc.GetInvestigation(ctx, o.InvestigationID) - if err != nil { - return nil, mapInvestigationError(err) - } - query := candidateFromOpportunity(o, inv.Repo) - return s.collisionsForQuery(ctx, inv, o.HypothesisID, o.ID, query, limit) + return s.collisionsForRelatedWorkSubject(ctx, subject, limit) } -func (s *Service) collisionsForQuery(ctx context.Context, inv *investigation.Investigation, hypothesisID, opportunityID string, query clustering.Candidate, limit int) (*contracts.CollisionCheckResult, error) { - neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, inv.Repo, query, corpus.ThreadKindPullRequest, true, limit) +func (s *Service) collisionsForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.CollisionCheckResult, error) { + neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, openPullRequestsOnly, limit) if err != nil { return nil, err } findings := make([]evidence.Evidence, 0, len(neighbors)) for _, n := range neighbors { - findings = append(findings, evidenceFromNeighbor(n, inv.Repo, inv.ID, hypothesisID, opportunityID, evidence.RelationContradicting)) + findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationContradicting)) } return &contracts.CollisionCheckResult{ - HypothesisID: hypothesisID, - OpportunityID: opportunityID, - Repo: inv.Repo, - Query: query.Title, + HypothesisID: subject.hypothesisID, + OpportunityID: subject.opportunityID, + Repo: subject.investigation.Repo, + Query: subject.query.Title, Findings: findings, SourceRevision: revision, Limit: effectiveLimit, @@ -637,7 +679,21 @@ func (s *Service) collisionsForQuery(ctx context.Context, inv *investigation.Inv }, nil } -func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, query clustering.Candidate, kind string, onlyOpen bool, limit int) ([]clustering.Neighbor, string, int, error) { +type similarThreadScope uint8 + +const ( + allSimilarThreads similarThreadScope = iota + 1 + openPullRequestsOnly +) + +func (s similarThreadScope) filters() (corpus.ThreadKindFilter, corpus.ThreadStateFilter) { + if s == openPullRequestsOnly { + return corpus.PullRequestThreadKind(), corpus.OpenThreadState() + } + return corpus.AnyThreadKind(), corpus.AnyThreadState() +} + +func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, query clustering.Candidate, scope similarThreadScope, limit int) ([]clustering.Neighbor, string, int, error) { if !repo.IsValid() { return nil, "", 0, errors.New("repository is required") } @@ -658,10 +714,7 @@ func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, q // performing network access. return nil, "", limit, nil } - state := "" - if onlyOpen { - state = "open" - } + kind, state := scope.filters() threads, err := c.ListThreadsFiltered(ctx, repository.ID, kind, state, similarityCandidateLimit(limit)) if err != nil { return nil, "", 0, err @@ -734,7 +787,7 @@ func candidateFromOpportunity(o *investigation.Opportunity, repo domain.RepoRef) func evidenceFromNeighbor(n clustering.Neighbor, _ domain.RepoRef, investigationID, hypothesisID, opportunityID string, relation evidence.Relation) evidence.Evidence { path := "issues" - if strings.EqualFold(n.Ref.Kind, corpus.ThreadKindPullRequest) { + if n.Ref.Kind == domain.PullRequestKind { path = "pull" } url := fmt.Sprintf("https://github.com/%s/%s/%s/%d", n.Ref.Owner, n.Ref.Repo, path, n.Ref.Number) diff --git a/internal/app/neighbors_test.go b/internal/app/neighbors_test.go index b2102538..c12a2643 100644 --- a/internal/app/neighbors_test.go +++ b/internal/app/neighbors_test.go @@ -11,6 +11,7 @@ import ( "github.com/morluto/gitcontribute/internal/config" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" ) func newNeighborService(t *testing.T) *Service { @@ -37,14 +38,14 @@ func seedRepoForNeighbors(t *testing.T, c *corpus.Corpus) *corpus.Repository { return repo } -func seedPullRequestForNeighbors(t *testing.T, c *corpus.Corpus, repoID int64, number int, title, body, author, state, baseRef string) *corpus.Thread { +func seedPullRequestForNeighbors(t *testing.T, c *corpus.Corpus, repoID int64, number int, title, body, author string, state domain.ThreadState, baseRef string) *corpus.Thread { t.Helper() ctx := context.Background() updated := time.Unix(int64(number)*1000, 0).UTC() payload := fmt.Sprintf(`{"BaseRef":"%s","HeadRef":"feature-%d","Title":"%s","Body":"%s","Author":"%s"}`, baseRef, number, title, body, author) thread, err := c.UpsertThread(ctx, corpus.Thread{ RepositoryID: repoID, - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: number, State: state, Title: title, @@ -65,7 +66,7 @@ func seedIssueForNeighbors(t *testing.T, c *corpus.Corpus, repoID int64, number updated := time.Unix(int64(number)*1000, 0).UTC() thread, err := c.UpsertThread(ctx, corpus.Thread{ RepositoryID: repoID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: number, State: "open", Title: title, diff --git a/internal/app/radar.go b/internal/app/radar.go index d23919cc..2e34e757 100644 --- a/internal/app/radar.go +++ b/internal/app/radar.go @@ -46,24 +46,24 @@ func (s *Service) contributionRadarAt(ctx context.Context, opts contracts.RadarO return nil, fmt.Errorf("%w: %s", errRepositoryNotFound, ref) } - totalOpenIssues, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.ThreadKindIssue, "open") + totalOpenIssues, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.IssueThreadKind(), corpus.OpenThreadState()) if err != nil { return nil, fmt.Errorf("count radar issues: %w", err) } - issues, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.ThreadKindIssue, "open", radarCandidatePopulation) + issues, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.IssueThreadKind(), corpus.OpenThreadState(), radarCandidatePopulation) if err != nil { return nil, fmt.Errorf("list radar issues: %w", err) } - totalOpenPullRequests, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.ThreadKindPullRequest, "open") + totalOpenPullRequests, err := c.CountThreadsFiltered(ctx, stored.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState()) if err != nil { return nil, fmt.Errorf("count open pull requests: %w", err) } - openPullRequests, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.ThreadKindPullRequest, "open", radarPullRequestPopulation) + openPullRequests, err := c.ListThreadsFiltered(ctx, stored.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState(), radarPullRequestPopulation) if err != nil { return nil, fmt.Errorf("list open pull requests: %w", err) } - relatedByIssue, relationshipScanCapped, err := radarPullRequestRelatedWork(ctx, c, stored, ref, issues, openPullRequests, "open") + relatedByIssue, relationshipScanCapped, err := radarPullRequestRelatedWork(ctx, c, stored, ref, issues, openPullRequests, corpus.OpenThreadState()) if err != nil { return nil, err } @@ -105,7 +105,7 @@ func (s *Service) contributionRadarAt(ctx context.Context, opts contracts.RadarO if cluster := duplicateByIssue[issue.Number]; cluster != nil { related = append(related, radar.RelatedWork{ Ref: "duplicate_cluster:" + cluster.StableID, Kind: "duplicate_cluster", Title: cluster.CanonicalRef, - Relation: relatedwork.RelationClusterCandidate, Direction: "local", URL: "local://clusters/" + cluster.StableID, + Relation: relatedwork.RelationClusterCandidate, Direction: radar.RelatedWorkLocal, URL: "local://clusters/" + cluster.StableID, Evidence: []radar.RelatedWorkEvidence{{Kind: "duplicate_cluster", SourceURL: "local://clusters/" + cluster.StableID, SourceAsOf: cluster.SourceAsOf}}, SourceUpdatedAt: cluster.SourceAsOf, }) @@ -114,7 +114,7 @@ func (s *Service) contributionRadarAt(ctx context.Context, opts contracts.RadarO linked := radarLinkedPullRequests(related) snapshots = append(snapshots, radar.IssueSnapshot{ Number: issue.Number, - State: issue.State, + State: string(issue.State), Title: issue.Title, Body: issue.Body, Labels: issue.Labels, @@ -152,7 +152,7 @@ func radarCoverage(items []corpus.Coverage, scope string) []radar.Coverage { out := make([]radar.Coverage, 0, len(items)) for _, item := range items { out = append(out, radar.Coverage{ - Facet: item.Facet, Scope: scope, Present: true, Complete: item.Complete, AsOf: item.SourceUpdatedAt, + Facet: item.Facet, Scope: scope, Complete: item.Complete, AsOf: item.SourceUpdatedAt, }) } return out @@ -174,12 +174,12 @@ func radarGuidanceStatus(coverage []corpus.Coverage, documentCount int) string { func radarLinkedPullRequests(values []radar.RelatedWork) []radar.LinkedPullRequest { out := []radar.LinkedPullRequest{} for _, value := range values { - if value.Kind != string(domain.PullRequestKind) || value.Direction != "inbound" || !strings.EqualFold(value.State, "open") { + if value.Kind != string(domain.PullRequestKind) || value.Direction != radar.RelatedWorkInbound || !strings.EqualFold(value.State, "open") { continue } out = append(out, radar.LinkedPullRequest{ Number: value.Number, Title: value.Title, URL: value.URL, - Closing: value.Relation == "claims_to_close", SourceUpdatedAt: value.SourceUpdatedAt, + Closing: value.Relation == relatedwork.RelationClaimsToClose, SourceUpdatedAt: value.SourceUpdatedAt, }) } sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number }) @@ -209,7 +209,7 @@ func radarDuplicateClusterFacts(ref domain.RepoRef, projection clusterprojection continue } for _, member := range cluster.Members { - if !member.Included || member.Ref.Kind != corpus.ThreadKindIssue || !strings.EqualFold(member.Ref.Owner, ref.Owner()) || !strings.EqualFold(member.Ref.Repo, ref.Repo()) { + if !member.Included || member.Ref.Kind != domain.IssueKind || !strings.EqualFold(member.Ref.Owner, ref.Owner()) || !strings.EqualFold(member.Ref.Repo, ref.Repo()) { continue } fact := &radar.DuplicateCluster{ diff --git a/internal/app/radar_related_work.go b/internal/app/radar_related_work.go index 621354c8..3e146313 100644 --- a/internal/app/radar_related_work.go +++ b/internal/app/radar_related_work.go @@ -23,11 +23,11 @@ const ( type rawRadarRelatedWork struct { reference relatedwork.Reference - direction string + direction radar.RelatedWorkDirection evidence radar.RelatedWorkEvidence } -func radarPullRequestRelatedWork(ctx context.Context, c *corpus.Corpus, stored *corpus.Repository, ref domain.RepoRef, issues, pullRequests []corpus.Thread, state string) (map[int][]radar.RelatedWork, bool, error) { +func radarPullRequestRelatedWork(ctx context.Context, c *corpus.Corpus, stored *corpus.Repository, ref domain.RepoRef, issues, pullRequests []corpus.Thread, state corpus.ThreadStateFilter) (map[int][]radar.RelatedWork, bool, error) { issueNumbers := make(map[int]struct{}, len(issues)) for _, issue := range issues { issueNumbers[issue.Number] = struct{}{} @@ -103,12 +103,12 @@ func radarReferenceTargetsIssue(linked relatedwork.Reference, ref domain.RepoRef return ok } -func radarPullRequestWork(ref domain.RepoRef, pullRequest corpus.Thread, relation, evidenceKind string, sourceAsOf time.Time) radar.RelatedWork { - url := threadURL(ref, string(domain.PullRequestKind), pullRequest.Number) +func radarPullRequestWork(ref domain.RepoRef, pullRequest corpus.Thread, relation relatedwork.Relation, evidenceKind string, sourceAsOf time.Time) radar.RelatedWork { + url := threadURL(ref, domain.PullRequestKind, pullRequest.Number) return radar.RelatedWork{ Ref: fmt.Sprintf("pull_request:%s#%d", ref, pullRequest.Number), Kind: string(domain.PullRequestKind), - Number: pullRequest.Number, Title: pullRequest.Title, State: pullRequest.State, - Relation: relation, Direction: "inbound", URL: url, + Number: pullRequest.Number, Title: pullRequest.Title, State: string(pullRequest.State), + Relation: relation, Direction: radar.RelatedWorkInbound, URL: url, Evidence: []radar.RelatedWorkEvidence{{Kind: evidenceKind, SourceURL: url, SourceAsOf: sourceAsOf}}, SourceUpdatedAt: pullRequest.SourceUpdatedAt, } @@ -129,17 +129,17 @@ func newRadarWorkAccumulator(repo domain.RepoRef, targetNumber int) *radarWorkAc } } -func (a *radarWorkAccumulator) appendText(text, direction, evidenceKind, sourceURL string, sourceAsOf time.Time) { +func (a *radarWorkAccumulator) appendText(text string, direction radar.RelatedWorkDirection, evidenceKind, sourceURL string, sourceAsOf time.Time) { for _, reference := range relatedwork.Extract(text, a.repo) { a.append(reference, direction, radar.RelatedWorkEvidence{Kind: evidenceKind, SourceURL: sourceURL, SourceAsOf: sourceAsOf}) } } -func (a *radarWorkAccumulator) append(reference relatedwork.Reference, direction string, evidence radar.RelatedWorkEvidence) { +func (a *radarWorkAccumulator) append(reference relatedwork.Reference, direction radar.RelatedWorkDirection, evidence radar.RelatedWorkEvidence) { if sameRepo(reference.Repo, a.repo) && reference.Number == a.targetNumber { return } - if direction == "outbound" && reference.Relation == relatedwork.RelationClaimsToClose { + if direction == radar.RelatedWorkOutbound && reference.Relation == relatedwork.RelationClaimsToClose { reference.Relation = relatedwork.RelationExplicitReference } key := radarReferenceKey(reference) @@ -190,9 +190,9 @@ func radarReferencePreferred(reference relatedwork.Reference, key string, otherP } func radarIssueDiscussionAndRelatedWork(ctx context.Context, c *corpus.Corpus, stored *corpus.Repository, issue corpus.Thread, ref domain.RepoRef, now time.Time) (radar.DiscussionSummary, []radar.RelatedWork, bool, error) { - issueURL := threadURL(ref, string(domain.IssueKind), issue.Number) + issueURL := threadURL(ref, domain.IssueKind, issue.Number) accumulator := newRadarWorkAccumulator(ref, issue.Number) - accumulator.appendText(issue.Title+"\n"+issue.Body, "outbound", "issue_text", issueURL, issue.SourceUpdatedAt) + accumulator.appendText(issue.Title+"\n"+issue.Body, radar.RelatedWorkOutbound, "issue_text", issueURL, issue.SourceUpdatedAt) comments, err := readRadarIssueComments(ctx, c, stored.ID, issue.ID, accumulator) if err != nil { return radar.DiscussionSummary{}, nil, false, err @@ -237,7 +237,7 @@ func readRadarIssueComments(ctx context.Context, c *corpus.Corpus, repoID, issue if sourceAsOf.IsZero() { sourceAsOf = comment.CreatedAt } - accumulator.appendText(comment.Body, "outbound", "issue_comment", comment.HTMLURL, sourceAsOf) + accumulator.appendText(comment.Body, radar.RelatedWorkOutbound, "issue_comment", comment.HTMLURL, sourceAsOf) } } return comments, nil @@ -267,7 +267,7 @@ func readRadarIssueTimeline(ctx context.Context, c *corpus.Corpus, repoID, issue if !ok { continue } - accumulator.append(reference, "inbound", radar.RelatedWorkEvidence{ + accumulator.append(reference, radar.RelatedWorkInbound, radar.RelatedWorkEvidence{ Kind: FacetIssueTimeline, SourceURL: issueURL, SourceAsOf: observation.SourceUpdatedAt, }) } @@ -321,7 +321,7 @@ func resolveRadarRelatedWork(ctx context.Context, c *corpus.Corpus, raw []rawRad return values, nil } -func resolveRadarReference(ctx context.Context, c *corpus.Corpus, reference relatedwork.Reference, direction string, evidence radar.RelatedWorkEvidence) (radar.RelatedWork, error) { +func resolveRadarReference(ctx context.Context, c *corpus.Corpus, reference relatedwork.Reference, direction radar.RelatedWorkDirection, evidence radar.RelatedWorkEvidence) (radar.RelatedWork, error) { kind := reference.Kind state, title := "", "" sourceUpdatedAt := time.Time{} @@ -335,8 +335,8 @@ func resolveRadarReference(ctx context.Context, c *corpus.Corpus, reference rela return radar.RelatedWork{}, fmt.Errorf("resolve related thread: %w", err) } if thread != nil { - kind = domain.ThreadKind(thread.Kind) - state, title, sourceUpdatedAt = thread.State, thread.Title, thread.SourceUpdatedAt + kind = thread.Kind + state, title, sourceUpdatedAt = string(thread.State), thread.Title, thread.SourceUpdatedAt } } kindName := string(kind) @@ -346,7 +346,7 @@ func resolveRadarReference(ctx context.Context, c *corpus.Corpus, reference rela return radar.RelatedWork{ Ref: fmt.Sprintf("%s:%s#%d", kindName, reference.Repo, reference.Number), Kind: kindName, Number: reference.Number, Title: title, State: state, Relation: reference.Relation, Direction: direction, - URL: threadURL(reference.Repo, string(kind), reference.Number), Evidence: []radar.RelatedWorkEvidence{evidence}, SourceUpdatedAt: sourceUpdatedAt, + URL: threadURL(reference.Repo, kind, reference.Number), Evidence: []radar.RelatedWorkEvidence{evidence}, SourceUpdatedAt: sourceUpdatedAt, }, nil } diff --git a/internal/app/radar_test.go b/internal/app/radar_test.go index fb086e42..fa7f1d7b 100644 --- a/internal/app/radar_test.go +++ b/internal/app/radar_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "strconv" "strings" "testing" "time" @@ -79,7 +78,7 @@ func newRadarTestFixture(t *testing.T) radarTestFixture { t.Fatal(err) } issue1, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Focused starter bug", Body: strings.Repeat("Steps to reproduce and expected behavior. ", 8) + "\n- [ ] add a regression test", Labels: []string{"good first issue", "help wanted"}, SourceUpdatedAt: now.Add(-24 * time.Hour), }, `{}`) @@ -87,14 +86,14 @@ func newRadarTestFixture(t *testing.T) radarTestFixture { t.Fatal(err) } issue2, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 2, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 2, State: "open", Title: "Assigned refactor", Body: "Refactor this package.", Assignees: []string{"alice"}, SourceUpdatedAt: now.Add(-48 * time.Hour), }, `{}`) if err != nil { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 9, State: "open", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 9, State: "open", Title: "Implement starter bug", Body: "Fixes #1", SourceUpdatedAt: now.Add(-30 * time.Minute), }, `{}`); err != nil { t.Fatal(err) @@ -221,7 +220,7 @@ func TestContributionRadarReadsStoredDuplicateCluster(t *testing.T) { t.Fatal(err) } if _, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 2, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 2, State: "open", Title: "same starter bug", Body: "duplicate of #1", Assignees: []string{"alice"}, SourceUpdatedAt: fixture.now, }, `{}`); err != nil { t.Fatal(err) @@ -258,14 +257,14 @@ func TestRadarPullRequestClosingReferenceIsPrecise(t *testing.T) { if err != nil { t.Fatal(err) } - issues, err := fixture.svc.corpus.ListThreadsFiltered(fixture.ctx, stored.ID, corpus.ThreadKindIssue, "open", 500) + issues, err := fixture.svc.corpus.ListThreadsFiltered(fixture.ctx, stored.ID, corpus.IssueThreadKind(), corpus.OpenThreadState(), 500) if err != nil { t.Fatal(err) } links, _, err := radarPullRequestRelatedWork(fixture.ctx, fixture.svc.corpus, stored, ref, issues, []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, Number: 8, Title: "Handle both reports", + Kind: domain.PullRequestKind, Number: 8, Title: "Handle both reports", Body: "Fixes #1 and also discusses #2. Fixes other/project#3.", - }}, "open") + }}, corpus.OpenThreadState()) if err != nil { t.Fatal(err) } @@ -283,7 +282,7 @@ func TestRadarPullRequestClosingReferenceIsPrecise(t *testing.T) { func TestContributionRadarUsesAuthoritativeClosingIssueProjection(t *testing.T) { t.Parallel() fixture := newRadarTestFixture(t) - pr, err := fixture.svc.corpus.GetThread(fixture.ctx, fixture.repoID, corpus.ThreadKindPullRequest, 9) + pr, err := fixture.svc.corpus.GetThread(fixture.ctx, fixture.repoID, domain.PullRequestKind, 9) if err != nil || pr == nil { t.Fatalf("get PR: %+v, %v", pr, err) } @@ -297,10 +296,22 @@ func TestContributionRadarUsesAuthoritativeClosingIssueProjection(t *testing.T) if err != nil || observation == nil { t.Fatalf("latest PR observation: %+v, %v", observation, err) } + subject, err := corpus.NewPullRequestPortfolioSubject(pr.ID) + if err != nil { + t.Fatal(err) + } + linkedIssue, err := corpus.NewPortfolioLinkedIssueSignal("owner/repo#1") + if err != nil { + t.Fatal(err) + } + sourceRef, err := corpus.NewThreadObservationRef(observation.ID) + if err != nil { + t.Fatal(err) + } if _, err := fixture.svc.corpus.ReplacePortfolioSignals(fixture.ctx, corpus.PortfolioSignalSnapshot{ - Subject: corpus.PortfolioSubject{Kind: corpus.PortfolioSubjectPullRequest, Ref: strconv.FormatInt(pr.ID, 10)}, - Facet: corpus.PortfolioFacetLinkedIssues, Signals: []corpus.PortfolioSignal{{Kind: corpus.PortfolioSignalLinkedIssue, Value: "owner/repo#1"}}, - SourceUpdatedAt: pr.SourceUpdatedAt, SourceObservationRefs: []corpus.ObservationRef{{Kind: "thread", ID: observation.ID}}, + Subject: subject, + Facet: corpus.PortfolioFacetLinkedIssues, Signals: []corpus.PortfolioSignal{linkedIssue}, + SourceUpdatedAt: pr.SourceUpdatedAt, SourceObservationRefs: []corpus.ObservationRef{sourceRef}, }); err != nil { t.Fatal(err) } @@ -321,7 +332,7 @@ func TestContributionRadarUnifiesCommentDependenciesAndTimelineCrossReferences(t fixture := newRadarTestFixture(t) for _, number := range []int{10, 11} { if _, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindPullRequest, Number: number, State: "open", + RepositoryID: fixture.repoID, Kind: domain.PullRequestKind, Number: number, State: "open", Title: fmt.Sprintf("Related PR %d", number), Body: "No issue link in PR text.", SourceUpdatedAt: fixture.now.Add(time.Duration(number) * time.Minute), }, `{}`); err != nil { t.Fatal(err) @@ -376,7 +387,7 @@ func TestContributionRadarPreservesRepeatedReferenceEvidence(t *testing.T) { t.Parallel() fixture := newRadarTestFixture(t) if _, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindPullRequest, Number: 10, State: "open", + RepositoryID: fixture.repoID, Kind: domain.PullRequestKind, Number: 10, State: "open", Title: "Related PR", SourceUpdatedAt: fixture.now.Add(-10 * time.Minute), }, `{}`); err != nil { t.Fatal(err) diff --git a/internal/app/reader.go b/internal/app/reader.go index c149cc78..0a266055 100644 --- a/internal/app/reader.go +++ b/internal/app/reader.go @@ -76,8 +76,15 @@ func (r *corpusReader) ReadThreads(ctx context.Context, ref domain.RepoRef, q do return nil, nil, fmt.Errorf("%w: %s", errRepositoryNotFound, ref) } - kind := string(q.Kind) - threads, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, kind, string(q.State), q.Merged, q.Limit) + kind, err := corpus.ParseThreadKindFilter(string(q.Kind)) + if err != nil { + return nil, nil, err + } + state, err := corpus.ParseThreadStateFilter(string(q.State)) + if err != nil { + return nil, nil, err + } + threads, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, kind, state, corpus.MergeFilterFromPointer(q.Merged), q.Limit) if err != nil { return nil, nil, fmt.Errorf("list threads: %w", err) } @@ -181,7 +188,7 @@ func corpusRepoToDomain(ref domain.RepoRef, repo *corpus.Repository) domain.Repo func corpusThreadToDomain(ref domain.RepoRef, t corpus.Thread) domain.Thread { typeVariant := domain.IssueThread() - if t.Kind == corpus.ThreadKindPullRequest { + if t.Kind == domain.PullRequestKind { typeVariant = domain.PullRequestThread(domain.PullRequestDetails{Merge: t.Merge}) } dt := domain.Thread{ @@ -192,7 +199,7 @@ func corpusThreadToDomain(ref domain.RepoRef, t corpus.Thread) domain.Thread { Title: t.Title, Body: t.Body, Author: t.Author, - State: domain.ThreadState(t.State), + State: t.State, Labels: t.Labels, CreatedAt: t.SourceCreatedAt, UpdatedAt: t.SourceUpdatedAt, diff --git a/internal/app/readiness.go b/internal/app/readiness.go index bd074920..1bb00e75 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -172,7 +172,7 @@ func (r *readinessEvaluator) targetThreadOpen() (contracts.ReadinessCheck, error if r.repository == nil { return r.check("target_thread_open", readinessUnknown, "Target thread cannot be checked because repository metadata is missing.", []string{r.inv.ThreadBaseline.Ref()}, "Run an explicit sync for the repository."), nil } - thread, err := r.corpus.GetThread(r.ctx, r.repository.ID, string(r.inv.ThreadBaseline.Kind), r.inv.ThreadBaseline.Number) + thread, err := r.corpus.GetThread(r.ctx, r.repository.ID, r.inv.ThreadBaseline.Kind, r.inv.ThreadBaseline.Number) if err != nil { return contracts.ReadinessCheck{}, fmt.Errorf("read readiness target thread: %w", err) } @@ -190,9 +190,13 @@ func (r *readinessEvaluator) baselineFresh() (contracts.ReadinessCheck, error) { if r.inv.ThreadBaseline == nil { return r.check("baseline_freshness", readinessUnknown, "No immutable thread baseline is recorded.", nil, "Start from a stored thread or re-check the target manually."), nil } + revision, err := sourceRevisionFromThreadBaseline(*r.inv.ThreadBaseline) + if err != nil { + return contracts.ReadinessCheck{}, err + } item := &evidence.Evidence{ Type: evidence.EvidenceTypeGitHubSource, - SourceProvenance: []evidence.SourceRevision{sourceRevisionFromThreadBaseline(*r.inv.ThreadBaseline)}, + SourceProvenance: []evidence.SourceRevision{revision}, } freshness, err := evidence.NewFreshnessEvaluator(r.corpus).Evaluate(r.ctx, item) if err != nil { diff --git a/internal/app/readiness_test.go b/internal/app/readiness_test.go index d15c2d13..be0720a1 100644 --- a/internal/app/readiness_test.go +++ b/internal/app/readiness_test.go @@ -116,7 +116,7 @@ func TestOpportunityReadinessReportsPassWarnBlockUnknown(t *testing.T) { t.Fatal(err) } if _, err := fixture.svc.corpus.UpsertThread(fixture.ctx, corpus.Thread{ - RepositoryID: fixture.repoID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: fixture.repoID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Retry parser cancellation updated", Body: thread.Body, Author: thread.Author, AuthorAssociation: thread.AuthorAssociation, Labels: thread.Labels, SourceCreatedAt: thread.SourceCreatedAt, SourceUpdatedAt: fixture.now.Add(time.Hour), diff --git a/internal/app/repeat_validation_request.go b/internal/app/repeat_validation_request.go new file mode 100644 index 00000000..0dce0696 --- /dev/null +++ b/internal/app/repeat_validation_request.go @@ -0,0 +1,178 @@ +package app + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type validationRunTarget uint8 + +const ( + validationRunBase validationRunTarget = iota + 1 + validationRunCandidate + validationRunBoth +) + +func parseValidationRunKind(value string) (evidence.RunKind, error) { + switch strings.TrimSpace(value) { + case "base": + return evidence.RunKindBase, nil + case "candidate": + return evidence.RunKindCandidate, nil + default: + return "", errors.New("validation kind must be base or candidate") + } +} + +func parseRepeatValidationTarget(value string) (validationRunTarget, error) { + if strings.TrimSpace(value) == "both" { + return validationRunBoth, nil + } + kind, err := parseValidationRunKind(value) + if err != nil { + return 0, errors.New("target must be base, candidate, or both") + } + if kind == evidence.RunKindBase { + return validationRunBase, nil + } + return validationRunCandidate, nil +} + +func (t validationRunTarget) String() string { + switch t { + case validationRunBase: + return "base" + case validationRunCandidate: + return "candidate" + case validationRunBoth: + return "both" + default: + panic("invalid validation run target") + } +} + +func (t validationRunTarget) kinds() []evidence.RunKind { + switch t { + case validationRunBase: + return []evidence.RunKind{evidence.RunKindBase} + case validationRunCandidate: + return []evidence.RunKind{evidence.RunKindCandidate} + case validationRunBoth: + return []evidence.RunKind{evidence.RunKindBase, evidence.RunKindCandidate} + default: + panic("invalid validation run target") + } +} + +type repeatValidationRequest struct { + definitionID string + target validationRunTarget + options evidence.RepeatValidationOptions +} + +type validationRunRequest struct { + definitionID string + kind evidence.RunKind +} + +func parseValidationRunOptions(definitionID string, opts contracts.RunValidationOptions) (validationRunRequest, error) { + if !opts.Execute { + return validationRunRequest{}, evidence.ErrExecutionNotAuthorized + } + definitionID = strings.TrimSpace(definitionID) + if definitionID == "" { + return validationRunRequest{}, errors.New("validation definition ID is required") + } + kind, err := parseValidationRunKind(opts.Kind) + if err != nil { + return validationRunRequest{}, err + } + return validationRunRequest{definitionID: definitionID, kind: kind}, nil +} + +func parseMCPRepeatValidationInput(in mcpcontract.RunValidationInput) (repeatValidationRequest, mcpcontract.RunValidationInput, error) { + if !in.Execute { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, errors.New("execute must be true to authorize host command execution") + } + definitionID := strings.TrimSpace(in.ID) + if definitionID == "" { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, errors.New("validation definition ID is required") + } + target, err := parseRepeatValidationTarget(in.Target) + if err != nil { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, err + } + if in.RunCount == 0 { + in.RunCount = 1 + } + if in.Concurrency == 0 { + in.Concurrency = 1 + } + if strings.TrimSpace(in.SampleInterval) == "" { + in.SampleInterval = "100ms" + } + perRunTimeout, err := parseOptionalDuration(in.PerRunTimeout) + if err != nil { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, fmt.Errorf("per_run_timeout: %w", err) + } + overallTimeout, err := parseOptionalDuration(in.OverallTimeout) + if err != nil { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, fmt.Errorf("overall_timeout: %w", err) + } + sampleInterval, err := parseOptionalDuration(in.SampleInterval) + if err != nil { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, fmt.Errorf("sample_interval: %w", err) + } + options, err := evidence.ParseRepeatValidationOptions(evidence.RepeatValidationOptions{ + Kinds: target.kinds(), RunCount: in.RunCount, Concurrency: in.Concurrency, + PerRunTimeout: perRunTimeout, OverallTimeout: overallTimeout, SampleInterval: sampleInterval, + }) + if err != nil { + return repeatValidationRequest{}, mcpcontract.RunValidationInput{}, err + } + canonical := mcpcontract.RunValidationInput{ + ID: definitionID, Target: target.String(), RunCount: options.RunCount, Concurrency: options.Concurrency, + PerRunTimeout: optionalDurationString(options.PerRunTimeout), OverallTimeout: optionalDurationString(options.OverallTimeout), + SampleInterval: optionalDurationString(options.SampleInterval), Execute: true, + } + return repeatValidationRequest{definitionID: definitionID, target: target, options: options}, canonical, nil +} + +func parseRepeatValidationOptions(definitionID string, opts contracts.RepeatValidationOptions) (repeatValidationRequest, error) { + if !opts.Execute { + return repeatValidationRequest{}, evidence.ErrExecutionNotAuthorized + } + definitionID = strings.TrimSpace(definitionID) + if definitionID == "" { + return repeatValidationRequest{}, errors.New("validation definition ID is required") + } + kinds := make([]evidence.RunKind, len(opts.Kinds)) + for i, value := range opts.Kinds { + kind, err := parseValidationRunKind(value) + if err != nil { + return repeatValidationRequest{}, evidence.ErrMissingRunKind + } + kinds[i] = kind + } + options, err := evidence.ParseRepeatValidationOptions(evidence.RepeatValidationOptions{ + Kinds: kinds, RunCount: opts.RunCount, Concurrency: opts.Concurrency, + PerRunTimeout: opts.PerRunTimeout, OverallTimeout: opts.OverallTimeout, SampleInterval: opts.SampleInterval, + }) + if err != nil { + return repeatValidationRequest{}, err + } + return repeatValidationRequest{definitionID: definitionID, options: options}, nil +} + +func optionalDurationString(value time.Duration) string { + if value == 0 { + return "" + } + return value.String() +} diff --git a/internal/app/repeat_validation_request_test.go b/internal/app/repeat_validation_request_test.go new file mode 100644 index 00000000..c25ecf7e --- /dev/null +++ b/internal/app/repeat_validation_request_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestParseMCPRepeatValidationInputProducesCanonicalAuthorizedRequest(t *testing.T) { + request, canonical, err := parseMCPRepeatValidationInput(mcpcontract.RunValidationInput{ + ID: " definition-1 ", Target: " both ", RunCount: 2, Concurrency: 2, + PerRunTimeout: " 30s ", OverallTimeout: " 2m ", Execute: true, + }) + if err != nil { + t.Fatal(err) + } + if request.definitionID != "definition-1" || request.target != validationRunBoth { + t.Fatalf("parsed request identity/target = %q/%v", request.definitionID, request.target) + } + if len(request.options.Kinds) != 2 || request.options.Kinds[0] != evidence.RunKindBase || request.options.Kinds[1] != evidence.RunKindCandidate { + t.Fatalf("parsed run kinds = %v", request.options.Kinds) + } + if request.options.PerRunTimeout != 30*time.Second || request.options.OverallTimeout != 2*time.Minute || request.options.SampleInterval != 100*time.Millisecond { + t.Fatalf("parsed durations = %+v", request.options) + } + if canonical.ID != "definition-1" || canonical.Target != "both" || canonical.PerRunTimeout != "30s" || canonical.OverallTimeout != "2m0s" || canonical.SampleInterval != "100ms" || !canonical.Execute { + t.Fatalf("canonical durable request = %+v", canonical) + } +} + +func TestParseMCPRepeatValidationInputRejectsInvalidRequestBeforeSubmission(t *testing.T) { + for name, in := range map[string]mcpcontract.RunValidationInput{ + "unauthorized": {ID: "definition-1", Target: "candidate"}, + "target": {ID: "definition-1", Target: "production", Execute: true}, + "run count": {ID: "definition-1", Target: "candidate", RunCount: 101, Concurrency: 1, Execute: true}, + "concurrency": {ID: "definition-1", Target: "candidate", RunCount: 1, Concurrency: 2, Execute: true}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := parseMCPRepeatValidationInput(in); err == nil { + t.Fatalf("invalid request was accepted: %+v", in) + } + }) + } +} diff --git a/internal/app/repeated_validation_mcp.go b/internal/app/repeated_validation_mcp.go index 20fa4091..2a18faf6 100644 --- a/internal/app/repeated_validation_mcp.go +++ b/internal/app/repeated_validation_mcp.go @@ -2,54 +2,24 @@ package app import ( "context" - "errors" - "fmt" "strings" "time" - "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/mcpcontract" ) // RunValidation submits one bounded validation execution group. func (r *MCPReader) RunValidation(ctx context.Context, in mcpcontract.RunValidationInput) (mcpcontract.JobReference, error) { - if !in.Execute { - return mcpcontract.JobReference{}, errors.New("execute must be true to authorize host command execution") - } - if in.RunCount == 0 { - in.RunCount = 1 - } - if in.Concurrency == 0 { - in.Concurrency = 1 - } - if in.SampleInterval == "" { - in.SampleInterval = "100ms" - } - kinds := []string{in.Target} - if in.Target == "both" { - kinds = []string{"base", "candidate"} - } - perRunTimeout, err := parseOptionalDuration(in.PerRunTimeout) + request, canonical, err := parseMCPRepeatValidationInput(in) if err != nil { - return mcpcontract.JobReference{}, fmt.Errorf("per_run_timeout: %w", err) - } - overallTimeout, err := parseOptionalDuration(in.OverallTimeout) - if err != nil { - return mcpcontract.JobReference{}, fmt.Errorf("overall_timeout: %w", err) - } - sampleInterval, err := parseOptionalDuration(in.SampleInterval) - if err != nil { - return mcpcontract.JobReference{}, fmt.Errorf("sample_interval: %w", err) - } - opts := contracts.RepeatValidationOptions{ - Kinds: kinds, RunCount: in.RunCount, Concurrency: in.Concurrency, - PerRunTimeout: perRunTimeout, OverallTimeout: overallTimeout, SampleInterval: sampleInterval, Execute: true, + return mcpcontract.JobReference{}, err } - id, err := r.submitJob(ctx, "run_validation_group", in, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { - if err := report("validation", jobProgressCounts(0, in.RunCount*len(kinds))); err != nil { + total := request.options.RunCount * len(request.options.Kinds) + id, err := r.submitJob(ctx, "run_validation_group", canonical, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { + if err := report("validation", jobProgressCounts(0, total)); err != nil { return nil, err } - result, err := r.RunValidationGroup(ctx, in.ID, opts) + result, err := r.runValidationGroup(ctx, request) if err != nil { return nil, err } @@ -65,7 +35,8 @@ func (r *MCPReader) RunValidation(ctx context.Context, in mcpcontract.RunValidat } func parseOptionalDuration(value string) (time.Duration, error) { - if strings.TrimSpace(value) == "" { + value = strings.TrimSpace(value) + if value == "" { return 0, nil } return time.ParseDuration(value) diff --git a/internal/app/research.go b/internal/app/research.go index 64a8d41e..6e62fc23 100644 --- a/internal/app/research.go +++ b/internal/app/research.go @@ -24,17 +24,16 @@ func (s *Service) ThreadResearchBrief(ctx context.Context, ref research.ThreadRe if err := finishCorpusRead(ctx, c, revision); err != nil { return nil, err } - complete, truncated, unknownCoverage := researchBriefCompleteness(brief) - provenance, err := offlineReadProvenance("research_brief", revision, ref, complete, truncated, unknownCoverage) + truncated, unknownCoverage := researchBriefCompleteness(brief) + provenance, err := offlineReadProvenance("research_brief", revision, ref, truncated, unknownCoverage) if err != nil { return nil, err } - brief.Provenance = research.ReadProvenance{ - SnapshotToken: provenance.SnapshotToken, Durable: provenance.Durable, - ObservationWatermark: provenance.ObservationWatermark, QueryDigestSHA256: provenance.QueryDigestSHA256, - Complete: provenance.Complete, Truncated: provenance.Truncated, UnknownCoverage: provenance.UnknownCoverage, - Limitations: append([]string(nil), provenance.Limitations...), - } + brief.Provenance = research.NewReadProvenance( + provenance.SnapshotToken, provenance.Durable, provenance.ObservationWatermark, + provenance.QueryDigestSHA256, provenance.Truncated(), provenance.UnknownCoverage(), + ) + brief.Provenance.Limitations = append([]string(nil), provenance.Limitations...) return brief, nil } if errors.Is(err, errRepositoryNotFound) || errors.Is(err, research.ErrThreadNotFound) || errors.Is(err, research.ErrThreadKindMismatch) { @@ -43,27 +42,23 @@ func (s *Service) ThreadResearchBrief(ctx context.Context, ref research.ThreadRe return nil, err } -func researchBriefCompleteness(brief *research.Brief) (complete, truncated, unknownCoverage bool) { +func researchBriefCompleteness(brief *research.Brief) (truncated, unknownCoverage bool) { statuses := []research.SectionStatus{ brief.Sections.CurrentState.Status, brief.Sections.Problem.Status, brief.Sections.Acceptance.Status, brief.Sections.Participants.Status, brief.Sections.Timeline.Status, brief.Sections.Duplicates.Status, brief.Sections.PullRequests.Status, brief.Sections.Code.Status, brief.Sections.Guidance.Status, brief.Sections.Health.Status, brief.Sections.Coverage.Status, brief.Sections.Next.Status, } - complete = true for _, status := range statuses { switch status { case research.StatusAvailable: case research.StatusPartial: - complete = false truncated = true case research.StatusUnknown: - complete = false unknownCoverage = true default: - complete = false unknownCoverage = true } } - return complete, truncated, unknownCoverage + return truncated, unknownCoverage } diff --git a/internal/app/research_reader.go b/internal/app/research_reader.go index da17c038..302fc803 100644 --- a/internal/app/research_reader.go +++ b/internal/app/research_reader.go @@ -42,7 +42,7 @@ func (r *corpusReader) ReadResearchThread(ctx context.Context, requested researc if thread == nil { return research.ThreadEvidence{}, fmt.Errorf("%w: %s#%d", research.ErrThreadNotFound, requested.Repo, requested.Number) } - storedKind := domain.ThreadKind(thread.Kind) + storedKind := thread.Kind if requested.Kind != "" && requested.Kind != storedKind { return research.ThreadEvidence{}, research.KindMismatchError(requested.Kind, storedKind) } @@ -53,7 +53,7 @@ func (r *corpusReader) ReadResearchThread(ctx context.Context, requested researc } evidence := research.ThreadEvidence{Thread: research.ThreadSnapshot{ Ref: resolved, Title: thread.Title, Body: thread.Body, Author: thread.Author, - AuthorAssociation: thread.AuthorAssociation, State: thread.State, StateReason: thread.StateReason, + AuthorAssociation: thread.AuthorAssociation, State: string(thread.State), StateReason: thread.StateReason, Labels: append([]string{}, thread.Labels...), Assignees: append([]string{}, thread.Assignees...), Draft: thread.Draft, Locked: thread.Locked, Milestone: thread.Milestone, Merge: thread.Merge, CreatedAt: thread.SourceCreatedAt, UpdatedAt: thread.SourceUpdatedAt, ClosedAt: thread.ClosedAt, @@ -142,7 +142,7 @@ func appendExplicitResearchRelations(ctx context.Context, c *corpus.Corpus, expl func appendClusterResearchRelations(ctx context.Context, c *corpus.Corpus, ref research.ThreadRef, result *research.RelationshipEvidence) error { cluster, err := c.GetClusterProjectionForMember(ctx, clustering.MemberRef{ - Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: string(ref.Kind), Number: ref.Number, + Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: ref.Kind, Number: ref.Number, }) if err != nil { return fmt.Errorf("get duplicate cluster: %w", err) @@ -162,8 +162,8 @@ func appendClusterResearchRelations(ctx context.Context, c *corpus.Corpus, ref r continue } result.DuplicateThreads = append(result.DuplicateThreads, research.RelatedThread{ - Ref: researchClusterRef(member.Ref), Kind: member.Ref.Kind, Number: member.Ref.Number, - Title: member.Title, State: member.State, Relation: "cluster_candidate", + Ref: researchClusterRef(member.Ref), Kind: string(member.Ref.Kind), Number: member.Ref.Number, + Title: member.Title, State: string(member.State), Relation: "cluster_candidate", Basis: member.Reason, URL: researchMemberURL(member.Ref), Source: clusterSource, }) if len(result.DuplicateThreads) > maxResearchRelatedThreads { @@ -175,7 +175,7 @@ func appendClusterResearchRelations(ctx context.Context, c *corpus.Corpus, ref r } func appendOpenPRResearchRelations(ctx context.Context, c *corpus.Corpus, storedRepo *corpus.Repository, ref research.ThreadRef, result *research.RelationshipEvidence) error { - openPRs, err := c.ListThreadsFiltered(ctx, storedRepo.ID, corpus.ThreadKindPullRequest, "open", maxResearchOpenPRScan+1) + openPRs, err := c.ListThreadsFiltered(ctx, storedRepo.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState(), maxResearchOpenPRScan+1) if err != nil { return fmt.Errorf("list open pull requests: %w", err) } @@ -205,8 +205,8 @@ func appendOpenPRResearchRelations(ctx context.Context, c *corpus.Corpus, stored ObservedAt: pullRequest.UpdatedAt, AsOf: pullRequest.SourceUpdatedAt, } result.PullRequests = append(result.PullRequests, research.RelatedThread{ - Ref: fmt.Sprintf("pull_request:%s#%d", ref.Repo, pullRequest.Number), Kind: corpus.ThreadKindPullRequest, - Number: pullRequest.Number, Title: pullRequest.Title, State: pullRequest.State, + Ref: fmt.Sprintf("pull_request:%s#%d", ref.Repo, pullRequest.Number), Kind: string(domain.PullRequestKind), + Number: pullRequest.Number, Title: pullRequest.Title, State: string(pullRequest.State), Relation: relation, Basis: basis, URL: fmt.Sprintf("https://github.com/%s/pull/%d", ref.Repo, pullRequest.Number), Source: source, }) } @@ -287,7 +287,7 @@ func (r *corpusReader) ReadResearchHealth(ctx context.Context, repo domain.RepoR return research.HealthEvidence{}, fmt.Errorf("compute health: %w", err) } healthAsOf := storedRepo.SourceUpdatedAt - threads, err := c.ListThreads(ctx, storedRepo.ID, "", 1) + threads, err := c.ListThreads(ctx, storedRepo.ID, corpus.AnyThreadKind(), 1) if err != nil { return research.HealthEvidence{}, fmt.Errorf("read health source time: %w", err) } @@ -305,7 +305,7 @@ func (r *corpusReader) ReadResearchHealth(ctx context.Context, repo domain.RepoR PullRequestResponseMedianHours: report.Response.PullRequests.Median, IssueResponseSampleSize: report.Response.Issues.SampleSize, PullRequestResponseSampleSize: report.Response.PullRequests.SampleSize, - ThreadSampleSize: report.Coverage.ThreadsSampleSize, ThreadsTruncated: report.Coverage.ThreadsTruncated, + ThreadSampleSize: report.Coverage.ThreadsSampleSize, ThreadsTruncated: report.Coverage.ThreadsTruncated(), }, []research.SourceRef{source}, researchHealthCoverageReason(report)) if err != nil { return research.HealthEvidence{}, fmt.Errorf("parse health evidence: %w", err) @@ -369,7 +369,7 @@ func researchThreadSource(ctx context.Context, c *corpus.Corpus, ref research.Th } func researchFacets(kind domain.ThreadKind) []string { - return facets.DefaultFor(string(kind)) + return facets.DefaultFor(kind) } func readResearchFacet(ctx context.Context, c *corpus.Corpus, repoID, threadID int64, ref research.ThreadRef, facet string) (research.FacetCoverage, []research.DiscussionItem, bool, error) { @@ -510,8 +510,8 @@ func resolveResearchReference(ctx context.Context, c *corpus.Corpus, candidate r return research.RelatedThread{}, fmt.Errorf("resolve referenced thread: %w", err) } if thread != nil { - kind = domain.ThreadKind(thread.Kind) - state, title = thread.State, thread.Title + kind = thread.Kind + state, title = string(thread.State), thread.Title } } resolved := research.ThreadRef{Repo: candidate.Repo, Kind: kind, Number: candidate.Number} @@ -611,7 +611,7 @@ func normalizeResearchSources(values []research.SourceRef) []research.SourceRef } func researchMemberIsTarget(member clustering.MemberRef, target research.ThreadRef) bool { - return strings.EqualFold(member.Owner, target.Repo.Owner()) && strings.EqualFold(member.Repo, target.Repo.Repo()) && member.Kind == string(target.Kind) && member.Number == target.Number + return strings.EqualFold(member.Owner, target.Repo.Owner()) && strings.EqualFold(member.Repo, target.Repo.Repo()) && member.Kind == target.Kind && member.Number == target.Number } func researchClusterRef(ref clustering.MemberRef) string { @@ -620,7 +620,7 @@ func researchClusterRef(ref clustering.MemberRef) string { func researchMemberURL(ref clustering.MemberRef) string { segment := "issues" - if ref.Kind == corpus.ThreadKindPullRequest { + if ref.Kind == domain.PullRequestKind { segment = "pull" } return fmt.Sprintf("https://github.com/%s/%s/%s/%d", ref.Owner, ref.Repo, segment, ref.Number) diff --git a/internal/app/research_test.go b/internal/app/research_test.go index 5b512beb..5206fe15 100644 --- a/internal/app/research_test.go +++ b/internal/app/research_test.go @@ -56,7 +56,7 @@ func newResearchFixture(t *testing.T) researchFixture { } } issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, State: "open", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "Retry parser cancellation", Body: "## Expected behavior\n- [ ] cancellation remains bounded\nRelated to #2", Author: "alice", AuthorAssociation: "CONTRIBUTOR", Labels: []string{"bug", "help wanted"}, SourceCreatedAt: now.Add(-10 * 24 * time.Hour), SourceUpdatedAt: now.Add(-4 * time.Hour), @@ -65,14 +65,14 @@ func newResearchFixture(t *testing.T) researchFixture { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 2, State: "closed", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 2, State: "closed", Title: "Older parser report", Author: "bob", SourceCreatedAt: now.Add(-20 * 24 * time.Hour), SourceUpdatedAt: now.Add(-8 * time.Hour), }, `{}`); err != nil { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 9, State: "open", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 9, State: "open", Title: "Implement retry cancellation", Body: "Fixes #1", Author: "carol", AuthorAssociation: "NONE", SourceCreatedAt: now.Add(-2 * 24 * time.Hour), SourceUpdatedAt: now.Add(-2 * time.Hour), }, `{}`); err != nil { @@ -212,7 +212,7 @@ func TestThreadResearchBriefBoundsStoredFacetPages(t *testing.T) { } for _, facet := range brief.Sections.Coverage.Facets { if facet.Scope == "thread" && facet.Facet == FacetIssueComments { - if !facet.Truncated || facet.Count != maxResearchFacetPages { + if !facet.Truncated() || facet.Count != maxResearchFacetPages { t.Fatalf("bounded facet = %+v", facet) } return diff --git a/internal/app/response_format.go b/internal/app/response_format.go new file mode 100644 index 00000000..29f0c50d --- /dev/null +++ b/internal/app/response_format.go @@ -0,0 +1,35 @@ +package app + +import ( + "errors" + "strings" +) + +type responseFormat uint8 + +const ( + conciseResponse responseFormat = iota + 1 + detailedResponse +) + +func parseResponseFormat(value string) (responseFormat, error) { + switch strings.TrimSpace(value) { + case "", "concise": + return conciseResponse, nil + case "detailed": + return detailedResponse, nil + default: + return 0, errors.New("response_format must be concise or detailed") + } +} + +func (f responseFormat) String() string { + if f == detailedResponse { + return "detailed" + } + return "concise" +} + +func (f responseFormat) includesDetails() bool { + return f == detailedResponse +} diff --git a/internal/app/search.go b/internal/app/search.go index ec66b23f..a19e63df 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -15,9 +15,44 @@ import ( "github.com/morluto/gitcontribute/internal/lens" ) +type searchMatchKind uint8 + +const ( + searchRepositoryMatch searchMatchKind = iota + 1 + searchIssueMatch + searchPullRequestMatch + searchCodeMatch +) + +func searchMatchKindForThread(kind domain.ThreadKind) (searchMatchKind, error) { + switch kind { + case domain.IssueKind: + return searchIssueMatch, nil + case domain.PullRequestKind: + return searchPullRequestMatch, nil + default: + return 0, fmt.Errorf("unsupported stored thread kind %q", kind) + } +} + +func (k searchMatchKind) String() string { + switch k { + case searchRepositoryMatch: + return "repo" + case searchIssueMatch: + return string(domain.IssueKind) + case searchPullRequestMatch: + return string(domain.PullRequestKind) + case searchCodeMatch: + return "code" + default: + return "" + } +} + type searchMatch struct { Repo domain.RepoRef - Kind string + Kind searchMatchKind Number int State string StateReason string @@ -30,17 +65,11 @@ type searchMatch struct { Draft bool ClosedAt time.Time Merge domain.MergeStatus - Description string - DefaultBranch string Language string - License string - Topics []string Archived bool - Fork bool Stars int Watchers int Forks int - OpenIssues int UpdatedAt time.Time URL string Score float64 @@ -63,79 +92,44 @@ type searchResult struct { const maxLensCandidates = 1000 -func (s *Service) searchCorpus(ctx context.Context, query string, opts contracts.SearchOptions) (searchResult, error) { - if opts.Limit <= 0 { - opts.Limit = 20 - } - if opts.Limit > 100 { - return searchResult{}, errors.New("search limit cannot exceed 100") - } - +func (s *Service) searchCorpus(ctx context.Context, request parsedSearchRequest) (searchResult, error) { + read := request.read() c, err := s.openReadOnlyCorpus(ctx) if err != nil { return searchResult{}, err } - revision, err := beginCorpusRead(ctx, c, opts.SnapshotToken) + revision, err := beginCorpusRead(ctx, c, read.snapshotToken) if err != nil { return searchResult{}, err } now := s.now() var result searchResult - if opts.Lens != "" { - if opts.Cursor != "" { - return searchResult{}, errors.New("cursor pagination cannot be combined with --lens because lens ranking is not cursor-stable") - } - result, err = s.searchWithLens(ctx, c, query, opts, now) - } else { - var repoID int64 - var repoRef domain.RepoRef - repoID, repoRef, err = s.resolveRepoFilter(ctx, c, opts) + switch parsed := request.(type) { + case repositorySearchRequest: + if parsed.repo.IsValid() { + result, err = s.searchRepositoryExact(ctx, c, read.query, parsed.repo) + } else { + result, err = s.searchRepositories(ctx, c, read.query, read.page, parsed.order) + } + case codeSearchRequest: + result, err = s.searchCode(ctx, c, read.query, parsed.repo, read.page) + case threadSearchRequest: + var scope corpus.ThreadRepositoryScope + var found bool + scope, found, err = s.resolveSearchRepository(ctx, c, parsed.repo) if err != nil { return searchResult{}, err } - switch opts.Kind { - case "repos": - if opts.Repo != "" { - if opts.Cursor != "" { - return searchResult{}, errors.New("cursor pagination is not supported for exact repository search") - } - ref, parseErr := s.parseRepoRef(opts.Repo) - if parseErr != nil { - return searchResult{}, parseErr - } - result, err = s.searchRepositoryExact(ctx, c, query, ref) - } else { - result, err = s.searchRepositories(ctx, c, query, opts.Limit, opts.Cursor, opts.Sort) - } - case "code": - ref, parseErr := s.parseRepoRef(opts.Repo) - if parseErr != nil { - return searchResult{}, parseErr - } - result, err = s.searchCode(ctx, c, query, ref, opts.Limit, opts.Cursor) - case "all": - return searchResult{}, errors.New("combined search is not supported because FTS ranks from different indexes are not comparable; choose repos, threads, issues, prs, or code") - default: - kind := "" - switch opts.Kind { - case "issue", "issues": - kind = corpus.ThreadKindIssue - case "pr", "prs", "pull_request": - kind = corpus.ThreadKindPullRequest - case "threads", "": - kind = "" - default: - return searchResult{}, fmt.Errorf("unsupported search kind %q", opts.Kind) - } - if repoRef != (domain.RepoRef{}) && repoID == 0 { - result = searchResult{Query: query, Total: 0, Matches: nil} - } else if query == "" { - result = searchResult{Query: query, Total: 0, Matches: nil} - } else { - result, err = s.searchThreads(ctx, c, query, repoID, repoRef, kind, opts) - } + if !found || read.query == "" { + result = searchResult{Query: read.query, Matches: nil} + } else { + result, err = s.searchThreads(ctx, c, read.query, scope, parsed.criteria, read.page) } + case lensSearchRequest: + result, err = s.searchWithLens(ctx, c, parsed, now) + default: + return searchResult{}, errors.New("invalid parsed search request") } if err != nil { return searchResult{}, err @@ -143,46 +137,35 @@ func (s *Service) searchCorpus(ctx context.Context, query string, opts contracts if err := finishCorpusRead(ctx, c, revision); err != nil { return searchResult{}, err } - result.SnapshotToken = snapshotIdentity(opts.SnapshotToken, revision) + result.SnapshotToken = snapshotIdentity(read.snapshotToken, revision) result.ObservationWatermark = revision return result, nil } -func (s *Service) parseRepoRef(repo string) (domain.RepoRef, error) { - if repo == "" { - return domain.RepoRef{}, nil - } - ref, err := domain.ParseRepoRef(repo) - if err != nil { - return domain.RepoRef{}, fmt.Errorf("invalid repository filter %q: %w", repo, err) - } - return ref, nil -} - -func (s *Service) resolveRepoFilter(ctx context.Context, c *corpus.Corpus, opts contracts.SearchOptions) (int64, domain.RepoRef, error) { - if opts.Repo == "" || opts.Kind == "code" || opts.Kind == "all" || opts.Kind == "repos" { - return 0, domain.RepoRef{}, nil - } - ref, err := s.parseRepoRef(opts.Repo) - if err != nil { - return 0, domain.RepoRef{}, err +func (s *Service) resolveSearchRepository(ctx context.Context, c *corpus.Corpus, ref domain.RepoRef) (corpus.ThreadRepositoryScope, bool, error) { + if !ref.IsValid() { + return corpus.AllThreadRepositories(), true, nil } repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { - return 0, domain.RepoRef{}, err + return corpus.ThreadRepositoryScope{}, false, err } if repo == nil { - return 0, ref, nil + return corpus.ThreadRepositoryScope{}, false, nil + } + scope, err := corpus.NewThreadRepositoryScope(ref, repo.ID) + if err != nil { + return corpus.ThreadRepositoryScope{}, false, err } - return repo.ID, ref, nil + return scope, true, nil } -func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query string, repoID int64, ref domain.RepoRef, kind string, opts contracts.SearchOptions) (searchResult, error) { +func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query string, scope corpus.ThreadRepositoryScope, criteria threadSearchCriteria, pageRequest corpus.SearchPage) (searchResult, error) { filter := corpus.SearchFilter{ - RepoID: repoID, Repo: ref.String(), Kind: kind, State: opts.State, StateReason: opts.StateReason, Merged: opts.Merged, Author: opts.Author, - Association: opts.Association, Assignee: opts.Assignee, - Labels: opts.Labels, UpdatedAfter: opts.UpdatedAfter, UpdatedBefore: opts.UpdatedBefore, Limit: opts.Limit, Cursor: opts.Cursor, - Sort: opts.Sort, MatchMode: opts.MatchMode, + Repository: scope, Kind: criteria.kind.corpusThreadKind(), State: criteria.state, StateReason: criteria.stateReason, Merge: criteria.merge, Author: criteria.author, + Association: criteria.association, Assignee: criteria.assignee, + Labels: criteria.labels, UpdatedAfter: criteria.updatedAfter, UpdatedBefore: criteria.updatedBefore, Page: pageRequest, + Order: criteria.order, TermMatch: criteria.match, } page, err := c.SearchThreadsPage(ctx, query, filter) if err != nil { @@ -223,11 +206,15 @@ func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query str if err != nil { return searchResult{}, fmt.Errorf("parse stored repository: %w", err) } + kind, err := searchMatchKindForThread(t.Kind) + if err != nil { + return searchResult{}, err + } m := searchMatch{ Repo: ref, - Kind: t.Kind, + Kind: kind, Number: t.Number, - State: t.State, + State: string(t.State), StateReason: t.StateReason, Title: t.Title, Body: t.Body, @@ -235,16 +222,18 @@ func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query str AuthorAssociation: t.AuthorAssociation, Labels: t.Labels, Assignees: t.Assignees, - Draft: t.Draft, ClosedAt: t.ClosedAt, Merge: t.Merge, - Language: repo.Language, - Archived: repo.Archived, - Stars: repo.Stars, - Watchers: repo.Watchers, - Forks: repo.Forks, - UpdatedAt: t.SourceUpdatedAt, - URL: threadURL(ref, t.Kind, t.Number), - Freshness: t.SourceUpdatedAt, - Coverage: coverage, + Draft: t.Draft, + ClosedAt: t.ClosedAt, + Merge: t.Merge, + Language: repo.Language, + Archived: repo.Archived, + Stars: repo.Stars, + Watchers: repo.Watchers, + Forks: repo.Forks, + UpdatedAt: t.SourceUpdatedAt, + URL: threadURL(ref, t.Kind, t.Number), + Freshness: t.SourceUpdatedAt, + Coverage: coverage, } m.MatchSource = t.MatchSource m.MatchExcerpt = t.MatchExcerpt @@ -265,8 +254,8 @@ func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query str }, nil } -func (s *Service) searchRepositories(ctx context.Context, c *corpus.Corpus, query string, limit int, cursor, sort string) (searchResult, error) { - page, err := c.ListRepositoriesWithOptions(ctx, query, corpus.RepositorySearchOptions{Limit: limit, Cursor: cursor, Sort: sort}) +func (s *Service) searchRepositories(ctx context.Context, c *corpus.Corpus, query string, pageRequest corpus.SearchPage, order corpus.SearchOrder) (searchResult, error) { + page, err := c.ListRepositoriesWithOptions(ctx, query, corpus.RepositorySearchOptions{Page: pageRequest, Order: order}) if err != nil { return searchResult{}, fmt.Errorf("list repositories: %w", err) } @@ -337,19 +326,17 @@ func repositorySearchMatch(r corpus.Repository, coverage []string) (searchMatch, return searchMatch{}, fmt.Errorf("parse stored repository: %w", err) } m := searchMatch{ - Repo: ref, Kind: "repo", Title: ref.String(), Body: r.Description, - URL: fmt.Sprintf("https://github.com/%s", ref), Description: r.Description, - DefaultBranch: r.DefaultBranch, Language: r.Language, License: r.License, - Topics: r.Topics, Archived: r.Archived, Fork: r.Fork, Stars: r.Stars, - Watchers: r.Watchers, Forks: r.Forks, OpenIssues: r.OpenIssues, + Repo: ref, Kind: searchRepositoryMatch, Title: ref.String(), Body: r.Description, + URL: fmt.Sprintf("https://github.com/%s", ref), Language: r.Language, + Archived: r.Archived, Stars: r.Stars, Watchers: r.Watchers, Forks: r.Forks, UpdatedAt: r.SourceUpdatedAt, Freshness: r.SourceUpdatedAt, Coverage: coverage, } m.Score = bm25Score(r.Rank) return m, nil } -func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef, limit int, cursor string) (searchResult, error) { - page, err := c.SearchCodeWithOptions(ctx, query, corpus.CodeSearchOptions{Ref: ref, Limit: limit, Cursor: cursor}) +func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef, pageRequest corpus.SearchPage) (searchResult, error) { + page, err := c.SearchCodeWithOptions(ctx, query, corpus.CodeSearchOptions{Ref: ref, Page: pageRequest}) if err != nil { return searchResult{}, err } @@ -360,7 +347,7 @@ func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string coverage := []string{"code"} m := searchMatch{ Repo: match.Repo, - Kind: "code", + Kind: searchCodeMatch, Title: match.Path, Body: match.Content, URL: fmt.Sprintf("https://github.com/%s/blob/%s/%s", match.Repo, match.Commit, match.Path), @@ -395,16 +382,17 @@ func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string }, nil } -func (s *Service) searchWithLens(ctx context.Context, c *corpus.Corpus, query string, opts contracts.SearchOptions, now time.Time) (searchResult, error) { - lensRecord, err := c.GetLens(ctx, opts.Lens) +func (s *Service) searchWithLens(ctx context.Context, c *corpus.Corpus, request lensSearchRequest, now time.Time) (searchResult, error) { + read := request.read() + lensRecord, err := c.GetLens(ctx, request.lens) if err != nil { return searchResult{}, fmt.Errorf("load lens: %w", err) } if lensRecord == nil { - return searchResult{}, failure.NotFound(fmt.Errorf("lens %q not found", opts.Lens)) + return searchResult{}, failure.NotFound(fmt.Errorf("lens %q not found", request.lens)) } def := lensRecord.Definition - matches, err := s.collectLensMatches(ctx, c, query, opts, now) + matches, err := s.collectLensMatches(ctx, c, read.query, request.selection) if err != nil { return searchResult{}, err } @@ -423,10 +411,7 @@ func (s *Service) searchWithLens(ctx context.Context, c *corpus.Corpus, query st } totalEligible := len(results) - limit := opts.Limit - if limit <= 0 { - limit = 20 - } + limit := read.page.Limit() if limit > len(results) { limit = len(results) } @@ -441,108 +426,64 @@ func (s *Service) searchWithLens(ctx context.Context, c *corpus.Corpus, query st m.Score = roundScore(r.Score) out = append(out, m) } - return searchResult{Query: query, Total: totalEligible, Matches: out, NextCursor: ""}, nil + return searchResult{Query: read.query, Total: totalEligible, Matches: out, NextCursor: ""}, nil } -func (s *Service) collectLensMatches(ctx context.Context, c *corpus.Corpus, query string, opts contracts.SearchOptions, now time.Time) ([]searchMatch, error) { - var err error - var repoRef domain.RepoRef - var repoID int64 - if opts.Repo != "" { - ref, err := s.parseRepoRef(opts.Repo) - if err != nil { - return nil, err - } - repoRef = ref - repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) - if err != nil { - return nil, err - } - if repo == nil { - return []searchMatch{}, nil - } - repoID = repo.ID +func (s *Service) collectLensMatches(ctx context.Context, c *corpus.Corpus, query string, selection lensSearchSelection) ([]searchMatch, error) { + repoRef := selection.repository() + scope, found, err := s.resolveSearchRepository(ctx, c, repoRef) + if err != nil { + return nil, err + } + if !found { + return []searchMatch{}, nil } - var matches []searchMatch - switch opts.Kind { - case "repos": - if repoRef == (domain.RepoRef{}) { - matches, err = s.collectRepositoryMatches(ctx, c, query, opts) - } else { - var result searchResult - result, err = s.searchRepositoryExact(ctx, c, query, repoRef) - matches = result.Matches - } - case "code": - matches, err = s.collectCodeMatches(ctx, c, query, repoRef, opts) - case "all": - threadMatches, err := s.collectThreadMatches(ctx, c, query, repoID, repoRef, "", opts, now) + switch selected := selection.(type) { + case repositoryLensSelection: + if repoRef.IsValid() { + result, err := s.searchRepositoryExact(ctx, c, query, repoRef) + return result.Matches, err + } + return s.collectRepositoryMatches(ctx, c, query) + case codeLensSelection: + return s.collectCodeMatches(ctx, c, query, repoRef) + case threadLensSelection: + return s.collectThreadMatches(ctx, c, query, scope, selected.criteria) + case allLensSelection: + threadMatches, err := s.collectThreadMatches(ctx, c, query, scope, selected.criteria) if err != nil { return nil, err } var repoMatches []searchMatch - if repoRef == (domain.RepoRef{}) { - repoMatches, err = s.collectRepositoryMatches(ctx, c, query, opts) - } else { - var result searchResult - result, err = s.searchRepositoryExact(ctx, c, query, repoRef) + if repoRef.IsValid() { + result, searchErr := s.searchRepositoryExact(ctx, c, query, repoRef) + if searchErr != nil { + return nil, searchErr + } repoMatches = result.Matches + } else { + repoMatches, err = s.collectRepositoryMatches(ctx, c, query) + if err != nil { + return nil, err + } } + codeMatches, err := s.collectCodeMatches(ctx, c, query, repoRef) if err != nil { return nil, err } - codeMatches, err := s.collectCodeMatches(ctx, c, query, repoRef, opts) - if err != nil { - return nil, err - } - matches = append(threadMatches, repoMatches...) - matches = append(matches, codeMatches...) + matches := append(threadMatches, repoMatches...) + return append(matches, codeMatches...), nil default: - kind := threadKindFromSearchKind(opts.Kind) - if kind == "" && opts.Kind != "" && opts.Kind != "threads" { - return nil, fmt.Errorf("unsupported search kind %q", opts.Kind) - } - matches, err = s.collectThreadMatches(ctx, c, query, repoID, repoRef, kind, opts, now) - } - if err != nil { - return nil, err + return nil, errors.New("invalid parsed lens selection") } - if repoRef != (domain.RepoRef{}) { - filtered := matches[:0] - for _, match := range matches { - if match.Repo == repoRef { - filtered = append(filtered, match) - } - } - matches = filtered - } - - return matches, nil } -func threadKindFromSearchKind(kind string) string { - switch kind { - case "issue", "issues": - return corpus.ThreadKindIssue - case "pr", "prs", "pull_request": - return corpus.ThreadKindPullRequest - case "threads", "": - return "" - default: - return "" - } -} - -func (s *Service) collectThreadMatches(ctx context.Context, c *corpus.Corpus, query string, repoID int64, ref domain.RepoRef, kind string, opts contracts.SearchOptions, _ time.Time) ([]searchMatch, error) { +func (s *Service) collectThreadMatches(ctx context.Context, c *corpus.Corpus, query string, scope corpus.ThreadRepositoryScope, criteria threadSearchCriteria) ([]searchMatch, error) { var out []searchMatch cursor := "" for len(out) < maxLensCandidates { - collectOpts := opts - collectOpts.Limit = 100 - collectOpts.Cursor = cursor - collectOpts.Lens = "" - res, err := s.searchThreads(ctx, c, query, repoID, ref, kind, collectOpts) + res, err := s.searchThreads(ctx, c, query, scope, criteria, corpus.MaximumSearchPage().WithCursor(cursor)) if err != nil { return nil, err } @@ -558,11 +499,11 @@ func (s *Service) collectThreadMatches(ctx context.Context, c *corpus.Corpus, qu return out, nil } -func (s *Service) collectRepositoryMatches(ctx context.Context, c *corpus.Corpus, query string, opts contracts.SearchOptions) ([]searchMatch, error) { +func (s *Service) collectRepositoryMatches(ctx context.Context, c *corpus.Corpus, query string) ([]searchMatch, error) { var out []searchMatch cursor := "" for len(out) < maxLensCandidates { - res, err := s.searchRepositories(ctx, c, query, 100, cursor, opts.Sort) + res, err := s.searchRepositories(ctx, c, query, corpus.MaximumSearchPage().WithCursor(cursor), corpus.RelevanceSearchOrder()) if err != nil { return nil, err } @@ -578,11 +519,11 @@ func (s *Service) collectRepositoryMatches(ctx context.Context, c *corpus.Corpus return out, nil } -func (s *Service) collectCodeMatches(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef, _ contracts.SearchOptions) ([]searchMatch, error) { +func (s *Service) collectCodeMatches(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef) ([]searchMatch, error) { var out []searchMatch cursor := "" for len(out) < maxLensCandidates { - res, err := s.searchCode(ctx, c, query, ref, 100, cursor) + res, err := s.searchCode(ctx, c, query, ref, corpus.MaximumSearchPage().WithCursor(cursor)) if err != nil { return nil, err } @@ -601,23 +542,23 @@ func (s *Service) collectCodeMatches(ctx context.Context, c *corpus.Corpus, quer func candidateFromMatch(m searchMatch, now time.Time) lens.Candidate { id := m.Repo.String() switch m.Kind { - case corpus.ThreadKindIssue, corpus.ThreadKindPullRequest: + case searchIssueMatch, searchPullRequestMatch: id = fmt.Sprintf("%s#%d", m.Repo, m.Number) - case "code": + case searchCodeMatch: id = fmt.Sprintf("%s/%s", m.Repo, m.Title) } cand := lens.Candidate{ ID: id, Repository: m.Repo.String(), - Kind: m.Kind, + Kind: m.Kind.String(), State: m.State, Language: m.Language, Archived: m.Archived, Stars: m.Stars, UpdatedAt: m.UpdatedAt, } - if m.Kind == corpus.ThreadKindIssue || m.Kind == corpus.ThreadKindPullRequest { + if m.Kind == searchIssueMatch || m.Kind == searchPullRequestMatch { cand.Assigned = len(m.Assignees) > 0 } cand.Signals = candidateSignals(m, now) @@ -693,9 +634,9 @@ func boundedText(value string, maxRunes int) string { return string(runes[:maxRunes]) + "…" } -func threadURL(ref domain.RepoRef, kind string, number int) string { +func threadURL(ref domain.RepoRef, kind domain.ThreadKind, number int) string { path := "issues" - if kind == corpus.ThreadKindPullRequest { + if kind == domain.PullRequestKind { path = "pull" } return fmt.Sprintf("https://github.com/%s/%s/%d", ref, path, number) @@ -703,20 +644,18 @@ func threadURL(ref domain.RepoRef, kind string, number int) string { // Search performs a local-only corpus search and supports repo and kind filters. func (s *Service) Search(ctx context.Context, query string, opts contracts.SearchOptions) (*contracts.SearchResult, error) { - if opts.Limit <= 0 { - opts.Limit = 20 - } - if opts.Limit > 100 { - return nil, errors.New("search limit cannot exceed 100") + request, err := parseServiceSearchRequest(query, opts) + if err != nil { + return nil, err } - res, err := s.searchCorpus(ctx, query, opts) + res, err := s.searchCorpus(ctx, request) if err != nil { return nil, err } matches := make([]contracts.SearchMatch, len(res.Matches)) for i, m := range res.Matches { matches[i] = contracts.SearchMatch{ - Kind: m.Kind, + Kind: m.Kind.String(), Repo: contracts.RepoRef{Owner: m.Repo.Owner(), Repo: m.Repo.Repo()}, Title: m.Title, Number: m.Number, @@ -734,10 +673,10 @@ func (s *Service) Search(ctx context.Context, query string, opts contracts.Searc } } return &contracts.SearchResult{ - Query: query, - Kind: opts.Kind, - Repo: opts.Repo, - Limit: opts.Limit, + Query: request.read().query, + Kind: request.kind().String(), + Repo: request.repository().String(), + Limit: request.read().page.Limit(), Total: res.Total, Matches: matches, NextCursor: res.NextCursor, diff --git a/internal/app/search_request.go b/internal/app/search_request.go new file mode 100644 index 00000000..89fa481a --- /dev/null +++ b/internal/app/search_request.go @@ -0,0 +1,314 @@ +package app + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" +) + +type searchKind uint8 + +const ( + searchThreads searchKind = iota + searchIssues + searchPullRequests + searchRepositories + searchCodeDocuments + searchAll +) + +func parseSearchKind(value string) (searchKind, error) { + switch strings.TrimSpace(value) { + case "", "threads": + return searchThreads, nil + case "issue", "issues": + return searchIssues, nil + case "pr", "prs", "pull_request": + return searchPullRequests, nil + case "repos": + return searchRepositories, nil + case "code": + return searchCodeDocuments, nil + case "all": + return searchAll, nil + default: + return 0, fmt.Errorf("unsupported search kind %q", value) + } +} + +func (k searchKind) String() string { + switch k { + case searchThreads: + return "threads" + case searchIssues: + return "issues" + case searchPullRequests: + return "prs" + case searchRepositories: + return "repos" + case searchCodeDocuments: + return "code" + case searchAll: + return "all" + default: + return "" + } +} + +func (k searchKind) corpusThreadKind() corpus.ThreadKindFilter { + switch k { + case searchIssues: + return corpus.IssueThreadKind() + case searchPullRequests: + return corpus.PullRequestThreadKind() + default: + return corpus.AnyThreadKind() + } +} + +type threadSearchCriteria struct { + kind searchKind + state corpus.ThreadStateFilter + stateReason corpus.ThreadStateReason + merge corpus.MergeFilter + author string + association string + assignee string + labels []string + updatedAfter time.Time + updatedBefore time.Time + order corpus.SearchOrder + match corpus.TermMatch +} + +func (c threadSearchCriteria) hasMetadataFilters() bool { + return !c.state.IsAny() || !c.stateReason.IsAny() || !c.merge.IsAny() || + c.author != "" || c.association != "" || c.assignee != "" || len(c.labels) > 0 || + !c.updatedAfter.IsZero() || !c.updatedBefore.IsZero() +} + +type searchRead struct { + query string + page corpus.SearchPage + snapshotToken string +} + +type parsedSearchRequest interface { + read() searchRead + kind() searchKind + repository() domain.RepoRef + isParsedSearchRequest() +} + +type repositorySearchRequest struct { + searchRead + repo domain.RepoRef + order corpus.SearchOrder +} + +func (r repositorySearchRequest) read() searchRead { return r.searchRead } +func (repositorySearchRequest) kind() searchKind { return searchRepositories } +func (r repositorySearchRequest) repository() domain.RepoRef { return r.repo } +func (repositorySearchRequest) isParsedSearchRequest() {} + +type threadSearchRequest struct { + searchRead + repo domain.RepoRef + criteria threadSearchCriteria +} + +func (r threadSearchRequest) read() searchRead { return r.searchRead } +func (r threadSearchRequest) kind() searchKind { return r.criteria.kind } +func (r threadSearchRequest) repository() domain.RepoRef { return r.repo } +func (threadSearchRequest) isParsedSearchRequest() {} + +type codeSearchRequest struct { + searchRead + repo domain.RepoRef +} + +func (r codeSearchRequest) read() searchRead { return r.searchRead } +func (codeSearchRequest) kind() searchKind { return searchCodeDocuments } +func (r codeSearchRequest) repository() domain.RepoRef { return r.repo } +func (codeSearchRequest) isParsedSearchRequest() {} + +type lensSearchSelection interface { + kind() searchKind + repository() domain.RepoRef + isLensSearchSelection() +} + +type repositoryLensSelection struct{ repo domain.RepoRef } + +func (repositoryLensSelection) kind() searchKind { return searchRepositories } +func (s repositoryLensSelection) repository() domain.RepoRef { return s.repo } +func (repositoryLensSelection) isLensSearchSelection() {} + +type threadLensSelection struct { + repo domain.RepoRef + criteria threadSearchCriteria +} + +func (s threadLensSelection) kind() searchKind { return s.criteria.kind } +func (s threadLensSelection) repository() domain.RepoRef { return s.repo } +func (threadLensSelection) isLensSearchSelection() {} + +type codeLensSelection struct{ repo domain.RepoRef } + +func (codeLensSelection) kind() searchKind { return searchCodeDocuments } +func (s codeLensSelection) repository() domain.RepoRef { return s.repo } +func (codeLensSelection) isLensSearchSelection() {} + +type allLensSelection struct { + repo domain.RepoRef + criteria threadSearchCriteria +} + +func (allLensSelection) kind() searchKind { return searchAll } +func (s allLensSelection) repository() domain.RepoRef { return s.repo } +func (allLensSelection) isLensSearchSelection() {} + +type lensSearchRequest struct { + searchRead + lens string + selection lensSearchSelection +} + +func (r lensSearchRequest) read() searchRead { return r.searchRead } +func (r lensSearchRequest) kind() searchKind { return r.selection.kind() } +func (r lensSearchRequest) repository() domain.RepoRef { return r.selection.repository() } +func (lensSearchRequest) isParsedSearchRequest() {} + +func parseServiceSearchRequest(query string, opts contracts.SearchOptions) (parsedSearchRequest, error) { + var repo domain.RepoRef + if strings.TrimSpace(opts.Repo) != "" { + parsed, err := domain.ParseRepoRef(opts.Repo) + if err != nil { + return nil, fmt.Errorf("invalid repository filter %q: %w", opts.Repo, err) + } + repo = parsed + } + return parseSearchRequest(query, opts, repo) +} + +// parseSearchRequest consumes a loose boundary representation and returns one +// concrete operation. Callers must not pass SearchOptions farther inward. +func parseSearchRequest(query string, opts contracts.SearchOptions, repo domain.RepoRef) (parsedSearchRequest, error) { + page, err := corpus.ParseSearchPage(opts.Limit, opts.Cursor) + if err != nil { + return nil, err + } + kind, err := parseSearchKind(opts.Kind) + if err != nil { + return nil, err + } + order, err := corpus.ParseSearchOrder(opts.Sort) + if err != nil { + return nil, err + } + match, err := corpus.ParseTermMatch(opts.MatchMode) + if err != nil { + return nil, err + } + state, err := corpus.ParseThreadStateFilter(opts.State) + if err != nil { + return nil, err + } + stateReason, err := corpus.ParseThreadStateReason(opts.StateReason) + if err != nil { + return nil, err + } + if state.IsOpen() && !stateReason.IsAny() { + return nil, errors.New("state_reason cannot be combined with open state") + } + if !opts.UpdatedAfter.IsZero() && !opts.UpdatedBefore.IsZero() && opts.UpdatedBefore.Before(opts.UpdatedAfter) { + return nil, errors.New("updated_before must not be earlier than updated_after") + } + criteria := threadSearchCriteria{ + kind: kind, state: state, stateReason: stateReason, merge: corpus.MergeFilterFromPointer(opts.Merged), + author: strings.TrimSpace(opts.Author), association: strings.TrimSpace(opts.Association), + assignee: strings.TrimSpace(opts.Assignee), labels: normalizeSearchLabels(opts.Labels), + updatedAfter: opts.UpdatedAfter, updatedBefore: opts.UpdatedBefore, order: order, match: match, + } + if kind == searchIssues && !criteria.merge.IsAny() { + return nil, errors.New("merged filter is only valid for pull-request or combined thread search") + } + + read := searchRead{query: strings.TrimSpace(query), page: page, snapshotToken: opts.SnapshotToken} + lensName := strings.TrimSpace(opts.Lens) + if lensName != "" { + if page.Cursor() != "" { + return nil, errors.New("cursor pagination cannot be combined with --lens because lens ranking is not cursor-stable") + } + if order.IsUpdated() { + return nil, errors.New("sort cannot be combined with a lens because the lens defines the final ranking") + } + selection, err := parseLensSearchSelection(kind, repo, criteria) + if err != nil { + return nil, err + } + return lensSearchRequest{searchRead: read, lens: lensName, selection: selection}, nil + } + + switch kind { + case searchRepositories: + if criteria.hasMetadataFilters() || match.IsAny() { + return nil, errors.New("thread filters and match_mode are not supported for repository search") + } + if repo.IsValid() && page.Cursor() != "" { + return nil, errors.New("cursor pagination is not supported for exact repository search") + } + return repositorySearchRequest{searchRead: read, repo: repo, order: order}, nil + case searchCodeDocuments: + if criteria.hasMetadataFilters() || match.IsAny() { + return nil, errors.New("thread filters and match_mode are not supported for code search") + } + if order.IsUpdated() { + return nil, errors.New("code search supports relevance order only") + } + return codeSearchRequest{searchRead: read, repo: repo}, nil + case searchAll: + return nil, errors.New("combined search is not supported because FTS ranks from different indexes are not comparable; choose repos, threads, issues, prs, or code") + case searchThreads, searchIssues, searchPullRequests: + return threadSearchRequest{searchRead: read, repo: repo, criteria: criteria}, nil + default: + return nil, errors.New("invalid parsed search kind") + } +} + +func parseLensSearchSelection(kind searchKind, repo domain.RepoRef, criteria threadSearchCriteria) (lensSearchSelection, error) { + switch kind { + case searchRepositories: + if criteria.hasMetadataFilters() || criteria.match.IsAny() { + return nil, errors.New("thread filters and match_mode are not supported for repository search") + } + return repositoryLensSelection{repo: repo}, nil + case searchCodeDocuments: + if criteria.hasMetadataFilters() || criteria.match.IsAny() { + return nil, errors.New("thread filters and match_mode are not supported for code search") + } + return codeLensSelection{repo: repo}, nil + case searchThreads, searchIssues, searchPullRequests: + return threadLensSelection{repo: repo, criteria: criteria}, nil + case searchAll: + criteria.kind = searchThreads + return allLensSelection{repo: repo, criteria: criteria}, nil + default: + return nil, errors.New("invalid parsed lens selection") + } +} + +func normalizeSearchLabels(labels []string) []string { + normalized := make([]string, 0, len(labels)) + for _, label := range labels { + if label = strings.TrimSpace(label); label != "" { + normalized = append(normalized, label) + } + } + return normalized +} diff --git a/internal/app/search_test.go b/internal/app/search_test.go index 1eb738d1..d59a71bf 100644 --- a/internal/app/search_test.go +++ b/internal/app/search_test.go @@ -53,7 +53,7 @@ func TestSearchReturnsNextCursorAndCoverage(t *testing.T) { } for i := 1; i <= 5; i++ { - if _, err := c.ApplyThreadObservation(ctx, repo.ID, corpus.ThreadKindIssue, i, "open", "term title", "body", "a", time.Unix(int64(i), 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, i, "open", "term title", "body", "a", time.Unix(int64(i), 0).UTC(), `{}`); err != nil { t.Fatalf("apply thread %d: %v", i, err) } } @@ -126,7 +126,7 @@ func TestSearchUpdatedBeforeBoundsResultsAndBindsCursor(t *testing.T) { base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) for number := 1; number <= 3; number++ { if _, err := svc.corpus.ApplyThreadObservation( - ctx, repo.ID, corpus.ThreadKindIssue, number, "open", "numeric drift", "wrong result", "alice", + ctx, repo.ID, domain.IssueKind, number, "open", "numeric drift", "wrong result", "alice", base.Add(time.Duration(number-1)*24*time.Hour), `{}`, ); err != nil { t.Fatal(err) @@ -167,7 +167,7 @@ func TestThreadSearchMergesRepositoryAndThreadCoverage(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, corpus.ThreadKindIssue, 1, "open", "search term", "body", "author", time.Unix(2, 0).UTC(), `{}`) + thread, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "search term", "body", "author", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } @@ -198,7 +198,7 @@ func TestMCPSearchDefaultsCompactAndOffersFullView(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, corpus.ThreadKindIssue, 1, "open", "alpha beta", "full body detail", "author", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "alpha beta", "full body detail", "author", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatal(err) } reader := &MCPReader{Service: svc} @@ -258,7 +258,7 @@ func TestSearchAllDoesNotInventCrossIndexRanking(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, corpus.ThreadKindIssue, 1, "open", "term", "body", "alice", time.Unix(95, 0).UTC(), `{}`); err != nil { + if _, err := svc.corpus.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "term", "body", "alice", time.Unix(95, 0).UTC(), `{}`); err != nil { t.Fatal(err) } svc.SetClock(func() time.Time { return time.Unix(100, 0).UTC() }) @@ -277,6 +277,54 @@ func TestSearchHardMaxLimit(t *testing.T) { } } +func TestSearchRejectsContradictoryBoundaryStates(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + merged := true + after := time.Unix(20, 0).UTC() + before := time.Unix(10, 0).UTC() + tests := []struct { + name string + opts contracts.SearchOptions + }{ + {name: "unknown kind", opts: contracts.SearchOptions{Kind: "discussion"}}, + {name: "unknown order", opts: contracts.SearchOptions{Kind: "threads", Sort: "popular"}}, + {name: "unknown term mode", opts: contracts.SearchOptions{Kind: "threads", MatchMode: "phrase"}}, + {name: "unknown thread state", opts: contracts.SearchOptions{Kind: "threads", State: "draft"}}, + {name: "unknown close reason", opts: contracts.SearchOptions{Kind: "threads", StateReason: "duplicate"}}, + {name: "malformed repository", opts: contracts.SearchOptions{Kind: "threads", Repo: "owner/repo/extra"}}, + {name: "repository with thread state", opts: contracts.SearchOptions{Kind: "repos", State: "open"}}, + {name: "repository with any-term mode", opts: contracts.SearchOptions{Kind: "repos", MatchMode: "any"}}, + {name: "code with updated order", opts: contracts.SearchOptions{Kind: "code", Sort: "updated"}}, + {name: "code with thread author", opts: contracts.SearchOptions{Kind: "code", Author: "alice"}}, + {name: "issue with merged state", opts: contracts.SearchOptions{Kind: "issue", Merged: &merged}}, + {name: "open with close reason", opts: contracts.SearchOptions{Kind: "threads", State: "open", StateReason: "completed"}}, + {name: "reversed update interval", opts: contracts.SearchOptions{Kind: "threads", UpdatedAfter: after, UpdatedBefore: before}}, + {name: "lens with storage order", opts: contracts.SearchOptions{Kind: "threads", Lens: "ranked", Sort: "updated"}}, + {name: "exact repository with cursor", opts: contracts.SearchOptions{Kind: "repos", Repo: "owner/repo", Cursor: "cursor"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := svc.Search(ctx, "term", test.opts); err == nil { + t.Fatal("search accepted contradictory input") + } + }) + } +} + +func TestSearchReturnsCanonicalParsedBoundaryValues(t *testing.T) { + t.Parallel() + svc := newSearchTestService(t) + result, err := svc.Search(context.Background(), " term ", contracts.SearchOptions{Kind: "pull_request"}) + if err != nil { + t.Fatal(err) + } + if result.Query != "term" || result.Kind != "prs" || result.Limit != corpus.DefaultSearchPageSize { + t.Fatalf("canonical search result = %+v", result) + } +} + func TestExplainMatchReturnsFactualReasons(t *testing.T) { t.Parallel() ctx := context.Background() @@ -296,7 +344,7 @@ func TestExplainMatchReturnsFactualReasons(t *testing.T) { } updated := time.Unix(50, 0).UTC() - if _, err := c.ApplyThreadObservation(ctx, repo.ID, corpus.ThreadKindIssue, 1, "open", "term title", "body", "a", updated, `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "term title", "body", "a", updated, `{}`); err != nil { t.Fatalf("apply thread: %v", err) } @@ -376,7 +424,7 @@ func seedLensCorpus(t *testing.T, svc *Service) { title string body string labels []string - state string + state domain.ThreadState }{ {1, "fix login crash", "login crashes on startup", []string{"bug"}, "open"}, {2, "login crash on startup", "the login page crashes", nil, "open"}, @@ -388,7 +436,7 @@ func seedLensCorpus(t *testing.T, svc *Service) { updated := base.Add(time.Duration(5-th.number) * time.Hour) if _, err := c.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: th.number, State: th.state, Title: th.title, @@ -543,7 +591,7 @@ func TestSearchAllIsRejectedEvenWithRepositoryScope(t *testing.T) { t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, Number: 1, + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "shared term", SourceUpdatedAt: time.Now().UTC(), }, `{}`); err != nil { t.Fatal(err) diff --git a/internal/app/service_dependencies_test.go b/internal/app/service_dependencies_test.go new file mode 100644 index 00000000..d6b88966 --- /dev/null +++ b/internal/app/service_dependencies_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "time" + + "github.com/morluto/gitcontribute/internal/deepwiki" + "github.com/morluto/gitcontribute/internal/discovery" + "github.com/morluto/gitcontribute/internal/github" +) + +func (s *Service) SetClock(clock func() time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.clock = clock +} + +func (s *Service) SetGitHubReader(reader github.Reader) { + s.mu.Lock() + defer s.mu.Unlock() + s.ghReader = reader +} + +func (s *Service) SetDeepWikiReader(reader deepwiki.Reader) { + s.mu.Lock() + defer s.mu.Unlock() + s.deepWikiReader = reader +} + +func (s *Service) SetArchiveFetcher(fetcher discovery.ArchiveFetcher) { + s.mu.Lock() + defer s.mu.Unlock() + s.archiveFetcher = fetcher +} diff --git a/internal/app/setup.go b/internal/app/setup.go index 3049e6e9..84c692a2 100644 --- a/internal/app/setup.go +++ b/internal/app/setup.go @@ -53,7 +53,7 @@ func (s *Service) setup(ctx context.Context, opts contracts.SetupOptions, observ if run.report.HasFailures() { return run.report, nil } - if run.operation == clientsetup.Configure { + if run.request.kind.configuresProduct() { run.configure() } if err := run.registerClients(); err != nil { @@ -67,86 +67,65 @@ func (s *Service) setup(ctx context.Context, opts contracts.SetupOptions, observ type setupRun struct { service *Service ctx context.Context - opts contracts.SetupOptions + request setupRequest observer contracts.SetupObserver - operation clientsetup.Operation report *contracts.SetupReport clientOptions clientsetup.Options clientReport clientsetup.Report managedRuntime string installedExecutable string - mcpCommandPending bool - configurationOK bool + mcpCommandState setupMCPCommandState + readiness setupReadiness } +type setupMCPCommandState uint8 + +const ( + setupMCPCommandReady setupMCPCommandState = iota + setupMCPCommandPending +) + +type setupReadiness uint8 + +const ( + setupReady setupReadiness = iota + setupBlocked +) + func (s *Service) newSetupRun(ctx context.Context, opts contracts.SetupOptions, observer contracts.SetupObserver) (*setupRun, error) { if err := ctx.Err(); err != nil { return nil, err } - if opts.Version == "" { - opts.Version = s.version - } - operation := clientsetup.Configure - if opts.Remove { - operation = clientsetup.Remove - } - if opts.Remove && opts.Mode != "" { - return nil, errors.New("an access mode is not supported by remove") - } - if operation == clientsetup.Configure && opts.Mode != contracts.SetupModeMCP && opts.Mode != contracts.SetupModeCLI && opts.Mode != contracts.SetupModeBoth { - return nil, errors.New("setup has no selected access mode") - } - if operation == clientsetup.Configure && opts.Mode == contracts.SetupModeCLI && (len(opts.Clients) > 0 || opts.AllClients) { - return nil, errors.New("CLI mode cannot configure MCP clients") - } - clients, err := s.setupClients(opts) + request, err := parseSetupRequest(opts, s.version) if err != nil { return nil, err } - if strings.TrimSpace(opts.Repository) != "" { - if _, err := setupRepoRef(opts.Repository); err != nil { - return nil, err - } - } + operation := request.kind.clientOperation() run := &setupRun{ - service: s, ctx: ctx, opts: opts, observer: observer, operation: operation, - report: &contracts.SetupReport{Operation: string(operation), DryRun: opts.DryRun}, + service: s, ctx: ctx, request: request, observer: observer, + report: &contracts.SetupReport{Operation: string(operation), DryRun: request.execution.dryRun()}, clientOptions: clientsetup.Options{ - Operation: operation, Clients: clients, All: opts.AllClients, DryRun: opts.DryRun, - Home: s.paths.HomeDir(), Executable: opts.Executable, + Operation: operation, Clients: append([]clientsetup.Client(nil), request.clients...), DryRun: request.execution.dryRun(), + Home: s.paths.HomeDir(), }, - configurationOK: true, } - if operation == clientsetup.Configure && opts.Mode == contracts.SetupModeMCP { + switch request.kind { + case setupMCP: dataDir, err := s.paths.DataDir() if err != nil { return nil, err } - run.managedRuntime, err = managedbinary.Destination(dataDir, opts.Version) + run.managedRuntime, err = managedbinary.Destination(dataDir, request.version) if err != nil { return nil, err } run.clientOptions.Executable = run.managedRuntime - } else if operation == clientsetup.Configure && opts.Mode == contracts.SetupModeBoth { - run.mcpCommandPending = true + case setupBoth: + run.mcpCommandState = setupMCPCommandPending } return run, nil } -func (s *Service) setupClients(opts contracts.SetupOptions) ([]clientsetup.Client, error) { - if !opts.Remove && !opts.Mode.ConfiguresMCP() { - return nil, nil - } - clients := make([]clientsetup.Client, 0, len(opts.Clients)) - for _, value := range opts.Clients { - clients = append(clients, clientsetup.Client(strings.ToLower(strings.TrimSpace(value)))) - } - if len(clients) == 0 && !opts.AllClients { - return nil, errors.New("no coding-agent targets selected; pass --codex, --claude, --devin, or --all-clients") - } - return clients, nil -} - func (r *setupRun) preflightClients() (bool, error) { if !r.configuresClients() { return false, nil @@ -168,7 +147,7 @@ func (r *setupRun) preflightClients() (bool, error) { } func (r *setupRun) preflightCorpus() (bool, error) { - if r.operation != clientsetup.Configure { + if !r.request.kind.configuresProduct() { return false, nil } inspection, err := r.service.InspectCorpus(r.ctx) @@ -197,28 +176,28 @@ func (r *setupRun) preflightCorpus() (bool, error) { } func (r *setupRun) setupRuntime() error { - if r.operation != clientsetup.Configure { + if !r.request.kind.configuresProduct() { return nil } - if !r.opts.Mode.InstallsCLI() { + if !r.request.kind.installsCLI() { return r.installManagedRuntime() } setupStarted(r.observer, contracts.SetupPhaseCLI) - step, executable := installCLI(r.ctx, r.opts.Version, r.opts.DryRun) + step, executable := installCLI(r.ctx, r.request.version, r.request.execution.dryRun()) r.report.Steps = append(r.report.Steps, step) setupCompleted(r.observer, step) r.installedExecutable = executable if executable == "" { - if !r.opts.DryRun { - r.mcpCommandPending = false + if !r.request.execution.dryRun() { + r.mcpCommandState = setupMCPCommandReady r.report.MCPCommandPending = false } return nil } - if !r.opts.Mode.ConfiguresMCP() { + if !r.request.kind.configuresClients() { return nil } - r.mcpCommandPending = false + r.mcpCommandState = setupMCPCommandReady r.clientOptions.Executable = executable planOptions := r.clientOptions planOptions.DryRun = true @@ -241,24 +220,24 @@ func (r *setupRun) installManagedRuntime() error { } if found { step.Status = "failed" - step.Message = fmt.Sprintf("newer private MCP runtime %s is already installed at %s; this bootstrap is %s; no changes were made; run `npx --yes gitcontribute@latest setup`", newer.Version, newer.Path, normalizeVersion(r.opts.Version)) + step.Message = fmt.Sprintf("newer private MCP runtime %s is already installed at %s; this bootstrap is %s; no changes were made; run `npx --yes gitcontribute@latest setup`", newer.Version, newer.Path, normalizeVersion(r.request.version)) r.report.Steps = append(r.report.Steps, step) return nil } - if r.opts.DryRun { + if r.request.execution.dryRun() { step.Status = "would install" r.report.Steps = append(r.report.Steps, step) return nil } setupStarted(r.observer, contracts.SetupPhaseMCPRuntime) r.installedExecutable = r.managedRuntime - source := r.opts.Executable - if source == "" { - var err error - source, err = os.Executable() - if err != nil { - return fmt.Errorf("resolve packaged executable: %w", err) - } + executable := r.service.executable + if executable == nil { + executable = os.Executable + } + source, err := executable() + if err != nil { + return fmt.Errorf("resolve packaged executable: %w", err) } installed, err := managedbinary.Install(source, r.managedRuntime) if err != nil { @@ -281,7 +260,7 @@ func (r *setupRun) newerManagedRuntime() (managedbinary.InstalledRuntime, bool, if err != nil { return managedbinary.InstalledRuntime{}, false, err } - requested := normalizeVersion(r.opts.Version) + requested := normalizeVersion(r.request.version) for i := range runtimes { if isNewerVersion(requested, runtimes[i].Version) { return runtimes[i], true, nil @@ -298,24 +277,25 @@ func (r *setupRun) configure() { _, statErr := os.Stat(configPath) configExisted = statErr == nil } - tokenSource := strings.TrimSpace(r.opts.TokenSource) + tokenSource := strings.TrimSpace(r.request.tokenSource) if tokenSource == "" { tokenSource = autoTokenSource() } - if tokenSource == "env" && strings.TrimSpace(r.opts.TokenSourceKey) == "" { - r.opts.TokenSourceKey = "GITHUB_TOKEN" + tokenSourceKey := r.request.tokenSourceKey + if tokenSource == "env" && strings.TrimSpace(tokenSourceKey) == "" { + tokenSourceKey = "GITHUB_TOKEN" } - r.report.Authentication = &contracts.SetupAuthentication{Method: tokenSource, Key: r.opts.TokenSourceKey} - options := contracts.ConfigureOptions{DryRun: r.opts.DryRun, TokenSource: &tokenSource} - if r.opts.TokenSourceKey != "" { - options.TokenSourceKey = &r.opts.TokenSourceKey + r.report.Authentication = &contracts.SetupAuthentication{Method: tokenSource, Key: tokenSourceKey} + options := contracts.ConfigureOptions{DryRun: r.request.execution.dryRun(), TokenSource: &tokenSource} + if tokenSourceKey != "" { + options.TokenSourceKey = &tokenSourceKey } configured, err := r.service.Configure(r.ctx, options) - step := configurationStep(configured, err, configExisted, r.opts.DryRun) + step := configurationStep(configured, err, configExisted, r.request.execution.dryRun()) r.report.Steps = append(r.report.Steps, step) setupCompleted(r.observer, step) if err != nil { - r.configurationOK = false + r.readiness = setupBlocked } r.initializeCorpus(err == nil) } @@ -338,7 +318,7 @@ func configurationStep(configured *contracts.ConfigureResult, err error, existed } func (r *setupRun) initializeCorpus(configured bool) { - if r.opts.DryRun { + if r.request.execution.dryRun() { step := contracts.SetupStep{Name: "corpus", Status: "would initialize"} inspection := r.report.Corpus if inspection == nil { @@ -370,7 +350,7 @@ func (r *setupRun) initializeCorpus(configured bool) { if err != nil { step.Status = "failed" step.Message = err.Error() - r.configurationOK = false + r.readiness = setupBlocked } r.report.Steps = append(r.report.Steps, step) setupCompleted(r.observer, step) @@ -380,7 +360,7 @@ func (r *setupRun) registerClients() error { if !r.configuresClients() { return nil } - if !r.opts.DryRun && r.configurationOK { + if !r.request.execution.dryRun() && r.readiness == setupReady { setupStarted(r.observer, contracts.SetupPhaseClients) r.clientOptions.DryRun = false report, err := clientsetup.Run(r.clientOptions) @@ -394,12 +374,12 @@ func (r *setupRun) registerClients() error { } func (r *setupRun) configuresClients() bool { - return r.operation == clientsetup.Remove || r.opts.Mode.ConfiguresMCP() + return r.request.kind.configuresClients() } func (r *setupRun) setClientReport(report clientsetup.Report) { r.clientReport = report - if r.mcpCommandPending { + if r.mcpCommandState == setupMCPCommandPending { r.report.MCPCommand = nil r.report.MCPCommandPending = true return @@ -413,31 +393,28 @@ func (r *setupRun) setClientReport(report clientsetup.Report) { func (r *setupRun) appendClientResults() { for _, result := range r.clientReport.Results { - step := contracts.SetupStep{Name: string(result.Client), Path: result.Path, Status: result.Status, Message: result.Error} + step := contracts.SetupStep{Name: string(result.Client), Path: result.Path, Status: string(result.Status), Message: result.Error} r.report.Steps = append(r.report.Steps, step) - if !r.opts.DryRun && r.operation == clientsetup.Configure && (result.Status == "configured" || result.Status == "updated") { + if !r.request.execution.dryRun() && r.request.kind.configuresProduct() && (result.Status == clientsetup.ChangeConfigured || result.Status == clientsetup.ChangeUpdated) { r.report.RestartClients = append(r.report.RestartClients, string(result.Client)) } setupCompleted(r.observer, step) } if skill := r.clientReport.CodexSkill; skill.Status != "" { - step := contracts.SetupStep{Name: "codex-skill", Path: skill.Path, Status: skill.Status, Message: skill.Error} + step := contracts.SetupStep{Name: "codex-skill", Path: skill.Path, Status: string(skill.Status), Message: skill.Error} r.report.Steps = append(r.report.Steps, step) setupCompleted(r.observer, step) } } func (r *setupRun) addRepository() { - if r.operation != clientsetup.Configure || strings.TrimSpace(r.opts.Repository) == "" { + if !r.request.kind.configuresProduct() || r.request.repository == nil { return } setupStarted(r.observer, contracts.SetupPhaseRepository) - ref, err := setupRepoRef(r.opts.Repository) - step := contracts.SetupStep{Name: "repository", Status: "added", Message: r.opts.Repository} - if err != nil { - step.Status = "failed" - step.Message = err.Error() - } else if r.opts.DryRun { + ref := *r.request.repository + step := contracts.SetupStep{Name: "repository", Status: "added", Message: ref.String()} + if r.request.execution.dryRun() { step.Status = "would add" } else if _, err := r.service.AddRepoSource(r.ctx, setupSourceName(ref), []contracts.RepoRef{ref}); err != nil { step.Status = "failed" @@ -448,7 +425,7 @@ func (r *setupRun) addRepository() { } func (r *setupRun) verify() { - if r.operation != clientsetup.Configure || r.opts.DryRun { + if !r.request.kind.configuresProduct() || r.request.execution.dryRun() { return } setupStarted(r.observer, contracts.SetupPhaseVerification) @@ -490,7 +467,7 @@ func (r *setupRun) verifyAppliedSetup() error { for _, result := range report.Results { if result.Error != "" { failures = append(failures, string(result.Client)+": "+result.Error) - } else if result.Status != "already configured" { + } else if result.Status != clientsetup.ChangeAlreadyConfigured { failures = append(failures, fmt.Sprintf("%s: registration does not match the configured MCP command", result.Client)) } } @@ -544,7 +521,7 @@ func (s *Service) DiscoverSetup(ctx context.Context) (*contracts.SetupDiscovery, } result := &contracts.SetupDiscovery{Version: s.version} - for _, client := range clientsetup.AllClients { + for _, client := range clientsetup.SupportedClients() { registered, path, err := clientsetup.CheckRegistration(client, home) item := contracts.SetupClientDiscovery{ Name: string(client), @@ -566,7 +543,7 @@ func (s *Service) DiscoverSetup(ctx context.Context) (*contracts.SetupDiscovery, if err != nil { return nil, err } - result.ConfiguredTokenSource = cfg.TokenSource.Method + result.ConfiguredTokenSource = string(cfg.TokenSource.Method) result.ConfiguredTokenKey = cfg.TokenSource.Key _, ghErr := exec.LookPath("gh") result.GitHubCLIAvailable = ghErr == nil diff --git a/internal/app/setup_request.go b/internal/app/setup_request.go new file mode 100644 index 00000000..63c4e078 --- /dev/null +++ b/internal/app/setup_request.go @@ -0,0 +1,120 @@ +package app + +import ( + "errors" + "strings" + + "github.com/morluto/gitcontribute/internal/contracts" + clientsetup "github.com/morluto/gitcontribute/internal/setup" +) + +type setupKind uint8 + +const ( + setupKindInvalid setupKind = iota + setupMCP + setupCLI + setupBoth + setupRemove +) + +func (k setupKind) clientOperation() clientsetup.Operation { + if k == setupRemove { + return clientsetup.Remove + } + return clientsetup.Configure +} + +func (k setupKind) configuresClients() bool { + return k == setupMCP || k == setupBoth || k == setupRemove +} + +func (k setupKind) installsCLI() bool { + return k == setupCLI || k == setupBoth +} + +func (k setupKind) configuresProduct() bool { + return k != setupRemove +} + +type setupExecution uint8 + +const ( + setupApply setupExecution = iota + setupPlan +) + +func (e setupExecution) dryRun() bool { return e == setupPlan } + +type setupRequest struct { + kind setupKind + execution setupExecution + clients []clientsetup.Client + tokenSource string + tokenSourceKey string + repository *contracts.RepoRef + version string +} + +func parseSetupRequest(opts contracts.SetupOptions, defaultVersion string) (setupRequest, error) { + kind, err := parseSetupKind(opts) + if err != nil { + return setupRequest{}, err + } + execution := setupApply + if opts.DryRun { + execution = setupPlan + } + request := setupRequest{ + kind: kind, execution: execution, + tokenSource: opts.TokenSource, tokenSourceKey: opts.TokenSourceKey, + version: opts.Version, + } + if request.version == "" { + request.version = defaultVersion + } + if kind.configuresClients() { + if len(opts.Clients) == 0 && !opts.AllClients { + return setupRequest{}, errors.New("no coding-agent targets selected; pass --codex, --claude, --devin, or --all-clients") + } + if opts.AllClients { + request.clients = clientsetup.SupportedClients() + } else { + request.clients, err = clientsetup.ParseClients(opts.Clients) + if err != nil { + return setupRequest{}, err + } + } + } + if strings.TrimSpace(opts.Repository) != "" { + ref, err := setupRepoRef(opts.Repository) + if err != nil { + return setupRequest{}, err + } + request.repository = &ref + } + return request, nil +} + +func parseSetupKind(opts contracts.SetupOptions) (setupKind, error) { + if opts.Remove { + if opts.Mode != "" { + return setupKindInvalid, errors.New("an access mode is not supported by remove") + } + return setupRemove, nil + } + selectedClients := len(opts.Clients) > 0 || opts.AllClients + switch opts.Mode { + case contracts.SetupModeMCP: + return setupMCP, nil + case contracts.SetupModeCLI: + if selectedClients { + return setupKindInvalid, errors.New("CLI mode cannot configure MCP clients") + } + return setupCLI, nil + case contracts.SetupModeBoth: + return setupBoth, nil + default: + return setupKindInvalid, errors.New("setup has no selected access mode") + } +} diff --git a/internal/app/setup_test.go b/internal/app/setup_test.go index 24094e93..b1b0db23 100644 --- a/internal/app/setup_test.go +++ b/internal/app/setup_test.go @@ -29,8 +29,9 @@ func TestSetupInitializesAndRegistersWithoutNetwork(t *testing.T) { } defer svc.Close() packagedExecutable := writeTestExecutable(t, filepath.Join(home, "bin")) + svc.stubExecutablePath(packagedExecutable) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex", "claude"}, TokenSource: "none", Repository: "morluto/gitcontribute", Executable: packagedExecutable, + Mode: contracts.SetupModeMCP, Clients: []string{"codex", "claude"}, TokenSource: "none", Repository: "morluto/gitcontribute", }) if err != nil { t.Fatal(err) @@ -47,7 +48,7 @@ func TestSetupInitializesAndRegistersWithoutNetwork(t *testing.T) { if err != nil || len(sources.Sources) != 1 || sources.Sources[0].Name != "morluto-gitcontribute" { t.Fatalf("sources=%+v err=%v", sources, err) } - second, err := svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex", "claude"}, TokenSource: "none", Executable: packagedExecutable}) + second, err := svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex", "claude"}, TokenSource: "none"}) if err != nil { t.Fatal(err) } @@ -69,9 +70,9 @@ func TestSetupRemoveStillUnregistersSelectedMCPClientsWithoutAnAccessMode(t *tes t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(writeTestExecutable(t, filepath.Join(home, "bin"))) if _, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", - Executable: writeTestExecutable(t, filepath.Join(home, "bin")), }); err != nil { t.Fatal(err) } @@ -114,9 +115,10 @@ func TestSetupMCPOnlyInstallsManagedBinaryAndRegistersItsAbsolutePath(t *testing if err := os.WriteFile(source, []byte("packaged-native-binary"), 0o755); err != nil { t.Fatal(err) } + svc.stubExecutablePath(source) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: source, + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", }) if err != nil { t.Fatal(err) @@ -196,8 +198,9 @@ func TestSetupMCPOnlyReusesMatchingManagedBinary(t *testing.T) { if err := os.WriteFile(source, []byte("packaged-native-binary"), 0o755); err != nil { t.Fatal(err) } + svc.stubExecutablePath(source) opts := contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: source, + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", } if _, err := svc.Setup(context.Background(), opts); err != nil { t.Fatal(err) @@ -268,8 +271,9 @@ func TestSetupRejectsStaleBootstrapWhenNewerPrivateRuntimeExists(t *testing.T) { t.Fatal(err) } + svc.stubExecutablePath(filepath.Join(home, "npm-cache", "gitcontribute")) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: filepath.Join(home, "npm-cache", "gitcontribute"), + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", }) if err != nil { t.Fatal(err) @@ -355,10 +359,10 @@ func TestSetupBothDryRunDoesNotPresentTheBootstrapExecutableAsFinalMCPCommand(t } defer svc.Close() t.Setenv("PATH", "") + svc.stubExecutablePath("/temporary/npm-cache/gitcontribute") report, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeBoth, Clients: []string{"codex"}, TokenSource: "none", DryRun: true, - Executable: "/temporary/npm-cache/gitcontribute", }) if err != nil { t.Fatal(err) @@ -405,10 +409,10 @@ func TestSetupStopsBeforeConfigurationWhenManagedRuntimeCannotBeInstalled(t *tes t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(filepath.Join(home, "missing", "gitcontribute")) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", - Executable: filepath.Join(home, "missing", "gitcontribute"), }) if err != nil { t.Fatal(err) @@ -436,7 +440,8 @@ func TestSetupDryRunWritesNothing(t *testing.T) { t.Fatal(err) } defer svc.Close() - report, err := svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", DryRun: true, Executable: "/bin/gitcontribute"}) + svc.stubExecutablePath("/bin/gitcontribute") + report, err := svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", DryRun: true}) if err != nil { t.Fatal(err) } @@ -464,8 +469,9 @@ func TestSetupDoesNotInferClientMutationFromDetection(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath("/bin/gitcontribute") - _, err = svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, TokenSource: "none", Executable: "/bin/gitcontribute"}) + _, err = svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, TokenSource: "none"}) if err == nil || !strings.Contains(err.Error(), "no coding-agent targets selected") { t.Fatalf("error = %v", err) } @@ -549,7 +555,8 @@ func TestSetupRejectsRepositoryBeforeWriting(t *testing.T) { t.Fatal(err) } defer svc.Close() - _, err = svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Repository: "not a repository", Executable: "/bin/gitcontribute"}) + svc.stubExecutablePath("/bin/gitcontribute") + _, err = svc.Setup(context.Background(), contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Repository: "not a repository"}) if err == nil { t.Fatal("setup accepted invalid repository") } @@ -625,8 +632,9 @@ func TestSetupWithProgressReportsRealApplicationPhases(t *testing.T) { } defer svc.Close() observer := &recordingSetupObserver{} + svc.stubExecutablePath(writeTestExecutable(t, filepath.Join(home, "bin"))) report, err := svc.SetupWithProgress(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: writeTestExecutable(t, filepath.Join(home, "bin")), + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", }, observer) if err != nil { t.Fatal(err) @@ -661,10 +669,10 @@ func TestSetupInstallsAndReportsCodexSkill(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(writeTestExecutable(t, filepath.Join(home, "bin"))) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", - Executable: writeTestExecutable(t, filepath.Join(home, "bin")), }) if err != nil { t.Fatal(err) diff --git a/internal/app/setup_verification_test.go b/internal/app/setup_verification_test.go index 4a53286d..3fb78f9c 100644 --- a/internal/app/setup_verification_test.go +++ b/internal/app/setup_verification_test.go @@ -28,10 +28,10 @@ func TestSetupVerificationDoesNotResolveCredentials(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(writeTestExecutable(t, filepath.Join(home, "bin"))) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "env", TokenSourceKey: "GITCONTRIBUTE_TEST_MISSING_TOKEN", - Executable: writeTestExecutable(t, filepath.Join(home, "bin")), }) if err != nil { t.Fatal(err) @@ -62,10 +62,10 @@ func TestSetupVerificationReportsFailedRequiredChecks(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(writeTestExecutable(t, filepath.Join(home, "bin"))) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", - Executable: writeTestExecutable(t, filepath.Join(home, "bin")), }) if err != nil { t.Fatal(err) @@ -97,8 +97,9 @@ func TestSetupVerificationIgnoresConcurrentWriter(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(executable) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: executable, + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", }) if err != nil { t.Fatal(err) @@ -166,7 +167,8 @@ func TestSetupVerificationRejectsMismatchedClientRegistration(t *testing.T) { } defer svc.Close() executable := writeTestExecutable(t, filepath.Join(home, "bin")) - opts := contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: executable} + svc.stubExecutablePath(executable) + opts := contracts.SetupOptions{Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none"} if report, err := svc.Setup(context.Background(), opts); err != nil || report.HasFailures() { t.Fatalf("setup = %+v, %v", report, err) } @@ -222,8 +224,9 @@ func TestSetupReportsRestartOnlyWhenClientRegistrationChanges(t *testing.T) { t.Fatal(err) } defer svc.Close() + svc.stubExecutablePath(executable) report, err := svc.Setup(context.Background(), contracts.SetupOptions{ - Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", Executable: executable, + Mode: contracts.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", }) if err != nil { t.Fatal(err) diff --git a/internal/app/surfaces_extra.go b/internal/app/surfaces_extra.go index 97e0f5c7..75f0b565 100644 --- a/internal/app/surfaces_extra.go +++ b/internal/app/surfaces_extra.go @@ -10,6 +10,7 @@ import ( "time" "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/exporter" "github.com/morluto/gitcontribute/internal/repositorycontext" @@ -59,9 +60,11 @@ func (s *Service) PlanRepositoryContextSync(_ context.Context, repo contracts.Re } func planRepositoryContextSync(repo contracts.RepoRef, maxRequests int) (*contracts.SyncPlanResult, error) { - if _, err := domain.NewRepoRef(repo.Owner, repo.Repo); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } + repo = contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()} required := repositorycontext.RequestCost() if maxRequests == 0 { maxRequests = required @@ -79,11 +82,11 @@ func (s *Service) ArchiveSync(ctx context.Context, repo contracts.RepoRef, opts if opts.Since < 0 { return nil, errors.New("since duration cannot be negative") } - syncOpts := SyncOptions{State: opts.State, Numbers: opts.Numbers, MaxPages: opts.MaxPages, MaxRequests: opts.MaxRequests} + syncInput := threadSyncInput{State: opts.State, Numbers: opts.Numbers, MaxPages: opts.MaxPages, MaxRequests: opts.MaxRequests} if opts.Since > 0 { - syncOpts.Since = s.now().Add(-opts.Since) + syncInput.Since = s.now().Add(-opts.Since) } - return s.syncThreadHeaders(ctx, repo, syncOpts) + return s.syncThreadHeaders(ctx, repo, syncInput) } // PlanArchiveSync computes the conservative request ceiling before resolving a @@ -96,40 +99,39 @@ func (s *Service) PlanArchiveSync(_ context.Context, repo contracts.RepoRef, opt if opts.Since < 0 { return nil, errors.New("since duration cannot be negative") } - syncOpts := SyncOptions{State: opts.State, Numbers: opts.Numbers, MaxPages: opts.MaxPages, MaxRequests: opts.MaxRequests} + syncInput := threadSyncInput{State: opts.State, Numbers: opts.Numbers, MaxPages: opts.MaxPages, MaxRequests: opts.MaxRequests} if opts.Since > 0 { - syncOpts.Since = s.now().Add(-opts.Since) + syncInput.Since = s.now().Add(-opts.Since) } - normalized, plan, err := planThreadSyncOptions(syncOpts) + _, plan, err := parseThreadSync(syncInput) if err != nil { return nil, err } return &contracts.SyncPlanResult{ Repo: repo, FixedRequests: 0, ThreadRequestCeiling: plan.threadRequestCeiling, - PlannedRequests: plan.plannedRequests, RequestBudget: normalized.MaxRequests, - MaxPages: normalized.MaxPages, ExactThreads: len(normalized.Numbers), + PlannedRequests: plan.plannedRequests, RequestBudget: plan.requestBudget, + MaxPages: plan.maxPages, ExactThreads: plan.exactThreads, }, nil } // Hydrate adapts the explicit CLI hydration contract to selective hydration. func (s *Service) Hydrate(ctx context.Context, repo contracts.RepoRef, number int, opts contracts.HydrateOptions) (*contracts.HydrateResult, error) { - if err := s.refreshHydrationThreadHeader(ctx, repo, opts.Kind, number); err != nil { - return nil, fmt.Errorf("refresh thread header: %w", err) - } - result, err := s.HydrateThread(ctx, repo, number, HydrateOptions{Kind: opts.Kind, Facets: opts.Facets, MaxPages: opts.MaxPages}) + target, err := parseHydrationTarget(repo, number, hydrateThreadInput{Kind: opts.Kind, Facets: opts.Facets, MaxPages: opts.MaxPages}) if err != nil { return nil, err } - out := &contracts.HydrateResult{ - Repo: result.Repo, Number: result.Number, Kind: result.Kind, - Pages: result.Pages, Requests: result.Requests + 1, - Message: "refreshed thread header and " + result.Message, - Facets: make([]contracts.HydratedFacet, len(result.Facets)), + if err := s.refreshHydrationThreadHeader(ctx, target); err != nil { + return nil, fmt.Errorf("refresh thread header: %w", err) } - for i, facet := range result.Facets { - out.Facets[i] = contracts.HydratedFacet{Facet: facet.Facet, Count: facet.Count, Pages: facet.Pages, Complete: facet.Complete} + result, err := s.hydrateThread(ctx, target) + if err != nil { + return nil, err } - return out, nil + out := *result + out.Requests++ + out.Message = "refreshed thread header and " + result.Message + out.Facets = append([]contracts.HydratedFacet(nil), result.Facets...) + return &out, nil } // Coverage returns repository-level facet coverage without network access. @@ -173,11 +175,13 @@ func (s *Service) ArchiveThreads(ctx context.Context, repo contracts.RepoRef, ki if kind == "all" { kind = "" } - if kind != "" && kind != "issue" && kind != "pull_request" { - return nil, fmt.Errorf("unsupported thread kind %q", kind) + kindFilter, err := corpus.ParseThreadKindFilter(kind) + if err != nil { + return nil, err } - if state != "" && state != "all" && state != "open" && state != "closed" { - return nil, fmt.Errorf("unsupported thread state %q", state) + stateFilter, err := corpus.ParseThreadStateFilter(state) + if err != nil { + return nil, err } ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) if err != nil { @@ -196,7 +200,7 @@ func (s *Service) ArchiveThreads(ctx context.Context, repo contracts.RepoRef, ki } // Apply both kind and state filters at the corpus boundary so the bounded // limit is applied to already-matching rows. - threads, err := c.ListThreadsFiltered(ctx, stored.ID, kind, state, limit) + threads, err := c.ListThreadsFiltered(ctx, stored.ID, kindFilter, stateFilter, limit) if err != nil { return nil, err } @@ -204,7 +208,7 @@ func (s *Service) ArchiveThreads(ctx context.Context, repo contracts.RepoRef, ki var freshest time.Time for _, thread := range threads { out.Threads = append(out.Threads, contracts.ThreadListItem{ - Kind: thread.Kind, Number: thread.Number, State: thread.State, Title: thread.Title, + Kind: string(thread.Kind), Number: thread.Number, State: string(thread.State), Title: thread.Title, Author: thread.Author, Labels: thread.Labels, UpdatedAt: formatTime(thread.SourceUpdatedAt), }) if thread.SourceUpdatedAt.After(freshest) { @@ -252,12 +256,16 @@ func (s *Service) RunHistory(ctx context.Context, limit int) (*contracts.RunList // NeighborQuery returns transparent local nearest-thread results. func (s *Service) NeighborQuery(ctx context.Context, repo contracts.RepoRef, kind string, number, limit int) (*contracts.NeighborListResult, error) { - result, err := s.Neighbors(ctx, repo, kind, number, limit) + target, err := parseSimilarityThread(repo, kind, number) + if err != nil { + return nil, err + } + result, err := s.neighborsForThread(ctx, target, limit) if err != nil { return nil, err } out := &contracts.NeighborListResult{ - Repo: repo, Kind: result.Kind, Number: result.Number, SourceRevision: result.SourceRevision, + Repo: contracts.RepoRef{Owner: target.repository.Owner(), Repo: target.repository.Repo()}, Kind: result.Kind, Number: result.Number, SourceRevision: result.SourceRevision, Neighbors: make([]contracts.NeighborResult, len(result.Neighbors)), } for i, neighbor := range result.Neighbors { @@ -283,11 +291,11 @@ func (s *Service) ExportDossier(ctx context.Context, repo contracts.RepoRef, for return nil, err } var b bytes.Buffer - format, err = normalizeExportFormat(format) + parsedFormat, err := parseExportFormat(format) if err != nil { return nil, err } - if format == "json" { + if parsedFormat == exportJSON { err = exporter.ExportDossierJSON(&b, d) } else { err = exporter.ExportDossierMarkdown(&b, d) @@ -295,7 +303,7 @@ func (s *Service) ExportDossier(ctx context.Context, repo contracts.RepoRef, for if err != nil { return nil, err } - return &contracts.ExportResult{Kind: "dossier", Format: format, Content: b.String()}, nil + return &contracts.ExportResult{Kind: "dossier", Format: parsedFormat.String(), Content: b.String()}, nil } // ExportEvidence renders a deterministic redacted investigation evidence bundle. @@ -305,11 +313,11 @@ func (s *Service) ExportEvidence(ctx context.Context, investigationID, format st return nil, err } var b bytes.Buffer - format, err = normalizeExportFormat(format) + parsedFormat, err := parseExportFormat(format) if err != nil { return nil, err } - if format == "json" { + if parsedFormat == exportJSON { err = exporter.ExportEvidenceJSON(&b, evidence) } else { err = exporter.ExportEvidenceMarkdown(&b, evidence) @@ -317,16 +325,34 @@ func (s *Service) ExportEvidence(ctx context.Context, investigationID, format st if err != nil { return nil, err } - return &contracts.ExportResult{Kind: "evidence", Format: format, Content: b.String()}, nil + return &contracts.ExportResult{Kind: "evidence", Format: parsedFormat.String(), Content: b.String()}, nil } -func normalizeExportFormat(format string) (string, error) { - format = strings.ToLower(strings.TrimSpace(format)) - if format == "md" { - format = "markdown" +type exportFormat uint8 + +const ( + exportJSON exportFormat = iota + 1 + exportMarkdown +) + +func parseExportFormat(value string) (exportFormat, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "json": + return exportJSON, nil + case "md", "markdown": + return exportMarkdown, nil + default: + return 0, errors.New("export format must be json or markdown") } - if format != "json" && format != "markdown" { - return "", errors.New("export format must be json or markdown") +} + +func (f exportFormat) String() string { + switch f { + case exportJSON: + return "json" + case exportMarkdown: + return "markdown" + default: + panic("invalid export format") } - return format, nil } diff --git a/internal/app/surfaces_test.go b/internal/app/surfaces_test.go index 9e955dfa..e9f34b28 100644 --- a/internal/app/surfaces_test.go +++ b/internal/app/surfaces_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/morluto/gitcontribute/internal/clusterprojection" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/lens" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -25,19 +27,19 @@ func seedRepoAndThreads(t *testing.T, c *corpus.Corpus) { } threads := []struct { - kind string + kind domain.ThreadKind number int title string body string author string labels []string }{ - {corpus.ThreadKindIssue, 1, "fix login crash", "login crashes on startup", "alice", []string{"bug"}}, - {corpus.ThreadKindIssue, 2, "login crash on startup", "the login page crashes", "alice", []string{"bug"}}, - {corpus.ThreadKindIssue, 3, "unrelated feature", "add dark mode", "bob", nil}, - {corpus.ThreadKindIssue, 4, "fix login crash", "duplicate of #1", "alice", []string{"bug"}}, - {corpus.ThreadKindIssue, 5, "api network timeout", "requests time out", "carol", []string{"bug"}}, - {corpus.ThreadKindIssue, 6, "timeout in api requests", "network timeout", "carol", []string{"bug"}}, + {domain.IssueKind, 1, "fix login crash", "login crashes on startup", "alice", []string{"bug"}}, + {domain.IssueKind, 2, "login crash on startup", "the login page crashes", "alice", []string{"bug"}}, + {domain.IssueKind, 3, "unrelated feature", "add dark mode", "bob", nil}, + {domain.IssueKind, 4, "fix login crash", "duplicate of #1", "alice", []string{"bug"}}, + {domain.IssueKind, 5, "api network timeout", "requests time out", "carol", []string{"bug"}}, + {domain.IssueKind, 6, "timeout in api requests", "network timeout", "carol", []string{"bug"}}, } base := time.Unix(1000, 0).UTC() @@ -360,7 +362,7 @@ func TestMCPReaderFindClustersAndCoverage(t *testing.T) { reader := svc.MCPReader() clusters, err := reader.FindClusters(ctx, mcpcontract.FindClustersInput{ Targets: []mcpcontract.ClusterTarget{ - {Owner: "owner", Repo: "repo"}, + {Owner: " owner ", Repo: " repo "}, {Owner: "owner", Repo: "missing"}, }, Limit: 10, @@ -371,6 +373,9 @@ func TestMCPReaderFindClustersAndCoverage(t *testing.T) { if clusters.Status != "partial" || len(clusters.Items) != 2 || clusters.Items[0].Value == nil || clusters.Items[1].Reason != "repository_not_indexed" { t.Fatalf("cluster batch = %+v", clusters) } + if clusters.Items[0].Key != "owner/repo" || clusters.Items[0].Value.Owner != "owner" || clusters.Items[0].Value.Repo != "repo" { + t.Fatalf("canonical cluster target = %+v", clusters.Items[0]) + } clusterSet := clusters.Items[0].Value if clusterSet.Total == 0 { t.Fatal("expected clusters from MCP") @@ -393,7 +398,7 @@ func TestMCPReaderFindClustersAndCoverage(t *testing.T) { } member := clusterSet.Clusters[0].Canonical containing, err := reader.FindClusters(ctx, mcpcontract.FindClustersInput{ - Targets: []mcpcontract.ClusterTarget{{Owner: "owner", Repo: "repo", Kind: member.Kind, Number: member.Number}}, + Targets: []mcpcontract.ClusterTarget{{Owner: " owner ", Repo: " repo ", Kind: " " + member.Kind + " ", Number: member.Number}}, Limit: 10, }) if err != nil || len(containing.Items) != 1 || containing.Items[0].Value == nil || containing.Items[0].Value.Total != 1 || len(containing.Items[0].Value.Clusters) != 1 || containing.Items[0].Value.Clusters[0].StableID != clusterSet.Clusters[0].StableID { @@ -402,7 +407,7 @@ func TestMCPReaderFindClustersAndCoverage(t *testing.T) { neighbors, err := reader.(*MCPReader).FindNeighbors(ctx, mcpcontract.FindNeighborsInput{ Threads: []mcpcontract.ThreadRef{ - {Owner: "owner", Repo: "repo", Kind: member.Kind, Number: member.Number}, + {Owner: " owner ", Repo: " repo ", Kind: " " + member.Kind + " ", Number: member.Number}, {Owner: "owner", Repo: "missing", Kind: "issue", Number: 1}, }, Limit: 5, @@ -413,6 +418,9 @@ func TestMCPReaderFindClustersAndCoverage(t *testing.T) { if neighbors.Status != "partial" || len(neighbors.Items) != 2 || neighbors.Items[0].Value == nil || neighbors.Items[1].Reason != "repository_not_indexed" { t.Fatalf("neighbor batch = %+v", neighbors) } + if neighbors.Items[0].Value.Owner != "owner" || neighbors.Items[0].Value.Repo != "repo" || strings.Contains(neighbors.Items[0].Key, " ") { + t.Fatalf("canonical neighbor target = %+v", neighbors.Items[0]) + } cov, err := reader.GetCoverage(ctx, mcpcontract.GetCoverageInput{Targets: []mcpcontract.CoverageTarget{{Type: mcpcontract.CoverageTargetRepository, Repository: mcpcontract.RepositoryRef{Owner: "owner", Repo: "repo"}}}}) if err != nil { diff --git a/internal/app/sync_budget.go b/internal/app/sync_budget.go index 7eb7ee7c..e9948316 100644 --- a/internal/app/sync_budget.go +++ b/internal/app/sync_budget.go @@ -6,9 +6,6 @@ import ( const defaultSyncBatchMaxRequests = 1000 -func syncRequestBudgetUnavailable(key string, required, remaining int) map[string]any { - return map[string]any{ - "key": key, "status": "unavailable", "reason": "request_budget_exceeded", - "message": fmt.Sprintf("planned sync requires %d requests but only %d remain", required, remaining), - } +func syncRequestBudgetMessage(required, remaining int) string { + return fmt.Sprintf("planned sync requires %d requests but only %d remain", required, remaining) } diff --git a/internal/app/sync_budget_test.go b/internal/app/sync_budget_test.go index e8667e47..b4d4bc25 100644 --- a/internal/app/sync_budget_test.go +++ b/internal/app/sync_budget_test.go @@ -8,6 +8,8 @@ import ( "github.com/morluto/gitcontribute/internal/config" "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" ) @@ -68,8 +70,8 @@ func (r *authoredHeaderReader) SearchAuthoredPullRequests(_ context.Context, opt return r.searchResult, nil } return github.AuthoredPullRequestSearchResult{Items: []github.Issue{ - {RepositoryOwner: "owner", RepositoryName: "repo", Kind: github.ThreadKindPullRequest, Number: 2, State: "open", Title: "first", CreatedAt: r.now, UpdatedAt: r.now}, - {RepositoryOwner: "owner", RepositoryName: "repo", Kind: github.ThreadKindPullRequest, Number: 3, State: "open", Title: "second", CreatedAt: r.now, UpdatedAt: r.now}, + {RepositoryOwner: "owner", RepositoryName: "repo", Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "first", CreatedAt: r.now, UpdatedAt: r.now}, + {RepositoryOwner: "owner", RepositoryName: "repo", Kind: domain.PullRequestKind, Number: 3, State: "open", Title: "second", CreatedAt: r.now, UpdatedAt: r.now}, }}, nil } @@ -87,12 +89,12 @@ func TestAuthoredPullRequestSyncScopesDiscoveryBeforeLimit(t *testing.T) { } now := time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC) reader := &authoredHeaderReader{now: now, searchResult: github.AuthoredPullRequestSearchResult{Items: []github.Issue{ - {RepositoryOwner: "acme", RepositoryName: "other", Kind: github.ThreadKindPullRequest, Number: 9, State: "open", Title: "newer unrelated", UpdatedAt: now.Add(time.Second)}, - {RepositoryOwner: "acme", RepositoryName: "rocket", Kind: github.ThreadKindPullRequest, Number: 7, State: "open", Title: "selected", UpdatedAt: now}, + {RepositoryOwner: "acme", RepositoryName: "other", Kind: domain.PullRequestKind, Number: 9, State: "open", Title: "newer unrelated", UpdatedAt: now.Add(time.Second)}, + {RepositoryOwner: "acme", RepositoryName: "rocket", Kind: domain.PullRequestKind, Number: 7, State: "open", Title: "selected", UpdatedAt: now}, }}} svc.SetGitHubReader(reader) scope := &mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"} - out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{Repository: scope, State: "open", Limit: 1, MaxRequests: 20}, func(string, string) error { return nil }) + out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{Repository: scope, State: syncOpenThreads, Limit: 1, MaxRequests: 20}, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -115,11 +117,11 @@ func TestAuthoredPullRequestSyncReportsItemLimitTruncation(t *testing.T) { } now := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC) svc.SetGitHubReader(&authoredHeaderReader{now: now, searchResult: github.AuthoredPullRequestSearchResult{ - Items: []github.Issue{{RepositoryOwner: "owner", RepositoryName: "repo", Kind: github.ThreadKindPullRequest, Number: 2, State: "open", Title: "first", CreatedAt: now, UpdatedAt: now}}, + Items: []github.Issue{{RepositoryOwner: "owner", RepositoryName: "repo", Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "first", CreatedAt: now, UpdatedAt: now}}, Total: 2, Page: github.PageInfo{HasNext: true, NextPage: 2}, }}) - out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: "open", Limit: 1, MaxRequests: 20}, func(string, string) error { return nil }) + out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: syncOpenThreads, Limit: 1, MaxRequests: 20}, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -144,7 +146,7 @@ func TestAuthoredPullRequestSyncReusesSearchHeadersWithoutNPlusOne(t *testing.T) reader := &authoredHeaderReader{now: now} svc.SetGitHubReader(reader) - out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: "open", Limit: 2, MaxRequests: 20}, func(string, string) error { return nil }) + out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: syncOpenThreads, Limit: 2, MaxRequests: 20}, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -175,7 +177,7 @@ func TestAuthoredPullRequestSyncReusesSearchHeadersWithoutNPlusOne(t *testing.T) if err != nil || repo == nil || repo.ExternalID != "R_repo" || !repo.SourceUpdatedAt.Equal(now) { t.Fatalf("repository context did not replace authored identity: %+v, %v", repo, err) } - threads, err := c.ListThreadsFiltered(ctx, repo.ID, "pull_request", "open", 10) + threads, err := c.ListThreadsFiltered(ctx, repo.ID, corpus.PullRequestThreadKind(), corpus.OpenThreadState(), 10) if err != nil || len(threads) != 2 || threads[0].Number != 3 || threads[1].Number != 2 { t.Fatalf("threads = %+v, %v", threads, err) } @@ -196,7 +198,7 @@ func TestAuthoredPullRequestMinimumBudgetMakesSyncProgress(t *testing.T) { now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) svc.SetGitHubReader(&authoredHeaderReader{now: now}) minimum := 2 - out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: "open", Limit: 2, MaxRequests: minimum}, func(string, string) error { return nil }) + out, err := svc.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{State: syncOpenThreads, Limit: 2, MaxRequests: minimum}, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -224,10 +226,14 @@ func TestSyncThreadsBatchReportsMissingRepositoryWithoutNetworkAccess(t *testing if err != nil { t.Fatal(err) } - items, ok := out["items"].([]map[string]any) - if !ok || len(items) != 1 || items[0]["reason"] != "repository_not_indexed" || out["status"] != "partial" || out["requests"] != 0 { + items := out.Items + if len(items) != 1 || items[0].Status() != mcpcontract.BatchItemUnavailable || out.Status != batchOperationPartial || out.Requests != 0 { t.Fatalf("result = %+v", out) } + assertJSONDocumentEqual(t, items[0], `{ + "key":"owner/repo","status":"unavailable","reason":"repository_not_indexed", + "message":"repository is not stored; call github.sync_repository_context first" + }`) } func TestSyncThreadsBatchThreadTotalCountsRequestedThreads(t *testing.T) { @@ -253,8 +259,8 @@ func TestSyncThreadsBatchThreadTotalCountsRequestedThreads(t *testing.T) { if err != nil { t.Fatal(err) } - items, ok := out["items"].([]map[string]any) - if !ok || len(items) != 2 || out["total"] != 2 || out["completed"] != 0 || out["status"] != "partial" { + items := out.Items + if len(items) != 2 || out.Total != 2 || out.Completed != 0 || out.Status != batchOperationPartial { t.Fatalf("thread-mode result = %+v", out) } } diff --git a/internal/app/sync_headers.go b/internal/app/sync_headers.go index 401b8526..b683182c 100644 --- a/internal/app/sync_headers.go +++ b/internal/app/sync_headers.go @@ -53,25 +53,23 @@ type syncThreadWriter struct { owner string repo string repositoryID int64 - kind string + kind syncThreadKind threads []contracts.SyncThreadRef updated int sourceUpdatedAt time.Time } -func syncThreadHeaderSelection(ctx context.Context, c *corpus.Corpus, reader github.Reader, ref domain.RepoRef, repoID int64, sourceUpdatedAt time.Time, opts SyncOptions, provided []github.Issue, budget *syncRequestBudget) (syncThreadSelection, error) { - writer := &syncThreadWriter{ctx: ctx, corpus: c, owner: ref.Owner(), repo: ref.Repo(), repositoryID: repoID, kind: opts.Kind, sourceUpdatedAt: sourceUpdatedAt} - if provided != nil { - if err := writer.storeAll(provided); err != nil { - return syncThreadSelection{}, err - } - return writer.result(0, false, false), nil - } - if len(opts.Numbers) > 0 { - requests, err := syncExactThreadHeaders(ctx, reader, ref, opts.Numbers, budget, writer) +func syncThreadHeaderSelection(ctx context.Context, c *corpus.Corpus, reader github.Reader, ref domain.RepoRef, repoID int64, sourceUpdatedAt time.Time, request threadSyncRequest, budget *syncRequestBudget) (syncThreadSelection, error) { + writer := &syncThreadWriter{ctx: ctx, corpus: c, owner: ref.Owner(), repo: ref.Repo(), repositoryID: repoID, kind: request.kind, sourceUpdatedAt: sourceUpdatedAt} + switch selection := request.selection.(type) { + case exactThreadSync: + requests, err := syncExactThreadHeaders(ctx, reader, ref, selection.numbers, budget, writer) return writer.result(requests, false, false), err + case listedThreadSync: + return syncListedThreadHeaders(ctx, reader, ref, request.kind, selection, budget, writer) + default: + panic("unreachable thread sync selection") } - return syncListedThreadHeaders(ctx, reader, ref, opts, budget, writer) } func (w *syncThreadWriter) storeAll(issues []github.Issue) error { @@ -87,7 +85,7 @@ func (w *syncThreadWriter) store(issue github.Issue) error { if err := w.ctx.Err(); err != nil { return err } - if w.kind != "both" && string(issue.Kind) != w.kind { + if !w.kind.includes(string(issue.Kind)) { return nil } thread, payload, err := threadFromIssue(issue) @@ -95,7 +93,7 @@ func (w *syncThreadWriter) store(issue github.Issue) error { return err } thread.RepositoryID = w.repositoryID - w.threads = append(w.threads, contracts.SyncThreadRef{Owner: w.owner, Repo: w.repo, Kind: thread.Kind, Number: thread.Number}) + w.threads = append(w.threads, contracts.SyncThreadRef{Owner: w.owner, Repo: w.repo, Kind: string(thread.Kind), Number: thread.Number}) if _, err := corpus.RetryBusyValue(w.ctx, func(ctx context.Context) (*corpus.Thread, error) { return w.corpus.UpsertThread(ctx, thread, payload) }); err != nil { @@ -140,13 +138,13 @@ func syncExactThreadHeaders(ctx context.Context, reader github.Reader, ref domai return requests, nil } -func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref domain.RepoRef, opts SyncOptions, budget *syncRequestBudget, writer *syncThreadWriter) (syncThreadSelection, error) { +func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref domain.RepoRef, kind syncThreadKind, selection listedThreadSync, budget *syncRequestBudget, writer *syncThreadWriter) (syncThreadSelection, error) { perPage := 100 - if opts.MaxItems > 0 { - perPage = min(perPage, opts.MaxItems) + if selection.maxItems > 0 { + perPage = min(perPage, selection.maxItems) } listOpts := github.ListIssueOptions{ - State: opts.State, Sort: "updated", Direction: "desc", Since: opts.Since, + State: selection.state.String(), Sort: "updated", Direction: "desc", Since: selection.since, PageOptions: github.PageOptions{Page: 1, PerPage: perPage}, } requests, truncated, requestCapped := 0, false, false @@ -168,7 +166,7 @@ func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref doma if err := writer.store(issue); err != nil { return syncThreadSelection{}, err } - if opts.MaxItems > 0 && writer.updated >= opts.MaxItems { + if selection.maxItems > 0 && writer.updated >= selection.maxItems { truncated = res.Page.HasNext || index < len(res.Items)-1 reachedLimit = true break @@ -180,7 +178,7 @@ func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref doma if !res.Page.HasNext { break } - if requests >= opts.MaxPages { + if requests >= selection.maxPages { truncated = true break } @@ -189,10 +187,10 @@ func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref doma break } listOpts.Page = res.Page.NextPage - if opts.MaxItems > 0 { - listOpts.PerPage = min(100, opts.MaxItems-writer.updated) + if selection.maxItems > 0 { + listOpts.PerPage = min(100, selection.maxItems-writer.updated) } } - complete := opts.Kind == "both" && opts.State == "all" && opts.Since.IsZero() && !truncated + complete := kind.includesAll() && selection.state.isAll() && selection.since.IsZero() && !truncated return writer.result(requests, complete, requestCapped), nil } diff --git a/internal/app/sync_metadata_test.go b/internal/app/sync_metadata_test.go index 2ee2df50..fceae747 100644 --- a/internal/app/sync_metadata_test.go +++ b/internal/app/sync_metadata_test.go @@ -6,7 +6,7 @@ import ( "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" ) @@ -62,7 +62,7 @@ func TestSyncMapsIssueMetadataToThread(t *testing.T) { repo: github.Repository{Owner: "owner", Name: "repo", NodeID: "R_1", UpdatedAt: now}, issues: []github.Issue{{ Number: 1, - Kind: github.ThreadKindIssue, + Kind: domain.IssueKind, State: "closed", StateReason: "completed", Title: "bug", @@ -98,7 +98,7 @@ func TestSyncMapsIssueMetadataToThread(t *testing.T) { if repo == nil { t.Fatal("repository not found") } - thread, err := c.GetThread(ctx, repo.ID, corpus.ThreadKindIssue, 1) + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatalf("get thread: %v", err) } diff --git a/internal/app/sync_options_test.go b/internal/app/sync_options_test.go index 48976fd4..8e1d738b 100644 --- a/internal/app/sync_options_test.go +++ b/internal/app/sync_options_test.go @@ -38,7 +38,7 @@ func TestSyncPassesStateAndSinceAndMarksPartialCoverage(t *testing.T) { defer func() { _ = svc.Close() }() syncRepositoryContextForTest(t, svc, contracts.RepoRef{Owner: "octocat", Repo: "test"}) since := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) - result, err := svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, SyncOptions{ + result, err := svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, threadSyncInput{ State: "open", Since: since, MaxPages: 2, }) if err != nil { @@ -91,7 +91,7 @@ func TestSyncEnforcesExactItemLimit(t *testing.T) { defer func() { _ = svc.Close() }() repo := contracts.RepoRef{Owner: "octocat", Repo: "test"} syncRepositoryContextForTest(t, svc, repo) - result, err := svc.syncThreadHeaders(context.Background(), repo, SyncOptions{Kind: "pull_request", MaxItems: 1, MaxPages: 1}) + result, err := svc.syncThreadHeaders(context.Background(), repo, threadSyncInput{Kind: "pull_request", MaxItems: 1, MaxPages: 1}) if err != nil { t.Fatal(err) } @@ -369,7 +369,7 @@ func TestSyncRejectsExactSelectionOverRequestBudgetBeforeIO(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, SyncOptions{ + _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, threadSyncInput{ Numbers: []int{1, 2}, MaxRequests: 1, }) if err == nil || !strings.Contains(err.Error(), "exact thread selection requires") { @@ -384,7 +384,7 @@ func TestSyncRejectsUnboundedPageLimit(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, SyncOptions{MaxPages: 1001}) + _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, threadSyncInput{MaxPages: 1001}) if err == nil { t.Fatal("expected max-pages validation error") } @@ -393,12 +393,34 @@ func TestSyncRejectsUnboundedPageLimit(t *testing.T) { func TestSyncRejectsInvalidRequestBudgets(t *testing.T) { t.Parallel() for _, maxRequests := range []int{-1, maxSyncRequests + 1} { - if _, err := normalizeThreadSyncOptions(SyncOptions{MaxRequests: maxRequests}); err == nil { + if _, _, err := parseThreadSync(threadSyncInput{MaxRequests: maxRequests}); err == nil { t.Fatalf("max requests %d unexpectedly accepted", maxRequests) } } } +func TestThreadSyncParsesDisjointExactAndListedSelections(t *testing.T) { + t.Parallel() + exactRequest, exactPlan, err := parseThreadSync(threadSyncInput{Kind: " pull_request ", Numbers: []int{3, 1, 3}, MaxRequests: 3}) + if err != nil { + t.Fatal(err) + } + exact, ok := exactRequest.selection.(exactThreadSync) + if !ok || !reflect.DeepEqual(exact.numbers, []int{1, 3}) || exactRequest.kind != syncPullRequests || exactPlan.exactThreads != 2 { + t.Fatalf("exact request = %+v, plan = %+v", exactRequest, exactPlan) + } + + since := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + listedRequest, listedPlan, err := parseThreadSync(threadSyncInput{State: " open ", Since: since, MaxItems: 7, MaxPages: 9, MaxRequests: 4}) + if err != nil { + t.Fatal(err) + } + listed, ok := listedRequest.selection.(listedThreadSync) + if !ok || listed.state != syncOpenThreads || !listed.since.Equal(since) || listed.maxItems != 7 || listed.maxPages != 9 || listedPlan.threadRequestCeiling != 4 { + t.Fatalf("listed request = %+v, plan = %+v", listedRequest, listedPlan) + } +} + func TestSyncRejectsConflictingExactFilters(t *testing.T) { t.Parallel() paths := config.NewPaths(&config.Env{Home: t.TempDir()}) @@ -406,7 +428,7 @@ func TestSyncRejectsConflictingExactFilters(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, SyncOptions{ + _, err = svc.syncThreadHeaders(context.Background(), contracts.RepoRef{Owner: "octocat", Repo: "test"}, threadSyncInput{ State: "open", Numbers: []int{1}, }) if err == nil { diff --git a/internal/app/sync_repository_context_request.go b/internal/app/sync_repository_context_request.go new file mode 100644 index 00000000..c8a5f92c --- /dev/null +++ b/internal/app/sync_repository_context_request.go @@ -0,0 +1,56 @@ +package app + +import ( + "errors" + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" + "github.com/morluto/gitcontribute/internal/repositorycontext" +) + +type repositoryContextSyncRequest struct { + repositories []domain.RepoRef + maxRequests int +} + +func parseRepositoryContextSyncInput(in mcpcontract.SyncRepositoryContextInput) (repositoryContextSyncRequest, mcpcontract.SyncRepositoryContextInput, error) { + if len(in.Repositories) < 1 || len(in.Repositories) > 100 { + return repositoryContextSyncRequest{}, mcpcontract.SyncRepositoryContextInput{}, errors.New("repositories must contain 1 to 100 items") + } + repositories := make([]domain.RepoRef, len(in.Repositories)) + canonical := make([]mcpcontract.RepositoryRef, len(in.Repositories)) + seen := make(map[string]struct{}, len(in.Repositories)) + for i, input := range in.Repositories { + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { + return repositoryContextSyncRequest{}, mcpcontract.SyncRepositoryContextInput{}, err + } + key := strings.ToLower(ref.String()) + if _, duplicate := seen[key]; duplicate { + return repositoryContextSyncRequest{}, mcpcontract.SyncRepositoryContextInput{}, mcpcontract.InvalidArgument("repositories", fmt.Sprintf("duplicate repository %s", ref), nil) + } + seen[key] = struct{}{} + repositories[i] = ref + canonical[i] = mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()} + } + request, err := newRepositoryContextSyncRequest(repositories, in.MaxRequests) + if err != nil { + return repositoryContextSyncRequest{}, mcpcontract.SyncRepositoryContextInput{}, err + } + return request, mcpcontract.SyncRepositoryContextInput{Repositories: canonical, MaxRequests: request.maxRequests}, nil +} + +func newRepositoryContextSyncRequest(repositories []domain.RepoRef, maxRequests int) (repositoryContextSyncRequest, error) { + if maxRequests == 0 { + maxRequests = defaultSyncBatchMaxRequests + } + if maxRequests < repositorycontext.RequestCost() || maxRequests > defaultSyncBatchMaxRequests { + return repositoryContextSyncRequest{}, fmt.Errorf( + "max requests must be between %d and %d", + repositorycontext.RequestCost(), defaultSyncBatchMaxRequests, + ) + } + return repositoryContextSyncRequest{repositories: append([]domain.RepoRef(nil), repositories...), maxRequests: maxRequests}, nil +} diff --git a/internal/app/sync_request.go b/internal/app/sync_request.go new file mode 100644 index 00000000..3b3d655b --- /dev/null +++ b/internal/app/sync_request.go @@ -0,0 +1,212 @@ +package app + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// threadSyncInput is the transient loose boundary shape for one repository's +// thread-header synchronization. +type threadSyncInput struct { + Kind string + State string + Since time.Time + Numbers []int + MaxItems int + MaxPages int + MaxRequests int +} + +// threadSyncRequest is the only executable thread-header synchronization +// representation. Its selection is either exact or listed, never both. +type threadSyncRequest struct { + kind syncThreadKind + selection threadSyncSelection + maxRequests int +} + +type threadSyncSelection interface { + isThreadSyncSelection() +} + +type exactThreadSync struct { + numbers []int +} + +func (exactThreadSync) isThreadSyncSelection() {} + +type listedThreadSync struct { + state syncThreadState + since time.Time + maxItems int + maxPages int +} + +func (listedThreadSync) isThreadSyncSelection() {} + +type syncRequestPlan struct { + threadRequestCeiling int + plannedRequests int + requestBudget int + maxPages int + exactThreads int +} + +// parseThreadSync parses modes, bounds, and the exact-versus-listed selection +// before any corpus or network I/O begins. +func parseThreadSync(input threadSyncInput) (threadSyncRequest, syncRequestPlan, error) { + kind, err := parseSyncThreadKind(input.Kind) + if err != nil { + return threadSyncRequest{}, syncRequestPlan{}, err + } + state, err := parseSyncThreadState(input.State) + if err != nil { + return threadSyncRequest{}, syncRequestPlan{}, err + } + return newThreadSyncRequest(kind, state, input.Since, input.Numbers, input.MaxItems, input.MaxPages, input.MaxRequests) +} + +func newThreadSyncRequest( + kind syncThreadKind, + state syncThreadState, + since time.Time, + numbers []int, + maxItems, maxPages, maxRequests int, +) (threadSyncRequest, syncRequestPlan, error) { + if maxPages <= 0 { + maxPages = 1000 + } + if maxPages > 1000 { + return threadSyncRequest{}, syncRequestPlan{}, errors.New("max pages cannot exceed 1000") + } + if maxItems < 0 || maxItems > 1000 { + return threadSyncRequest{}, syncRequestPlan{}, errors.New("max items must be between 0 and 1000") + } + if maxRequests == 0 { + maxRequests = defaultSyncMaxRequests + } + if maxRequests < 1 || maxRequests > maxSyncRequests { + return threadSyncRequest{}, syncRequestPlan{}, fmt.Errorf("max requests must be between 1 and %d", maxSyncRequests) + } + if len(numbers) > 100 { + return threadSyncRequest{}, syncRequestPlan{}, errors.New("exact thread selection cannot exceed 100 numbers") + } + + canonicalNumbers := make([]int, 0, len(numbers)) + seen := make(map[int]struct{}, len(numbers)) + for _, number := range numbers { + if number <= 0 { + return threadSyncRequest{}, syncRequestPlan{}, errors.New("thread numbers must be positive") + } + if _, duplicate := seen[number]; duplicate { + continue + } + seen[number] = struct{}{} + canonicalNumbers = append(canonicalNumbers, number) + } + sort.Ints(canonicalNumbers) + + request := threadSyncRequest{kind: kind, maxRequests: maxRequests} + plan := syncRequestPlan{requestBudget: maxRequests, maxPages: maxPages} + if len(canonicalNumbers) > 0 { + if !state.isAll() || !since.IsZero() { + return threadSyncRequest{}, syncRequestPlan{}, errors.New("state and since filters cannot be combined with exact thread numbers") + } + if len(canonicalNumbers) > maxRequests { + return threadSyncRequest{}, syncRequestPlan{}, fmt.Errorf( + "exact thread selection requires at least %d requests; max requests is %d", + len(canonicalNumbers), maxRequests, + ) + } + request.selection = exactThreadSync{numbers: canonicalNumbers} + plan.threadRequestCeiling = len(canonicalNumbers) + plan.plannedRequests = len(canonicalNumbers) + plan.exactThreads = len(canonicalNumbers) + return request, plan, nil + } + + request.selection = listedThreadSync{state: state, since: since, maxItems: maxItems, maxPages: maxPages} + plan.threadRequestCeiling = min(maxPages, maxRequests) + plan.plannedRequests = plan.threadRequestCeiling + return request, plan, nil +} + +func (r threadSyncRequest) planCapped(plan syncRequestPlan) bool { + listed, ok := r.selection.(listedThreadSync) + return ok && plan.threadRequestCeiling < listed.maxPages +} + +type syncThreadKind uint8 + +const ( + syncAllThreads syncThreadKind = iota + syncIssues + syncPullRequests +) + +func parseSyncThreadKind(value string) (syncThreadKind, error) { + switch strings.TrimSpace(value) { + case "", "both": + return syncAllThreads, nil + case "issue": + return syncIssues, nil + case "pull_request": + return syncPullRequests, nil + default: + return 0, errors.New("kind must be issue, pull_request, or both") + } +} + +func (k syncThreadKind) String() string { + switch k { + case syncIssues: + return "issue" + case syncPullRequests: + return "pull_request" + default: + return "both" + } +} + +func (k syncThreadKind) includes(kind string) bool { + return k == syncAllThreads || k.String() == kind +} + +func (k syncThreadKind) includesAll() bool { return k == syncAllThreads } + +type syncThreadState uint8 + +const ( + syncAllStates syncThreadState = iota + syncOpenThreads + syncClosedThreads +) + +func parseSyncThreadState(value string) (syncThreadState, error) { + switch strings.TrimSpace(value) { + case "", "all": + return syncAllStates, nil + case "open": + return syncOpenThreads, nil + case "closed": + return syncClosedThreads, nil + default: + return 0, errors.New("state must be open, closed, or all") + } +} + +func (s syncThreadState) String() string { + switch s { + case syncOpenThreads: + return "open" + case syncClosedThreads: + return "closed" + default: + return "all" + } +} + +func (s syncThreadState) isAll() bool { return s == syncAllStates } diff --git a/internal/app/sync_thread_operations.go b/internal/app/sync_thread_operations.go index a0475bef..99058dc3 100644 --- a/internal/app/sync_thread_operations.go +++ b/internal/app/sync_thread_operations.go @@ -32,7 +32,11 @@ func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts. return nil, err } if stored == nil { - payload, err := json.Marshal(map[string]any{"source": "authored_pull_request_search", "owner": ref.Owner(), "repo": ref.Repo()}) + payload, err := json.Marshal(struct { + Source string `json:"source"` + Owner string `json:"owner"` + Repo string `json:"repo"` + }{Source: "authored_pull_request_search", Owner: ref.Owner(), Repo: ref.Repo()}) if err != nil { return nil, err } @@ -49,7 +53,7 @@ func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts. } defer failRunOnError(ctx, c, run.ID, &resultErr) writer := &syncThreadWriter{ - ctx: ctx, corpus: c, owner: ref.Owner(), repo: ref.Repo(), repositoryID: stored.ID, kind: "pull_request", sourceUpdatedAt: sourceUpdatedAt, + ctx: ctx, corpus: c, owner: ref.Owner(), repo: ref.Repo(), repositoryID: stored.ID, kind: syncPullRequests, sourceUpdatedAt: sourceUpdatedAt, } if err := writer.storeAll(issues); err != nil { return nil, err @@ -66,16 +70,19 @@ func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts. }, nil } -func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, syncOpts SyncOptions) (_ *contracts.SyncResult, resultErr error) { +func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, input threadSyncInput) (*contracts.SyncResult, error) { ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) if err != nil { return nil, err } - var plan syncRequestPlan - syncOpts, plan, err = planThreadSyncOptions(syncOpts) + request, plan, err := parseThreadSync(input) if err != nil { return nil, err } + return s.executeThreadSync(ctx, ref, request, plan) +} + +func (s *Service) executeThreadSync(ctx context.Context, ref domain.RepoRef, request threadSyncRequest, plan syncRequestPlan) (_ *contracts.SyncResult, resultErr error) { c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -99,18 +106,25 @@ func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, return nil, err } defer failRunOnError(ctx, c, run.ID, &resultErr) - budget := newSyncRequestBudget(syncOpts.MaxRequests) - selection, err := syncThreadHeaderSelection(ctx, c, reader, ref, repoProjection.ID, repoProjection.SourceUpdatedAt, syncOpts, nil, budget) + budget := newSyncRequestBudget(request.maxRequests) + selection, err := syncThreadHeaderSelection(ctx, c, reader, ref, repoProjection.ID, repoProjection.SourceUpdatedAt, request, budget) if err != nil { return nil, err } if err := c.AdvanceFacet(ctx, repoProjection.ID, nil, "threads", selection.sourceUpdatedAt, selection.complete, run.ID); err != nil { return nil, fmt.Errorf("advance threads facet: %w", err) } - requestCapped := selection.requestCapped || (len(syncOpts.Numbers) == 0 && plan.threadRequestCeiling < syncOpts.MaxPages) - stats, err := json.Marshal(map[string]any{ - "pages": selection.requests, "threads": selection.updated, "complete": selection.complete, - "requests": budget.used, "request_budget": budget.limit, "request_capped": requestCapped, + requestCapped := selection.requestCapped || request.planCapped(plan) + stats, err := json.Marshal(struct { + Pages int `json:"pages"` + Threads int `json:"threads"` + Complete bool `json:"complete"` + Requests int `json:"requests"` + RequestBudget int `json:"request_budget"` + RequestCapped bool `json:"request_capped"` + }{ + Pages: selection.requests, Threads: selection.updated, Complete: selection.complete, + Requests: budget.used, RequestBudget: budget.limit, RequestCapped: requestCapped, }) if err != nil { return nil, fmt.Errorf("marshal sync statistics: %w", err) @@ -119,8 +133,8 @@ func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, return nil, err } return &contracts.SyncResult{ - Repo: repo, Threads: selection.threads, Updated: selection.updated, Requests: budget.used, PlannedRequests: plan.plannedRequests, - RequestBudget: syncOpts.MaxRequests, Capped: requestCapped, + Repo: contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, Threads: selection.threads, Updated: selection.updated, Requests: budget.used, PlannedRequests: plan.plannedRequests, + RequestBudget: request.maxRequests, Capped: requestCapped, Message: fmt.Sprintf("fetched %d thread headers across %d thread requests", selection.updated, selection.requests), }, nil } diff --git a/internal/app/sync_threads_request.go b/internal/app/sync_threads_request.go index 8c52479e..02dd726e 100644 --- a/internal/app/sync_threads_request.go +++ b/internal/app/sync_threads_request.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -24,9 +23,9 @@ type syncThreadsSelection interface { } type repositoryThreadSelection struct { - repositories []mcpcontract.RepositoryRef - kind string - state string + repositories []domain.RepoRef + kind syncThreadKind + state syncThreadState updatedAfter time.Time limitPerRepository int } @@ -34,11 +33,25 @@ type repositoryThreadSelection struct { func (repositoryThreadSelection) isSyncThreadsSelection() {} type exactThreadSelection struct { - threads []mcpcontract.ThreadRef + threads []exactThreadTarget } func (exactThreadSelection) isSyncThreadsSelection() {} +type exactThreadTarget struct { + repository domain.RepoRef + kind syncThreadKind + number int +} + +func (t exactThreadTarget) wire() mcpcontract.ThreadRef { + kind := "" + if !t.kind.includesAll() { + kind = t.kind.String() + } + return mcpcontract.ThreadRef{Owner: t.repository.Owner(), Repo: t.repository.Repo(), Kind: kind, Number: t.number} +} + func parseSyncThreadsInput(in mcpcontract.SyncThreadsInput) (syncThreadsRequest, mcpcontract.SyncThreadsInput, error) { if in.MaxRequests == 0 { in.MaxRequests = defaultSyncBatchMaxRequests @@ -72,29 +85,31 @@ func parseRepositoryThreadSelection(in mcpcontract.SyncThreadsInput) (repository return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("repositories must contain 1 to 50 items") } in.Repositories = append([]mcpcontract.RepositoryRef(nil), in.Repositories...) + repositories := make([]domain.RepoRef, len(in.Repositories)) for i := range in.Repositories { ref, err := domain.NewRepoRef(in.Repositories[i].Owner, in.Repositories[i].Repo) if err != nil { return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, err } in.Repositories[i] = mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()} + repositories[i] = ref } if err := rejectDuplicateRepositoryRefs(in.Repositories); err != nil { return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, err } - in.Kind = strings.TrimSpace(in.Kind) - if in.Kind == "" { - in.Kind = "both" - } - if in.Kind != corpus.ThreadKindIssue && in.Kind != corpus.ThreadKindPullRequest && in.Kind != "both" { - return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("kind must be issue, pull_request, or both") + kind, err := parseSyncThreadKind(in.Kind) + if err != nil { + return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, err } + in.Kind = kind.String() if in.State == "" { in.State = "open" } - if in.State != "open" && in.State != "closed" && in.State != "all" { - return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("state must be open, closed, or all") + state, err := parseSyncThreadState(in.State) + if err != nil { + return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, err } + in.State = state.String() var updatedAfter time.Time if in.UpdatedAfter != "" { parsed, err := time.Parse(time.RFC3339, in.UpdatedAfter) @@ -110,9 +125,9 @@ func parseRepositoryThreadSelection(in mcpcontract.SyncThreadsInput) (repository return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("limit_per_repository must be between 1 and 1000") } selection := repositoryThreadSelection{ - repositories: append([]mcpcontract.RepositoryRef(nil), in.Repositories...), - kind: in.Kind, - state: in.State, + repositories: repositories, + kind: kind, + state: state, updatedAfter: updatedAfter, limitPerRepository: in.LimitPerRepository, } @@ -130,6 +145,7 @@ func parseExactThreadSelection(in mcpcontract.SyncThreadsInput) (exactThreadSele return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("threads must contain 1 to 100 items") } in.Threads = append([]mcpcontract.ThreadRef(nil), in.Threads...) + threads := make([]exactThreadTarget, len(in.Threads)) for i, thread := range in.Threads { ref, err := domain.NewRepoRef(thread.Owner, thread.Repo) if err != nil { @@ -138,15 +154,20 @@ func parseExactThreadSelection(in mcpcontract.SyncThreadsInput) (exactThreadSele if thread.Number <= 0 { return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, mcpcontract.InvalidArgument(fmt.Sprintf("threads[%d].number", i), "must be positive", nil) } - kind := strings.TrimSpace(thread.Kind) - if kind != "" && kind != corpus.ThreadKindIssue && kind != corpus.ThreadKindPullRequest { + kindValue := strings.TrimSpace(thread.Kind) + if kindValue == "both" { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, mcpcontract.InvalidArgument(fmt.Sprintf("threads[%d].kind", i), "must be issue or pull_request when provided", nil) + } + kind, err := parseSyncThreadKind(kindValue) + if err != nil { return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, mcpcontract.InvalidArgument(fmt.Sprintf("threads[%d].kind", i), "must be issue or pull_request when provided", nil) } in.Threads[i].Owner, in.Threads[i].Repo = ref.Owner(), ref.Repo() - in.Threads[i].Kind = kind + in.Threads[i].Kind = kindValue + threads[i] = exactThreadTarget{repository: ref, kind: kind, number: thread.Number} } if err := rejectDuplicateThreadRefs(in.Threads); err != nil { return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, err } - return exactThreadSelection{threads: append([]mcpcontract.ThreadRef(nil), in.Threads...)}, in, nil + return exactThreadSelection{threads: threads}, in, nil } diff --git a/internal/app/thread_reference.go b/internal/app/thread_reference.go new file mode 100644 index 00000000..79b8d6db --- /dev/null +++ b/internal/app/thread_reference.go @@ -0,0 +1,45 @@ +package app + +import ( + "errors" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +// parsedThreadReference is the canonical identity of one thread lookup. Kind +// explicitly represents either an exact issue/pull-request kind or both kinds. +// Boundary DTOs are converted once before corpus batch keys are built. +type parsedThreadReference struct { + repository domain.RepoRef + kind corpus.ThreadKindFilter + number int +} + +func parseThreadReference(input mcpcontract.ThreadRef) (parsedThreadReference, error) { + repository, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { + return parsedThreadReference{}, err + } + kind, err := corpus.ParseThreadKindFilter(input.Kind) + if err != nil { + return parsedThreadReference{}, err + } + if input.Number < 1 { + return parsedThreadReference{}, errors.New("thread number must be positive") + } + return parsedThreadReference{repository: repository, kind: kind, number: input.Number}, nil +} + +func (r parsedThreadReference) repositoryKey() corpus.RepositoryKey { + return corpus.RepositoryKey{Owner: r.repository.Owner(), Name: r.repository.Repo()} +} + +func (r parsedThreadReference) threadKey(repositoryID int64) corpus.ThreadKey { + return corpus.ThreadKey{RepositoryID: repositoryID, Kind: r.kind, Number: r.number} +} + +func (r parsedThreadReference) wire() mcpcontract.ThreadRef { + return mcpcontract.ThreadRef{Owner: r.repository.Owner(), Repo: r.repository.Repo(), Kind: r.kind.String(), Number: r.number} +} diff --git a/internal/app/tracking.go b/internal/app/tracking.go index 93b7f70b..10288d0e 100644 --- a/internal/app/tracking.go +++ b/internal/app/tracking.go @@ -61,13 +61,17 @@ func (s *Service) ListTriageEvents(ctx context.Context, opts contracts.ListTriag // RecordContribution stores prepared or submitted contribution metadata for an // opportunity, keeping it separate from live GitHub state. func (s *Service) RecordContribution(ctx context.Context, opts contracts.RecordContributionOptions) (*contracts.ContributionResult, error) { + kind, err := tracking.ParseContributionKind(opts.Kind) + if err != nil { + return nil, err + } c, err := s.openCorpus(ctx) if err != nil { return nil, err } item, err := tracking.NewService(c).RecordContribution(ctx, &tracking.Contribution{ OpportunityID: opts.OpportunityID, - Kind: normalizeContributionKind(opts.Kind), + Kind: kind, Title: opts.Title, Body: opts.Body, Reference: opts.Reference, @@ -94,13 +98,17 @@ func (s *Service) GetContribution(ctx context.Context, id string) (*contracts.Co // ListContributions returns contribution metadata in prepared-at order. func (s *Service) ListContributions(ctx context.Context, opts contracts.ListContributionsOptions) (*contracts.ContributionListResult, error) { + kind, err := tracking.ParseContributionKindFilter(opts.Kind) + if err != nil { + return nil, err + } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } items, err := tracking.NewService(c).ListContributions(ctx, tracking.ContributionFilter{ OpportunityID: opts.OpportunityID, - Kind: normalizeContributionKind(opts.Kind), + Kind: kind, Limit: opts.Limit, }) if err != nil { @@ -219,15 +227,6 @@ func parseTrackingTarget(raw string) (string, string, error) { return kind, strings.TrimSpace(ref), nil } -func normalizeContributionKind(kind string) string { - switch strings.ToLower(strings.TrimSpace(kind)) { - case "pr", "pull_request", "pullrequest": - return "pull_request" - default: - return strings.TrimSpace(kind) - } -} - func triageEventResult(e *tracking.TriageEvent) *contracts.TriageEventResult { if e == nil { return nil @@ -256,7 +255,7 @@ func contributionResult(c *tracking.Contribution) *contracts.ContributionResult return &contracts.ContributionResult{ ID: c.ID, OpportunityID: c.OpportunityID, - Kind: c.Kind, + Kind: string(c.Kind), Title: c.Title, Body: c.Body, Reference: c.Reference, diff --git a/internal/app/tui.go b/internal/app/tui.go index 2ae70ec2..b4950389 100644 --- a/internal/app/tui.go +++ b/internal/app/tui.go @@ -30,7 +30,7 @@ func (s *Service) Load(ctx context.Context) (tuicontract.Data, error) { var repos []corpus.Repository cursor := "" for { - page, err := c.ListRepositoriesWithOptions(ctx, "", corpus.RepositorySearchOptions{Limit: maxTUIItems, Cursor: cursor, Sort: "updated"}) + page, err := c.ListRepositoriesWithOptions(ctx, "", corpus.RepositorySearchOptions{Page: corpus.MaximumSearchPage().WithCursor(cursor), Order: corpus.UpdatedSearchOrder()}) if err != nil { return tuicontract.Data{}, err } @@ -72,22 +72,22 @@ func (s *Service) Load(ctx context.Context) (tuicontract.Data, error) { data.SyncStatuses = append(data.SyncStatuses, syncStatus) } - threadTotal, err := c.CountThreadsFiltered(ctx, repo.ID, "", "") + threadTotal, err := c.CountThreadsFiltered(ctx, repo.ID, corpus.AnyThreadKind(), corpus.AnyThreadState()) if err != nil { return tuicontract.Data{}, err } threadWindow := data.Windows["threads"] threadWindow.Total += threadTotal data.Windows["threads"] = threadWindow - threads, err := c.ListThreads(ctx, repo.ID, "", maxTUIItems) + threads, err := c.ListThreads(ctx, repo.ID, corpus.AnyThreadKind(), maxTUIItems) if err != nil { return tuicontract.Data{}, err } for _, thread := range threads { data.Threads = append(data.Threads, tuicontract.Item{ - Kind: thread.Kind, ID: fmt.Sprintf("%d", thread.ID), Ref: fmt.Sprintf("%s#%d", ref, thread.Number), - Title: thread.Title, Subtitle: thread.State + " by " + thread.Author, Detail: thread.Body, - Status: thread.State, Source: threadURL(ref, thread.Kind, thread.Number), AsOf: formatTime(thread.SourceUpdatedAt), + Kind: string(thread.Kind), ID: fmt.Sprintf("%d", thread.ID), Ref: fmt.Sprintf("%s#%d", ref, thread.Number), + Title: thread.Title, Subtitle: string(thread.State) + " by " + thread.Author, Detail: thread.Body, + Status: string(thread.State), Source: threadURL(ref, thread.Kind, thread.Number), AsOf: formatTime(thread.SourceUpdatedAt), }) } } @@ -213,7 +213,7 @@ func (s *Service) Load(ctx context.Context) (tuicontract.Data, error) { } data.Contributions = append(data.Contributions, tuicontract.Item{ Kind: "contribution", ID: contribution.ID, Ref: contribution.Reference, - Title: contribution.Title, Subtitle: contribution.Kind + " · " + status, + Title: contribution.Title, Subtitle: string(contribution.Kind) + " · " + status, Detail: contribution.Body, Status: status, Stage: "submitted", Source: contribution.ReferenceURL, AsOf: formatTime(contribution.UpdatedAt), }) @@ -329,7 +329,7 @@ func radarCandidateItem(candidate radar.Candidate) tuicontract.Item { summary = related.Title } if related.Relation != "" { - summary += " · " + strings.ReplaceAll(related.Relation, "_", " ") + summary += " · " + strings.ReplaceAll(string(related.Relation), "_", " ") } assessment.Related = append(assessment.Related, tuicontract.Fact{ Code: related.Kind, Summary: summary, Source: related.URL, diff --git a/internal/app/tui_actions.go b/internal/app/tui_actions.go index f2c19678..8d204308 100644 --- a/internal/app/tui_actions.go +++ b/internal/app/tui_actions.go @@ -31,7 +31,7 @@ func (s *Service) Actions(_ context.Context, item tuicontract.Item) ([]tuicontra return []tuicontract.Action{{ ID: tuiActionStartInvestigation, Label: "Start investigation", Description: "Create or reopen the local investigation and seed hypothesis.", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }}, nil case "hypothesis": return researchActions(), nil @@ -45,7 +45,7 @@ func (s *Service) Actions(_ context.Context, item tuicontract.Item) ([]tuicontra return []tuicontract.Action{{ ID: tuiActionRefreshClusters, Label: "Refresh related-work clusters", Description: "Recompute and persist the local duplicate projection.", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }}, nil default: return nil, nil diff --git a/internal/app/tui_actions_test.go b/internal/app/tui_actions_test.go index 4b4e0c72..84bdd81c 100644 --- a/internal/app/tui_actions_test.go +++ b/internal/app/tui_actions_test.go @@ -19,7 +19,7 @@ func TestTUIActionsExposeOnlyContextualApplicationOperations(t *testing.T) { t.Fatal(err) } if len(actions) != 1 || actions[0].ID != tuiActionStartInvestigation || - actions[0].Capability != tuicontract.CapabilityLocalWrite || !actions[0].RequiresConfirmation { + actions[0].Capability != tuicontract.CapabilityLocalWrite || !actions[0].RequiresConfirmation() { t.Fatalf("candidate actions = %+v", actions) } @@ -52,7 +52,7 @@ func TestTUIActionsExposeOnlyContextualApplicationOperations(t *testing.T) { t.Fatalf("hypothesis actions = %+v", actions) } for _, action := range actions { - if action.Capability != tuicontract.CapabilityOfflineRead || action.RequiresConfirmation { + if action.Capability != tuicontract.CapabilityOfflineRead || action.RequiresConfirmation() { t.Fatalf("research action crosses unexpected boundary: %+v", action) } } @@ -128,7 +128,7 @@ func TestTUIActionsExposeOnlyContextualApplicationOperations(t *testing.T) { t.Fatal(err) } if len(actions) != 1 || actions[0].ID != tuiActionRefreshClusters || - actions[0].Capability != tuicontract.CapabilityLocalWrite || !actions[0].RequiresConfirmation { + actions[0].Capability != tuicontract.CapabilityLocalWrite || !actions[0].RequiresConfirmation() { t.Fatalf("repository actions = %+v", actions) } result, err = fixture.svc.ExecuteAction(fixture.ctx, tuicontract.ActionRequest{ diff --git a/internal/app/tui_test.go b/internal/app/tui_test.go index 4ebe3980..fb08af2c 100644 --- a/internal/app/tui_test.go +++ b/internal/app/tui_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/tuicontract" ) @@ -32,7 +33,7 @@ func TestTUILoadReadsBoundedLocalData(t *testing.T) { if len(data.Repositories) != 1 || data.Repositories[0].Ref != "owner/repo" { t.Fatalf("repositories=%+v", data.Repositories) } - if len(data.Threads) != 1 || data.Threads[0].Detail != "local body" || data.Threads[0].Kind != corpus.ThreadKindIssue { + if len(data.Threads) != 1 || data.Threads[0].Detail != "local body" || data.Threads[0].Kind != string(domain.IssueKind) { t.Fatalf("threads=%+v", data.Threads) } if len(data.Candidates) != 1 || data.Candidates[0].Ref != "issue:owner/repo#7" || data.Candidates[0].Assessment == nil { diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 6f2eea8e..e2339c64 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -7,9 +7,7 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" - "runtime" "strings" "github.com/morluto/gitcontribute/internal/config" @@ -19,47 +17,6 @@ import ( "golang.org/x/mod/semver" ) -var ( - upgradeCommand = func(ctx context.Context, name string, args ...string) ([]byte, error) { - if name != "npm" { - return nil, fmt.Errorf("unsupported upgrade command %q", name) - } - return runNPMCommand(ctx, args) - } - runtimeContractCommand = func(ctx context.Context, path string) ([]byte, error) { - return exec.CommandContext(ctx, path, "runtime-contract").CombinedOutput() - } - osExecutable = os.Executable - upgradeGOOS = runtime.GOOS -) - -func runNPMCommand(ctx context.Context, args []string) ([]byte, error) { - if len(args) == 0 { - return nil, errors.New("missing npm arguments") - } - - var command *exec.Cmd - switch { - case len(args) == 3 && args[0] == "view" && args[1] == "gitcontribute" && args[2] == "version": - command = exec.CommandContext(ctx, "npm", "view", "gitcontribute", "version") - case len(args) == 2 && args[0] == "root" && args[1] == "--global": - command = exec.CommandContext(ctx, "npm", "root", "--global") - case len(args) == 3 && args[0] == "install" && args[1] == "--global": - version, err := clientsetup.ResolveNPMVersion(strings.TrimPrefix(args[2], "gitcontribute@")) - if err != nil || args[2] != "gitcontribute@"+version { - return nil, fmt.Errorf("unsupported npm install target %q", args[2]) - } - command = exec.CommandContext(ctx, "npm") - command.Args = []string{"npm", "install", "--global", "gitcontribute@" + version} - default: - return nil, fmt.Errorf("unsupported npm arguments %q", args) - } - if args[0] != "install" { - return command.Output() - } - return command.CombinedOutput() -} - // Upgrade checks npm for the latest release and updates persistent npm // installations when explicitly authorized. It reports inspectable stages // covering the npm launcher, private MCP runtime, configured client runtime, @@ -73,21 +30,28 @@ func (s *Service) Upgrade(ctx context.Context, opts contracts.UpgradeOptions) (* if s.paths == nil { s.paths = config.NewPaths(nil) } + environment := s.upgradeEnv.withDefaults() + executable := s.executable + if executable == nil { + executable = os.Executable + } + intent := parseUpgradeIntent(opts.Check, opts.Yes) - latest := "" - if opts.Check || opts.Yes { + var target npmVersion + if intent != upgradeInspect { var err error - latest, err = latestNPMVersion(ctx) + target, err = latestNPMVersion(ctx, environment.npm) if err != nil { return nil, err } } + latest := target.String() current := normalizeVersion(s.version) - details := discoverInstallation(ctx) + details := discoverInstallation(ctx, environment, executable) report := &contracts.UpgradeReport{ - Context: details.context, + Context: details.kind.String(), Current: current, Latest: latest, } @@ -105,17 +69,17 @@ func (s *Service) Upgrade(ctx context.Context, opts contracts.UpgradeOptions) (* report.Stages = append(report.Stages, s.schemaStage(ctx)) - report.Stages = append(report.Stages, activationStage(report, opts)) - report.Stages = append(report.Stages, rollbackStage(report)) + report.Stages = append(report.Stages, activationStage(report, intent, environment.installPolicy, details.kind)) + report.Stages = append(report.Stages, rollbackStage(report, details.kind)) - setCommandAndStatus(report) + setCommandAndStatus(report, details.kind) recoveringNewerCorpus := stageStatus(report, "corpus-schema") == "newer" - if shouldInstall(report, opts) { - if err := runNPMInstall(ctx, latest); err != nil { + if shouldInstall(report, intent, environment.installPolicy, details.kind) { + if err := runNPMInstall(ctx, environment.npm, target); err != nil { return nil, err } - if err := verifyGlobalNPMVersion(ctx, latest); err != nil { + if err := verifyGlobalNPMVersion(ctx, environment.npm, target); err != nil { return nil, err } setStage(report, contracts.UpgradeStage{ @@ -128,75 +92,71 @@ func (s *Service) Upgrade(ctx context.Context, opts contracts.UpgradeOptions) (* }) report.Status = "updated" report.Command = "" - if recoveringNewerCorpus && !s.validateNewerCorpusTarget(ctx, report, details.executable, latest) { + if recoveringNewerCorpus && !s.validateNewerCorpusTarget(ctx, report, details.executable, latest, environment.runtimeContract) { return report, nil } } - if opts.Yes && stageStatus(report, "corpus-schema") != "incompatible" && len(outdatedPrivateRuntimeClients(report)) > 0 { - s.activatePrivateRuntime(ctx, report, details) + if intent == upgradeApply && stageStatus(report, "corpus-schema") != "incompatible" && len(outdatedPrivateRuntimeClients(report)) > 0 { + s.activatePrivateRuntime(ctx, report, details, environment.runtimeContract) } - if opts.Yes && registrationRepairAllowed(report) && len(staleRegistrationClients(report)) > 0 { + if intent == upgradeApply && registrationRepairAllowed(report) && len(staleRegistrationClients(report)) > 0 { s.repairStaleRegistrations(ctx, report) } return report, nil } -func verifyGlobalNPMVersion(ctx context.Context, want string) error { - root, err := upgradeCommand(ctx, "npm", "root", "--global") +func verifyGlobalNPMVersion(ctx context.Context, npm npmUpgradeClient, want npmVersion) error { + root, err := npm.globalRoot(ctx) if err != nil { return fmt.Errorf("verify global npm root: %w", err) } packageRoot := filepath.Join(strings.TrimSpace(string(root)), "gitcontribute") - if got := readPackageVersion(packageRoot); got != want { - return fmt.Errorf("verify installed npm release: got %q, want %q", got, want) + if got := readPackageVersion(packageRoot); got != want.String() { + return fmt.Errorf("verify installed npm release: got %q, want %q", got, want.String()) } return nil } -func latestNPMVersion(ctx context.Context) (string, error) { - output, err := upgradeCommand(ctx, "npm", "view", "gitcontribute", "version") +func latestNPMVersion(ctx context.Context, npm npmUpgradeClient) (npmVersion, error) { + output, err := npm.latestVersion(ctx) if err != nil { - return "", fmt.Errorf("check latest npm release: %w", err) + return npmVersion{}, fmt.Errorf("check latest npm release: %w", err) } version := normalizeVersion(string(output)) - resolved, err := clientsetup.ResolveNPMVersion(version) + resolved, err := parseNPMVersion(version) if err != nil { - return "", fmt.Errorf("validate latest npm release: %w", err) + return npmVersion{}, fmt.Errorf("validate latest npm release: %w", err) } return resolved, nil } -func runNPMInstall(ctx context.Context, version string) error { - resolved, err := clientsetup.ResolveNPMVersion(version) - if err != nil { - return fmt.Errorf("validate latest npm release: %w", err) - } - if _, err := upgradeCommand(ctx, "npm", "install", "--global", "gitcontribute@"+resolved); err != nil { +func runNPMInstall(ctx context.Context, npm npmUpgradeClient, version npmVersion) error { + if _, err := npm.install(ctx, version); err != nil { return fmt.Errorf("install latest npm release: %w", err) } return nil } -func shouldInstall(report *contracts.UpgradeReport, opts contracts.UpgradeOptions) bool { - if !opts.Yes { +func shouldInstall(report *contracts.UpgradeReport, intent upgradeIntent, policy npmInstallPolicy, kind installationKind) bool { + if intent != upgradeApply { return false } - if report.Context != "global-npm" { + if kind != installationGlobalNPM { return false } - if upgradeGOOS == "windows" { + if policy == npmInstallAfterExit { return false } if status := stageStatus(report, "corpus-schema"); status == "migration_required" || status == "incompatible" || status == "failed" { return false } - disposition := reportVersionDisposition(report) + disposition := reportVersionDisposition(report, kind) return disposition == versionUpgrade || disposition == versionPrerelease } -func setCommandAndStatus(report *contracts.UpgradeReport) { +func setCommandAndStatus(report *contracts.UpgradeReport, kind installationKind) { if stageStatus(report, "corpus-schema") == "migration_required" { report.Status = "schema migration required" return @@ -210,13 +170,13 @@ func setCommandAndStatus(report *contracts.UpgradeReport) { return } - switch report.Context { - case "npx": + switch kind { + case installationNPX: report.Status = "npx" - case "other": + case installationOther: report.Status = "not managed" - case "project-npm": - switch reportVersionDisposition(report) { + case installationProjectNPM: + switch reportVersionDisposition(report, kind) { case versionUnavailable: report.Status = "awaiting confirmation" case versionCurrent: @@ -232,8 +192,8 @@ func setCommandAndStatus(report *contracts.UpgradeReport) { case versionInvalid: report.Status = "version comparison unavailable" } - case "global-npm": - switch reportVersionDisposition(report) { + case installationGlobalNPM: + switch reportVersionDisposition(report, kind) { case versionUnavailable: report.Status = "awaiting confirmation" case versionCurrent: @@ -304,9 +264,9 @@ func isNewerVersion(current, target string) bool { return disposition == versionUpgrade || disposition == versionPrerelease } -func reportVersionDisposition(report *contracts.UpgradeReport) versionDisposition { +func reportVersionDisposition(report *contracts.UpgradeReport, kind installationKind) versionDisposition { current := report.Current - if report.Context == "global-npm" || report.Context == "project-npm" { + if kind == installationGlobalNPM || kind == installationProjectNPM { if installed := stageVersion(report, "npm-launcher"); installed != "" { current = installed } @@ -317,65 +277,65 @@ func reportVersionDisposition(report *contracts.UpgradeReport) versionDispositio func installationStage(details installDetails, current string) contracts.UpgradeStage { return contracts.UpgradeStage{ Name: "installation", - Status: details.context, + Status: details.kind.String(), Path: details.executable, Version: current, - Message: installMessage(details.context), + Message: installMessage(details.kind), } } -func installMessage(context string) string { - switch context { - case "npx": +func installMessage(kind installationKind) string { + switch kind { + case installationNPX: return "npx resolves versions on demand; no persistent installation to update" - case "project-npm": + case installationProjectNPM: return "project-local npm installation" - case "global-npm": + case installationGlobalNPM: return "global npm installation" - case "other": + case installationOther: return "executable is not inside a managed npm package" default: return "" } } -func discoverInstallation(ctx context.Context) installDetails { +func discoverInstallation(ctx context.Context, environment upgradeEnvironment, executablePath func() (string, error)) installDetails { if os.Getenv("npm_command") == "exec" || os.Getenv("npm_lifecycle_event") == "npx" { - executable, _ := osExecutable() - return installDetails{context: "npx", executable: executable} + executable, _ := executablePath() + return installDetails{kind: installationNPX, executable: executable} } - executable, err := osExecutable() + executable, err := executablePath() if err != nil { - return installDetails{context: "other"} + return installDetails{kind: installationOther} } normalized := filepath.ToSlash(executable) if !strings.Contains(normalized, "/node_modules/gitcontribute/") { - return installDetails{context: "other", executable: executable} + return installDetails{kind: installationOther, executable: executable} } - globalRoot, err := upgradeCommand(ctx, "npm", "root", "--global") + globalRoot, err := environment.npm.globalRoot(ctx) if err != nil { - return installDetails{context: "project-npm", executable: executable} + return installDetails{kind: installationProjectNPM, executable: executable} } root := strings.TrimSpace(string(globalRoot)) return installDetails{ - context: classifyNPMExecutable(executable, root), + kind: installationKindFromExecutable(executable, root), executable: executable, npmRoot: root, } } -func classifyNPMExecutable(executable, globalRoot string) string { +func executableWithinNPMRoot(executable, globalRoot string) bool { executable = filepath.Clean(executable) globalPackage := filepath.Join(filepath.Clean(globalRoot), "gitcontribute") relative, err := filepath.Rel(globalPackage, executable) if err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "global-npm" + return true } - return "project-npm" + return false } func npmPackageRoot(details installDetails) string { - if details.context == "global-npm" && details.npmRoot != "" { + if details.kind == installationGlobalNPM && details.npmRoot != "" { return filepath.Join(filepath.Clean(details.npmRoot), "gitcontribute") } normalized := filepath.ToSlash(details.executable) @@ -390,8 +350,8 @@ func npmLauncherStage(details installDetails, _ string, latest string) contracts stage := contracts.UpgradeStage{Name: "npm-launcher"} root := npmPackageRoot(details) if root == "" { - stage.Status = details.context - stage.Message = installMessage(details.context) + stage.Status = details.kind.String() + stage.Message = installMessage(details.kind) return stage } stage.Path = root @@ -571,7 +531,7 @@ func (s *Service) configuredRuntimesStage(ctx context.Context, current, latest s outdated := 0 stale := 0 failed := 0 - for _, client := range clientsetup.AllClients { + for _, client := range clientsetup.SupportedClients() { c, err := inspectConfiguredClient(home, client, target) if err != nil { c = contracts.UpgradeConfiguredClient{Name: string(client), Status: "failed", Message: err.Error()} diff --git a/internal/app/upgrade_activation.go b/internal/app/upgrade_activation.go index b6748737..edf893ed 100644 --- a/internal/app/upgrade_activation.go +++ b/internal/app/upgrade_activation.go @@ -18,12 +18,12 @@ import ( ) type installDetails struct { - context string + kind installationKind executable string npmRoot string } -func (s *Service) validateNewerCorpusTarget(ctx context.Context, report *contracts.UpgradeReport, candidate, target string) bool { +func (s *Service) validateNewerCorpusTarget(ctx context.Context, report *contracts.UpgradeReport, candidate, target string, command runtimeContractCommand) bool { fail := func(err error) bool { stage := upgradeStage(report, "activation") stage.Status = "target_validation_failed" @@ -39,7 +39,7 @@ func (s *Service) validateNewerCorpusTarget(ctx context.Context, report *contrac if err := verifySetupExecutable(candidate); err != nil { return fail(fmt.Errorf("installed target executable is not usable: %w", err)) } - contract, err := readRuntimeContract(ctx, candidate) + contract, err := readRuntimeContract(ctx, command, candidate) if err != nil { return fail(fmt.Errorf("installed target runtime contract is unreadable: %w", err)) } @@ -93,7 +93,7 @@ func (s *Service) validateNewerCorpusTarget(ctx context.Context, report *contrac } } -func (s *Service) activatePrivateRuntime(ctx context.Context, report *contracts.UpgradeReport, details installDetails) { +func (s *Service) activatePrivateRuntime(ctx context.Context, report *contracts.UpgradeReport, details installDetails, command runtimeContractCommand) { clients := outdatedPrivateRuntimeClients(report) if len(clients) == 0 { return @@ -133,18 +133,18 @@ func (s *Service) activatePrivateRuntime(ctx context.Context, report *contracts. return } - contract, err := readRuntimeContract(ctx, candidate) + contract, err := readRuntimeContract(ctx, command, candidate) if err != nil { s.setPrivateActivationFailure(report, len(clients), fmt.Errorf("runtime contract is unreadable: %w", err)) return } if normalizeVersion(contract.Version) != normalizeVersion(target) { message := fmt.Errorf("staged executable reports version %s, not target %s", contract.Version, target) - if details.context == "npx" { + if details.kind == installationNPX { message = fmt.Errorf("npx bootstrap reports version %s and cannot activate target %s; run `npx --yes gitcontribute@latest setup`", contract.Version, target) } s.setPrivateActivationFailure(report, len(clients), message) - if details.context == "npx" { + if details.kind == installationNPX { report.Action = message.Error() } return @@ -204,7 +204,7 @@ func (s *Service) activatePrivateRuntime(ctx context.Context, report *contracts. s.setPrivateActivationFailure(report, len(clients), fmt.Errorf("verify staged private MCP runtime: %w", err)) return } - destinationContract, err := readRuntimeContract(ctx, destination) + destinationContract, err := readRuntimeContract(ctx, command, destination) if err != nil { s.setPrivateActivationFailure(report, len(clients), fmt.Errorf("installed runtime contract is unreadable: %w", err)) return @@ -221,11 +221,12 @@ func (s *Service) activatePrivateRuntime(ctx context.Context, report *contracts. } func (s *Service) activateConfiguredClients(ctx context.Context, report *contracts.UpgradeReport, clients []string, destination, target string) { - setupClients := make([]clientsetup.Client, 0, len(clients)) - for _, name := range clients { - setupClients = append(setupClients, clientsetup.Client(name)) + setupClients, err := clientsetup.ParseClients(clients) + if err != nil { + s.setPrivateActivationFailure(report, len(clients), fmt.Errorf("parse configured clients: %w", err)) + return } - _, err := clientsetup.ActivateExistingAndVerify(ctx, clientsetup.Options{ + _, err = clientsetup.ActivateExistingAndVerify(ctx, clientsetup.Options{ Clients: setupClients, Home: s.paths.HomeDir(), Executable: destination, }, func() error { return s.verifyPrivateActivation(ctx, report, setupClients, destination, target) }) if err != nil { @@ -257,9 +258,15 @@ func (s *Service) repairStaleRegistrations(ctx context.Context, report *contract if len(clients) == 0 { return } - setupClients := make([]clientsetup.Client, 0, len(clients)) - for _, name := range clients { - setupClients = append(setupClients, clientsetup.Client(name)) + setupClients, err := clientsetup.ParseClients(clients) + if err != nil { + stage := upgradeStage(report, "activation") + stage.Status = "failed" + stage.Message = fmt.Sprintf("parse stale MCP registrations: %s", err) + setStage(report, stage) + report.Status = "registration repair failed" + report.Action = "inspect configured client registrations before retrying upgrade" + return } if _, err := clientsetup.RepairExisting(ctx, s.paths.HomeDir(), setupClients); err != nil { stage := upgradeStage(report, "activation") @@ -321,8 +328,8 @@ func (s *Service) repairStaleRegistrations(ctx context.Context, report *contract } } -func readRuntimeContract(ctx context.Context, path string) (*contracts.RuntimeContractResult, error) { - out, err := runtimeContractCommand(ctx, path) +func readRuntimeContract(ctx context.Context, command runtimeContractCommand, path string) (*contracts.RuntimeContractResult, error) { + out, err := command(ctx, path) if err != nil { return nil, fmt.Errorf("execute %s runtime-contract: %w", path, err) } diff --git a/internal/app/upgrade_activation_test.go b/internal/app/upgrade_activation_test.go index 71a2f039..c3cd1ca6 100644 --- a/internal/app/upgrade_activation_test.go +++ b/internal/app/upgrade_activation_test.go @@ -22,7 +22,7 @@ func TestUpgradeBlocksActivationWhenTargetSchemaExceedsCorpus(t *testing.T) { home, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") dbPath := filepath.Join(home, "gitcontribute.db") svc.cfg.Database = dbPath - setRuntimeContract(t, "1.2.4", 999) + setRuntimeContract(t, svc, "1.2.4", 999) db, err := corpus.Open(context.Background(), dbPath) if err != nil { @@ -53,7 +53,7 @@ func TestUpgradeBlocksActivationWhenTargetSchemaExceedsCorpus(t *testing.T) { func TestUpgradeBlocksActivationWithInvalidRuntimeContract(t *testing.T) { _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") - setRuntimeContractOutput(t, "not-json") + setRuntimeContractOutput(t, svc, "not-json") report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -96,7 +96,7 @@ func TestUpgradeReportsTargetRuntimeUnavailableWhenNoStagedExecutable(t *testing func TestUpgradeBlocksActivationWithTrailingRuntimeContract(t *testing.T) { _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") - setRuntimeContractOutput(t, `{"name":"gitcontribute","version":"1.2.4","supported_schema_version":1}{"unexpected":"second value"}`) + setRuntimeContractOutput(t, svc, `{"name":"gitcontribute","version":"1.2.4","supported_schema_version":1}{"unexpected":"second value"}`) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -117,14 +117,12 @@ func TestUpgradeBlocksActivationWithTrailingRuntimeContract(t *testing.T) { } func TestReadRuntimeContractAcceptsUnknownFields(t *testing.T) { - original := runtimeContractCommand - t.Cleanup(func() { runtimeContractCommand = original }) var gotPath string - runtimeContractCommand = func(_ context.Context, path string) ([]byte, error) { + command := func(_ context.Context, path string) ([]byte, error) { gotPath = path return []byte(`{"name":"gitcontribute","version":"1.2.4","supported_schema_lineage":"gitcontribute-canonical-v1","supported_schema_version":28,"future_field":{"enabled":true}}`), nil } - contract, err := readRuntimeContract(context.Background(), "/release/candidate") + contract, err := readRuntimeContract(context.Background(), command, "/release/candidate") if err != nil { t.Fatal(err) } @@ -143,17 +141,15 @@ func TestUpgradeRejectsDestinationRuntimeContractDisagreementBeforeRegistration( if err != nil { t.Fatal(err) } - original := runtimeContractCommand - t.Cleanup(func() { runtimeContractCommand = original }) var paths []string - runtimeContractCommand = func(_ context.Context, path string) ([]byte, error) { + svc.stubRuntimeContract(func(_ context.Context, path string) ([]byte, error) { paths = append(paths, path) schema := 1 if filepath.Clean(path) == filepath.Clean(destination) { schema = 2 } return []byte(fmt.Sprintf(`{"name":"gitcontribute","version":"1.2.4","supported_schema_lineage":"gitcontribute-canonical-v1","supported_schema_version":%d}`, schema)), nil - } + }) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -175,15 +171,6 @@ func TestUpgradeRejectsDestinationRuntimeContractDisagreementBeforeRegistration( func TestUpgradeRejectsMismatchedPostInstallNPMVersion(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - originalGOOS := upgradeGOOS - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - upgradeGOOS = originalGOOS - }) - upgradeGOOS = "linux" home := t.TempDir() globalRoot := filepath.Join(home, "global", "lib", "node_modules") @@ -197,8 +184,7 @@ func TestUpgradeRejectsMismatchedPostInstallNPMVersion(t *testing.T) { } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return exe, nil } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { switch { case name == "npm" && len(args) >= 2 && args[0] == "root" && args[1] == "--global": return []byte(globalRoot + "\n"), nil @@ -213,6 +199,9 @@ func TestUpgradeRejectsMismatchedPostInstallNPMVersion(t *testing.T) { } svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return exe, nil }) + svc.stubUpgradeCommand(command) + svc.stubUpgradePlatform("linux") _, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err == nil { t.Fatal("expected error when installed npm version does not match target") @@ -224,7 +213,7 @@ func TestUpgradeRejectsMismatchedPostInstallNPMVersion(t *testing.T) { func TestUpgradeBlocksActivationWhenRuntimeContractLacksSupportedSchema(t *testing.T) { _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") - setRuntimeContractOutput(t, `{"name":"gitcontribute","version":"1.2.4"}`) + setRuntimeContractOutput(t, svc, `{"name":"gitcontribute","version":"1.2.4"}`) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { diff --git a/internal/app/upgrade_config_test.go b/internal/app/upgrade_config_test.go deleted file mode 100644 index a80d0d89..00000000 --- a/internal/app/upgrade_config_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package app - -import ( - "os" - "path/filepath" - "strings" - "testing" - - clientsetup "github.com/morluto/gitcontribute/internal/setup" -) - -func TestReadClaudeCommandRejectsNonStringArguments(t *testing.T) { - path := filepath.Join(t.TempDir(), ".claude.json") - data := `{"mcpServers":{"gitcontribute":{"command":"node","args":["mcp",123]}}}` - if err := os.WriteFile(path, []byte(data), 0600); err != nil { - t.Fatal(err) - } - if _, err := clientsetup.ReadCommandFile(clientsetup.Claude, path); err == nil || !strings.Contains(err.Error(), "args[1]") { - t.Fatalf("readClaudeCommand error = %v, want indexed non-string argument error", err) - } -} diff --git a/internal/app/upgrade_environment.go b/internal/app/upgrade_environment.go new file mode 100644 index 00000000..5ee9090e --- /dev/null +++ b/internal/app/upgrade_environment.go @@ -0,0 +1,149 @@ +package app + +import ( + "context" + "os/exec" + "runtime" + + clientsetup "github.com/morluto/gitcontribute/internal/setup" +) + +// npmVersion is a registry-safe release identifier. Construct it at the +// registry or CLI boundary so process execution never receives an unchecked +// package target. +type npmVersion struct { + value string +} + +func parseNPMVersion(value string) (npmVersion, error) { + resolved, err := clientsetup.ResolveNPMVersion(value) + if err != nil { + return npmVersion{}, err + } + return npmVersion{value: resolved}, nil +} + +func (v npmVersion) String() string { + return v.value +} + +type npmUpgradeClient struct { + latestVersion func(context.Context) ([]byte, error) + globalRoot func(context.Context) ([]byte, error) + install func(context.Context, npmVersion) ([]byte, error) +} + +type runtimeContractCommand func(context.Context, string) ([]byte, error) + +type npmInstallPolicy uint8 + +const ( + npmInstallPolicyUnset npmInstallPolicy = iota + npmInstallAutomatic + npmInstallAfterExit +) + +func npmInstallPolicyFor(goos string) npmInstallPolicy { + if goos == "windows" { + return npmInstallAfterExit + } + return npmInstallAutomatic +} + +type upgradeEnvironment struct { + npm npmUpgradeClient + runtimeContract runtimeContractCommand + installPolicy npmInstallPolicy +} + +func productionUpgradeEnvironment() upgradeEnvironment { + return upgradeEnvironment{ + npm: npmUpgradeClient{ + latestVersion: func(ctx context.Context) ([]byte, error) { + return exec.CommandContext(ctx, "npm", "view", "gitcontribute", "version").Output() + }, + globalRoot: func(ctx context.Context) ([]byte, error) { + return exec.CommandContext(ctx, "npm", "root", "--global").Output() + }, + install: func(ctx context.Context, version npmVersion) ([]byte, error) { + command := exec.CommandContext(ctx, "npm") + command.Args = []string{"npm", "install", "--global", "gitcontribute@" + version.String()} + return command.CombinedOutput() + }, + }, + runtimeContract: func(ctx context.Context, path string) ([]byte, error) { + return exec.CommandContext(ctx, path, "runtime-contract").CombinedOutput() + }, + installPolicy: npmInstallPolicyFor(runtime.GOOS), + } +} + +// withDefaults supports deliberately small Service values in package tests +// and library callers while keeping every Upgrade invocation on one immutable +// snapshot of its process capabilities. +func (e upgradeEnvironment) withDefaults() upgradeEnvironment { + defaults := productionUpgradeEnvironment() + if e.npm.latestVersion == nil { + e.npm.latestVersion = defaults.npm.latestVersion + } + if e.npm.globalRoot == nil { + e.npm.globalRoot = defaults.npm.globalRoot + } + if e.npm.install == nil { + e.npm.install = defaults.npm.install + } + if e.runtimeContract == nil { + e.runtimeContract = defaults.runtimeContract + } + if e.installPolicy == npmInstallPolicyUnset { + e.installPolicy = defaults.installPolicy + } + return e +} + +type upgradeIntent uint8 + +const ( + upgradeInspect upgradeIntent = iota + upgradeCheck + upgradeApply +) + +func parseUpgradeIntent(check, apply bool) upgradeIntent { + if apply { + return upgradeApply + } + if check { + return upgradeCheck + } + return upgradeInspect +} + +type installationKind uint8 + +const ( + installationOther installationKind = iota + installationNPX + installationProjectNPM + installationGlobalNPM +) + +func (k installationKind) String() string { + switch k { + case installationNPX: + return "npx" + case installationProjectNPM: + return "project-npm" + case installationGlobalNPM: + return "global-npm" + default: + return "other" + } +} + +func installationKindFromExecutable(executable, globalRoot string) installationKind { + if executableWithinNPMRoot(executable, globalRoot) { + return installationGlobalNPM + } + return installationProjectNPM +} diff --git a/internal/app/upgrade_environment_test.go b/internal/app/upgrade_environment_test.go new file mode 100644 index 00000000..3478e518 --- /dev/null +++ b/internal/app/upgrade_environment_test.go @@ -0,0 +1,41 @@ +package app + +import "context" + +type upgradeCommandStub func(context.Context, string, ...string) ([]byte, error) + +func (s *Service) stubUpgradeCommand(command upgradeCommandStub) { + environment := s.upgradeEnv.withDefaults() + environment.npm = npmUpgradeClient{ + latestVersion: func(ctx context.Context) ([]byte, error) { + return command(ctx, "npm", "view", "gitcontribute", "version") + }, + globalRoot: func(ctx context.Context) ([]byte, error) { + return command(ctx, "npm", "root", "--global") + }, + install: func(ctx context.Context, version npmVersion) ([]byte, error) { + return command(ctx, "npm", "install", "--global", "gitcontribute@"+version.String()) + }, + } + s.upgradeEnv = environment +} + +func (s *Service) stubExecutable(executable func() (string, error)) { + s.executable = executable +} + +func (s *Service) stubExecutablePath(path string) { + s.stubExecutable(func() (string, error) { return path, nil }) +} + +func (s *Service) stubRuntimeContract(command runtimeContractCommand) { + environment := s.upgradeEnv.withDefaults() + environment.runtimeContract = command + s.upgradeEnv = environment +} + +func (s *Service) stubUpgradePlatform(goos string) { + environment := s.upgradeEnv.withDefaults() + environment.installPolicy = npmInstallPolicyFor(goos) + s.upgradeEnv = environment +} diff --git a/internal/app/upgrade_registration_test.go b/internal/app/upgrade_registration_test.go index da580cfc..ab8c3397 100644 --- a/internal/app/upgrade_registration_test.go +++ b/internal/app/upgrade_registration_test.go @@ -15,25 +15,19 @@ import ( ) func TestUpgradeCheckReportsStaleRegistration(t *testing.T) { - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + commandStub := func(_ context.Context, name string, args ...string) ([]byte, error) { if name == "npm" && reflect.DeepEqual(args, []string{"view", "gitcontribute", "version"}) { return []byte("1.2.3\n"), nil } t.Fatalf("unexpected command: %s %v", name, args) return nil, nil } - osExecutable = func() (string, error) { return "/opt/gitcontribute", nil } - home := t.TempDir() command := filepath.Join(home, "bin", "1.2.3", "gitcontribute") writeStaleCodexConfig(t, home, command) svc := testService(t, home, "1.2.3", "") + svc.stubUpgradeCommand(commandStub) + svc.stubExecutable(func() (string, error) { return "/opt/gitcontribute", nil }) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Check: true}) if err != nil { @@ -53,25 +47,19 @@ func TestUpgradeCheckReportsStaleRegistration(t *testing.T) { } func TestUpgradeYesRepairsStaleRegistrationAndRequiresRestart(t *testing.T) { - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + commandStub := func(_ context.Context, name string, args ...string) ([]byte, error) { if name == "npm" && reflect.DeepEqual(args, []string{"view", "gitcontribute", "version"}) { return []byte("1.2.3\n"), nil } t.Fatalf("unexpected command: %s %v", name, args) return nil, nil } - osExecutable = func() (string, error) { return "/opt/gitcontribute", nil } - home := t.TempDir() command := filepath.Join(home, "bin", "1.2.3", "gitcontribute") writeStaleCodexConfig(t, home, command) svc := testService(t, home, "1.2.3", "") + svc.stubUpgradeCommand(commandStub) + svc.stubExecutable(func() (string, error) { return "/opt/gitcontribute", nil }) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -95,21 +83,13 @@ func TestUpgradeYesRepairsStaleRegistrationAndRequiresRestart(t *testing.T) { } func TestUpgradeCheckReportsInstalledNewerRuntimeAndOlderRegistration(t *testing.T) { - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + commandStub := func(_ context.Context, name string, args ...string) ([]byte, error) { if name == "npm" && reflect.DeepEqual(args, []string{"view", "gitcontribute", "version"}) { return []byte("0.16.0\n"), nil } t.Fatalf("unexpected command: %s %v", name, args) return nil, nil } - osExecutable = func() (string, error) { return "/opt/gitcontribute", nil } - home := t.TempDir() paths := config.NewPaths(&config.Env{Home: home}) dataDir, err := paths.DataDir() @@ -130,6 +110,8 @@ func TestUpgradeCheckReportsInstalledNewerRuntimeAndOlderRegistration(t *testing } writeCodexConfig(t, home, filepath.Join(dataDir, "bin", "0.15.0", "gitcontribute")) svc := testService(t, home, "0.15.0", "") + svc.stubUpgradeCommand(commandStub) + svc.stubExecutable(func() (string, error) { return "/opt/gitcontribute", nil }) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Check: true}) if err != nil { diff --git a/internal/app/upgrade_setup_test.go b/internal/app/upgrade_setup_test.go index 737ade29..c46b5a77 100644 --- a/internal/app/upgrade_setup_test.go +++ b/internal/app/upgrade_setup_test.go @@ -16,7 +16,7 @@ import ( func TestUpgradeActivatesPrivateMCPRuntimeFromTargetRelease(t *testing.T) { home, _, _, _, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") - setRuntimeContract(t, "1.2.4", 1) + setRuntimeContract(t, svc, "1.2.4", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -30,7 +30,7 @@ func TestUpgradeActivatesPrivateMCPRuntimeFromTargetRelease(t *testing.T) { if err != nil { t.Fatal(err) } - launcher, err := clientsetup.ReadCommandFile(clientsetup.Codex, filepath.Join(home, ".codex", "config.toml")) + launcher, err := clientsetup.ReadCommand(clientsetup.Codex, home) if err != nil { t.Fatal(err) } @@ -52,7 +52,7 @@ func TestUpgradeNpxActivatesPrivateMCPRuntimeFromLatestRelease(t *testing.T) { home, _, _, _, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") t.Setenv("npm_command", "exec") t.Setenv("npm_lifecycle_event", "npx") - setRuntimeContract(t, "1.2.4", 1) + setRuntimeContract(t, svc, "1.2.4", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -69,7 +69,7 @@ func TestUpgradeNpxActivatesPrivateMCPRuntimeFromLatestRelease(t *testing.T) { if err != nil { t.Fatal(err) } - launcher, err := clientsetup.ReadCommandFile(clientsetup.Codex, filepath.Join(home, ".codex", "config.toml")) + launcher, err := clientsetup.ReadCommand(clientsetup.Codex, home) if err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestUpgradeNpxActivatesPrivateMCPRuntimeFromLatestRelease(t *testing.T) { func TestUpgradeActivatesAlreadyInstalledTargetRuntime(t *testing.T) { home, _, _, _, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.3") - setRuntimeContract(t, "1.2.4", 1) + setRuntimeContract(t, svc, "1.2.4", 1) dataDir, err := svc.paths.DataDir() if err != nil { t.Fatal(err) @@ -109,7 +109,7 @@ func TestUpgradeActivatesAlreadyInstalledTargetRuntime(t *testing.T) { if err != nil { t.Fatal(err) } - launcher, err := clientsetup.ReadCommandFile(clientsetup.Codex, filepath.Join(home, ".codex", "config.toml")) + launcher, err := clientsetup.ReadCommand(clientsetup.Codex, home) if err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestUpgradeNpxStaleBootstrapReportsExplicitLatestRecovery(t *testing.T) { _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.3") t.Setenv("npm_command", "exec") t.Setenv("npm_lifecycle_event", "npx") - setRuntimeContract(t, "1.2.3", 1) + setRuntimeContract(t, svc, "1.2.3", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { diff --git a/internal/app/upgrade_stages.go b/internal/app/upgrade_stages.go index 06a906b1..2dc57b11 100644 --- a/internal/app/upgrade_stages.go +++ b/internal/app/upgrade_stages.go @@ -48,7 +48,7 @@ func (s *Service) schemaStage(ctx context.Context) contracts.UpgradeStage { return stage } -func activationStage(report *contracts.UpgradeReport, opts contracts.UpgradeOptions) contracts.UpgradeStage { +func activationStage(report *contracts.UpgradeReport, intent upgradeIntent, policy npmInstallPolicy, kind installationKind) contracts.UpgradeStage { stage := contracts.UpgradeStage{Name: "activation"} switch stageStatus(report, "corpus-schema") { case "migration_required": @@ -76,7 +76,7 @@ func activationStage(report *contracts.UpgradeReport, opts contracts.UpgradeOpti } if clients := staleRegistrationClients(report); len(clients) > 0 { stage.Status = "repair_required" - if opts.Yes { + if intent == upgradeApply { stage.Status = "repair_pending" stage.Message = "repair stale MCP registrations, then restart the configured MCP clients (quit and reopen each one)" } else { @@ -85,18 +85,18 @@ func activationStage(report *contracts.UpgradeReport, opts contracts.UpgradeOpti report.Action = stage.Message return stage } - switch report.Context { - case "npx": + switch kind { + case installationNPX: stage.Status = "not_required" stage.Message = "npx resolves versions on demand; no activation needed" - case "other": + case installationOther: stage.Status = "manual" stage.Message = "installation method is not managed automatically" - case "project-npm": + case installationProjectNPM: stage.Status = "manual" stage.Message = "project npm installation; update with npm install --save-dev" - case "global-npm": - switch reportVersionDisposition(report) { + case installationGlobalNPM: + switch reportVersionDisposition(report, kind) { case versionUnavailable: stage.Status = "awaiting_confirmation" stage.Message = "pass --check or --yes to evaluate the latest release" @@ -110,9 +110,9 @@ func activationStage(report *contracts.UpgradeReport, opts contracts.UpgradeOpti stage.Status = "manual" stage.Message = "installed and registry versions cannot be compared safely" case versionUpgrade, versionPrerelease: - switch { - case opts.Yes: - if upgradeGOOS == "windows" { + switch intent { + case upgradeApply: + if policy == npmInstallAfterExit { stage.Status = "manual" stage.Message = "close running GitContribute processes, then run the displayed command" } else { @@ -120,9 +120,9 @@ func activationStage(report *contracts.UpgradeReport, opts contracts.UpgradeOpti stage.Message = "install the latest release, then restart the configured MCP clients (quit and reopen each one)" report.RestartClients = registeredClients(report) } - case opts.Check: + case upgradeCheck: stage.Status = "review" - if reportVersionDisposition(report) == versionPrerelease { + if reportVersionDisposition(report, kind) == versionPrerelease { stage.Message = "a newer prerelease is available; pass --yes to install" } else { stage.Message = "latest release is available; pass --yes to install" @@ -188,16 +188,16 @@ func registeredClients(report *contracts.UpgradeReport) []string { return names } -func rollbackStage(report *contracts.UpgradeReport) contracts.UpgradeStage { +func rollbackStage(report *contracts.UpgradeReport, kind installationKind) contracts.UpgradeStage { stage := contracts.UpgradeStage{Name: "rollback"} - switch report.Context { - case "npx": + switch kind { + case installationNPX: stage.Status = "not_applicable" stage.Message = "no persistent installation to roll back" - case "global-npm": + case installationGlobalNPM: stage.Status = "limited" stage.Message = "npm global installs cannot be rolled back automatically; reinstall the previous version with npm if needed" - case "project-npm": + case installationProjectNPM: stage.Status = "manual" stage.Message = "roll back by reinstalling the previous version in the project" default: diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go index 8d9dacfa..4f9c82b4 100644 --- a/internal/app/upgrade_test.go +++ b/internal/app/upgrade_test.go @@ -20,16 +20,15 @@ import ( ) func TestUpgradeNpxDoesNotInstallGlobalPackage(t *testing.T) { - original := upgradeCommand - t.Cleanup(func() { upgradeCommand = original }) var calls [][]string - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { calls = append(calls, append([]string{name}, args...)) return []byte("1.2.4\n"), nil } t.Setenv("npm_command", "exec") t.Setenv("npm_lifecycle_event", "npx") svc := &Service{version: "1.2.3", paths: config.NewPaths(&config.Env{Home: t.TempDir()})} + svc.stubUpgradeCommand(command) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { t.Fatal(err) @@ -56,17 +55,13 @@ func TestUpgradeDoesNotInstallAcrossSchemaIncompatibility(t *testing.T) { Context: "global-npm", Current: "1.2.3", Latest: "1.2.4", Stages: []contracts.UpgradeStage{{Name: "corpus-schema", Status: status}}, } - if shouldInstall(report, contracts.UpgradeOptions{Yes: true}) { + if shouldInstall(report, upgradeApply, npmInstallAutomatic, installationGlobalNPM) { t.Fatalf("schema status %q authorized installation", status) } } } func TestUpgradeUsesSemanticVersionOrdering(t *testing.T) { - originalGOOS := upgradeGOOS - t.Cleanup(func() { upgradeGOOS = originalGOOS }) - upgradeGOOS = "linux" - tests := []struct { name string current string @@ -88,7 +83,7 @@ func TestUpgradeUsesSemanticVersionOrdering(t *testing.T) { globalRoot := t.TempDir() packageRoot := filepath.Join(globalRoot, "gitcontribute") writePackageJSON(t, packageRoot, tt.current) - details := installDetails{context: "global-npm", npmRoot: globalRoot} + details := installDetails{kind: installationGlobalNPM, npmRoot: globalRoot} stage := npmLauncherStage(details, tt.current, tt.target) if stage.Status != tt.wantStage { t.Fatalf("npm launcher status = %q, want %q", stage.Status, tt.wantStage) @@ -102,11 +97,11 @@ func TestUpgradeUsesSemanticVersionOrdering(t *testing.T) { {Name: "corpus-schema", Status: "current"}, }, } - setCommandAndStatus(report) + setCommandAndStatus(report, installationGlobalNPM) if report.Status != tt.wantStatus { t.Fatalf("status = %q, want %q", report.Status, tt.wantStatus) } - if got := shouldInstall(report, contracts.UpgradeOptions{Yes: true}); got != tt.wantInstall { + if got := shouldInstall(report, upgradeApply, npmInstallAutomatic, installationGlobalNPM); got != tt.wantInstall { t.Fatalf("shouldInstall = %t, want %t", got, tt.wantInstall) } }) @@ -116,7 +111,7 @@ func TestUpgradeUsesSemanticVersionOrdering(t *testing.T) { func TestDetectInstallContextDefaultsOther(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - if got := discoverInstallation(context.Background()).context; got == "npx" { + if got := discoverInstallation(context.Background(), productionUpgradeEnvironment(), os.Executable).kind; got == installationNPX { t.Fatalf("context = %s", got) } } @@ -125,10 +120,10 @@ func TestClassifyNPMExecutableDistinguishesProjectAndGlobalInstalls(t *testing.T globalRoot := filepath.Join(string(filepath.Separator), "usr", "local", "lib", "node_modules") global := filepath.Join(globalRoot, "gitcontribute", "npm", "bin", "native", "linux-x64", "gitcontribute") project := filepath.Join(string(filepath.Separator), "work", "project", "node_modules", "gitcontribute", "npm", "bin", "native", "linux-x64", "gitcontribute") - if got := classifyNPMExecutable(global, globalRoot); got != "global-npm" { + if got := installationKindFromExecutable(global, globalRoot); got != installationGlobalNPM { t.Fatalf("global context = %q", got) } - if got := classifyNPMExecutable(project, globalRoot); got != "project-npm" { + if got := installationKindFromExecutable(project, globalRoot); got != installationProjectNPM { t.Fatalf("project context = %q", got) } } @@ -136,12 +131,6 @@ func TestClassifyNPMExecutableDistinguishesProjectAndGlobalInstalls(t *testing.T func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) home := t.TempDir() globalRoot := filepath.Join(home, "global", "lib", "node_modules") @@ -155,8 +144,7 @@ func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return exe, nil } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { if len(args) >= 2 && args[0] == "root" && args[1] == "--global" { return []byte(globalRoot + "\n"), nil } @@ -168,6 +156,8 @@ func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { } svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return exe, nil }) + svc.stubUpgradeCommand(command) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Check: true}) if err != nil { t.Fatal(err) @@ -195,15 +185,6 @@ func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - originalGOOS := upgradeGOOS - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - upgradeGOOS = originalGOOS - }) - upgradeGOOS = "linux" home := t.TempDir() globalRoot := filepath.Join(home, "global", "lib", "node_modules") @@ -217,9 +198,8 @@ func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return exe, nil } var installArgs []string - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { if len(args) >= 2 && args[0] == "root" && args[1] == "--global" { return []byte(globalRoot + "\n"), nil } @@ -236,6 +216,9 @@ func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { } svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return exe, nil }) + svc.stubUpgradeCommand(command) + svc.stubUpgradePlatform("linux") report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { t.Fatal(err) @@ -256,14 +239,6 @@ func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - originalGOOS := upgradeGOOS - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - upgradeGOOS = originalGOOS - }) home := t.TempDir() globalRoot := filepath.Join(home, "global", "lib", "node_modules") @@ -277,10 +252,8 @@ func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return exe, nil } - upgradeGOOS = "windows" var calls [][]string - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { calls = append(calls, append([]string{name}, args...)) if len(args) >= 2 && args[0] == "root" && args[1] == "--global" { return []byte(globalRoot + "\n"), nil @@ -293,6 +266,9 @@ func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { } svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return exe, nil }) + svc.stubUpgradeCommand(command) + svc.stubUpgradePlatform("windows") report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { t.Fatal(err) @@ -313,12 +289,6 @@ func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { func TestUpgradeProjectNPMReportsManualUpdate(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) home := t.TempDir() projectRoot := filepath.Join(home, "project") @@ -332,8 +302,7 @@ func TestUpgradeProjectNPMReportsManualUpdate(t *testing.T) { } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return exe, nil } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { if len(args) >= 2 && args[0] == "root" && args[1] == "--global" { return []byte("/other/global/lib/node_modules\n"), nil } @@ -345,6 +314,8 @@ func TestUpgradeProjectNPMReportsManualUpdate(t *testing.T) { } svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return exe, nil }) + svc.stubUpgradeCommand(command) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Check: true}) if err != nil { t.Fatal(err) @@ -430,16 +401,6 @@ func TestUpgradeConfiguredRuntimeOutdated(t *testing.T) { func TestUpgradeCombinedInstallActivatesPrivateRuntimeFromInstalledPackage(t *testing.T) { t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - originalGOOS := upgradeGOOS - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - upgradeGOOS = originalGOOS - }) - upgradeGOOS = "linux" - setRuntimeContract(t, "1.2.4", 1) home := t.TempDir() globalRoot := filepath.Join(home, "global", "lib", "node_modules") @@ -452,8 +413,7 @@ func TestUpgradeCombinedInstallActivatesPrivateRuntimeFromInstalledPackage(t *te t.Fatal(err) } writePackageJSON(t, pkgRoot, "1.2.3") - osExecutable = func() (string, error) { return source, nil } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { switch { case name == "npm" && len(args) >= 2 && args[0] == "root": return []byte(globalRoot + "\n"), nil @@ -474,6 +434,10 @@ func TestUpgradeCombinedInstallActivatesPrivateRuntimeFromInstalledPackage(t *te writeCodexConfig(t, home, oldRuntime) svc := testService(t, home, "1.2.3", "") + svc.stubExecutable(func() (string, error) { return source, nil }) + svc.stubUpgradeCommand(command) + svc.stubUpgradePlatform("linux") + setRuntimeContract(t, svc, "1.2.4", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { t.Fatal(err) @@ -488,7 +452,7 @@ func TestUpgradeCombinedInstallActivatesPrivateRuntimeFromInstalledPackage(t *te func TestUpgradeOlderUnmanagedBinaryDoesNotChangePrivateRegistration(t *testing.T) { _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.3") - setRuntimeContract(t, "1.2.3", 1) + setRuntimeContract(t, svc, "1.2.3", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -583,17 +547,6 @@ func TestUpgradeCorpusSchemaIncompatible(t *testing.T) { } func TestUpgradeRecoversNewerSchemaInCanonicalLineage(t *testing.T) { - originalCmd := upgradeCommand - originalExec := osExecutable - originalContract := runtimeContractCommand - originalGOOS := upgradeGOOS - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - runtimeContractCommand = originalContract - upgradeGOOS = originalGOOS - }) - upgradeGOOS = "linux" t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") @@ -631,8 +584,7 @@ func TestUpgradeRecoversNewerSchemaInCanonicalLineage(t *testing.T) { t.Fatal(err) } writePackageJSON(t, packageRoot, "1.2.3") - osExecutable = func() (string, error) { return executable, nil } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { switch { case name == "npm" && len(args) >= 2 && args[0] == "root" && args[1] == "--global": return []byte(globalRoot + "\n"), nil @@ -646,7 +598,7 @@ func TestUpgradeRecoversNewerSchemaInCanonicalLineage(t *testing.T) { return nil, nil } } - runtimeContractCommand = func(_ context.Context, _ string) ([]byte, error) { + contract := func(_ context.Context, _ string) ([]byte, error) { return []byte(fmt.Sprintf( `{"name":"gitcontribute","version":"1.2.4","supported_schema_lineage":%q,"supported_schema_version":%d}`, corpus.SupportedSchemaLineage(), currentSchema+1, @@ -654,6 +606,10 @@ func TestUpgradeRecoversNewerSchemaInCanonicalLineage(t *testing.T) { } svc := testService(t, home, "1.2.3", dbPath) + svc.stubExecutable(func() (string, error) { return executable, nil }) + svc.stubUpgradeCommand(command) + svc.stubRuntimeContract(contract) + svc.stubUpgradePlatform("linux") report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { t.Fatal(err) @@ -664,36 +620,27 @@ func TestUpgradeRecoversNewerSchemaInCanonicalLineage(t *testing.T) { assertStage(t, report, "corpus-schema", "current") } -func setRuntimeContract(t *testing.T, version string, supportedSchema int64) { +func setRuntimeContract(t *testing.T, svc *Service, version string, supportedSchema int64) { t.Helper() - original := runtimeContractCommand - t.Cleanup(func() { runtimeContractCommand = original }) - runtimeContractCommand = func(_ context.Context, _ string) ([]byte, error) { + svc.stubRuntimeContract(func(_ context.Context, _ string) ([]byte, error) { return []byte(fmt.Sprintf(`{"name":"gitcontribute","version":%q,"supported_schema_lineage":%q,"supported_schema_version":%d}`, version, corpus.SupportedSchemaLineage(), supportedSchema)), nil - } + }) } -func setRuntimeContractOutput(t *testing.T, output string) { +func setRuntimeContractOutput(t *testing.T, svc *Service, output string) { t.Helper() - original := runtimeContractCommand - t.Cleanup(func() { runtimeContractCommand = original }) - runtimeContractCommand = func(_ context.Context, _ string) ([]byte, error) { + svc.stubRuntimeContract(func(_ context.Context, _ string) ([]byte, error) { return []byte(output), nil - } + }) } func setupUpgradeActivationTest(t *testing.T, currentVersion, targetVersion, candidateVersion string) (home, source, configPath string, want []byte, svc *Service) { t.Helper() t.Setenv("npm_command", "") t.Setenv("npm_lifecycle_event", "") - originalCmd := upgradeCommand - originalExec := osExecutable - t.Cleanup(func() { - upgradeCommand = originalCmd - osExecutable = originalExec - }) home = t.TempDir() + var executable func() (string, error) if candidateVersion != "" { source = filepath.Join(home, "release", "gitcontribute") if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { @@ -702,11 +649,11 @@ func setupUpgradeActivationTest(t *testing.T, currentVersion, targetVersion, can if err := os.WriteFile(source, []byte("release-"+candidateVersion), 0o755); err != nil { t.Fatal(err) } - osExecutable = func() (string, error) { return source, nil } + executable = func() (string, error) { return source, nil } } else { - osExecutable = func() (string, error) { return "", errors.New("no executable") } + executable = func() (string, error) { return "", errors.New("no executable") } } - upgradeCommand = func(_ context.Context, name string, args ...string) ([]byte, error) { + command := func(_ context.Context, name string, args ...string) ([]byte, error) { if name == "npm" && len(args) >= 2 && args[0] == "view" && args[1] == "gitcontribute" { return []byte(targetVersion + "\n"), nil } @@ -722,6 +669,8 @@ func setupUpgradeActivationTest(t *testing.T, currentVersion, targetVersion, can t.Fatal(err) } svc = testService(t, home, currentVersion, "") + svc.stubExecutable(executable) + svc.stubUpgradeCommand(command) return } @@ -730,7 +679,7 @@ func testService(t *testing.T, home, version, database string) *Service { paths := config.NewPaths(&config.Env{Home: home}) cfg := config.Default() cfg.Database = database - return &Service{version: version, paths: paths, cfg: cfg} + return &Service{version: version, paths: paths, cfg: cfg, upgradeEnv: productionUpgradeEnvironment()} } func writePackageJSON(t *testing.T, root, version string) { @@ -784,9 +733,9 @@ func joinCalls(calls [][]string) string { return strings.Join(parts, "; ") } -func TestRunNPMCommandRejectsEmptyArgumentsWithoutExecution(t *testing.T) { +func TestParseNPMVersionRejectsCommandArguments(t *testing.T) { t.Parallel() - if _, err := runNPMCommand(context.Background(), nil); err == nil || err.Error() != "missing npm arguments" { - t.Fatalf("empty npm arguments error = %v", err) + if _, err := parseNPMVersion("1.2.3 --ignore-scripts"); err == nil { + t.Fatal("unsafe npm version was accepted") } } diff --git a/internal/app/workspace.go b/internal/app/workspace.go index 8dc58f48..71d88c8d 100644 --- a/internal/app/workspace.go +++ b/internal/app/workspace.go @@ -181,7 +181,13 @@ func (s *Service) ShowWorkspace(ctx context.Context, id string) (*contracts.Work if err != nil { return nil, fmt.Errorf("read workspace status: %w", err) } - ws.Dirty = st.Dirty + hasUntracked, err := mgr.HasUntrackedWorkspace(ctx, ws) + if err != nil { + return nil, fmt.Errorf("read workspace untracked files: %w", err) + } + if err := ws.SetChanges(st.Dirty, hasUntracked); err != nil { + return nil, err + } return workspaceResult(ws), nil } @@ -308,8 +314,8 @@ func workspaceResult(ws *workspace.Workspace) *contracts.WorkspaceResult { BaseSHA: ws.BaseSHA, CandidateSHA: ws.CandidateSHA, MergeBase: ws.MergeBase, - Dirty: ws.Dirty, - HasUntracked: ws.HasUntracked, + Dirty: ws.Dirty(), + HasUntracked: ws.HasUntracked(), Ownership: string(ws.Ownership), CreatedAt: formatTime(ws.CreatedAt), } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 67e12918..a87fa07c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,6 +15,7 @@ import ( "github.com/alecthomas/kong" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/discovery" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/lens" gitlog "github.com/morluto/gitcontribute/internal/log" @@ -54,18 +55,6 @@ func (c *CLI) SetLogger(logger *slog.Logger) { // SetTUIRunner wires the optional terminal UI adapter. func (c *CLI) SetTUIRunner(runner contracts.TUIRunner) { c.tui = runner } -// SetInput replaces stdin for commands that explicitly import from "-". -func (c *CLI) SetInput(input io.Reader) { - if input == nil { - input = strings.NewReader("") - } - c.stdin = input -} - -// SetSetupPrompter replaces the interactive setup adapter. It is intended for -// tests and alternate accessible frontends. -func (c *CLI) SetSetupPrompter(prompter SetupPrompter) { c.setupPrompter = prompter } - func (c *CLI) writeProgressf(format string, args ...any) error { if _, err := fmt.Fprintf(c.stderr, format, args...); err != nil { return c.mapError(fmt.Errorf("write progress: %w", err)) @@ -758,9 +747,11 @@ func parseGHArchiveEvents(events string) ([]string, error) { if p == "" { continue } - if !discovery.IsKnownEventType(p) { + eventType, err := discovery.ParseEventType(p) + if err != nil { return nil, fmt.Errorf("unknown GH Archive event type %q", p) } + p = string(eventType) if _, ok := seen[p]; ok { continue } @@ -1117,9 +1108,11 @@ func (c *CLI) runSearch(ctx context.Context, command string, cmd *searchCmd) err return NewCLIError(ExitUsage, fmt.Errorf("limit must be between 1 and %d", maxSearchLimit)) } if opts.Repo != "" { - if _, err := parseRepo(opts.Repo); err != nil { + repo, err := parseRepo(opts.Repo) + if err != nil { return NewCLIError(ExitUsage, fmt.Errorf("invalid --repo value: %w", err)) } + opts.Repo = repo.Owner + "/" + repo.Repo } if selected.UpdatedAfter != "" { updatedAfter, err := time.Parse(time.RFC3339, selected.UpdatedAfter) @@ -1177,11 +1170,11 @@ func (c *CLI) mapError(err error) error { } func parseRepo(s string) (contracts.RepoRef, error) { - parts := strings.Split(s, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + ref, err := domain.ParseRepoRef(s) + if err != nil { return contracts.RepoRef{}, NewCLIError(ExitUsage, fmt.Errorf("invalid repository %q: expected OWNER/REPO", s)) } - return contracts.RepoRef{Owner: parts[0], Repo: parts[1]}, nil + return contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, nil } func (c *CLI) runCoverage(ctx context.Context, cmd *coverageCmd) error { @@ -1462,28 +1455,34 @@ func parseCollectionMember(raw string) (contracts.CollectionMember, error) { } if kind == "repository" { - if _, err := parseRepo(ref); err != nil { + parsed, err := parseRepo(ref) + if err != nil { return contracts.CollectionMember{}, fmt.Errorf("invalid repository reference %q", ref) } + ref = parsed.Owner + "/" + parsed.Repo } else { - if err := parseCollectionThreadRef(ref); err != nil { + parsed, err := parseCollectionThreadRef(ref) + if err != nil { return contracts.CollectionMember{}, err } + ref = parsed } return contracts.CollectionMember{Kind: kind, Ref: ref}, nil } -func parseCollectionThreadRef(ref string) error { +func parseCollectionThreadRef(ref string) (string, error) { parts := strings.Split(ref, "#") if len(parts) != 2 { - return fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", ref) + return "", fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", ref) } - if _, err := parseRepo(parts[0]); err != nil { - return fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", ref) + repository, err := parseRepo(parts[0]) + if err != nil { + return "", fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", ref) } - if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err != nil || n <= 0 { - return fmt.Errorf("invalid thread reference %q: expected positive number", ref) + number, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || number <= 0 { + return "", fmt.Errorf("invalid thread reference %q: expected positive number", ref) } - return nil + return fmt.Sprintf("%s/%s#%d", repository.Owner, repository.Repo, number), nil } diff --git a/internal/cli/cli_dependencies_test.go b/internal/cli/cli_dependencies_test.go new file mode 100644 index 00000000..c4c2f0c7 --- /dev/null +++ b/internal/cli/cli_dependencies_test.go @@ -0,0 +1,15 @@ +package cli + +import ( + "io" + "strings" +) + +func (c *CLI) SetInput(input io.Reader) { + if input == nil { + input = strings.NewReader("") + } + c.stdin = input +} + +func (c *CLI) SetSetupPrompter(prompter SetupPrompter) { c.setupPrompter = prompter } diff --git a/internal/cli/output.go b/internal/cli/output.go index c407ca72..808637d0 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -203,7 +203,7 @@ func writeRadarRelatedWork(b *strings.Builder, values []radar.RelatedWork) { limit := min(len(values), displayLimit) parts := make([]string, 0, limit+1) for _, value := range values[:limit] { - part := value.Relation + " " + value.Ref + part := string(value.Relation) + " " + value.Ref if value.State != "" { part += " [" + value.State + "]" } diff --git a/internal/clustering/candidate_text.go b/internal/clustering/candidate_text.go index 0766b73a..1b0b2849 100644 --- a/internal/clustering/candidate_text.go +++ b/internal/clustering/candidate_text.go @@ -1,13 +1,10 @@ package clustering -import ( - "github.com/morluto/gitcontribute/internal/domain" - "github.com/morluto/gitcontribute/internal/similarity" -) +import "github.com/morluto/gitcontribute/internal/similarity" func duplicateThread(candidate Candidate) similarity.ThreadText { return similarity.ThreadText{ - Ref: similarity.ThreadRef{Repo: candidate.Repo, Kind: domain.ThreadKind(candidate.Kind), Number: candidate.Number}, + Ref: similarity.ThreadRef{Repo: candidate.Repo, Kind: candidate.Kind, Number: candidate.Number}, Title: candidate.Title, Body: candidate.Body, Labels: candidate.Labels, Author: candidate.Author, } } diff --git a/internal/clustering/cluster.go b/internal/clustering/cluster.go index 9db252e0..7487be15 100644 --- a/internal/clustering/cluster.go +++ b/internal/clustering/cluster.go @@ -280,7 +280,7 @@ func SourceRevision(candidates []Candidate) string { } sort.Strings(labels) lines[i] = fmt.Sprintf("%q/%q:%q#%d thread=%d created=%d updated=%d state=%q title=%q body=%q author=%q labels=%q", - strings.ToLower(c.Repo.Owner()), strings.ToLower(c.Repo.Repo()), strings.ToLower(c.Kind), c.Number, + strings.ToLower(c.Repo.Owner()), strings.ToLower(c.Repo.Repo()), strings.ToLower(string(c.Kind)), c.Number, c.ThreadID, c.CreatedAt.UnixNano(), c.UpdatedAt.UnixNano(), c.State, c.Title, c.Body, c.Author, labels) } sort.Strings(lines) diff --git a/internal/clustering/models.go b/internal/clustering/models.go index 9b8e06a1..ca57c131 100644 --- a/internal/clustering/models.go +++ b/internal/clustering/models.go @@ -2,6 +2,7 @@ package clustering import ( "fmt" + "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -11,7 +12,7 @@ import ( type MemberRef struct { Owner string Repo string - Kind string + Kind domain.ThreadKind Number int } @@ -37,9 +38,9 @@ func (m MemberRef) Less(other MemberRef) bool { type Candidate struct { ThreadID int64 Repo domain.RepoRef - Kind string + Kind domain.ThreadKind Number int - State string + State domain.ThreadState Title string Body string Author string @@ -63,7 +64,7 @@ type Member struct { ThreadID int64 Ref MemberRef Title string - State string + State domain.ThreadState Score float64 Reason string Included bool @@ -80,6 +81,21 @@ const ( ClusterRetired ClusterState = "retired" ) +// ParseClusterState converts durable text into a supported cluster lifecycle +// state. +func ParseClusterState(value string) (ClusterState, error) { + switch ClusterState(strings.TrimSpace(value)) { + case ClusterOpen: + return ClusterOpen, nil + case ClusterClosed: + return ClusterClosed, nil + case ClusterRetired: + return ClusterRetired, nil + default: + return "", fmt.Errorf("unsupported cluster state %q", value) + } +} + // Cluster is a group of duplicate-candidate threads. type Cluster struct { ID int64 @@ -104,6 +120,21 @@ const ( OverrideSetCanonical OverrideAction = "set_canonical" ) +// ParseOverrideAction converts durable text into a supported local governance +// decision. +func ParseOverrideAction(value string) (OverrideAction, error) { + switch OverrideAction(strings.TrimSpace(value)) { + case OverrideInclude: + return OverrideInclude, nil + case OverrideExclude: + return OverrideExclude, nil + case OverrideSetCanonical: + return OverrideSetCanonical, nil + default: + return "", fmt.Errorf("unsupported cluster override action %q", value) + } +} + // MembershipOverride records an explicit local include/exclude/canonical decision. type MembershipOverride struct { ID int64 diff --git a/internal/clustering/neighbors.go b/internal/clustering/neighbors.go index 5195c5e8..29bb216b 100644 --- a/internal/clustering/neighbors.go +++ b/internal/clustering/neighbors.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/ranking" "github.com/morluto/gitcontribute/internal/similarity" ) @@ -13,7 +14,7 @@ type Neighbor struct { ThreadID int64 Ref MemberRef Title string - State string + State domain.ThreadState Score float64 Reason string } diff --git a/internal/clustering/references.go b/internal/clustering/references.go index d39305ef..bbcc3831 100644 --- a/internal/clustering/references.go +++ b/internal/clustering/references.go @@ -12,7 +12,7 @@ func ExtractMemberRefs(text string, defaultRepo domain.RepoRef) []MemberRef { refs := similarity.ExtractRefs(text, defaultRepo) out := make([]MemberRef, len(refs)) for i, ref := range refs { - out[i] = MemberRef{Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: string(ref.Kind), Number: ref.Number} + out[i] = MemberRef{Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: ref.Kind, Number: ref.Number} } return out } diff --git a/internal/commitplan/models.go b/internal/commitplan/models.go index 722ef0b8..3f3dcd2d 100644 --- a/internal/commitplan/models.go +++ b/internal/commitplan/models.go @@ -15,20 +15,40 @@ type UntrackedFile struct { } // Unit is the smallest assignable file or hunk change. +type UnitKind string + +const ( + FileUnit UnitKind = "file" + HunkUnit UnitKind = "hunk" + UntrackedUnit UnitKind = "untracked" +) + +type FileOperation string + +const ( + OperationModify FileOperation = "modify" + OperationAdd FileOperation = "add" + OperationDelete FileOperation = "delete" + OperationRename FileOperation = "rename" + OperationCopy FileOperation = "copy" + OperationBinary FileOperation = "binary" + OperationMode FileOperation = "mode" +) + type Unit struct { - ID string `json:"id"` - Kind string `json:"kind"` - Path string `json:"path"` - OldPath string `json:"old_path,omitempty"` - Operation string `json:"operation"` - OldStart int32 `json:"old_start,omitempty"` - OldLines int32 `json:"old_lines,omitempty"` - NewStart int32 `json:"new_start,omitempty"` - NewLines int32 `json:"new_lines,omitempty"` - Patch string `json:"patch,omitempty"` - ContentHash string `json:"content_sha256"` - Generated bool `json:"generated"` - WhitespaceOnly bool `json:"whitespace_only"` + ID string `json:"id"` + Kind UnitKind `json:"kind"` + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` + Operation FileOperation `json:"operation"` + OldStart int32 `json:"old_start,omitempty"` + OldLines int32 `json:"old_lines,omitempty"` + NewStart int32 `json:"new_start,omitempty"` + NewLines int32 `json:"new_lines,omitempty"` + Patch string `json:"patch,omitempty"` + ContentHash string `json:"content_sha256"` + Generated bool `json:"generated"` + WhitespaceOnly bool `json:"whitespace_only"` } // Warning describes a condition requiring human or agent judgment. diff --git a/internal/commitplan/planner.go b/internal/commitplan/planner.go index 2b1142e1..5cc2756f 100644 --- a/internal/commitplan/planner.go +++ b/internal/commitplan/planner.go @@ -63,7 +63,7 @@ func Inspect(ctx context.Context, snapshot Snapshot) (Inventory, error) { if path == "" || objectID == "" { return Inventory{}, errors.New("untracked path and object ID are required") } - unit := newUnit("untracked", path, "", "add", []byte(path+"\x00"+objectID)) + unit := newUnit(UntrackedUnit, path, "", OperationAdd, []byte(path+"\x00"+objectID)) unit.ContentHash = objectID unit.Generated = generatedPath(path) result.Units = append(result.Units, unit) @@ -105,8 +105,8 @@ func fileUnits(file *diff.FileDiff) ([]Unit, []Warning, error) { generated := generatedPath(path) var units []Unit metadata := strings.Join(file.Extended, "\n") + "\n" + file.OrigName + "\n" + file.NewName - if operation != "modify" || len(file.Hunks) == 0 { - unit := newUnit("file", path, oldPath, operation, []byte(metadata)) + if operation != OperationModify || len(file.Hunks) == 0 { + unit := newUnit(FileUnit, path, oldPath, operation, []byte(metadata)) unit.Generated = generated units = append(units, unit) } @@ -115,8 +115,8 @@ func fileUnits(file *diff.FileDiff) ([]Unit, []Warning, error) { if err != nil { return nil, nil, fmt.Errorf("render hunk for %s: %w", path, err) } - identity := []byte(path + "\x00" + oldPath + "\x00" + operation + "\x00" + string(printed)) - unit := newUnit("hunk", path, oldPath, operation, identity) + identity := []byte(path + "\x00" + oldPath + "\x00" + string(operation) + "\x00" + string(printed)) + unit := newUnit(HunkUnit, path, oldPath, operation, identity) unit.OldStart, unit.OldLines = hunk.OrigStartLine, hunk.OrigLines unit.NewStart, unit.NewLines = hunk.NewStartLine, hunk.NewLines unit.Patch = string(printed) @@ -128,8 +128,8 @@ func fileUnits(file *diff.FileDiff) ([]Unit, []Warning, error) { if generated { warnings = append(warnings, Warning{Code: "generated_file", Message: "generated or snapshot file needs regeneration ownership", Path: path}) } - if operation == "rename" || operation == "copy" { - warnings = append(warnings, Warning{Code: operation, Message: operation + " metadata is an indivisible file unit", Path: path}) + if operation == OperationRename || operation == OperationCopy { + warnings = append(warnings, Warning{Code: string(operation), Message: string(operation) + " metadata is an indivisible file unit", Path: path}) } if binaryFile(file) { warnings = append(warnings, Warning{Code: "binary_file", Message: "binary change is one indivisible file unit", Path: path}) @@ -142,9 +142,9 @@ func fileUnits(file *diff.FileDiff) ([]Unit, []Warning, error) { return units, warnings, nil } -func newUnit(kind, path, oldPath, operation string, identity []byte) Unit { +func newUnit(kind UnitKind, path, oldPath string, operation FileOperation, identity []byte) Unit { hash := digest(identity) - return Unit{ID: kind + ":" + hash, Kind: kind, Path: path, OldPath: oldPath, Operation: operation, ContentHash: hash} + return Unit{ID: string(kind) + ":" + hash, Kind: kind, Path: path, OldPath: oldPath, Operation: operation, ContentHash: hash} } func cleanDiffPath(path string) string { @@ -158,23 +158,23 @@ func cleanDiffPath(path string) string { return path } -func fileOperation(file *diff.FileDiff) string { +func fileOperation(file *diff.FileDiff) FileOperation { extended := strings.Join(file.Extended, "\n") switch { case strings.Contains(extended, "rename from "): - return "rename" + return OperationRename case strings.Contains(extended, "copy from "): - return "copy" + return OperationCopy case file.OrigName == "/dev/null" || strings.Contains(extended, "new file mode "): - return "add" + return OperationAdd case file.NewName == "/dev/null" || strings.Contains(extended, "deleted file mode "): - return "delete" + return OperationDelete case binaryFile(file): - return "binary" + return OperationBinary case strings.Contains(extended, "old mode ") && strings.Contains(extended, "new mode "): - return "mode" + return OperationMode default: - return "modify" + return OperationModify } } diff --git a/internal/concern/models.go b/internal/concern/models.go index d180c02b..e610a367 100644 --- a/internal/concern/models.go +++ b/internal/concern/models.go @@ -3,8 +3,10 @@ package concern import ( + "encoding/json" "errors" "fmt" + "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -29,6 +31,14 @@ const ( StatusResolved Status = "resolved" ) +func ParseStatus(value string) (Status, error) { + status := Status(strings.TrimSpace(value)) + if !validStatus(status) { + return "", ErrInvalidStatus + } + return status, nil +} + // LinkKind describes an explicit, non-inferred relationship. type LinkKind string @@ -45,6 +55,14 @@ const ( LinkOpportunity LinkKind = "opportunity" ) +func ParseLinkKind(value string) (LinkKind, error) { + kind := LinkKind(strings.TrimSpace(value)) + if !validLinkKind(kind) { + return "", ErrInvalidLink + } + return kind, nil +} + // Link points to another local record or a credential-free repository ref. type Link struct { Kind LinkKind @@ -64,11 +82,103 @@ type StatusChange struct { // Promotion preserves the local concern's downstream workflow identity. type Promotion struct { - Kind string - InvestigationID string - HypothesisID string - OpportunityID string - PromotedAt time.Time + kind promotionKind + investigationID string + hypothesisID string + opportunityID string + promotedAt time.Time +} + +type promotionKind uint8 + +const ( + investigationPromotion promotionKind = iota + 1 + opportunityPromotion +) + +func NewInvestigationPromotion(investigationID, hypothesisID string, promotedAt time.Time) (*Promotion, error) { + return parsePromotion("investigation", investigationID, hypothesisID, "", promotedAt) +} + +func NewOpportunityPromotion(investigationID, hypothesisID, opportunityID string, promotedAt time.Time) (*Promotion, error) { + return parsePromotion("opportunity", investigationID, hypothesisID, opportunityID, promotedAt) +} + +func parsePromotion(kind, investigationID, hypothesisID, opportunityID string, promotedAt time.Time) (*Promotion, error) { + investigationID = strings.TrimSpace(investigationID) + hypothesisID = strings.TrimSpace(hypothesisID) + opportunityID = strings.TrimSpace(opportunityID) + if investigationID == "" || hypothesisID == "" { + return nil, errors.New("promotion investigation and hypothesis identities are required") + } + switch strings.TrimSpace(kind) { + case "investigation": + if opportunityID != "" { + return nil, errors.New("investigation promotion cannot carry an opportunity identity") + } + return &Promotion{kind: investigationPromotion, investigationID: investigationID, hypothesisID: hypothesisID, promotedAt: promotedAt}, nil + case "opportunity": + if opportunityID == "" { + return nil, errors.New("opportunity promotion identity is required") + } + return &Promotion{kind: opportunityPromotion, investigationID: investigationID, hypothesisID: hypothesisID, opportunityID: opportunityID, promotedAt: promotedAt}, nil + default: + return nil, fmt.Errorf("unsupported concern promotion kind %q", kind) + } +} + +func (p Promotion) Kind() string { + if p.kind == opportunityPromotion { + return "opportunity" + } + if p.kind == investigationPromotion { + return "investigation" + } + return "" +} + +func (p Promotion) InvestigationID() string { return p.investigationID } +func (p Promotion) HypothesisID() string { return p.hypothesisID } +func (p Promotion) OpportunityID() string { return p.opportunityID } +func (p Promotion) PromotedAt() time.Time { return p.promotedAt } + +func (p Promotion) valid() bool { + return p.Kind() != "" && p.investigationID != "" && p.hypothesisID != "" && + (p.kind != opportunityPromotion || p.opportunityID != "") && + (p.kind != investigationPromotion || p.opportunityID == "") +} + +// MarshalJSON retains the pre-sealing durable payload field names. +func (p Promotion) MarshalJSON() ([]byte, error) { + if !p.valid() { + return nil, errors.New("invalid concern promotion") + } + return json.Marshal(struct { + Kind string + InvestigationID string + HypothesisID string + OpportunityID string + PromotedAt time.Time + }{p.Kind(), p.InvestigationID(), p.HypothesisID(), p.OpportunityID(), p.PromotedAt()}) +} + +func (p *Promotion) UnmarshalJSON(data []byte) error { + var input struct { + Kind string + InvestigationID string + HypothesisID string + OpportunityID string + PromotedAt time.Time + } + if err := json.Unmarshal(data, &input); err != nil { + return err + } + parsed, err := parsePromotion(input.Kind, input.InvestigationID, input.HypothesisID, input.OpportunityID, input.PromotedAt) + if err != nil { + return err + } + *p = *parsed + return nil } // Concern is a durable local intake record. WorkspaceID is an opaque corpus @@ -119,22 +229,16 @@ func (c *Concern) ParseStored() error { return fmt.Errorf("concern audit entry %d has an unsupported status", i) } } + for i, link := range c.Links { + if !validLinkKind(link.Kind) || strings.TrimSpace(link.TargetType) == "" || strings.TrimSpace(link.TargetID) == "" { + return fmt.Errorf("concern link %d is invalid", i) + } + } if c.Status == StatusPromoted && c.Promotion == nil { return errors.New("promoted concern is missing promotion identity") } - if c.Promotion != nil { - switch c.Promotion.Kind { - case "investigation": - if c.Promotion.InvestigationID == "" || c.Promotion.HypothesisID == "" || c.Promotion.OpportunityID != "" { - return errors.New("invalid investigation promotion identity") - } - case "opportunity": - if c.Promotion.InvestigationID == "" || c.Promotion.HypothesisID == "" || c.Promotion.OpportunityID == "" { - return errors.New("invalid opportunity promotion identity") - } - default: - return fmt.Errorf("unsupported concern promotion kind %q", c.Promotion.Kind) - } + if c.Promotion != nil && !c.Promotion.valid() { + return errors.New("invalid concern promotion identity") } return nil } diff --git a/internal/concern/models_test.go b/internal/concern/models_test.go new file mode 100644 index 00000000..5dbc8d65 --- /dev/null +++ b/internal/concern/models_test.go @@ -0,0 +1,41 @@ +package concern + +import ( + "encoding/json" + "testing" + "time" +) + +func TestPromotionJSONRoundTripPreservesSealedVariant(t *testing.T) { + t.Parallel() + at := time.Unix(100, 0).UTC() + promotion, err := NewOpportunityPromotion(" inv-1 ", " hyp-1 ", " opp-1 ", at) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(promotion) + if err != nil { + t.Fatal(err) + } + var decoded Promotion + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Kind() != "opportunity" || decoded.InvestigationID() != "inv-1" || decoded.HypothesisID() != "hyp-1" || decoded.OpportunityID() != "opp-1" || !decoded.PromotedAt().Equal(at) { + t.Fatalf("decoded promotion = %+v", decoded) + } +} + +func TestPromotionJSONRejectsMixedIdentity(t *testing.T) { + t.Parallel() + var promotion Promotion + if err := json.Unmarshal([]byte(`{"Kind":"investigation","InvestigationID":"inv-1","HypothesisID":"hyp-1","OpportunityID":"opp-1"}`), &promotion); err == nil { + t.Fatal("expected investigation promotion with an opportunity identity to be rejected") + } + if status, err := ParseStatus(" accepted "); err != nil || status != StatusAccepted { + t.Fatalf("parsed status = %q, %v", status, err) + } + if kind, err := ParseLinkKind(" hotspot "); err != nil || kind != LinkHotspot { + t.Fatalf("parsed link kind = %q, %v", kind, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ca64b3a9..317abfb2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,8 +25,34 @@ type Config struct { // TokenSource describes how to obtain a GitHub token. The token itself is never // persisted here. type TokenSource struct { - Method string `toml:"method"` - Key string `toml:"key,omitempty"` + Method TokenSourceMethod `toml:"method"` + Key string `toml:"key,omitempty"` +} + +// TokenSourceMethod identifies one supported credential resolver. +type TokenSourceMethod string + +const ( + TokenSourceNone TokenSourceMethod = "none" + TokenSourceEnv TokenSourceMethod = "env" + TokenSourceGHCLI TokenSourceMethod = "gh-cli" + TokenSourceKeyring TokenSourceMethod = "keyring" +) + +// ParseTokenSourceMethod canonicalizes boundary input into a supported method. +func ParseTokenSourceMethod(value string) (TokenSourceMethod, error) { + switch TokenSourceMethod(strings.ToLower(strings.TrimSpace(value))) { + case TokenSourceNone: + return TokenSourceNone, nil + case TokenSourceEnv: + return TokenSourceEnv, nil + case TokenSourceGHCLI: + return TokenSourceGHCLI, nil + case TokenSourceKeyring: + return TokenSourceKeyring, nil + default: + return "", fmt.Errorf("invalid token_source method %q", value) + } } // Crawl holds crawl budgets and concurrency limits. @@ -41,7 +67,7 @@ type Crawl struct { // are left empty so that ApplyDefaults can resolve them against Paths. func Default() *Config { return &Config{ - TokenSource: TokenSource{Method: "none"}, + TokenSource: TokenSource{Method: TokenSourceNone}, Crawl: Crawl{ Budget: 1000, Concurrency: 4, @@ -59,6 +85,13 @@ func Load(r io.Reader) (*Config, error) { if err := dec.Decode(&cfg); err != nil { return nil, fmt.Errorf("decode config: %w", err) } + if cfg.TokenSource.Method != "" { + method, err := ParseTokenSourceMethod(string(cfg.TokenSource.Method)) + if err != nil { + return nil, err + } + cfg.TokenSource.Method = method + } return &cfg, nil } @@ -162,7 +195,7 @@ func ApplyDefaults(cfg *Config, paths *Paths) error { return errors.New("config is nil") } if cfg.TokenSource.Method == "" { - cfg.TokenSource.Method = "none" + cfg.TokenSource.Method = TokenSourceNone } if cfg.Crawl.Budget == 0 { cfg.Crawl.Budget = 1000 @@ -200,7 +233,11 @@ func ApplyEnv(cfg *Config, getenv func(string) string) error { cfg.Database = v } if v := getenv("GITCONTRIBUTE_TOKEN_SOURCE_METHOD"); v != "" { - cfg.TokenSource.Method = strings.ToLower(v) + method, err := ParseTokenSourceMethod(v) + if err != nil { + return fmt.Errorf("GITCONTRIBUTE_TOKEN_SOURCE_METHOD: %w", err) + } + cfg.TokenSource.Method = method } if v := getenv("GITCONTRIBUTE_TOKEN_SOURCE_KEY"); v != "" { cfg.TokenSource.Key = v @@ -241,14 +278,17 @@ func Validate(cfg *Config) error { return errors.New("database path must be set") } - switch cfg.TokenSource.Method { - case "none", "gh-cli": - case "env", "keyring": + method, err := ParseTokenSourceMethod(string(cfg.TokenSource.Method)) + if err != nil { + return err + } + cfg.TokenSource.Method = method + switch method { + case TokenSourceNone, TokenSourceGHCLI: + case TokenSourceEnv, TokenSourceKeyring: if strings.TrimSpace(cfg.TokenSource.Key) == "" { - return fmt.Errorf("token_source key is required when method is %s", cfg.TokenSource.Method) + return fmt.Errorf("token_source key is required when method is %s", method) } - default: - return fmt.Errorf("invalid token_source method %q", cfg.TokenSource.Method) } if cfg.Crawl.Budget <= 0 { diff --git a/internal/contracts/application_contracts.go b/internal/contracts/application_contracts.go index 70a8bf7f..a1554852 100644 --- a/internal/contracts/application_contracts.go +++ b/internal/contracts/application_contracts.go @@ -50,17 +50,32 @@ type UpgradeOptions struct { } type MetadataResult struct { - Name string `json:"name"` - Version string `json:"version"` - GoVersion string `json:"go_version"` - OS string `json:"os"` - Architecture string `json:"architecture"` - SchemaVersion int64 `json:"schema_version"` - SupportedSchemaVersion int64 `json:"supported_schema_version"` - ConfigPath string `json:"config_path"` - CorpusPath string `json:"corpus_path"` - Capabilities []string `json:"capabilities"` - Features map[string]bool `json:"features"` + Name string `json:"name"` + Version string `json:"version"` + GoVersion string `json:"go_version"` + OS string `json:"os"` + Architecture string `json:"architecture"` + SchemaVersion int64 `json:"schema_version"` + SupportedSchemaVersion int64 `json:"supported_schema_version"` + ConfigPath string `json:"config_path"` + CorpusPath string `json:"corpus_path"` + Capabilities []string `json:"capabilities"` + Features MetadataFeatures `json:"features"` +} + +// MetadataFeatures is the closed feature surface advertised by this build. +// Capabilities remain the extensible discovery list; these stable keys retain +// explicit false values for clients that negotiate individual features. +type MetadataFeatures struct { + ContributionRadar bool `json:"contribution_radar"` + ContributionReadiness bool `json:"contribution_readiness"` + EvidenceFreshness bool `json:"evidence_freshness"` + GitHubMutations bool `json:"github_mutations"` + MCPStdio bool `json:"mcp_stdio"` + SemanticSearch bool `json:"semantic_search"` + ThreadInvestigation bool `json:"thread_investigation"` + ThreadResearch bool `json:"thread_research"` + ValidationExec bool `json:"validation_exec"` } // ConfigureOptions uses pointers so callers can distinguish an omitted value @@ -94,9 +109,11 @@ type ConfigureResult struct { } type ControlCounts struct { - Repositories int `json:"repositories"` - Threads int `json:"threads"` - Sources int `json:"sources"` + Repositories int `json:"repositories"` + Threads int `json:"threads"` + Sources int `json:"sources"` + // FrontierReady is retained for response compatibility after the unused + // crawl frontier was retired. It is always zero. FrontierReady int `json:"frontier_ready"` ActiveRuns int `json:"active_runs"` ActiveJobs int `json:"active_jobs"` diff --git a/internal/contracts/archive_contracts.go b/internal/contracts/archive_contracts.go index 92e1239a..4a39dcc8 100644 --- a/internal/contracts/archive_contracts.go +++ b/internal/contracts/archive_contracts.go @@ -307,27 +307,29 @@ type CorpusLifecycleService interface { // CorpusRepositoryRemovalResult describes a repository-removal preview or result. type CorpusRepositoryRemovalResult struct { - Repo string `json:"repo"` - DryRun bool `json:"dry_run"` - Revision string `json:"revision"` - RepositoryObservations int `json:"repository_observations"` - Threads int `json:"threads"` - ThreadObservations int `json:"thread_observations"` - FacetObservations int `json:"facet_observations"` - FacetCoverage int `json:"facet_coverage"` - CodeSnapshots int `json:"code_snapshots"` - CodeDocuments int `json:"code_documents"` - Dossiers int `json:"dossiers"` - ClusterRuns int `json:"cluster_runs"` - Clusters int `json:"clusters"` - FrontierItems int `json:"frontier_items"` - DetachedTriageEvents int `json:"detached_triage_events"` - RemovedPortfolioLinks int `json:"removed_portfolio_links"` - RemovedResolutionRecords int `json:"removed_resolution_records"` - RemovedSignalSnapshots int `json:"removed_signal_snapshots"` - DetachedClusterMembers int `json:"detached_cluster_members"` - PreservedInvestigations int `json:"preserved_investigations"` - PreservedCrossRepoReferences int `json:"preserved_cross_repo_references"` + Repo string `json:"repo"` + DryRun bool `json:"dry_run"` + Revision string `json:"revision"` + RepositoryObservations int `json:"repository_observations"` + Threads int `json:"threads"` + ThreadObservations int `json:"thread_observations"` + FacetObservations int `json:"facet_observations"` + FacetCoverage int `json:"facet_coverage"` + CodeSnapshots int `json:"code_snapshots"` + CodeDocuments int `json:"code_documents"` + Dossiers int `json:"dossiers"` + ClusterRuns int `json:"cluster_runs"` + Clusters int `json:"clusters"` + // FrontierItems is retained for response compatibility after the unused + // crawl frontier was retired. It is always zero. + FrontierItems int `json:"frontier_items"` + DetachedTriageEvents int `json:"detached_triage_events"` + RemovedPortfolioLinks int `json:"removed_portfolio_links"` + RemovedResolutionRecords int `json:"removed_resolution_records"` + RemovedSignalSnapshots int `json:"removed_signal_snapshots"` + DetachedClusterMembers int `json:"detached_cluster_members"` + PreservedInvestigations int `json:"preserved_investigations"` + PreservedCrossRepoReferences int `json:"preserved_cross_repo_references"` } // CorpusProjectionResult describes one derived corpus projection. diff --git a/internal/contracts/setup_tracking_contracts.go b/internal/contracts/setup_tracking_contracts.go index bcd610ec..536128a6 100644 --- a/internal/contracts/setup_tracking_contracts.go +++ b/internal/contracts/setup_tracking_contracts.go @@ -91,10 +91,6 @@ type SetupOptions struct { // Version is the release used for persistent CLI or private MCP runtime // installation. Empty values inherit the running service version. Version string - // Executable is the packaged native program copied for MCP-only setup. It is - // injectable so installation behavior can be tested without copying the test - // process itself. - Executable string } // SetupStep describes one independently observable setup effect. Status is a diff --git a/internal/contribution/models.go b/internal/contribution/models.go index b5a275a5..7149bd3b 100644 --- a/internal/contribution/models.go +++ b/internal/contribution/models.go @@ -33,7 +33,7 @@ type DraftIdentity struct { ID string Revision int Repository string - Kind string + Kind domain.ThreadKind TitleBytes int BodyBytes int TitleSHA256 string @@ -46,11 +46,19 @@ type DraftIdentity struct { // the draft. type DraftDiagnostic struct { Code string - Severity string + Severity DraftDiagnosticSeverity Message string ByteOffset int } +// DraftDiagnosticSeverity distinguishes blocking draft errors from warnings. +type DraftDiagnosticSeverity string + +const ( + DraftDiagnosticError DraftDiagnosticSeverity = "error" + DraftDiagnosticWarning DraftDiagnosticSeverity = "warning" +) + // DraftArtifact is the common exact-byte view of an issue or pull-request // draft revision. type DraftArtifact struct { diff --git a/internal/contribution/renderer.go b/internal/contribution/renderer.go index f0756ab6..32ba8cb8 100644 --- a/internal/contribution/renderer.go +++ b/internal/contribution/renderer.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" ) @@ -61,7 +62,7 @@ func (r *Renderer) RenderIssue(in IssueInput) (*IssueDraft, error) { RenderedAt: time.Now().UTC(), ManifestID: in.ManifestID, } - populateDraftIdentity(&draft.DraftIdentity, in.Repo.String(), "issue", draft.Title, draft.Body, in.Evidence) + populateDraftIdentity(&draft.DraftIdentity, in.Repo.String(), domain.IssueKind, draft.Title, draft.Body, in.Evidence) draft.Warnings = append(draft.Warnings, ValidateRequiredTemplateSections([]byte(draft.Body), []byte(in.Guidance))...) return draft, nil } @@ -110,7 +111,7 @@ func (r *Renderer) RenderPullRequest(in PullRequestInput) (*PullRequestDraft, er RenderedAt: time.Now().UTC(), ManifestID: in.ManifestID, } - populateDraftIdentity(&draft.DraftIdentity, in.Repo.String(), "pull_request", draft.Title, draft.Body, in.Evidence) + populateDraftIdentity(&draft.DraftIdentity, in.Repo.String(), domain.PullRequestKind, draft.Title, draft.Body, in.Evidence) draft.Warnings = append(draft.Warnings, ValidateRequiredTemplateSections([]byte(draft.Body), []byte(in.Guidance))...) return draft, nil } @@ -202,14 +203,14 @@ func writeProofRun(b *strings.Builder, label string, run *evidence.ValidationRun if identity == "" { identity = run.WorkspaceSnapshotBefore } - if run.ExecutionOrigin == "external" && run.External != nil { + if run.ExecutionOrigin == evidence.ExecutionOriginExternal && run.External != nil { identity = run.External.Repository + "@" + run.External.Revision + " artifact " + run.External.ArtifactSHA256 } fmt.Fprintf(b, " - %s: %s (exit %d", label, run.Classification, run.ExitCode) if identity != "" { fmt.Fprintf(b, ", source `%s`", boundedText(identity, 160)) } - if run.ExecutionOrigin == "external" && run.External != nil { + if run.ExecutionOrigin == evidence.ExecutionOriginExternal && run.External != nil { fmt.Fprintf(b, ", external receipt `%s` from %s", run.External.ReceiptSHA256, run.External.Producer) } b.WriteString(")\n") diff --git a/internal/contribution/validate.go b/internal/contribution/validate.go index 2bad077f..69e71a56 100644 --- a/internal/contribution/validate.go +++ b/internal/contribution/validate.go @@ -4,12 +4,14 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "fmt" "regexp" "slices" "strings" "unicode/utf8" "github.com/google/uuid" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" @@ -22,7 +24,7 @@ var ( validClosing = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+/[\w.-]+)?#\d+\b`) ) -func populateDraftIdentity(identity *DraftIdentity, repo, kind, title, body string, all []*evidence.Evidence) { +func populateDraftIdentity(identity *DraftIdentity, repo string, kind domain.ThreadKind, title, body string, all []*evidence.Evidence) { identity.ID = uuid.NewString() identity.Repository = repo identity.Kind = kind @@ -42,13 +44,67 @@ func populateDraftIdentity(identity *DraftIdentity, repo, kind, title, body stri // EnsureDraftIdentity binds exact bytes for callers that construct drafts // directly rather than through Renderer. -func EnsureDraftIdentity(identity *DraftIdentity, repo, kind, title, body string) { +func EnsureDraftIdentity(identity *DraftIdentity, repo string, kind domain.ThreadKind, title, body string) { if identity.ID != "" { return } populateDraftIdentity(identity, repo, kind, title, body, nil) } +// ParseStored validates and canonicalizes a persisted issue draft. +func (d *IssueDraft) ParseStored() error { + if d == nil { + return fmt.Errorf("issue draft is required") + } + return d.parseStored(domain.IssueKind, d.Title, d.Body) +} + +// ParseStored validates and canonicalizes a persisted pull-request draft. +func (d *PullRequestDraft) ParseStored() error { + if d == nil { + return fmt.Errorf("pull request draft is required") + } + return d.parseStored(domain.PullRequestKind, d.Title, d.Body) +} + +func (d *DraftIdentity) parseStored(expected domain.ThreadKind, title, body string) error { + if d.ID == "" || d.Revision < 1 { + return fmt.Errorf("draft id and positive revision are required") + } + kind, err := domain.ParseThreadKind(string(d.Kind)) + if err != nil { + return err + } + if kind != expected { + return fmt.Errorf("stored %s draft has kind %q", expected, d.Kind) + } + d.Kind = kind + if d.TitleBytes != len([]byte(title)) || d.BodyBytes != len([]byte(body)) || + d.TitleSHA256 != sha256Text(title) || d.BodySHA256 != sha256Text(body) { + return fmt.Errorf("stored draft byte identity does not match its content") + } + for index := range d.Warnings { + severity, err := ParseDraftDiagnosticSeverity(string(d.Warnings[index].Severity)) + if err != nil { + return err + } + d.Warnings[index].Severity = severity + } + return nil +} + +// ParseDraftDiagnosticSeverity parses a durable draft diagnostic severity. +func ParseDraftDiagnosticSeverity(value string) (DraftDiagnosticSeverity, error) { + switch DraftDiagnosticSeverity(value) { + case DraftDiagnosticError: + return DraftDiagnosticError, nil + case DraftDiagnosticWarning: + return DraftDiagnosticWarning, nil + default: + return "", fmt.Errorf("unsupported draft diagnostic severity %q", value) + } +} + func sha256Text(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) @@ -59,7 +115,7 @@ func sha256Text(value string) string { func ValidateDraftBytes(title, body []byte) []DraftDiagnostic { var out []DraftDiagnostic if !utf8.Valid(title) || !utf8.Valid(body) { - return []DraftDiagnostic{{Code: "invalid_utf8", Severity: "error", Message: "title and body must be valid UTF-8"}} + return []DraftDiagnostic{{Code: "invalid_utf8", Severity: DraftDiagnosticError, Message: "title and body must be valid UTF-8"}} } document := goldmark.DefaultParser().Parse(text.NewReader(body)) _ = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { @@ -74,22 +130,22 @@ func ValidateDraftBytes(title, body []byte) []DraftDiagnostic { value := segment.Value(body) if at := bytes.Index(value, []byte(`\n`)); at >= 0 { out = append(out, DraftDiagnostic{ - Code: "literal_escaped_newline", Severity: "warning", + Code: "literal_escaped_newline", Severity: DraftDiagnosticWarning, Message: "literal \\\\n appears in a prose region", ByteOffset: segment.Start + at, }) } return ast.WalkContinue, nil }) if offset := unmatchedFenceOffset(body); offset >= 0 { - out = append(out, DraftDiagnostic{Code: "unterminated_fence", Severity: "error", Message: "fenced code block is unterminated", ByteOffset: offset}) + out = append(out, DraftDiagnostic{Code: "unterminated_fence", Severity: DraftDiagnosticError, Message: "fenced code block is unterminated", ByteOffset: offset}) } if match := templatePlaceholder.FindIndex(body); match != nil { - out = append(out, DraftDiagnostic{Code: "unresolved_placeholder", Severity: "error", Message: "unresolved template placeholder", ByteOffset: match[0]}) + out = append(out, DraftDiagnostic{Code: "unresolved_placeholder", Severity: DraftDiagnosticError, Message: "unresolved template placeholder", ByteOffset: match[0]}) } for _, line := range bytes.Split(body, []byte{'\n'}) { if closingCandidate.Match(line) && !validClosing.Match(line) { offset := bytes.Index(body, line) - out = append(out, DraftDiagnostic{Code: "malformed_closing_reference", Severity: "error", Message: "malformed GitHub closing reference", ByteOffset: offset}) + out = append(out, DraftDiagnostic{Code: "malformed_closing_reference", Severity: DraftDiagnosticError, Message: "malformed GitHub closing reference", ByteOffset: offset}) break } } @@ -113,7 +169,7 @@ func ValidateRequiredTemplateSections(body, guidance []byte) []DraftDiagnostic { for heading := range required { if _, ok := present[heading]; !ok { out = append(out, DraftDiagnostic{ - Code: "required_template_section_missing", Severity: "error", + Code: "required_template_section_missing", Severity: DraftDiagnosticError, Message: "required repository template section is absent: " + heading, }) } diff --git a/internal/contribution/validate_test.go b/internal/contribution/validate_test.go index 93fcc39c..bd950db2 100644 --- a/internal/contribution/validate_test.go +++ b/internal/contribution/validate_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "unicode/utf8" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestValidateDraftBytesUnderstandsMarkdownSourceRegions(t *testing.T) { @@ -28,7 +30,7 @@ func TestValidateDraftBytesUnderstandsMarkdownSourceRegions(t *testing.T) { func TestDraftIdentityUsesExactUnicodeAndCRLFBytes(t *testing.T) { identity := DraftIdentity{} title, body := "Fix ✓", "a\r\nb\n" - EnsureDraftIdentity(&identity, "owner/repo", "pull_request", title, body) + EnsureDraftIdentity(&identity, "owner/repo", domain.PullRequestKind, title, body) if identity.TitleBytes != len([]byte(title)) || identity.BodyBytes != len([]byte(body)) { t.Fatalf("byte lengths = %d/%d", identity.TitleBytes, identity.BodyBytes) } @@ -37,6 +39,34 @@ func TestDraftIdentityUsesExactUnicodeAndCRLFBytes(t *testing.T) { } } +func TestStoredDraftRejectsInvalidIdentityAndDiagnosticSeverity(t *testing.T) { + newDraft := func() *IssueDraft { + draft := &IssueDraft{OpportunityID: "opp", Title: "title", Body: "body"} + EnsureDraftIdentity(&draft.DraftIdentity, "owner/repo", domain.IssueKind, draft.Title, draft.Body) + draft.Revision = 1 + return draft + } + tests := []struct { + name string + mutate func(*IssueDraft) + }{ + {name: "wrong kind", mutate: func(draft *IssueDraft) { draft.Kind = domain.PullRequestKind }}, + {name: "changed bytes", mutate: func(draft *IssueDraft) { draft.Body = "changed" }}, + {name: "unknown severity", mutate: func(draft *IssueDraft) { + draft.Warnings = []DraftDiagnostic{{Code: "finding", Severity: "notice", Message: "message"}} + }}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + draft := newDraft() + testCase.mutate(draft) + if err := draft.ParseStored(); err == nil { + t.Fatal("invalid stored draft was accepted") + } + }) + } +} + func TestValidateRequiredTemplateSectionsDetectsChangedTemplate(t *testing.T) { guidance := []byte("## Test plan\n\nRequired.\n\n## Compatibility\n") body := []byte("## Compatibility\n\nNo changes.\n\n## Repository Guidance\n\n" + string(guidance)) diff --git a/internal/corpus/actor_facets.go b/internal/corpus/actor_facets.go index 8bae5e52..c77eafb4 100644 --- a/internal/corpus/actor_facets.go +++ b/internal/corpus/actor_facets.go @@ -6,10 +6,10 @@ import ( "encoding/json" "errors" "fmt" - "slices" - "strconv" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) type ActorSocialAccount struct{ Provider, URL, DisplayName string } @@ -28,7 +28,7 @@ type ActorContributionDay struct { Level string } type ActorContributionItem struct { - Kind string + Kind domain.ContributionKind OccurredAt time.Time RepositoryID *int64 TargetNodeID, TargetURL string @@ -37,7 +37,7 @@ type ActorContributionItem struct { } type ActorRepositoryContributionTotal struct { RepositoryID int64 - Kind string + Kind domain.ContributionKind Count int } type ActorContributionPeriodInput struct { @@ -147,6 +147,11 @@ func (c *Corpus) ApplyActorContributionPeriod(ctx context.Context, input ActorCo if len(payload) == 0 { payload = []byte(`{}`) } + var err error + payload, err = parseJSONPayload("actor contribution observation", payload) + if err != nil { + return err + } tx, err := c.db.BeginTx(ctx, nil) if err != nil { return err @@ -191,12 +196,20 @@ func (c *Corpus) ApplyActorContributionPeriod(ctx context.Context, input ActorCo } } for _, item := range input.Items { - if _, err := tx.ExecContext(ctx, `INSERT INTO actor_contribution_items(period_id,contribution_kind,occurred_at,repository_id,target_node_id,target_url,restricted,count) VALUES(?,?,?,?,?,?,?,?)`, periodID, item.Kind, encodeTime(item.OccurredAt), item.RepositoryID, item.TargetNodeID, item.TargetURL, boolToInt(item.Restricted), item.Count); err != nil { + kind, err := domain.ParseContributionKind(item.Kind.String()) + if err != nil { + return fmt.Errorf("parse contribution item kind: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_contribution_items(period_id,contribution_kind,occurred_at,repository_id,target_node_id,target_url,restricted,count) VALUES(?,?,?,?,?,?,?,?)`, periodID, kind.String(), encodeTime(item.OccurredAt), item.RepositoryID, item.TargetNodeID, item.TargetURL, boolToInt(item.Restricted), item.Count); err != nil { return err } } for _, total := range input.RepositoryTotals { - if _, err := tx.ExecContext(ctx, `INSERT INTO actor_repository_contribution_totals(period_id,repository_id,contribution_kind,contribution_count) VALUES(?,?,?,?)`, periodID, total.RepositoryID, total.Kind, total.Count); err != nil { + kind, err := domain.ParseContributionKind(total.Kind.String()) + if err != nil { + return fmt.Errorf("parse repository contribution total kind: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_repository_contribution_totals(period_id,repository_id,contribution_kind,contribution_count) VALUES(?,?,?,?)`, periodID, total.RepositoryID, kind.String(), total.Count); err != nil { return err } } @@ -213,6 +226,11 @@ func (c *Corpus) applyActorFacetSet(ctx context.Context, actorID int64, facet st if len(raw) == 0 { raw = []byte(`{}`) } + var err error + raw, err = parseJSONPayload("actor facet observation", raw) + if err != nil { + return err + } tx, err := c.db.BeginTx(ctx, nil) if err != nil { return err @@ -243,18 +261,9 @@ func orderingNewer(source, sequence, currentSource, currentSequence int64) bool return source > currentSource || (source == currentSource && sequence > currentSequence) } -type ContributionSearchOptions struct { - ActorRefs []string - RepositoryRefs []string - Kinds []string - OrganizationNodeID string - From, To time.Time - Sort, Order string - Limit int - Cursor string -} type ContributionSearchItem struct { - ActorKey, Login, Kind string + ActorKey, Login string + Kind domain.ContributionKind OccurredAt time.Time RepositoryRef, TargetNodeID, TargetURL string Restricted bool @@ -266,79 +275,61 @@ type ContributionSearchPage struct { NextCursor string } -func (c *Corpus) SearchActorContributions(ctx context.Context, opts ContributionSearchOptions) (ContributionSearchPage, error) { - if opts.Limit == 0 { - opts.Limit = 20 - } - if opts.Limit < 1 || opts.Limit > 100 { - return ContributionSearchPage{}, errors.New("contribution search limit must be 1 to 100") - } - if opts.Sort == "" { - opts.Sort = "occurred_at" - } - if opts.Order == "" { - opts.Order = "desc" - } - if opts.Sort != "occurred_at" && opts.Sort != "repository" && opts.Sort != "type" { - return ContributionSearchPage{}, errors.New("unsupported contribution sort") - } - if opts.Order != "asc" && opts.Order != "desc" { - return ContributionSearchPage{}, errors.New("contribution order must be asc or desc") - } +func (c *Corpus) SearchActorContributions(ctx context.Context, request ContributionSearchRequest) (ContributionSearchPage, error) { offset := 0 - if opts.Cursor != "" { - cursor, err := decodeCursor(opts.Cursor) - if err != nil || cursor.Scope != "actor_contributions" || cursor.Filter != contributionFilterKey(opts) { + if request.page.Cursor() != "" { + cursor, err := decodeCursor(request.page.Cursor()) + if err != nil || cursor.Scope != "actor_contributions" || cursor.Filter != request.filterKey() { return ContributionSearchPage{}, errors.New("invalid contribution cursor") } offset = int(cursor.ID) } where := " WHERE p.organization_node_id=?" - args := []any{opts.OrganizationNodeID} - if len(opts.ActorRefs) > 0 { - placeholders := make([]string, len(opts.ActorRefs)) - for i := range opts.ActorRefs { + args := []any{request.organizationNodeID} + if len(request.actorRefs) > 0 { + placeholders := make([]string, len(request.actorRefs)) + for i := range request.actorRefs { placeholders[i] = "?" } where += ` AND (a.actor_key IN (` + strings.Join(placeholders, ",") + `) OR a.node_id IN (` + strings.Join(placeholders, ",") + `) OR a.id IN (SELECT actor_id FROM actor_aliases WHERE active=1 AND normalized_login IN (` + strings.Join(placeholders, ",") + `)))` - for _, ref := range opts.ActorRefs { - args = append(args, strings.TrimSpace(ref)) + for _, ref := range request.actorRefs { + args = append(args, ref.String()) } - for _, ref := range opts.ActorRefs { - args = append(args, strings.TrimSpace(ref)) + for _, ref := range request.actorRefs { + args = append(args, ref.String()) } - for _, ref := range opts.ActorRefs { - args = append(args, normalizeLogin(ref)) + for _, ref := range request.actorRefs { + args = append(args, normalizeLogin(ref.String())) } } - if len(opts.Kinds) > 0 { - p := make([]string, len(opts.Kinds)) - for i, kind := range opts.Kinds { + if len(request.kinds) > 0 { + p := make([]string, len(request.kinds)) + for i, kind := range request.kinds { p[i] = "?" - args = append(args, kind) + args = append(args, kind.String()) } where += ` AND i.contribution_kind IN (` + strings.Join(p, ",") + `)` } - if len(opts.RepositoryRefs) > 0 { - p := make([]string, len(opts.RepositoryRefs)) - for i, ref := range opts.RepositoryRefs { + if len(request.repositories) > 0 { + p := make([]string, len(request.repositories)) + for i, ref := range request.repositories { p[i] = "?" - args = append(args, strings.ToLower(strings.TrimSpace(ref))) + args = append(args, strings.ToLower(ref.String())) } where += ` AND lower(COALESCE(r.owner||'/'||r.name,'')) IN (` + strings.Join(p, ",") + `)` } - if !opts.From.IsZero() { + if !request.from.IsZero() { where += ` AND i.occurred_at>=?` - args = append(args, encodeTime(opts.From)) + args = append(args, encodeTime(request.from)) } - if !opts.To.IsZero() { + if !request.to.IsZero() { where += ` AND i.occurred_at opts.Limit { - page.Items = page.Items[:opts.Limit] - page.NextCursor = encodeCursor(searchCursor{Scope: "actor_contributions", Filter: contributionFilterKey(opts), ID: int64(offset + opts.Limit)}) + if len(page.Items) > request.page.Limit() { + page.Items = page.Items[:request.page.Limit()] + page.NextCursor = encodeCursor(searchCursor{Scope: "actor_contributions", Filter: request.filterKey(), ID: int64(offset + request.page.Limit())}) } if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM (`+projection+`)`, args...).Scan(&page.Total); err != nil { return page, err } return page, nil } - -func contributionFilterKey(opts ContributionSearchOptions) string { - actors := append([]string(nil), opts.ActorRefs...) - repositories := append([]string(nil), opts.RepositoryRefs...) - kinds := append([]string(nil), opts.Kinds...) - for i := range actors { - actors[i] = normalizeLogin(actors[i]) - } - for i := range repositories { - repositories[i] = strings.ToLower(strings.TrimSpace(repositories[i])) - } - slices.Sort(actors) - slices.Sort(repositories) - slices.Sort(kinds) - return strings.Join([]string{opts.Sort, opts.Order, opts.OrganizationNodeID, strings.Join(actors, ","), strings.Join(repositories, ","), strings.Join(kinds, ","), strconv.FormatInt(encodeTime(opts.From), 10), strconv.FormatInt(encodeTime(opts.To), 10)}, "|") -} diff --git a/internal/corpus/actor_search_request.go b/internal/corpus/actor_search_request.go new file mode 100644 index 00000000..e4eebf13 --- /dev/null +++ b/internal/corpus/actor_search_request.go @@ -0,0 +1,141 @@ +package corpus + +import ( + "errors" + "fmt" + "strings" +) + +// ActorSearchInput is the transient loose actor-search boundary shape. +type ActorSearchInput struct { + Query string + Kinds []string + Sort string + Limit int + Cursor string +} + +// ActorSearchRequest is the only executable actor query representation. Its +// private fields keep construction behind ParseActorSearch. +type ActorSearchRequest struct { + query string + kinds actorKindSet + sort actorSort + page SearchPage +} + +// ParseActorSearch parses actor kinds, ordering, and paging once before a +// corpus read begins. +func ParseActorSearch(input ActorSearchInput) (ActorSearchRequest, error) { + page, err := ParseSearchPage(input.Limit, input.Cursor) + if err != nil { + return ActorSearchRequest{}, fmt.Errorf("actor search page: %w", err) + } + kinds, err := parseActorKindSet(input.Kinds) + if err != nil { + return ActorSearchRequest{}, err + } + sortMode, err := parseActorSort(input.Sort) + if err != nil { + return ActorSearchRequest{}, err + } + return ActorSearchRequest{query: strings.TrimSpace(input.Query), kinds: kinds, sort: sortMode, page: page}, nil +} + +type actorKindSet uint8 + +func parseActorKindSet(values []string) (actorKindSet, error) { + if len(values) > 5 { + return 0, errors.New("actor search kinds cannot exceed 5 items") + } + var set actorKindSet + for _, value := range values { + var bit actorKindSet + switch strings.TrimSpace(value) { + case "user": + bit = 1 << 0 + case "bot": + bit = 1 << 1 + case "organization": + bit = 1 << 2 + case "mannequin": + bit = 1 << 3 + case "unknown": + bit = 1 << 4 + default: + return 0, fmt.Errorf("unsupported actor kind %q", value) + } + if set&bit != 0 { + return 0, fmt.Errorf("duplicate actor kind %q", value) + } + set |= bit + } + return set, nil +} + +func (s actorKindSet) values() []string { + out := make([]string, 0, 5) + for index, value := range []string{"user", "bot", "organization", "mannequin", "unknown"} { + if s&(1< 100 { - return ActorSearchPage{}, errors.New("actor search limit must be between 1 and 100") - } - if options.Sort == "" { - options.Sort = "relevance" - } - validSort := map[string]bool{"relevance": true, "login": true, "followers": true, "public_repositories": true, "profile_updated_at": true, "observed_at": true} - if !validSort[options.Sort] { - return ActorSearchPage{}, errors.New("unsupported actor sort") - } +func (c *Corpus) SearchActors(ctx context.Context, request ActorSearchRequest) (ActorSearchPage, error) { offset := 0 - filterKey := actorSearchFilterKey(options) - if options.Cursor != "" { - cursor, err := decodeCursor(options.Cursor) - if err != nil || cursor.Scope != "actors" || cursor.Query != options.Query || cursor.Filter != filterKey { + filterKey := request.sort.String() + "|" + request.kinds.key() + if request.page.Cursor() != "" { + cursor, err := decodeCursor(request.page.Cursor()) + if err != nil || cursor.Scope != "actors" || cursor.Query != request.query || cursor.Filter != filterKey { return ActorSearchPage{}, errors.New("invalid actor search cursor") } offset = int(cursor.ID) } - ftsQuery := literalFTSQuery(options.Query) + ftsQuery := literalFTSQuery(request.query) from := `actors a LEFT JOIN actor_profiles p ON p.actor_id=a.id` where, args := ` WHERE 1=1`, []any{} rank := `0.0` @@ -576,22 +570,16 @@ func (c *Corpus) SearchActors(ctx context.Context, options ActorSearchOptions) ( args = append(args, ftsQuery) rank = `bm25(actors_fts, 0.0, 10.0, 5.0, 2.0, 2.0, 1.0)` } - if len(options.Kinds) > 0 { - placeholders := make([]string, len(options.Kinds)) - for i, kind := range options.Kinds { + kinds := request.kinds.values() + if len(kinds) > 0 { + placeholders := make([]string, len(kinds)) + for i, kind := range kinds { placeholders[i] = "?" args = append(args, kind) } where += ` AND a.kind IN (` + strings.Join(placeholders, ",") + `)` } - order := map[string]string{ - "relevance": rank + `, a.source_updated_at DESC, a.id`, - "login": `a.current_login COLLATE NOCASE, a.id`, - "followers": `COALESCE(p.followers,-1) DESC, a.id`, - "public_repositories": `COALESCE(p.public_repositories,-1) DESC, a.id`, - "profile_updated_at": `COALESCE(p.source_updated_at,0) DESC, a.id`, - "observed_at": `COALESCE(p.observed_at,0) DESC, a.id`, - }[options.Sort] + order := request.sort.expression(rank) // actorSelect includes its own FROM clause, so build the projection directly. query := `SELECT ` + rank + `, a.id, a.actor_key, a.provider, COALESCE(a.node_id,''), a.database_id, a.kind, a.current_login, a.source_updated_at, a.observation_sequence, a.created_at, a.updated_at, @@ -599,13 +587,13 @@ func (c *Corpus) SearchActors(ctx context.Context, options ActorSearchOptions) ( p.hireable, p.followers, p.following, p.public_repositories, p.public_gists, p.provider_created_at, p.source_updated_at, p.observation_sequence, p.observed_at, p.authorization_scope FROM ` + from + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?` - args = append(args, options.Limit+1, offset) + args = append(args, request.page.Limit()+1, offset) rows, err := c.db.QueryContext(ctx, query, args...) if err != nil { return ActorSearchPage{}, fmt.Errorf("search actors: %w", err) } defer func() { _ = rows.Close() }() - actors := make([]Actor, 0, options.Limit+1) + actors := make([]Actor, 0, request.page.Limit()+1) for rows.Next() { var rankValue float64 var actor Actor @@ -636,9 +624,9 @@ func (c *Corpus) SearchActors(ctx context.Context, options ActorSearchOptions) ( return ActorSearchPage{}, err } page := ActorSearchPage{Actors: actors} - if len(page.Actors) > options.Limit { - page.Actors = page.Actors[:options.Limit] - page.NextCursor = encodeCursor(searchCursor{Scope: "actors", Query: options.Query, Filter: filterKey, ID: int64(offset + options.Limit)}) + if len(page.Actors) > request.page.Limit() { + page.Actors = page.Actors[:request.page.Limit()] + page.NextCursor = encodeCursor(searchCursor{Scope: "actors", Query: request.query, Filter: filterKey, ID: int64(offset + request.page.Limit())}) } countQuery := `SELECT COUNT(*) FROM ` + from + where countArgs := args[:len(args)-2] @@ -647,9 +635,3 @@ func (c *Corpus) SearchActors(ctx context.Context, options ActorSearchOptions) ( } return page, nil } - -func actorSearchFilterKey(options ActorSearchOptions) string { - kinds := append([]string(nil), options.Kinds...) - slices.Sort(kinds) - return options.Sort + "|" + strings.Join(kinds, ",") -} diff --git a/internal/corpus/actors_test.go b/internal/corpus/actors_test.go index 187be3c2..0822e5c6 100644 --- a/internal/corpus/actors_test.go +++ b/internal/corpus/actors_test.go @@ -2,10 +2,29 @@ package corpus import ( "context" + "encoding/json" "testing" "time" ) +func mustActorSearch(t *testing.T, input ActorSearchInput) ActorSearchRequest { + t.Helper() + request, err := ParseActorSearch(input) + if err != nil { + t.Fatalf("parse actor search: %v", err) + } + return request +} + +func mustContributionSearch(t *testing.T, input ContributionSearchInput) ContributionSearchRequest { + t.Helper() + request, err := ParseContributionSearch(input) + if err != nil { + t.Fatalf("parse contribution search: %v", err) + } + return request +} + func TestActorProfileObservationReconcilesLoginToNodeIDAndPreservesNewerProjection(t *testing.T) { t.Parallel() ctx := context.Background() @@ -45,11 +64,11 @@ func TestActorProfileObservationReconcilesLoginToNodeIDAndPreservesNewerProjecti if _, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_1", &one, "user", "public", time.Unix(23, 0).UTC(), nil); err != nil { t.Fatal(err) } - machine, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Limit: 10}) + machine, err := c.SearchActors(ctx, mustActorSearch(t, ActorSearchInput{Query: "machine", Limit: 10})) if err != nil { t.Fatal(err) } - gardening, err := c.SearchActors(ctx, ActorSearchOptions{Query: "gardening", Limit: 10}) + gardening, err := c.SearchActors(ctx, mustActorSearch(t, ActorSearchInput{Query: "gardening", Limit: 10})) if err != nil { t.Fatal(err) } @@ -171,21 +190,21 @@ func TestSearchActorsReturnsNullableProfilesAndBoundedCursor(t *testing.T) { t.Fatal(err) } } - page, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Sort: "followers", Limit: 1}) + page, err := c.SearchActors(ctx, mustActorSearch(t, ActorSearchInput{Query: "machine", Sort: "followers", Limit: 1})) if err != nil { t.Fatal(err) } if len(page.Actors) != 1 || page.Total != 2 || page.NextCursor == "" || page.Actors[0].Login != "alice" { t.Fatalf("first page = %+v", page) } - next, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Sort: "followers", Limit: 1, Cursor: page.NextCursor}) + next, err := c.SearchActors(ctx, mustActorSearch(t, ActorSearchInput{Query: "machine", Sort: "followers", Limit: 1, Cursor: page.NextCursor})) if err != nil { t.Fatal(err) } if len(next.Actors) != 1 || next.Actors[0].Login != "alicia" { t.Fatalf("next page = %+v", next) } - if _, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Kinds: []string{"bot"}, Sort: "followers", Limit: 1, Cursor: page.NextCursor}); err == nil { + if _, err := c.SearchActors(ctx, mustActorSearch(t, ActorSearchInput{Query: "machine", Kinds: []string{"bot"}, Sort: "followers", Limit: 1, Cursor: page.NextCursor})); err == nil { t.Fatal("actor cursor was accepted with a different kind filter") } } @@ -213,14 +232,14 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), OrganizationNodeID: "O_acme", Complete: true, ObservedAt: from.Add(26 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour), Items: items}); err != nil { t.Fatal(err) } - page, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"acme/ml"}, Sort: "occurred_at", Limit: 1}) + page, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"acme/ml"}, Sort: "occurred_at", Limit: 1})) if err != nil { t.Fatal(err) } if len(page.Items) != 1 || page.Total != 2 || page.NextCursor == "" || page.Items[0].Kind != "issue" { t.Fatalf("page = %+v", page) } - organizationPage, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, OrganizationNodeID: "O_acme", Sort: "occurred_at", Limit: 10}) + organizationPage, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"alice"}, OrganizationNodeID: "O_acme", Sort: "occurred_at", Limit: 10})) if err != nil { t.Fatal(err) } @@ -228,7 +247,7 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { t.Fatalf("organization-scoped page = %+v", organizationPage) } for _, ref := range []string{actor.Key, actor.NodeID} { - exact, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{ref}, Sort: "occurred_at", Limit: 10}) + exact, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{ref}, Sort: "occurred_at", Limit: 10})) if err != nil { t.Fatal(err) } @@ -236,7 +255,7 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { t.Fatalf("actor reference %q returned %+v", ref, exact) } } - if _, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"other/repo"}, Sort: "occurred_at", Limit: 1, Cursor: page.NextCursor}); err == nil { + if _, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"other/repo"}, Sort: "occurred_at", Limit: 1, Cursor: page.NextCursor})); err == nil { t.Fatal("cursor was accepted with different repository filters") } covered, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(time.Hour), from.Add(12*time.Hour)) @@ -260,7 +279,7 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { if retained == nil || !retained.Complete { t.Fatalf("partial refresh replaced complete coverage: %+v", retained) } - pageAfterPartial, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, Sort: "occurred_at", Limit: 10}) + pageAfterPartial, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"alice"}, Sort: "occurred_at", Limit: 10})) if err != nil { t.Fatal(err) } @@ -276,6 +295,44 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { } } +func TestActorContributionKindsAreParsedAtStorageBoundaries(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + input := ActorContributionPeriodInput{ + ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), Complete: true, + ObservedAt: from.Add(25 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour), + Items: []ActorContributionItem{{Kind: " Future_Category ", OccurredAt: from.Add(time.Hour), Count: 1}}, + } + if err := c.ApplyActorContributionPeriod(ctx, input); err != nil { + t.Fatal(err) + } + page, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{Kinds: []string{"FUTURE_CATEGORY"}, Limit: 10})) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Items[0].Kind.String() != "future_category" { + t.Fatalf("canonical contribution page = %+v", page) + } + + input.SourceUpdatedAt = input.SourceUpdatedAt.Add(time.Hour) + input.Items = []ActorContributionItem{{}} + if err := c.ApplyActorContributionPeriod(ctx, input); err == nil { + t.Fatal("empty contribution kind was stored") + } + if _, err := c.db.ExecContext(ctx, `UPDATE actor_contribution_items SET contribution_kind=''`); err != nil { + t.Fatal(err) + } + if _, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{Limit: 10})); err == nil { + t.Fatal("corrupt stored contribution kind was accepted") + } +} + func TestActorContributionCoveragePrefersCompleteContainingPeriod(t *testing.T) { t.Parallel() ctx := context.Background() @@ -324,7 +381,7 @@ func TestActorContributionSearchDeduplicatesOverlappingPeriods(t *testing.T) { t.Fatal(err) } } - page, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{actor.Key}, From: from.Add(time.Hour), To: from.Add(12 * time.Hour), Limit: 10}) + page, err := c.SearchActorContributions(ctx, mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{actor.Key}, From: from.Add(time.Hour).Format(time.RFC3339), To: from.Add(12 * time.Hour).Format(time.RFC3339), Limit: 10})) if err != nil { t.Fatal(err) } @@ -332,3 +389,25 @@ func TestActorContributionSearchDeduplicatesOverlappingPeriods(t *testing.T) { t.Fatalf("overlapping contribution periods = %+v", page) } } + +func TestActorPersistenceRejectsMalformedObservationJSONBeforeWriting(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + if _, err := c.ApplyActorIdentityObservation(ctx, "github", "broken", "U_broken", nil, "user", "public", time.Unix(1, 0).UTC(), json.RawMessage(`not-json`)); err == nil { + t.Fatal("malformed actor identity observation was stored") + } + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + if err := c.ReplaceActorSocialAccounts(ctx, actor.ID, nil, true, time.Unix(2, 0).UTC(), time.Unix(2, 0).UTC(), "public", json.RawMessage(`not-json`)); err == nil { + t.Fatal("malformed actor facet observation was stored") + } + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ + ActorID: actor.ID, From: time.Unix(1, 0).UTC(), To: time.Unix(2, 0).UTC(), + Complete: true, RawPayload: json.RawMessage(`not-json`), + }); err == nil { + t.Fatal("malformed actor contribution observation was stored") + } +} diff --git a/internal/corpus/batch_reads.go b/internal/corpus/batch_reads.go index e1268faf..d98feeee 100644 --- a/internal/corpus/batch_reads.go +++ b/internal/corpus/batch_reads.go @@ -12,11 +12,11 @@ type RepositoryKey struct { Name string } -// ThreadKey identifies a thread projection in a batch result. An empty Kind -// requests the thread regardless of whether it is an issue or pull request. +// ThreadKey identifies a thread projection in a batch result. Kind explicitly +// represents either an exact issue/pull-request kind or both kinds. type ThreadKey struct { RepositoryID int64 - Kind string + Kind ThreadKindFilter Number int } @@ -119,12 +119,12 @@ func (c *Corpus) GetThreadsBatch(ctx context.Context, keys []ThreadKey) (map[Thr clauses := make([]string, len(keys)) args := make([]any, 0, len(keys)*3) for i, key := range keys { - if key.Kind == "" { + if key.Kind.IsAny() { clauses[i] = "(repository_id = ? AND number = ?)" args = append(args, key.RepositoryID, key.Number) } else { clauses[i] = "(repository_id = ? AND kind = ? AND number = ?)" - args = append(args, key.RepositoryID, key.Kind, key.Number) + args = append(args, key.RepositoryID, key.Kind.String(), key.Number) } } rows, err := c.db.QueryContext(ctx, ` @@ -148,11 +148,11 @@ func (c *Corpus) GetThreadsBatch(ctx context.Context, keys []ThreadKey) (map[Thr } for i := range threads { thread := &threads[i] - exact := ThreadKey{RepositoryID: thread.RepositoryID, Kind: thread.Kind, Number: thread.Number} + exact := ThreadKey{RepositoryID: thread.RepositoryID, Kind: ThreadKindFilter{kind: thread.Kind}, Number: thread.Number} if _, ok := requested[exact]; ok { out[exact] = thread } - anyKind := ThreadKey{RepositoryID: thread.RepositoryID, Number: thread.Number} + anyKind := ThreadKey{RepositoryID: thread.RepositoryID, Kind: AnyThreadKind(), Number: thread.Number} if _, ok := requested[anyKind]; ok { out[anyKind] = thread } diff --git a/internal/corpus/batch_reads_test.go b/internal/corpus/batch_reads_test.go index 46060b04..feb41c3a 100644 --- a/internal/corpus/batch_reads_test.go +++ b/internal/corpus/batch_reads_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestBatchReadsReturnRequestedRepositoriesThreadsAndCoverage(t *testing.T) { @@ -21,7 +23,7 @@ func TestBatchReadsReturnRequestedRepositoriesThreadsAndCoverage(t *testing.T) { t.Fatal(err) } thread, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 7, + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 7, State: "open", Title: "batch me", Body: "body", SourceUpdatedAt: now, }, `{}`) if err != nil { @@ -52,22 +54,22 @@ func TestBatchReadsReturnRequestedRepositoriesThreadsAndCoverage(t *testing.T) { } threads, err := c.GetThreadsBatch(ctx, []ThreadKey{ - {RepositoryID: repo.ID, Number: 7}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 7}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 7}, + {RepositoryID: repo.ID, Kind: AnyThreadKind(), Number: 7}, + {RepositoryID: repo.ID, Kind: IssueThreadKind(), Number: 7}, + {RepositoryID: repo.ID, Kind: PullRequestThreadKind(), Number: 7}, }) if err != nil { t.Fatal(err) } for _, key := range []ThreadKey{ - {RepositoryID: repo.ID, Number: 7}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 7}, + {RepositoryID: repo.ID, Kind: AnyThreadKind(), Number: 7}, + {RepositoryID: repo.ID, Kind: IssueThreadKind(), Number: 7}, } { if got := threads[key]; got == nil || got.ID != thread.ID { t.Fatalf("thread %v = %+v", key, got) } } - if threads[ThreadKey{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 7}] != nil { + if threads[ThreadKey{RepositoryID: repo.ID, Kind: PullRequestThreadKind(), Number: 7}] != nil { t.Fatal("unexpected pull request result") } diff --git a/internal/corpus/cluster_governance.go b/internal/corpus/cluster_governance.go index c51b258f..bbc109ca 100644 --- a/internal/corpus/cluster_governance.go +++ b/internal/corpus/cluster_governance.go @@ -23,11 +23,11 @@ func (c *Corpus) AddClusterOverride(ctx context.Context, clusterID int64, ref cl if err := validateClusterMemberRef(ref); err != nil { return err } - switch action { - case clustering.OverrideInclude, clustering.OverrideExclude, clustering.OverrideSetCanonical: - default: - return fmt.Errorf("unsupported override action %q", action) + parsedAction, err := clustering.ParseOverrideAction(string(action)) + if err != nil { + return err } + action = parsedAction reason = strings.TrimSpace(reason) if reason == "" { return errors.New("reason is required") @@ -39,15 +39,23 @@ func (c *Corpus) AddClusterOverride(ctx context.Context, clusterID int64, ref cl } defer rollbackSQLOnReturn(tx, &err) var owner, name string + var canonicalKind string var canonical clustering.MemberRef err = tx.QueryRowContext(ctx, `SELECT repo_owner, repo_name, canonical_kind, canonical_owner, canonical_repo, canonical_number - FROM clusters WHERE id=?`, clusterID).Scan(&owner, &name, &canonical.Kind, &canonical.Owner, &canonical.Repo, &canonical.Number) + FROM clusters WHERE id=?`, clusterID).Scan(&owner, &name, &canonicalKind, &canonical.Owner, &canonical.Repo, &canonical.Number) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("cluster %d not found", clusterID) } if err != nil { return err } + canonical.Kind, err = domain.ParseThreadKind(canonicalKind) + if err != nil { + return fmt.Errorf("decode cluster canonical member: %w", err) + } + if err := validateClusterMemberRef(canonical); err != nil { + return fmt.Errorf("decode cluster canonical member: %w", err) + } repo, err := domain.NewRepoRef(owner, name) if err != nil { return fmt.Errorf("decode cluster repository: %w", err) @@ -80,7 +88,7 @@ func validateClusterMemberRef(ref clustering.MemberRef) error { if strings.TrimSpace(ref.Owner) == "" || strings.TrimSpace(ref.Repo) == "" { return errors.New("member owner and repo are required") } - if ref.Kind != ThreadKindIssue && ref.Kind != ThreadKindPullRequest { + if ref.Kind != domain.IssueKind && ref.Kind != domain.PullRequestKind { return fmt.Errorf("unsupported member kind %q", ref.Kind) } if ref.Number < 1 { diff --git a/internal/corpus/cluster_projection.go b/internal/corpus/cluster_projection.go index 9cab9854..470c238e 100644 --- a/internal/corpus/cluster_projection.go +++ b/internal/corpus/cluster_projection.go @@ -24,6 +24,13 @@ func (c *Corpus) ListClusterProjection(ctx context.Context, repo domain.RepoRef, if limit < 1 || limit > 1000 { return clusterprojection.List{}, errors.New("cluster list limit must be between 1 and 1000") } + if state != "" { + parsed, err := clustering.ParseClusterState(string(state)) + if err != nil { + return clusterprojection.List{}, err + } + state = parsed + } tx, err := c.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return clusterprojection.List{}, err @@ -294,11 +301,20 @@ func loadClusterCandidatesTx(ctx context.Context, tx *sql.Tx, repo domain.RepoRe out := make([]clustering.Candidate, 0, maxCandidates) for rows.Next() { var candidate clustering.Candidate + var kind, state string var labels sql.NullString var created, updated int64 - if err := rows.Scan(&candidate.ThreadID, &candidate.Kind, &candidate.Number, &candidate.State, &candidate.Title, &candidate.Body, &candidate.Author, &labels, &created, &updated); err != nil { + if err := rows.Scan(&candidate.ThreadID, &kind, &candidate.Number, &state, &candidate.Title, &candidate.Body, &candidate.Author, &labels, &created, &updated); err != nil { return nil, err } + candidate.Kind, err = domain.ParseThreadKind(kind) + if err != nil { + return nil, fmt.Errorf("decode cluster candidate kind: %w", err) + } + candidate.State, err = domain.ParseThreadState(state) + if err != nil { + return nil, fmt.Errorf("decode cluster candidate state: %w", err) + } candidate.Repo = repo candidate.Labels = splitLabels(labels.String) candidate.CreatedAt = scanTime(created) @@ -385,17 +401,28 @@ func scanProjectionClusters(rows *sql.Rows, repo domain.RepoRef) ([]clustering.C type projectionScanner interface{ Scan(...any) error } func scanProjectionCluster(scanner projectionScanner, cluster *clustering.Cluster, includeRepo bool) error { - var state string + var state, canonicalKind string var owner, repo string var windowStart, windowEnd, created, updated int64 - destinations := []any{&cluster.ID, &cluster.StableID, &state, &cluster.Canonical.Kind, &cluster.Canonical.Owner, &cluster.Canonical.Repo, &cluster.Canonical.Number, &cluster.Revision, &windowStart, &windowEnd, &created, &updated} + destinations := []any{&cluster.ID, &cluster.StableID, &state, &canonicalKind, &cluster.Canonical.Owner, &cluster.Canonical.Repo, &cluster.Canonical.Number, &cluster.Revision, &windowStart, &windowEnd, &created, &updated} if includeRepo { destinations = append(destinations, &owner, &repo) } if err := scanner.Scan(destinations...); err != nil { return err } - cluster.State = clustering.ClusterState(state) + parsedState, err := clustering.ParseClusterState(state) + if err != nil { + return fmt.Errorf("decode cluster state: %w", err) + } + cluster.State = parsedState + cluster.Canonical.Kind, err = domain.ParseThreadKind(canonicalKind) + if err != nil { + return fmt.Errorf("decode cluster canonical member: %w", err) + } + if err := validateClusterMemberRef(cluster.Canonical); err != nil { + return fmt.Errorf("decode cluster canonical member: %w", err) + } if includeRepo { parsed, err := domain.NewRepoRef(owner, repo) if err != nil { @@ -430,11 +457,23 @@ func loadProjectionMembersTx(ctx context.Context, tx *sql.Tx, clusters []cluster for rows.Next() { var clusterID int64 var member clustering.Member + var kind, state string var threadID sql.NullInt64 var included int - if err := rows.Scan(&clusterID, &threadID, &member.Ref.Kind, &member.Ref.Owner, &member.Ref.Repo, &member.Ref.Number, &member.Title, &member.State, &member.Score, &member.Reason, &included); err != nil { + if err := rows.Scan(&clusterID, &threadID, &kind, &member.Ref.Owner, &member.Ref.Repo, &member.Ref.Number, &member.Title, &state, &member.Score, &member.Reason, &included); err != nil { return err } + member.Ref.Kind, err = domain.ParseThreadKind(kind) + if err != nil { + return fmt.Errorf("decode cluster member kind: %w", err) + } + member.State, err = domain.ParseThreadState(state) + if err != nil { + return fmt.Errorf("decode cluster member state: %w", err) + } + if err := validateClusterMemberRef(member.Ref); err != nil { + return fmt.Errorf("decode cluster member: %w", err) + } member.ThreadID, member.Included = threadID.Int64, included != 0 byID[clusterID].Members = append(byID[clusterID].Members, member) } @@ -454,13 +493,23 @@ func loadProjectionOverridesTx(ctx context.Context, tx *sql.Tx, repo domain.Repo } }() for rows.Next() { - var stableID, action string + var stableID, kind, action string var override clustering.MembershipOverride var created int64 - if err := rows.Scan(&stableID, &override.ID, &override.ClusterID, &override.Ref.Kind, &override.Ref.Owner, &override.Ref.Repo, &override.Ref.Number, &action, &override.Reason, &created); err != nil { + if err := rows.Scan(&stableID, &override.ID, &override.ClusterID, &kind, &override.Ref.Owner, &override.Ref.Repo, &override.Ref.Number, &action, &override.Reason, &created); err != nil { return err } - override.Action = clustering.OverrideAction(action) + override.Ref.Kind, err = domain.ParseThreadKind(kind) + if err != nil { + return fmt.Errorf("decode cluster override member: %w", err) + } + if err := validateClusterMemberRef(override.Ref); err != nil { + return fmt.Errorf("decode cluster override member: %w", err) + } + override.Action, err = clustering.ParseOverrideAction(action) + if err != nil { + return fmt.Errorf("decode cluster override: %w", err) + } override.CreatedAt = scanTime(created) byStable[stableID] = append(byStable[stableID], override) } @@ -510,6 +559,20 @@ func validateClusterProjectionCommit(commit clusterprojection.Commit) error { if cluster.Revision != commit.ExpectedSource { return fmt.Errorf("cluster %q source revision does not match commit", cluster.StableID) } + if _, err := clustering.ParseClusterState(string(cluster.State)); err != nil { + return fmt.Errorf("cluster %q: %w", cluster.StableID, err) + } + if err := validateClusterMemberRef(cluster.Canonical); err != nil { + return fmt.Errorf("cluster %q canonical member: %w", cluster.StableID, err) + } + for i, member := range cluster.Members { + if err := validateClusterMemberRef(member.Ref); err != nil { + return fmt.Errorf("cluster %q member %d: %w", cluster.StableID, i, err) + } + if _, err := domain.ParseThreadState(string(member.State)); err != nil { + return fmt.Errorf("cluster %q member %d: %w", cluster.StableID, i, err) + } + } } return nil } diff --git a/internal/corpus/cluster_projection_test.go b/internal/corpus/cluster_projection_test.go index 87e4845d..f263326b 100644 --- a/internal/corpus/cluster_projection_test.go +++ b/internal/corpus/cluster_projection_test.go @@ -3,6 +3,7 @@ package corpus import ( "context" "errors" + "strings" "testing" "time" @@ -20,7 +21,7 @@ func TestCommitClusterProjectionRejectsChangedSource(t *testing.T) { if err != nil { t.Fatal(err) } - thread := Thread{RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "open", Title: "first", SourceUpdatedAt: time.Unix(1, 0).UTC()} + thread := Thread{RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "first", SourceUpdatedAt: time.Unix(1, 0).UTC()} if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) } @@ -206,7 +207,7 @@ func TestCommitClusterProjectionRejectsClusterFromDifferentSource(t *testing.T) Revision: "different-source", State: clustering.ClusterOpen, Canonical: clustering.MemberRef{ - Owner: "acme", Repo: "rocket", Kind: ThreadKindIssue, Number: 1, + Owner: "acme", Repo: "rocket", Kind: domain.IssueKind, Number: 1, }, }}, }) @@ -272,3 +273,60 @@ func TestCommitClusterProjectionRejectsClusterFromDifferentRepository(t *testing t.Fatalf("commit error = %v, want cluster repository mismatch", err) } } + +func TestCommitClusterProjectionRejectsInvalidClusterState(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "rocket"}, `{}`); err != nil { + t.Fatal(err) + } + ref := domain.MustRepoRef("acme", "rocket") + maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() + snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) + if err != nil { + t.Fatal(err) + } + + _, err = c.CommitClusterProjection(ctx, clusterprojection.Commit{ + Repo: ref, + ExpectedSource: snapshot.SourceRevision, + ExpectedGovernance: snapshot.GovernanceRevision, + RuleVersion: similarity.DuplicateV1, + MaxCandidates: maxCandidates, + Clusters: []clustering.Cluster{{ + StableID: "cluster-1", + Repo: ref, + Revision: snapshot.SourceRevision, + State: "impossible", + Canonical: clustering.MemberRef{ + Owner: "acme", Repo: "rocket", Kind: domain.IssueKind, Number: 1, + }, + }}, + }) + if err == nil || !strings.Contains(err.Error(), `cluster "cluster-1": unsupported cluster state "impossible"`) { + t.Fatalf("commit error = %v, want invalid cluster state", err) + } +} + +func TestListClusterProjectionRejectsCorruptStoredState(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + ref := domain.MustRepoRef("acme", "rocket") + if _, err := c.db.ExecContext(ctx, ` + INSERT INTO clusters + (stable_id, repo_owner, repo_name, state, canonical_kind, canonical_owner, + canonical_repo, canonical_number, source_revision, source_window_start, + source_window_end, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "cluster-1", ref.Owner(), ref.Repo(), "impossible", domain.IssueKind, + ref.Owner(), ref.Repo(), 1, "revision", 1, 1, 1, 1); err != nil { + t.Fatal(err) + } + + _, err := c.ListClusterProjection(ctx, ref, "", 10) + if err == nil || !strings.Contains(err.Error(), `decode cluster state: unsupported cluster state "impossible"`) { + t.Fatalf("list error = %v, want corrupt cluster state", err) + } +} diff --git a/internal/corpus/code.go b/internal/corpus/code.go index 007d5e3b..05af3e53 100644 --- a/internal/corpus/code.go +++ b/internal/corpus/code.go @@ -30,9 +30,8 @@ type CodeMatch struct { // CodeSearchOptions scopes a paginated code-document keyword search. type CodeSearchOptions struct { - Ref domain.RepoRef - Limit int - Cursor string + Ref domain.RepoRef + Page SearchPage } // CodeSearchPage is a paginated result of a code-document keyword search. @@ -194,8 +193,15 @@ func storeCodeIndexArtifact(ctx context.Context, tx *sql.Tx, snapshotID int64, r manifestDigest := hex.EncodeToString(manifestHash[:]) artifactHash := sha256.Sum256(append([]byte("code-index\x00"), manifestBytes...)) artifactDigest := hex.EncodeToString(artifactHash[:]) - scopeJSON, _ := json.Marshal(map[string]any{"repository": ref.String(), "commit_sha": snapshot.Commit, "artifact_digest": artifactDigest}) - completenessJSON, _ := json.Marshal(map[string]any{"coverage_known": snapshot.Manifest.CoverageKnown, "truncated": snapshot.Manifest.Truncated}) + scopeJSON, _ := json.Marshal(struct { + Repository string `json:"repository"` + CommitSHA string `json:"commit_sha"` + ArtifactDigest string `json:"artifact_digest"` + }{Repository: ref.String(), CommitSHA: snapshot.Commit, ArtifactDigest: artifactDigest}) + completenessJSON, _ := json.Marshal(struct { + CoverageKnown bool `json:"coverage_known"` + Truncated bool `json:"truncated"` + }{CoverageKnown: snapshot.Manifest.CoverageKnown, Truncated: snapshot.Manifest.Truncated}) provenanceJSON, _ := json.Marshal(manifest.Provenance) tokenHash := sha256.Sum256(append([]byte(ReadSnapshotContractVersion+"\x00"), scopeJSON...)) snapshotToken := hex.EncodeToString(tokenHash[:]) @@ -399,7 +405,11 @@ func scanCodeSnapshot(row *sql.Row, ref domain.RepoRef) (*CodeSnapshotInfo, erro // SearchCode searches only the latest indexed snapshot of each repository. func (c *Corpus) SearchCode(ctx context.Context, query string, ref domain.RepoRef, limit int) ([]CodeMatch, error) { - page, err := c.SearchCodeWithOptions(ctx, query, CodeSearchOptions{Ref: ref, Limit: limit}) + request, err := ParseSearchPage(limit, "") + if err != nil { + return nil, err + } + page, err := c.SearchCodeWithOptions(ctx, query, CodeSearchOptions{Ref: ref, Page: request}) if err != nil { return nil, err } @@ -431,8 +441,8 @@ func (c *Corpus) SearchCodeWithOptions(ctx context.Context, query string, opts C } page := CodeSearchPage{Matches: matches} - if len(matches) > opts.Limit { - page.Matches = matches[:opts.Limit] + if len(matches) > opts.Page.Limit() { + page.Matches = matches[:opts.Page.Limit()] last := page.Matches[len(page.Matches)-1] page.NextCursor = encodeCursor(searchCursor{ Scope: "code", @@ -443,7 +453,7 @@ func (c *Corpus) SearchCodeWithOptions(ctx context.Context, query string, opts C ID: last.DocID, }) } - if len(matches) > opts.Limit || opts.Cursor != "" { + if len(matches) > opts.Page.Limit() || opts.Page.Cursor() != "" { page.Total, err = countCodeMatches(ctx, tx, ftsQuery, opts.Ref) if err != nil { return CodeSearchPage{}, err @@ -463,12 +473,6 @@ func (c *Corpus) SearchCodeWithOptions(ctx context.Context, query string, opts C } func (c *Corpus) prepareCodeSearch(ctx context.Context, query string, opts CodeSearchOptions) (CodeSearchOptions, string, string, *searchCursor, error) { - if opts.Limit <= 0 { - opts.Limit = 20 - } - if opts.Limit > 100 { - return opts, "", "", nil, errors.New("code search limit cannot exceed 100") - } ftsQuery := literalFTSQuery(query) if ftsQuery == "" { return opts, "", "", nil, nil @@ -482,7 +486,7 @@ func (c *Corpus) prepareCodeSearch(ctx context.Context, query string, opts CodeS } } repo := opts.Ref.String() - cursor, err := c.decodeCodeCursor(opts.Cursor, query, repo) + cursor, err := c.decodeCodeCursor(opts.Page.Cursor(), query, repo) return opts, ftsQuery, repo, cursor, err } @@ -507,7 +511,7 @@ func codeSearchStatement(ftsQuery string, opts CodeSearchOptions, cursor *search args = append(args, cursor.Rank, cursor.Rank, cursor.ID) } statement += ` ORDER BY bm25(code_documents_fts, 5.0, 1.0), d.id LIMIT ?` - return statement, append(args, opts.Limit+1) + return statement, append(args, opts.Page.Limit()+1) } func scanCodeSearchMatches(rows *sql.Rows) ([]CodeMatch, error) { @@ -566,8 +570,6 @@ func loadCodeSearchSnapshots(ctx context.Context, tx *sql.Tx, scoped domain.Repo return snapshots, nil } -const codeListLimit = 10000 - func (c *Corpus) latestCodeSnapshotID(ctx context.Context, ref domain.RepoRef) (int64, error) { var id int64 err := c.db.QueryRowContext(ctx, ` @@ -620,49 +622,6 @@ func (c *Corpus) GetCodeDocument(ctx context.Context, ref domain.RepoRef, path s return &match, nil } -// ListCodeDocuments returns all documents from the latest snapshot of a -// repository. Results are bounded to avoid unbounded offline work. -func (c *Corpus) ListCodeDocuments(ctx context.Context, ref domain.RepoRef) ([]CodeMatch, error) { - snapshotID, err := c.latestCodeSnapshotID(ctx, ref) - if err != nil { - return nil, err - } - if snapshotID == 0 { - return nil, nil - } - rows, err := c.db.QueryContext(ctx, ` - SELECT d.id, s.repo_owner, s.repo_name, s.commit_sha, d.path, d.content, d.bytes, d.language, s.id, s.created_at - FROM code_documents d - JOIN code_snapshots s ON s.id = d.snapshot_id - WHERE d.snapshot_id = ? - ORDER BY d.path - LIMIT ? - `, snapshotID, codeListLimit) - if err != nil { - return nil, fmt.Errorf("list code documents: %w", err) - } - defer func() { _ = rows.Close() }() - - var out []CodeMatch - for rows.Next() { - var match CodeMatch - var owner, repo string - var createdAt int64 - if err := rows.Scan(&match.DocID, &owner, &repo, &match.Commit, - &match.Path, &match.Content, &match.Bytes, &match.Language, &match.SnapshotID, &createdAt); err != nil { - return nil, err - } - parsed, err := domain.NewRepoRef(owner, repo) - if err != nil { - return nil, fmt.Errorf("decode code document repository: %w", err) - } - match.Repo = parsed - match.SnapshotCreatedAt = scanTime(createdAt) - out = append(out, match) - } - return out, rows.Err() -} - func countCodeMatches(ctx context.Context, queryer codeSnapshotQueryer, ftsQuery string, ref domain.RepoRef) (int, error) { statement := ` SELECT COUNT(*) diff --git a/internal/corpus/code_test.go b/internal/corpus/code_test.go index d13a484b..89dba39f 100644 --- a/internal/corpus/code_test.go +++ b/internal/corpus/code_test.go @@ -94,7 +94,7 @@ func TestCodeSnapshotsAreAtomicDeduplicatedAndSearchLatest(t *testing.T) { if len(matches) != 1 || matches[0].Commit != "second" || matches[0].Path != "new.go" { t.Fatalf("matches = %+v", matches) } - page, err := c.SearchCodeWithOptions(ctx, "needle", CodeSearchOptions{Ref: ref, Limit: 10}) + page, err := c.SearchCodeWithOptions(ctx, "needle", CodeSearchOptions{Ref: ref, Page: mustSearchPage(t, 10)}) if err != nil { t.Fatal(err) } diff --git a/internal/corpus/concerns.go b/internal/corpus/concerns.go index 0840678c..09e38132 100644 --- a/internal/corpus/concerns.go +++ b/internal/corpus/concerns.go @@ -20,6 +20,9 @@ func (c *Corpus) SaveConcern(ctx context.Context, item *concern.Concern) error { if item == nil || item.ID == "" { return errors.New("concern id is required") } + if err := item.ParseStored(); err != nil { + return fmt.Errorf("parse concern: %w", err) + } payload, err := json.Marshal(item) if err != nil { return fmt.Errorf("marshal concern: %w", err) @@ -108,6 +111,13 @@ func (c *Corpus) GetConcern(ctx context.Context, id string) (*concern.Concern, e // ListConcerns performs a bounded offline FTS5 search or updated-order list. func (c *Corpus) ListConcerns(ctx context.Context, filter concern.Filter) (_ *concern.ListResult, err error) { + if filter.Status != "" { + parsed, parseErr := concern.ParseStatus(string(filter.Status)) + if parseErr != nil { + return nil, parseErr + } + filter.Status = parsed + } query := literalFTSQuery(filter.Query) from, where := "FROM concerns c", []string{"1=1"} args := make([]any, 0, 4) @@ -181,8 +191,15 @@ func (c *Corpus) ListConcerns(ctx context.Context, filter concern.Filter) (_ *co current = item } if kind.Valid { + linkKind, err := concern.ParseLinkKind(kind.String) + if err != nil { + return nil, fmt.Errorf("parse concern link: %w", err) + } + if strings.TrimSpace(targetType.String) == "" || strings.TrimSpace(targetID.String) == "" { + return nil, errors.New("parse concern link: target type and identity are required") + } current.Links = append(current.Links, concern.Link{ - Kind: concern.LinkKind(kind.String), + Kind: linkKind, TargetType: targetType.String, TargetID: targetID.String, Note: note.String, @@ -204,6 +221,15 @@ func (c *Corpus) ListConcerns(ctx context.Context, filter concern.Filter) (_ *co // AddConcernLink idempotently stores one typed relationship. func (c *Corpus) AddConcernLink(ctx context.Context, id string, link concern.Link) error { + kind, err := concern.ParseLinkKind(string(link.Kind)) + if err != nil { + return err + } + link.Kind = kind + link.TargetType, link.TargetID = strings.TrimSpace(link.TargetType), strings.TrimSpace(link.TargetID) + if link.TargetType == "" || link.TargetID == "" { + return errors.New("concern link target type and identity are required") + } result, err := c.db.ExecContext(ctx, ` INSERT INTO concern_links (concern_id, kind, target_type, target_id, note, created_at) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM concerns WHERE id=?) @@ -239,10 +265,18 @@ func (c *Corpus) listConcernLinks(ctx context.Context, id string) (links []conce }() for rows.Next() { var link concern.Link + var kind string var createdAt int64 - if err := rows.Scan(&link.Kind, &link.TargetType, &link.TargetID, &link.Note, &createdAt); err != nil { + if err := rows.Scan(&kind, &link.TargetType, &link.TargetID, &link.Note, &createdAt); err != nil { return nil, err } + link.Kind, err = concern.ParseLinkKind(kind) + if err != nil { + return nil, fmt.Errorf("parse concern link: %w", err) + } + if strings.TrimSpace(link.TargetType) == "" || strings.TrimSpace(link.TargetID) == "" { + return nil, errors.New("parse concern link: target type and identity are required") + } link.CreatedAt = scanTime(createdAt) links = append(links, link) } @@ -297,13 +331,19 @@ func (c *Corpus) promoteConcernTx(ctx context.Context, tx *sql.Tx, id string, in if err := insertConcernWorkflowTx(ctx, tx, inv, hypothesis, opportunity); err != nil { return err } - promotion := &concern.Promotion{Kind: "investigation", InvestigationID: inv.ID, HypothesisID: hypothesis.ID, PromotedAt: inv.CreatedAt} + promotion, err := concern.NewInvestigationPromotion(inv.ID, hypothesis.ID, inv.CreatedAt) + if err != nil { + return err + } if opportunity != nil { - promotion.Kind, promotion.OpportunityID = "opportunity", opportunity.ID + promotion, err = concern.NewOpportunityPromotion(inv.ID, hypothesis.ID, opportunity.ID, inv.CreatedAt) + if err != nil { + return err + } } previous := item.Status item.Status, item.Promotion, item.UpdatedAt = concern.StatusPromoted, promotion, inv.CreatedAt - item.AuditTrail = append(item.AuditTrail, concern.StatusChange{From: previous, To: concern.StatusPromoted, Rationale: "promoted to " + promotion.Kind, At: inv.CreatedAt}) + item.AuditTrail = append(item.AuditTrail, concern.StatusChange{From: previous, To: concern.StatusPromoted, Rationale: "promoted to " + promotion.Kind(), At: inv.CreatedAt}) updatedPayload, err := json.Marshal(item) if err != nil { return fmt.Errorf("marshal promoted concern: %w", err) diff --git a/internal/corpus/concerns_test.go b/internal/corpus/concerns_test.go index e5ced16f..afd5baaa 100644 --- a/internal/corpus/concerns_test.go +++ b/internal/corpus/concerns_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "path/filepath" + "strings" "testing" "time" @@ -68,6 +69,47 @@ func TestDecodeConcernRejectsInvalidLifecycleState(t *testing.T) { } } +func TestGetConcernRejectsCorruptStoredLinkKind(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, err := Open(ctx, filepath.Join(t.TempDir(), "concerns.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + svc := concern.NewService(c) + item, err := svc.Create(ctx, &concern.Concern{ + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "abc", + Title: "typed links", ProblemStatement: "stored link kinds must remain valid", Confidence: 0.5, + }) + if err != nil { + t.Fatal(err) + } + if err := svc.Link(ctx, item.ID, concern.Link{Kind: concern.LinkRelated, TargetType: "thread", TargetID: "owner/repo:issue#1"}); err != nil { + t.Fatal(err) + } + conn, err := c.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, `PRAGMA ignore_check_constraints=ON`); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, `UPDATE concern_links SET kind='impossible' WHERE concern_id=?`, item.ID); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, `PRAGMA ignore_check_constraints=OFF`); err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + + if _, err := svc.Get(ctx, item.ID); !errors.Is(err, concern.ErrInvalidLink) || !strings.Contains(err.Error(), "parse concern link") { + t.Fatalf("get error = %v, want corrupt link kind", err) + } +} + func TestPromoteConcernIsAtomic(t *testing.T) { t.Parallel() ctx := context.Background() @@ -106,7 +148,7 @@ func TestPromoteConcernIsAtomic(t *testing.T) { if err != nil { t.Fatal(err) } - if promoted.Status != concern.StatusPromoted || promoted.Promotion == nil || promoted.Promotion.OpportunityID != opportunity.ID || len(promoted.Links) != 3 { + if promoted.Status != concern.StatusPromoted || promoted.Promotion == nil || promoted.Promotion.OpportunityID() != opportunity.ID || len(promoted.Links) != 3 { t.Fatalf("unexpected promoted concern: %+v", promoted) } if got, err := c.GetOpportunity(ctx, opportunity.ID); err != nil || len(got.EvidenceIDs) != 1 { diff --git a/internal/corpus/contribution_search_request.go b/internal/corpus/contribution_search_request.go new file mode 100644 index 00000000..04f74f23 --- /dev/null +++ b/internal/corpus/contribution_search_request.go @@ -0,0 +1,292 @@ +package corpus + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) + +// ContributionSearchInput is the transient loose contribution-search +// boundary shape. ParseContributionSearch is its only path into the query +// layer. +type ContributionSearchInput struct { + ActorRefs []string + RepositoryRefs []string + Kinds []string + Source string + OrganizationNodeID string + From string + To string + Sort string + Order string + Limit int + Cursor string +} + +// ContributionSearchRequest is the only executable contribution query +// representation. Every identity, bound, mode, and page has been parsed before +// a corpus read begins. Its private fields keep construction behind +// ParseContributionSearch. +type ContributionSearchRequest struct { + actorRefs []actorReference + repositories []domain.RepoRef + kinds []domain.ContributionKind + organizationNodeID string + from time.Time + to time.Time + sort contributionSort + order contributionOrder + page SearchPage +} + +// ParseContributionSearch parses contribution source, identities, time bounds, +// ordering, and paging once at the application boundary. +func ParseContributionSearch(input ContributionSearchInput) (ContributionSearchRequest, error) { + if source := strings.TrimSpace(input.Source); source != "" && source != "github_profile" { + return ContributionSearchRequest{}, errors.New("source must be github_profile; corpus_observation is not yet an indexed contribution source") + } + actorRefs, err := parseActorReferences(input.ActorRefs) + if err != nil { + return ContributionSearchRequest{}, err + } + repositories, err := parseContributionRepositories(input.RepositoryRefs) + if err != nil { + return ContributionSearchRequest{}, err + } + kinds, err := parseContributionKinds(input.Kinds) + if err != nil { + return ContributionSearchRequest{}, err + } + from, err := parseContributionBound("from", input.From) + if err != nil { + return ContributionSearchRequest{}, err + } + to, err := parseContributionBound("to", input.To) + if err != nil { + return ContributionSearchRequest{}, err + } + if !from.IsZero() && !to.IsZero() && !to.After(from) { + return ContributionSearchRequest{}, errors.New("to must be after from") + } + sortMode, err := parseContributionSort(input.Sort) + if err != nil { + return ContributionSearchRequest{}, err + } + order, err := parseContributionOrder(input.Order) + if err != nil { + return ContributionSearchRequest{}, err + } + page, err := ParseSearchPage(input.Limit, input.Cursor) + if err != nil { + return ContributionSearchRequest{}, fmt.Errorf("contribution search page: %w", err) + } + return ContributionSearchRequest{ + actorRefs: actorRefs, repositories: repositories, kinds: kinds, + organizationNodeID: strings.TrimSpace(input.OrganizationNodeID), + from: from, to: to, sort: sortMode, order: order, page: page, + }, nil +} + +// ActorReferences returns the canonical requested actor filters in caller +// order. The returned slice cannot mutate the executable query. +func (r ContributionSearchRequest) ActorReferences() []string { + refs := make([]string, len(r.actorRefs)) + for i, ref := range r.actorRefs { + refs[i] = ref.String() + } + return refs +} + +// OrganizationNodeID returns the optional exact GitHub organization scope. +func (r ContributionSearchRequest) OrganizationNodeID() string { return r.organizationNodeID } + +// From returns the optional inclusive contribution bound. +func (r ContributionSearchRequest) From() time.Time { return r.from } + +// To returns the optional exclusive contribution bound. +func (r ContributionSearchRequest) To() time.Time { return r.to } + +// FromString returns the canonical wire spelling of the inclusive bound. +func (r ContributionSearchRequest) FromString() string { return formatContributionBound(r.from) } + +// ToString returns the canonical wire spelling of the exclusive bound. +func (r ContributionSearchRequest) ToString() string { return formatContributionBound(r.to) } + +func (r ContributionSearchRequest) filterKey() string { + actors := r.ActorReferences() + repositories := make([]string, len(r.repositories)) + for i, ref := range r.repositories { + repositories[i] = strings.ToLower(ref.String()) + } + kinds := make([]string, len(r.kinds)) + for i, kind := range r.kinds { + kinds[i] = kind.String() + } + slices.Sort(actors) + slices.Sort(repositories) + slices.Sort(kinds) + canonical, _ := json.Marshal(struct { + Sort, Order, Organization string + Actors, Repositories, Kinds []string + From, To int64 + }{ + Sort: r.sort.String(), Order: r.order.String(), Organization: r.organizationNodeID, + Actors: actors, Repositories: repositories, Kinds: kinds, + From: encodeTime(r.from), To: encodeTime(r.to), + }) + return fmt.Sprintf("%x", sha256.Sum256(canonical)) +} + +type actorReference string + +func parseActorReferences(values []string) ([]actorReference, error) { + if len(values) > 100 { + return nil, errors.New("actors are limited to 100 items") + } + parsed := make([]actorReference, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("actors[%d] is required", index) + } + if _, duplicate := seen[value]; duplicate { + continue + } + seen[value] = struct{}{} + parsed = append(parsed, actorReference(value)) + } + return parsed, nil +} + +func (r actorReference) String() string { return string(r) } + +func parseContributionRepositories(values []string) ([]domain.RepoRef, error) { + if len(values) > 100 { + return nil, errors.New("repositories are limited to 100 items") + } + parsed := make([]domain.RepoRef, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + ref, err := domain.ParseRepoRef(value) + if err != nil { + return nil, fmt.Errorf("repositories[%d]: %w", index, err) + } + key := strings.ToLower(ref.String()) + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + parsed = append(parsed, ref) + } + return parsed, nil +} + +func parseContributionKinds(values []string) ([]domain.ContributionKind, error) { + if len(values) > 20 { + return nil, errors.New("kinds are limited to 20 items") + } + parsed := make([]domain.ContributionKind, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + kind, err := domain.ParseContributionKind(value) + if err != nil { + return nil, fmt.Errorf("kinds[%d] is required: %w", index, err) + } + if _, duplicate := seen[kind.String()]; duplicate { + continue + } + seen[kind.String()] = struct{}{} + parsed = append(parsed, kind) + } + return parsed, nil +} + +func parseContributionBound(field, value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s must be RFC 3339", field) + } + return parsed, nil +} + +func formatContributionBound(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339Nano) +} + +type contributionSort uint8 + +func parseContributionSort(value string) (contributionSort, error) { + switch strings.TrimSpace(value) { + case "", "occurred_at": + return 0, nil + case "repository": + return 1, nil + case "type": + return 2, nil + default: + return 0, errors.New("unsupported contribution sort") + } +} + +func (s contributionSort) String() string { + switch s { + case 1: + return "repository" + case 2: + return "type" + default: + return "occurred_at" + } +} + +func (s contributionSort) expression() string { + switch s { + case 1: + return "repository_ref" + case 2: + return "contribution_kind" + default: + return "occurred_at" + } +} + +type contributionOrder bool + +func parseContributionOrder(value string) (contributionOrder, error) { + switch strings.TrimSpace(value) { + case "", "desc": + return false, nil + case "asc": + return true, nil + default: + return false, errors.New("contribution order must be asc or desc") + } +} + +func (o contributionOrder) String() string { + if o { + return "asc" + } + return "desc" +} + +func (o contributionOrder) sqlDirection() string { + if o { + return "ASC" + } + return "DESC" +} diff --git a/internal/corpus/contribution_search_request_test.go b/internal/corpus/contribution_search_request_test.go new file mode 100644 index 00000000..5f405ea2 --- /dev/null +++ b/internal/corpus/contribution_search_request_test.go @@ -0,0 +1,73 @@ +package corpus + +import ( + "strings" + "testing" + "time" +) + +func TestParseContributionSearchCanonicalizesBoundaryValues(t *testing.T) { + t.Parallel() + request, err := ParseContributionSearch(ContributionSearchInput{ + ActorRefs: []string{" alice ", "alice", "U_1"}, + RepositoryRefs: []string{" Acme/Rocket ", "acme/rocket"}, + Kinds: []string{" issue ", "issue", "future_kind"}, + Source: " github_profile ", OrganizationNodeID: " O_acme ", + From: "2025-01-01T08:00:00+08:00", To: "2025-01-02T08:00:00+08:00", + Sort: " repository ", Order: " asc ", Limit: 7, + }) + if err != nil { + t.Fatal(err) + } + if got := request.ActorReferences(); len(got) != 2 || got[0] != "alice" || got[1] != "U_1" { + t.Fatalf("actor references = %v", got) + } + if request.OrganizationNodeID() != "O_acme" || request.FromString() != "2025-01-01T08:00:00+08:00" || request.ToString() != "2025-01-02T08:00:00+08:00" { + t.Fatalf("parsed request = %+v", request) + } + if request.sort.String() != "repository" || request.order.String() != "asc" || request.page.Limit() != 7 { + t.Fatalf("query modes = %+v", request) + } + if len(request.repositories) != 1 || len(request.kinds) != 2 || request.kinds[1].String() != "future_kind" { + t.Fatalf("parsed filters = repositories %v kinds %v", request.repositories, request.kinds) + } +} + +func TestParseContributionSearchRejectsInvalidBoundaryStates(t *testing.T) { + t.Parallel() + validFrom := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC).Format(time.RFC3339) + validTo := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339) + tests := []struct { + name string + input ContributionSearchInput + want string + }{ + {name: "source", input: ContributionSearchInput{Source: "corpus_observation"}, want: "source must be github_profile"}, + {name: "actor", input: ContributionSearchInput{ActorRefs: []string{" "}}, want: "actors[0] is required"}, + {name: "repository", input: ContributionSearchInput{RepositoryRefs: []string{"acme/rocket/extra"}}, want: "repositories[0]"}, + {name: "kind", input: ContributionSearchInput{Kinds: []string{""}}, want: "kinds[0] is required"}, + {name: "from", input: ContributionSearchInput{From: "yesterday"}, want: "from must be RFC 3339"}, + {name: "period", input: ContributionSearchInput{From: validFrom, To: validTo}, want: "to must be after from"}, + {name: "sort", input: ContributionSearchInput{Sort: "newest"}, want: "unsupported contribution sort"}, + {name: "order", input: ContributionSearchInput{Order: "sideways"}, want: "contribution order must be asc or desc"}, + {name: "page", input: ContributionSearchInput{Limit: 101}, want: "contribution search page"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := ParseContributionSearch(test.input) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestContributionFilterKeyCannotCollideThroughDelimiters(t *testing.T) { + t.Parallel() + first := mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"a,b", "c"}}) + second := mustContributionSearch(t, ContributionSearchInput{ActorRefs: []string{"a", "b,c"}}) + if first.filterKey() == second.filterKey() { + t.Fatal("distinct actor sets produced the same cursor filter key") + } +} diff --git a/internal/corpus/control.go b/internal/corpus/control.go index d7519a04..a81e08e6 100644 --- a/internal/corpus/control.go +++ b/internal/corpus/control.go @@ -9,13 +9,12 @@ import ( // ControlStats is a bounded local snapshot used by status and diagnostics. type ControlStats struct { - Repositories int - Threads int - Sources int - FrontierReady int - ActiveRuns int - ActiveJobs int - Freshest time.Time + Repositories int + Threads int + Sources int + ActiveRuns int + ActiveJobs int + Freshest time.Time } // SchemaVersion returns the applied Goose schema version. @@ -45,7 +44,7 @@ func (c *Corpus) SchemaVersions(ctx context.Context) (current, target int64, err } // ControlStats returns local counts without triggering refresh or hydration. -func (c *Corpus) ControlStats(ctx context.Context, now time.Time) (ControlStats, error) { +func (c *Corpus) ControlStats(ctx context.Context) (ControlStats, error) { var out ControlStats queries := []struct { dst *int @@ -55,7 +54,6 @@ func (c *Corpus) ControlStats(ctx context.Context, now time.Time) (ControlStats, {&out.Repositories, `SELECT COUNT(*) FROM repositories`, nil}, {&out.Threads, `SELECT COUNT(*) FROM threads`, nil}, {&out.Sources, `SELECT COUNT(*) FROM discovery_sources WHERE enabled = 1`, nil}, - {&out.FrontierReady, `SELECT COUNT(*) FROM frontier_items WHERE state = 'queued' AND earliest_run_at <= ?`, []any{encodeTime(now)}}, {&out.ActiveRuns, `SELECT COUNT(*) FROM runs WHERE status = ?`, []any{RunStatusRunning}}, } for _, item := range queries { diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go index 27cd0b6e..605b590e 100644 --- a/internal/corpus/corpus.go +++ b/internal/corpus/corpus.go @@ -19,8 +19,6 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS -var openLeaseHandoff = func(string) error { return nil } - // Corpus is a durable, product-owned SQLite archive for GitHub repositories // and threads. It stores immutable observations and separately maintained // current projections, runs, coverage facts, and FTS5 search indexes. @@ -72,7 +70,11 @@ func Open(ctx context.Context, path string) (_ *Corpus, returnErr error) { if err != nil { return nil, err } - lease, err := acquireCorpusLease(path, needsInitialization, map[bool]string{true: "initialize corpus", false: "open corpus"}[needsInitialization]) + leaseMode, operation := sharedCorpusLease, "open corpus" + if needsInitialization { + leaseMode, operation = exclusiveCorpusLease, "initialize corpus" + } + lease, err := acquireCorpusLease(path, leaseMode, operation) if err != nil { return nil, err } @@ -184,18 +186,25 @@ func handoffInitializedCorpus(ctx context.Context, path string, c *Corpus, lease // Do not retain a handle to the inode initialized under the exclusive // lease. Restore may replace the path while this process waits to reacquire // a shared lease, so close first and reopen only after that lease is held. + if err := releaseInitializedCorpus(c, lease); err != nil { + return nil, lease, err + } + return reopenInitializedCorpus(ctx, path) +} + +func releaseInitializedCorpus(c *Corpus, lease *corpusLease) error { if err := c.db.Close(); err != nil { - return nil, lease, fmt.Errorf("close initialized corpus before lease handoff: %w", err) + return fmt.Errorf("close initialized corpus before lease handoff: %w", err) } c.db = nil if err := lease.release(); err != nil { - return nil, lease, fmt.Errorf("release migration lease: %w", err) + return fmt.Errorf("release migration lease: %w", err) } - lease = nil - if err := openLeaseHandoff(path); err != nil { - return nil, nil, fmt.Errorf("complete corpus lease handoff: %w", err) - } - lease, err := acquireCorpusLease(path, false, "open corpus") + return nil +} + +func reopenInitializedCorpus(ctx context.Context, path string) (*Corpus, *corpusLease, error) { + lease, err := acquireCorpusLease(path, sharedCorpusLease, "open corpus") if err != nil { return nil, nil, err } @@ -203,7 +212,7 @@ func handoffInitializedCorpus(ctx context.Context, path string, c *Corpus, lease if err != nil { return nil, lease, err } - c = &Corpus{db: db, lease: lease, watchDSN: corpusWatchDSN(path)} + c := &Corpus{db: db, lease: lease, watchDSN: corpusWatchDSN(path)} if err := validateOpenCorpusSchema(ctx, c); err != nil { return nil, lease, errors.Join(err, db.Close()) } @@ -250,7 +259,7 @@ func OpenReadOnly(ctx context.Context, path string) (_ *Corpus, returnErr error) if current > target { return nil, &UnsupportedSchemaError{Current: current, Target: target} } - lease, err := acquireCorpusLease(path, false, "open corpus read-only") + lease, err := acquireCorpusLease(path, sharedCorpusLease, "open corpus read-only") if err != nil { return nil, err } diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go index 25e1537f..b9ba60b8 100644 --- a/internal/corpus/corpus_test.go +++ b/internal/corpus/corpus_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/morluto/gitcontribute/internal/domain" ) func TestMigrationLoggerFatalfRecordsError(t *testing.T) { @@ -53,7 +54,7 @@ func TestOpenAndPragmas(t *testing.T) { } } -func TestOpenReopensPathAfterInitializationLeaseHandoff(t *testing.T) { +func TestInitializationLeaseHandoffReopensReplacedPath(t *testing.T) { ctx := context.Background() dir := t.TempDir() target := filepath.Join(dir, "target.db") @@ -70,16 +71,22 @@ func TestOpenReopensPathAfterInitializationLeaseHandoff(t *testing.T) { t.Fatal(err) } - originalHandoff := openLeaseHandoff - t.Cleanup(func() { openLeaseHandoff = originalHandoff }) - openLeaseHandoff = func(path string) error { - if path != target { - return fmt.Errorf("handoff path = %q, want %q", path, target) - } - return replaceDatabaseFile(replacement, target) + lease, err := acquireCorpusLease(target, exclusiveCorpusLease, "initialize test corpus") + if err != nil { + t.Fatal(err) } - - c, err := Open(ctx, target) + initialized, err := openWritableCorpus(ctx, target, lease, true) + if err != nil { + _ = lease.release() + t.Fatal(err) + } + if err := releaseInitializedCorpus(initialized, lease); err != nil { + t.Fatal(err) + } + if err := replaceDatabaseFile(replacement, target); err != nil { + t.Fatal(err) + } + c, _, err := reopenInitializedCorpus(ctx, target) if err != nil { t.Fatal(err) } @@ -407,7 +414,7 @@ func TestSearchTreatsFTSOperatorsAndQuotesLiterally(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", `fix OR unmatched " quote`, "body", "author", time.Now(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", `fix OR unmatched " quote`, "body", "author", time.Now(), `{}`); err != nil { t.Fatal(err) } for _, query := range []string{"OR", `unmatched "`, "fix"} { @@ -441,7 +448,7 @@ func TestSourceAndLocalProjectionTimesRemainDistinct(t *testing.T) { t.Fatalf("local projection times reused source clocks: %+v", repo) } thread, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "open", Title: "title", + RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "title", SourceCreatedAt: sourceCreated, SourceUpdatedAt: sourceUpdated, }, `{}`) if err != nil { @@ -501,14 +508,14 @@ func TestThreadDelayedObservations(t *testing.T) { newer := time.Unix(2000, 0).UTC() older := time.Unix(1000, 0).UTC() - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "first title", "body", "a", newer, `{"comments":0}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "first title", "body", "a", newer, `{"comments":0}`); err != nil { t.Fatalf("apply newer thread observation: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "stale title", "body", "a", older, `{"comments":0}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "stale title", "body", "a", older, `{"comments":0}`); err != nil { t.Fatalf("apply older thread observation: %v", err) } - thread, err := c.GetThread(ctx, repo.ID, ThreadKindIssue, 1) + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatalf("get thread: %v", err) } @@ -538,11 +545,11 @@ func TestThreadObservationReplayIsIdempotent(t *testing.T) { } sourceUpdatedAt := time.Unix(1000, 0).UTC() for range 2 { - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "title", "body", "author", sourceUpdatedAt, `{"id":1}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "title", "body", "author", sourceUpdatedAt, `{"id":1}`); err != nil { t.Fatal(err) } } - thread, err := c.GetThread(ctx, repo.ID, ThreadKindIssue, 1) + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatal(err) } @@ -566,14 +573,14 @@ func TestThreadEqualTimestampSequenceOrdering(t *testing.T) { } ts := time.Unix(4000, 0).UTC() - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "first", "body", "a", ts, `p1`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "first", "body", "a", ts, `p1`); err != nil { t.Fatalf("apply first thread observation: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "second", "body", "a", ts, `p2`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "second", "body", "a", ts, `p2`); err != nil { t.Fatalf("apply second thread observation: %v", err) } - thread, err := c.GetThread(ctx, repo.ID, ThreadKindIssue, 1) + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatalf("get thread: %v", err) } @@ -594,7 +601,7 @@ func TestIndependentFacetAdvancement(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 42, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 42, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatalf("apply thread: %v", err) } @@ -696,13 +703,13 @@ func TestLocalSearch(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "searchable term", "body text here", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "searchable term", "body text here", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply matching thread: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 2, "open", "unrelated", "nothing", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 2, "open", "unrelated", "nothing", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply unrelated thread: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 3, "open", "another term", "more body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 3, "open", "another term", "more body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply pr thread: %v", err) } @@ -758,7 +765,7 @@ func TestCoverageIsIndependentFromProjections(t *testing.T) { t.Fatalf("metadata coverage mismatch: %+v", cov) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "title", "body", "a", time.Unix(10, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "title", "body", "a", time.Unix(10, 0).UTC(), `{}`) if err != nil { t.Fatalf("apply thread: %v", err) } @@ -812,14 +819,14 @@ func TestProjectionIgnoresStaleThreadObservationsBySourceUpdatedAt(t *testing.T) older := time.Unix(1000, 0).UTC() // Apply observations out of chronological order. - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "new", "b", "a", newer, `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "new", "b", "a", newer, `{}`); err != nil { t.Fatalf("apply newer: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "old", "b", "a", older, `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "old", "b", "a", older, `{}`); err != nil { t.Fatalf("apply older: %v", err) } - thread, err := c.GetThread(ctx, repo.ID, ThreadKindIssue, 1) + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatalf("get thread: %v", err) } diff --git a/internal/corpus/discovery.go b/internal/corpus/discovery.go index 10726202..9acd3680 100644 --- a/internal/corpus/discovery.go +++ b/internal/corpus/discovery.go @@ -5,14 +5,40 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" ) +// DiscoverySourceKind identifies the schema of one durable source +// definition. +type DiscoverySourceKind string + +const ( + DiscoverySourceSearch DiscoverySourceKind = "search" + DiscoverySourceRepos DiscoverySourceKind = "repos" + DiscoverySourceGHArchive DiscoverySourceKind = "gharchive" +) + +// ParseDiscoverySourceKind converts storage or boundary text into a supported +// source variant. +func ParseDiscoverySourceKind(value string) (DiscoverySourceKind, error) { + switch DiscoverySourceKind(strings.ToLower(strings.TrimSpace(value))) { + case DiscoverySourceSearch: + return DiscoverySourceSearch, nil + case DiscoverySourceRepos: + return DiscoverySourceRepos, nil + case DiscoverySourceGHArchive: + return DiscoverySourceGHArchive, nil + default: + return "", fmt.Errorf("unsupported discovery source kind %q", value) + } +} + // DiscoverySource is one durable repository-discovery definition. type DiscoverySource struct { ID int64 Name string - Kind string + Kind DiscoverySourceKind Definition string Enabled bool CreatedAt time.Time @@ -37,11 +63,16 @@ type SourcePartition struct { // SaveDiscoverySource creates or updates a named source definition. func (c *Corpus) SaveDiscoverySource(ctx context.Context, source DiscoverySource) (*DiscoverySource, error) { - if source.Name == "" || source.Kind == "" { + if source.Name == "" { return nil, errors.New("discovery source name and kind are required") } + kind, err := ParseDiscoverySourceKind(string(source.Kind)) + if err != nil { + return nil, err + } + source.Kind = kind now := encodeTime(time.Now()) - _, err := c.db.ExecContext(ctx, ` + _, err = c.db.ExecContext(ctx, ` INSERT INTO discovery_sources (name, kind, definition, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET kind=excluded.kind, definition=excluded.definition, @@ -56,18 +87,23 @@ func (c *Corpus) SaveDiscoverySource(ctx context.Context, source DiscoverySource // GetDiscoverySource returns a named source or nil. func (c *Corpus) GetDiscoverySource(ctx context.Context, name string) (*DiscoverySource, error) { var source DiscoverySource + var kind string var enabled int var created, updated int64 err := c.db.QueryRowContext(ctx, ` SELECT id, name, kind, definition, enabled, created_at, updated_at FROM discovery_sources WHERE name=? - `, name).Scan(&source.ID, &source.Name, &source.Kind, &source.Definition, &enabled, &created, &updated) + `, name).Scan(&source.ID, &source.Name, &kind, &source.Definition, &enabled, &created, &updated) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get discovery source: %w", err) } + source.Kind, err = ParseDiscoverySourceKind(kind) + if err != nil { + return nil, fmt.Errorf("get discovery source: %w", err) + } source.Enabled = enabled != 0 source.CreatedAt = scanTime(created) source.UpdatedAt = scanTime(updated) @@ -97,11 +133,16 @@ func (c *Corpus) ListDiscoverySources(ctx context.Context) (DiscoverySourceList, var result DiscoverySourceList for rows.Next() { var source DiscoverySource + var kind string var enabled int var created, updated int64 - if err := rows.Scan(&source.ID, &source.Name, &source.Kind, &source.Definition, &enabled, &created, &updated, &result.Total); err != nil { + if err := rows.Scan(&source.ID, &source.Name, &kind, &source.Definition, &enabled, &created, &updated, &result.Total); err != nil { return DiscoverySourceList{}, err } + source.Kind, err = ParseDiscoverySourceKind(kind) + if err != nil { + return DiscoverySourceList{}, fmt.Errorf("list discovery sources: %w", err) + } source.Enabled = enabled != 0 source.CreatedAt = scanTime(created) source.UpdatedAt = scanTime(updated) diff --git a/internal/corpus/discovery_test.go b/internal/corpus/discovery_test.go index 0e2a2f0b..7ff515c9 100644 --- a/internal/corpus/discovery_test.go +++ b/internal/corpus/discovery_test.go @@ -81,6 +81,32 @@ func TestDiscoverySourcesAndPartitionsPersist(t *testing.T) { } } +func TestDiscoverySourceKindsAreParsedAtStorageBoundaries(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + + source, err := c.SaveDiscoverySource(ctx, DiscoverySource{Name: "canonical", Kind: " SEARCH ", Definition: `{}`}) + if err != nil { + t.Fatal(err) + } + if source.Kind != DiscoverySourceSearch { + t.Fatalf("canonical source kind = %q", source.Kind) + } + if _, err := c.SaveDiscoverySource(ctx, DiscoverySource{Name: "invalid", Kind: "feed"}); err == nil { + t.Fatal("unsupported discovery source kind was stored") + } + if _, err := c.db.ExecContext(ctx, `UPDATE discovery_sources SET kind='feed' WHERE id=?`, source.ID); err != nil { + t.Fatal(err) + } + if _, err := c.GetDiscoverySource(ctx, source.Name); err == nil { + t.Fatal("discovery source read accepted an invalid stored kind") + } + if _, err := c.ListDiscoverySources(ctx); err == nil { + t.Fatal("discovery source list accepted an invalid stored kind") + } +} + func TestDiscoverySourceListExposesHardCapTruncation(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/corpus/evidence_freshness.go b/internal/corpus/evidence_freshness.go index a3a3f5bb..6944d349 100644 --- a/internal/corpus/evidence_freshness.go +++ b/internal/corpus/evidence_freshness.go @@ -17,7 +17,7 @@ func (c *Corpus) CurrentSourceRevision(ctx context.Context, subject evidence.Sou if err := subject.Validate(); err != nil { return nil, err } - switch subject.Kind { + switch subject.Kind() { case evidence.SourceSubjectRepository: return c.currentRepositorySourceRevision(ctx, subject) case evidence.SourceSubjectThread: @@ -25,11 +25,12 @@ func (c *Corpus) CurrentSourceRevision(ctx context.Context, subject evidence.Sou case evidence.SourceSubjectFacet, evidence.SourceSubjectGuidance: return c.currentFacetSourceRevision(ctx, subject) default: - return nil, fmt.Errorf("unsupported source subject kind %q", subject.Kind) + return nil, errors.New("source subject is not parsed") } } func (c *Corpus) currentRepositorySourceRevision(ctx context.Context, subject evidence.SourceSubject) (*evidence.SourceRevision, error) { + repository := subject.Repository() var sourceUpdatedAt, sequence, observedAt int64 err := c.db.QueryRowContext(ctx, ` SELECT r.source_updated_at, r.observation_sequence, @@ -38,11 +39,13 @@ func (c *Corpus) currentRepositorySourceRevision(ctx context.Context, subject ev AND o.observation_sequence=r.observation_sequence LIMIT 1), r.updated_at) FROM repositories r WHERE r.owner=? COLLATE NOCASE AND r.name=? COLLATE NOCASE - `, subject.Owner, subject.Repo).Scan(&sourceUpdatedAt, &sequence, &observedAt) + `, repository.Owner(), repository.Repo()).Scan(&sourceUpdatedAt, &sequence, &observedAt) return scannedSourceRevision(subject, sourceUpdatedAt, sequence, observedAt, err) } func (c *Corpus) currentThreadSourceRevision(ctx context.Context, subject evidence.SourceSubject) (*evidence.SourceRevision, error) { + repository := subject.Repository() + threadKind, number, _ := subject.Thread() var sourceUpdatedAt, sequence, observedAt int64 err := c.db.QueryRowContext(ctx, ` SELECT t.source_updated_at, t.observation_sequence, @@ -53,15 +56,16 @@ func (c *Corpus) currentThreadSourceRevision(ctx context.Context, subject eviden JOIN repositories r ON r.id=t.repository_id WHERE r.owner=? COLLATE NOCASE AND r.name=? COLLATE NOCASE AND t.kind=? AND t.number=? - `, subject.Owner, subject.Repo, subject.ThreadKind, subject.Number).Scan(&sourceUpdatedAt, &sequence, &observedAt) + `, repository.Owner(), repository.Repo(), threadKind, number).Scan(&sourceUpdatedAt, &sequence, &observedAt) return scannedSourceRevision(subject, sourceUpdatedAt, sequence, observedAt, err) } func (c *Corpus) currentFacetSourceRevision(ctx context.Context, subject evidence.SourceSubject) (*evidence.SourceRevision, error) { + repository := subject.Repository() var repoID int64 err := c.db.QueryRowContext(ctx, ` SELECT id FROM repositories WHERE owner=? COLLATE NOCASE AND name=? COLLATE NOCASE - `, subject.Owner, subject.Repo).Scan(&repoID) + `, repository.Owner(), repository.Repo()).Scan(&repoID) if errors.Is(err, sql.ErrNoRows) { return nil, evidence.ErrSourceRevisionUnavailable } @@ -70,11 +74,12 @@ func (c *Corpus) currentFacetSourceRevision(ctx context.Context, subject evidenc } var threadID sql.NullInt64 - if subject.ThreadKind != "" { + threadKind, number, threadScoped := subject.Thread() + if threadScoped { threadID.Valid = true err = c.db.QueryRowContext(ctx, ` SELECT id FROM threads WHERE repository_id=? AND kind=? AND number=? - `, repoID, subject.ThreadKind, subject.Number).Scan(&threadID.Int64) + `, repoID, threadKind, number).Scan(&threadID.Int64) if errors.Is(err, sql.ErrNoRows) { return nil, evidence.ErrSourceRevisionUnavailable } @@ -82,8 +87,8 @@ func (c *Corpus) currentFacetSourceRevision(ctx context.Context, subject evidenc return nil, fmt.Errorf("resolve source thread: %w", err) } } - facet := subject.Facet - if subject.Kind == evidence.SourceSubjectGuidance { + facet := subject.Facet() + if subject.Kind() == evidence.SourceSubjectGuidance { facet = evidence.GuidanceFacet } var sourceUpdatedAt, sequence, observedAt int64 diff --git a/internal/corpus/evidence_freshness_test.go b/internal/corpus/evidence_freshness_test.go index 9fdbd809..7b6e6836 100644 --- a/internal/corpus/evidence_freshness_test.go +++ b/internal/corpus/evidence_freshness_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" ) @@ -21,11 +22,14 @@ func TestEvidenceThreadFreshnessAndProvenancePersistence(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 42, "open", "bug", "body", "alice", time.Unix(20, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 42, "open", "bug", "body", "alice", time.Unix(20, 0).UTC(), `{}`) + if err != nil { + t.Fatal(err) + } + subject, err := evidence.NewThreadSourceSubject(domain.MustRepoRef("owner", "repo"), domain.IssueKind, 42) if err != nil { t.Fatal(err) } - subject := evidence.SourceSubject{Kind: evidence.SourceSubjectThread, Owner: "owner", Repo: "repo", ThreadKind: ThreadKindIssue, Number: 42} revision, err := c.CurrentSourceRevision(ctx, subject) if err != nil || revision == nil { t.Fatalf("CurrentSourceRevision = (%+v, %v)", revision, err) @@ -60,7 +64,7 @@ func TestEvidenceThreadFreshnessAndProvenancePersistence(t *testing.T) { } // The same source timestamp with a later local observation sequence wins. - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 42, "open", "updated bug", "body", "alice", time.Unix(20, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 42, "open", "updated bug", "body", "alice", time.Unix(20, 0).UTC(), `{}`); err != nil { t.Fatal(err) } freshness, err = evaluator.Evaluate(ctx, items[0]) @@ -74,14 +78,15 @@ func TestEvidenceFacetFreshnessIgnoresUnrelatedFacet(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) repo, _ := c.ApplyRepositoryObservation(ctx, "owner", "repo", "R1", time.Unix(10, 0).UTC(), `{}`) - thread, _ := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "bug", "", "alice", time.Unix(20, 0).UTC(), `{}`) + thread, _ := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "bug", "", "alice", time.Unix(20, 0).UTC(), `{}`) threadID := thread.ID commentsAt := time.Unix(30, 0).UTC() if err := c.ApplyFacetObservationSet(ctx, repo.ID, &threadID, "issue_comments", commentsAt, []FacetObservationInput{{SourceUpdatedAt: commentsAt, Payload: `{}`}}, true, 0); err != nil { t.Fatal(err) } - subject := evidence.SourceSubject{ - Kind: evidence.SourceSubjectFacet, Owner: "owner", Repo: "repo", ThreadKind: ThreadKindIssue, Number: 1, Facet: "issue_comments", + subject, err := evidence.ParseSourceSubject("facet", "owner", "repo", string(domain.IssueKind), 1, "issue_comments") + if err != nil { + t.Fatal(err) } recorded, err := c.CurrentSourceRevision(ctx, subject) if err != nil || recorded == nil { @@ -112,10 +117,14 @@ func TestEvidenceFreshnessMissingRevisionIsUnknownAndReadOnly(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) + subject, err := evidence.NewRepositorySourceSubject(domain.MustRepoRef("missing", "repo")) + if err != nil { + t.Fatal(err) + } item := &evidence.Evidence{ Type: evidence.EvidenceTypeGitHubSource, SourceProvenance: []evidence.SourceRevision{{ - Subject: evidence.SourceSubject{Kind: evidence.SourceSubjectRepository, Owner: "missing", Repo: "repo"}, + Subject: subject, SourceUpdatedAt: time.Unix(10, 0).UTC(), ObservationSequence: 1, ObservedAt: time.Unix(11, 0).UTC(), }}, } diff --git a/internal/corpus/feedback_search_request.go b/internal/corpus/feedback_search_request.go new file mode 100644 index 00000000..82e3cc32 --- /dev/null +++ b/internal/corpus/feedback_search_request.go @@ -0,0 +1,263 @@ +package corpus + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// FeedbackSearchInput is the transient loose representation accepted at an +// untrusted search boundary. ParseFeedbackSearchQuery is its only path into +// the query layer. +type FeedbackSearchInput struct { + FeedbackAuthor string + PullRequestAuthor string + State string + Merged string + ThreadState string + Channel string + Text string + CreatedAfter string + CreatedBefore string + UpdatedAfter string + UpdatedBefore string + Sort string + Order string + Limit int + Cursor string +} + +// FeedbackSearchQuery is a parsed repository-independent feedback query. +// Bind it to one stored repository before execution. +type FeedbackSearchQuery struct { + feedbackAuthor string + pullRequestAuthor string + state ThreadStateFilter + merge MergeFilter + threadState feedbackThreadState + channel FeedbackChannel + text string + createdAfter time.Time + createdBefore time.Time + updatedAfter time.Time + updatedBefore time.Time + sort feedbackSort + order feedbackOrder + page SearchPage +} + +// FeedbackSearchRequest is an executable query bound to exactly one stored +// repository. Its fields are private so repository-wide coverage cannot be +// accidentally combined with a cross-repository result set. +type FeedbackSearchRequest struct { + repositoryID int64 + query FeedbackSearchQuery +} + +// ParseFeedbackSearchQuery parses all loose modes, dates, and paging once. +func ParseFeedbackSearchQuery(input FeedbackSearchInput) (FeedbackSearchQuery, error) { + page, err := ParseSearchPage(input.Limit, input.Cursor) + if err != nil { + return FeedbackSearchQuery{}, fmt.Errorf("feedback search page: %w", err) + } + state, err := ParseThreadStateFilter(input.State) + if err != nil { + return FeedbackSearchQuery{}, fmt.Errorf("feedback search state: %w", err) + } + merge, err := ParseMergeFilter(input.Merged) + if err != nil { + return FeedbackSearchQuery{}, fmt.Errorf("feedback search merged: %w", err) + } + threadState, err := parseFeedbackThreadState(input.ThreadState) + if err != nil { + return FeedbackSearchQuery{}, err + } + channel, err := parseFeedbackChannelFilter(input.Channel) + if err != nil { + return FeedbackSearchQuery{}, err + } + sortMode, err := parseFeedbackSort(input.Sort) + if err != nil { + return FeedbackSearchQuery{}, err + } + order, err := parseFeedbackOrder(input.Order) + if err != nil { + return FeedbackSearchQuery{}, err + } + createdAfter, err := parseFeedbackSearchTime("created_after", input.CreatedAfter) + if err != nil { + return FeedbackSearchQuery{}, err + } + createdBefore, err := parseFeedbackSearchTime("created_before", input.CreatedBefore) + if err != nil { + return FeedbackSearchQuery{}, err + } + updatedAfter, err := parseFeedbackSearchTime("updated_after", input.UpdatedAfter) + if err != nil { + return FeedbackSearchQuery{}, err + } + updatedBefore, err := parseFeedbackSearchTime("updated_before", input.UpdatedBefore) + if err != nil { + return FeedbackSearchQuery{}, err + } + if !createdAfter.IsZero() && !createdBefore.IsZero() && createdAfter.After(createdBefore) { + return FeedbackSearchQuery{}, errors.New("feedback search created_after must not be after created_before") + } + if !updatedAfter.IsZero() && !updatedBefore.IsZero() && updatedAfter.After(updatedBefore) { + return FeedbackSearchQuery{}, errors.New("feedback search updated_after must not be after updated_before") + } + if state.IsOpen() && merge.IsMerged() { + return FeedbackSearchQuery{}, errors.New("feedback search cannot select merged open pull requests") + } + if !threadState.isAny() && channel != 0 && channel != FeedbackReviewThreads { + return FeedbackSearchQuery{}, errors.New("feedback search thread_state requires the review_threads channel") + } + return FeedbackSearchQuery{ + feedbackAuthor: strings.TrimSpace(input.FeedbackAuthor), pullRequestAuthor: strings.TrimSpace(input.PullRequestAuthor), + state: state, merge: merge, threadState: threadState, channel: channel, text: strings.TrimSpace(input.Text), + createdAfter: createdAfter, createdBefore: createdBefore, updatedAfter: updatedAfter, updatedBefore: updatedBefore, + sort: sortMode, order: order, page: page, + }, nil +} + +// InRepository binds a parsed query to one stored repository row. +func (q FeedbackSearchQuery) InRepository(repositoryID int64) (FeedbackSearchRequest, error) { + if repositoryID <= 0 { + return FeedbackSearchRequest{}, fmt.Errorf("feedback search repository id must be positive, got %d", repositoryID) + } + return FeedbackSearchRequest{repositoryID: repositoryID, query: q}, nil +} + +// Limit returns the normalized result bound for adapter provenance. +func (q FeedbackSearchQuery) Limit() int { return q.page.Limit() } + +func parseFeedbackSearchTime(field, value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s must be RFC3339: %w", field, err) + } + return parsed, nil +} + +type feedbackThreadState uint8 + +func parseFeedbackThreadState(value string) (feedbackThreadState, error) { + switch strings.TrimSpace(value) { + case "", "all": + return 0, nil + case "resolved": + return 1, nil + case "unresolved": + return 2, nil + default: + return 0, errors.New("feedback search thread_state must be resolved, unresolved, or all") + } +} + +func (s feedbackThreadState) isAny() bool { return s == 0 } +func (s feedbackThreadState) isResolved() bool { return s == 1 } + +func (s feedbackThreadState) String() string { + switch s { + case 1: + return "resolved" + case 2: + return "unresolved" + default: + return "all" + } +} + +func parseFeedbackChannelFilter(value string) (FeedbackChannel, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + return ParseFeedbackChannel(value) +} + +type feedbackSort uint8 + +func parseFeedbackSort(value string) (feedbackSort, error) { + switch strings.TrimSpace(value) { + case "", "updated": + return 0, nil + case "feedback_author": + return 1, nil + case "pull_request_state": + return 2, nil + case "merge_state": + return 3, nil + case "created": + return 4, nil + case "pull_request_number": + return 5, nil + default: + return 0, errors.New("feedback search sort is unsupported") + } +} + +func (s feedbackSort) String() string { + switch s { + case 1: + return "feedback_author" + case 2: + return "pull_request_state" + case 3: + return "merge_state" + case 4: + return "created" + case 5: + return "pull_request_number" + default: + return "updated" + } +} + +func (s feedbackSort) expression() string { + switch s { + case 1: + return "lower(p.author)" + case 2: + return "t.state" + case 3: + return "CASE WHEN t.merged_known = 0 THEN 0 WHEN t.merged = 0 THEN 1 ELSE 2 END" + case 4: + return "p.created_at" + case 5: + return "t.number" + default: + return "p.updated_at" + } +} + +type feedbackOrder bool + +func parseFeedbackOrder(value string) (feedbackOrder, error) { + switch strings.TrimSpace(value) { + case "", "desc": + return false, nil + case "asc": + return true, nil + default: + return false, errors.New("feedback search order must be asc or desc") + } +} + +func (o feedbackOrder) String() string { + if o { + return "asc" + } + return "desc" +} + +func (o feedbackOrder) sqlDirection() string { + if o { + return "ASC" + } + return "DESC" +} diff --git a/internal/corpus/feedback_search_request_test.go b/internal/corpus/feedback_search_request_test.go new file mode 100644 index 00000000..5bc41b8f --- /dev/null +++ b/internal/corpus/feedback_search_request_test.go @@ -0,0 +1,54 @@ +package corpus + +import "testing" + +func TestParseFeedbackSearchQueryRejectsContradictoryBoundaryStates(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input FeedbackSearchInput + }{ + {name: "reversed created interval", input: FeedbackSearchInput{CreatedAfter: "2026-08-02T00:00:00Z", CreatedBefore: "2026-08-01T00:00:00Z"}}, + {name: "reversed updated interval", input: FeedbackSearchInput{UpdatedAfter: "2026-08-02T00:00:00Z", UpdatedBefore: "2026-08-01T00:00:00Z"}}, + {name: "merged open pull request", input: FeedbackSearchInput{State: "open", Merged: "true"}}, + {name: "resolution on issue comments", input: FeedbackSearchInput{Channel: "issue_comments", ThreadState: "resolved"}}, + {name: "unknown state", input: FeedbackSearchInput{State: "draft"}}, + {name: "unknown merge mode", input: FeedbackSearchInput{Merged: "maybe"}}, + {name: "unknown channel", input: FeedbackSearchInput{Channel: "commits"}}, + {name: "unknown sort", input: FeedbackSearchInput{Sort: "random"}}, + {name: "unknown order", input: FeedbackSearchInput{Order: "sideways"}}, + {name: "oversized page", input: FeedbackSearchInput{Limit: MaximumSearchPageSize + 1}}, + {name: "invalid timestamp", input: FeedbackSearchInput{CreatedAfter: "yesterday"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ParseFeedbackSearchQuery(test.input); err == nil { + t.Fatal("feedback search accepted contradictory input") + } + }) + } +} + +func TestParseFeedbackSearchQueryProducesCanonicalExecutableRequest(t *testing.T) { + t.Parallel() + query, err := ParseFeedbackSearchQuery(FeedbackSearchInput{FeedbackAuthor: " alice ", PullRequestAuthor: " bob ", Text: " latency "}) + if err != nil { + t.Fatal(err) + } + if query.feedbackAuthor != "alice" || query.pullRequestAuthor != "bob" || query.text != "latency" { + t.Fatalf("canonical text fields = %+v", query) + } + if !query.state.IsAny() || !query.merge.IsAny() || !query.threadState.isAny() || query.channel != 0 || query.sort.String() != "updated" || query.order.String() != "desc" || query.Limit() != DefaultSearchPageSize { + t.Fatalf("canonical modes = %+v", query) + } + request, err := query.InRepository(42) + if err != nil { + t.Fatal(err) + } + if request.repositoryID != 42 { + t.Fatalf("repository id = %d, want 42", request.repositoryID) + } + if _, err := query.InRepository(0); err == nil { + t.Fatal("feedback search accepted an unbound repository") + } +} diff --git a/internal/corpus/feedback_selection.go b/internal/corpus/feedback_selection.go new file mode 100644 index 00000000..a85201bc --- /dev/null +++ b/internal/corpus/feedback_selection.go @@ -0,0 +1,163 @@ +package corpus + +import ( + "errors" + "fmt" + "strings" +) + +// FeedbackChannel identifies one product-owned pull-request feedback facet. +type FeedbackChannel uint8 + +const ( + FeedbackIssueComments FeedbackChannel = iota + 1 + FeedbackSubmittedReviews + FeedbackInlineComments + FeedbackReviewThreads +) + +// ParseFeedbackChannel parses one canonical feedback channel. +func ParseFeedbackChannel(value string) (FeedbackChannel, error) { + switch strings.TrimSpace(value) { + case "issue_comments": + return FeedbackIssueComments, nil + case "submitted_reviews": + return FeedbackSubmittedReviews, nil + case "inline_comments": + return FeedbackInlineComments, nil + case "review_threads": + return FeedbackReviewThreads, nil + default: + return 0, fmt.Errorf("unsupported feedback channel %q", value) + } +} + +// String returns the provider and storage spelling. +func (c FeedbackChannel) String() string { + switch c { + case FeedbackIssueComments: + return "issue_comments" + case FeedbackSubmittedReviews: + return "submitted_reviews" + case FeedbackInlineComments: + return "inline_comments" + case FeedbackReviewThreads: + return "review_threads" + default: + return "" + } +} + +// Facet returns the corpus facet that stores this feedback channel. +func (c FeedbackChannel) Facet() string { return feedbackFacet(c) } + +// FeedbackThreadSelection is the review-thread population fetched from +// GitHub. The zero value is invalid so persisted discovery always records the +// scope that established its coverage. +type FeedbackThreadSelection uint8 + +const ( + AllFeedbackThreads FeedbackThreadSelection = iota + 1 + UnresolvedFeedbackThreads +) + +// ParseFeedbackThreadSelection parses one explicit acquisition scope. +func ParseFeedbackThreadSelection(value string) (FeedbackThreadSelection, error) { + switch strings.TrimSpace(value) { + case "all": + return AllFeedbackThreads, nil + case "unresolved": + return UnresolvedFeedbackThreads, nil + default: + return 0, errors.New("thread_state must be unresolved or all") + } +} + +// String returns the provider and storage spelling. +func (s FeedbackThreadSelection) String() string { + switch s { + case AllFeedbackThreads: + return "all" + case UnresolvedFeedbackThreads: + return "unresolved" + default: + return "" + } +} + +// FeedbackSelection binds a non-empty unique channel set to the review-thread +// acquisition scope used to establish coverage. +type FeedbackSelection struct { + channels uint8 + threadState FeedbackThreadSelection +} + +// ParseFeedbackSelection parses and canonicalizes a feedback acquisition +// selection. Channel order is not semantically meaningful. +func ParseFeedbackSelection(channels []string, threadState string) (FeedbackSelection, error) { + if len(channels) < 1 || len(channels) > 4 { + return FeedbackSelection{}, errors.New("channels must contain 1 to 4 items") + } + var bits uint8 + for _, value := range channels { + channel, err := ParseFeedbackChannel(value) + if err != nil { + return FeedbackSelection{}, err + } + bit := uint8(1 << (channel - 1)) + if bits&bit != 0 { + return FeedbackSelection{}, fmt.Errorf("duplicate feedback channel %q", value) + } + bits |= bit + } + selection, err := ParseFeedbackThreadSelection(threadState) + if err != nil { + return FeedbackSelection{}, err + } + return FeedbackSelection{channels: bits, threadState: selection}, nil +} + +// AllFeedbackSelection returns complete-channel, all-thread acquisition. +func AllFeedbackSelection() FeedbackSelection { + return FeedbackSelection{channels: 0b1111, threadState: AllFeedbackThreads} +} + +// Valid reports whether the selection can establish a coverage fact. +func (s FeedbackSelection) Valid() bool { + return s.channels != 0 && s.channels&^uint8(0b1111) == 0 && s.threadState.String() != "" +} + +// Channels returns the selected channels in stable canonical order. +func (s FeedbackSelection) Channels() []string { + values := s.ChannelValues() + out := make([]string, 0, len(values)) + for _, channel := range values { + out = append(out, channel.String()) + } + return out +} + +// ChannelValues returns the selected product-owned channels in stable order. +func (s FeedbackSelection) ChannelValues() []FeedbackChannel { + out := make([]FeedbackChannel, 0, 4) + for _, channel := range []FeedbackChannel{FeedbackIssueComments, FeedbackSubmittedReviews, FeedbackInlineComments, FeedbackReviewThreads} { + if s.Includes(channel) { + out = append(out, channel) + } + } + return out +} + +// ThreadState returns the selected review-thread population. +func (s FeedbackSelection) ThreadState() string { return s.threadState.String() } + +// Includes reports whether one channel belongs to the selection. +func (s FeedbackSelection) Includes(channel FeedbackChannel) bool { + if channel < FeedbackIssueComments || channel > FeedbackReviewThreads { + return false + } + return s.channels&uint8(1<<(channel-1)) != 0 +} + +// Equal reports semantic selection equality independent of input order. +func (s FeedbackSelection) Equal(other FeedbackSelection) bool { return s == other } diff --git a/internal/corpus/feedback_selection_test.go b/internal/corpus/feedback_selection_test.go new file mode 100644 index 00000000..dcbc3437 --- /dev/null +++ b/internal/corpus/feedback_selection_test.go @@ -0,0 +1,47 @@ +package corpus + +import ( + "slices" + "testing" +) + +func TestFeedbackSelectionCanonicalizesAndOwnsChannelSet(t *testing.T) { + t.Parallel() + selection, err := ParseFeedbackSelection([]string{"review_threads", "issue_comments"}, "unresolved") + if err != nil { + t.Fatal(err) + } + want := []string{"issue_comments", "review_threads"} + if got := selection.Channels(); !slices.Equal(got, want) { + t.Fatalf("channels = %v, want %v", got, want) + } + if selection.ThreadState() != "unresolved" || !selection.Includes(FeedbackIssueComments) || selection.Includes(FeedbackInlineComments) { + t.Fatalf("selection = %+v", selection) + } + channels := selection.Channels() + channels[0] = "mutated" + if got := selection.Channels(); !slices.Equal(got, want) { + t.Fatalf("returned channels mutated selection: %v", got) + } + reordered, err := ParseFeedbackSelection([]string{"issue_comments", "review_threads"}, "unresolved") + if err != nil || !selection.Equal(reordered) { + t.Fatalf("reordered selection = %+v, err=%v", reordered, err) + } +} + +func TestFeedbackSelectionRejectsInvalidCoverageStates(t *testing.T) { + t.Parallel() + for _, input := range []struct { + channels []string + state string + }{ + {state: "all"}, + {channels: []string{"issue_comments", "issue_comments"}, state: "all"}, + {channels: []string{"commits"}, state: "all"}, + {channels: []string{"issue_comments"}, state: "resolved"}, + } { + if _, err := ParseFeedbackSelection(input.channels, input.state); err == nil { + t.Fatalf("accepted invalid selection: %+v", input) + } + } +} diff --git a/internal/corpus/frontier.go b/internal/corpus/frontier.go deleted file mode 100644 index 89b87789..00000000 --- a/internal/corpus/frontier.go +++ /dev/null @@ -1,323 +0,0 @@ -package corpus - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" -) - -// FrontierState describes the durable lifecycle of queued crawl work. -const ( - FrontierQueued = "queued" - FrontierLeased = "leased" - FrontierCompleted = "completed" - FrontierFailed = "failed" -) - -// Frontier failure classifications keep terminal source outcomes distinct. -const ( - FrontierFailureTransientExhausted = "transient_exhausted" - FrontierFailureAbsent = "absent" - FrontierFailureUnauthorized = "unauthorized" - FrontierFailureDeleted = "deleted" - FrontierFailureArchived = "archived" - FrontierFailurePermanent = "permanent" -) - -// FrontierItem is a deduplicated unit of repository, thread, or facet work. -// WorkKey is a stable product-owned identity chosen by the caller. -type FrontierItem struct { - ID int64 - WorkKey string - SubjectKind string - Owner string - Repo string - ThreadKind string - ThreadNumber int - Facet string - Priority int - Reason string - Source string - Attempts int - MaxAttempts int - EarliestRunAt time.Time - BudgetEstimate int - State string - LeaseOwner string - LeaseExpiresAt *time.Time - FailureKind string - LastError string - CreatedAt time.Time - UpdatedAt time.Time -} - -// EnqueueFrontierItem inserts work once. Replaying the same WorkKey returns -// the existing item without resetting attempts or terminal state. -func (c *Corpus) EnqueueFrontierItem(ctx context.Context, item FrontierItem) (*FrontierItem, bool, error) { - if strings.TrimSpace(item.WorkKey) == "" { - return nil, false, errors.New("frontier work key is required") - } - if strings.TrimSpace(item.SubjectKind) == "" { - return nil, false, errors.New("frontier subject kind is required") - } - if item.MaxAttempts <= 0 { - item.MaxAttempts = 3 - } - if item.BudgetEstimate <= 0 { - item.BudgetEstimate = 1 - } - now := encodeTime(time.Now()) - res, err := c.db.ExecContext(ctx, ` - INSERT INTO frontier_items ( - work_key, subject_kind, owner, repo, thread_kind, thread_number, facet, - priority, reason, source, max_attempts, earliest_run_at, budget_estimate, - state, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (work_key) DO NOTHING - `, item.WorkKey, item.SubjectKind, item.Owner, item.Repo, item.ThreadKind, - item.ThreadNumber, item.Facet, item.Priority, item.Reason, item.Source, - item.MaxAttempts, encodeTime(item.EarliestRunAt), item.BudgetEstimate, - FrontierQueued, now, now) - if err != nil { - return nil, false, fmt.Errorf("enqueue frontier item: %w", err) - } - inserted, err := res.RowsAffected() - if err != nil { - return nil, false, fmt.Errorf("read enqueue result: %w", err) - } - stored, err := c.GetFrontierItem(ctx, item.WorkKey) - return stored, inserted == 1, err -} - -// GetFrontierItem returns work by its stable key, or nil when absent. -func (c *Corpus) GetFrontierItem(ctx context.Context, workKey string) (*FrontierItem, error) { - row := c.db.QueryRowContext(ctx, frontierSelect+` WHERE work_key = ?`, workKey) - item, err := scanFrontierItem(row) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("get frontier item: %w", err) - } - return item, nil -} - -// LeaseFrontierItems atomically claims ready work for a bounded interval. -// Expired leases are eligible for another worker. Higher priority wins, then -// earlier eligibility and insertion order. -func (c *Corpus) LeaseFrontierItems(ctx context.Context, worker string, now time.Time, leaseDuration time.Duration, limit, budget int) ([]FrontierItem, error) { - if strings.TrimSpace(worker) == "" { - return nil, errors.New("frontier worker is required") - } - if leaseDuration <= 0 { - return nil, errors.New("frontier lease duration must be positive") - } - if limit <= 0 { - return []FrontierItem{}, nil - } - if budget <= 0 { - budget = int(^uint(0) >> 1) - } - - tx, err := c.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin frontier lease: %w", err) - } - defer func() { _ = tx.Rollback() }() - - nowEncoded := encodeTime(now) - if _, err := tx.ExecContext(ctx, ` - UPDATE frontier_items - SET state = ?, failure_kind = ?, lease_owner = NULL, - lease_expires_at = NULL, updated_at = ? - WHERE state = ? AND lease_expires_at <= ? AND attempts >= max_attempts - `, FrontierFailed, FrontierFailureTransientExhausted, nowEncoded, - FrontierLeased, nowEncoded); err != nil { - return nil, fmt.Errorf("expire exhausted frontier leases: %w", err) - } - rows, err := tx.QueryContext(ctx, frontierSelect+` - WHERE attempts < max_attempts - AND earliest_run_at <= ? - AND (state = ? OR (state = ? AND lease_expires_at <= ?)) - ORDER BY priority DESC, earliest_run_at, id - LIMIT ? - `, nowEncoded, FrontierQueued, FrontierLeased, nowEncoded, limit) - if err != nil { - return nil, fmt.Errorf("select frontier lease candidates: %w", err) - } - candidates, err := scanFrontierItems(rows) - _ = rows.Close() - if err != nil { - return nil, fmt.Errorf("scan frontier lease candidates: %w", err) - } - - remaining := budget - leased := make([]FrontierItem, 0, len(candidates)) - for _, item := range candidates { - if item.BudgetEstimate > remaining { - continue - } - expires := now.Add(leaseDuration) - res, err := tx.ExecContext(ctx, ` - UPDATE frontier_items - SET state = ?, lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1, updated_at = ? - WHERE id = ? AND attempts < max_attempts AND earliest_run_at <= ? - AND (state = ? OR (state = ? AND lease_expires_at <= ?)) - `, FrontierLeased, worker, encodeTime(expires), nowEncoded, item.ID, - nowEncoded, FrontierQueued, FrontierLeased, nowEncoded) - if err != nil { - return nil, fmt.Errorf("lease frontier item %d: %w", item.ID, err) - } - changed, err := res.RowsAffected() - if err != nil { - return nil, fmt.Errorf("read frontier lease result: %w", err) - } - if changed == 0 { - continue - } - item.State = FrontierLeased - item.LeaseOwner = worker - item.LeaseExpiresAt = &expires - item.Attempts++ - item.UpdatedAt = now - leased = append(leased, item) - remaining -= item.BudgetEstimate - } - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit frontier lease: %w", err) - } - return leased, nil -} - -// CompleteFrontierItem marks leased work complete. Only the lease owner can -// complete it, preventing a stale worker from overwriting a newer attempt. -func (c *Corpus) CompleteFrontierItem(ctx context.Context, id int64, worker string, now time.Time) error { - return c.finishFrontierLease(ctx, id, worker, FrontierCompleted, "", "", time.Time{}, now) -} - -// ReleaseFrontierItem returns leased but unstarted work to the queue. Because -// leasing increments attempts, releasing unstarted work refunds that attempt. -// Only the current lease owner can release the item. -func (c *Corpus) ReleaseFrontierItem(ctx context.Context, id int64, worker string, now time.Time) error { - res, err := c.db.ExecContext(ctx, ` - UPDATE frontier_items - SET state = ?, attempts = MAX(attempts - 1, 0), lease_owner = NULL, - lease_expires_at = NULL, failure_kind = NULL, last_error = NULL, - updated_at = ? - WHERE id = ? AND state = ? AND lease_owner = ? - `, FrontierQueued, encodeTime(now), id, FrontierLeased, worker) - if err != nil { - return fmt.Errorf("release frontier item %d: %w", id, err) - } - changed, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("read frontier release result: %w", err) - } - if changed == 0 { - return fmt.Errorf("frontier item %d is not leased by %q", id, worker) - } - return nil -} - -// RetryFrontierItem releases leased work after a transient failure. Once the -// attempt limit is reached, the item becomes terminally failed. -func (c *Corpus) RetryFrontierItem(ctx context.Context, id int64, worker, message string, earliestRunAt, now time.Time) error { - return c.finishFrontierLease(ctx, id, worker, FrontierQueued, "", message, earliestRunAt, now) -} - -// FailFrontierItem marks a leased item terminally failed. -func (c *Corpus) FailFrontierItem(ctx context.Context, id int64, worker, failureKind, message string, now time.Time) error { - if strings.TrimSpace(failureKind) == "" { - return errors.New("frontier failure kind is required") - } - return c.finishFrontierLease(ctx, id, worker, FrontierFailed, failureKind, message, time.Time{}, now) -} - -func (c *Corpus) finishFrontierLease(ctx context.Context, id int64, worker, requestedState, failureKind, message string, earliestRunAt, now time.Time) error { - if requestedState == FrontierQueued { - failureKind = FrontierFailureTransientExhausted - } - res, err := c.db.ExecContext(ctx, ` - UPDATE frontier_items - SET state = CASE - WHEN ? = ? AND attempts >= max_attempts THEN ? - ELSE ? - END, - earliest_run_at = ?, lease_owner = NULL, lease_expires_at = NULL, - failure_kind = CASE - WHEN ? = ? AND attempts < max_attempts THEN NULL - ELSE NULLIF(?, '') - END, - last_error = NULLIF(?, ''), updated_at = ? - WHERE id = ? AND state = ? AND lease_owner = ? - `, requestedState, FrontierQueued, FrontierFailed, requestedState, - encodeTime(earliestRunAt), requestedState, FrontierQueued, failureKind, - message, encodeTime(now), id, FrontierLeased, worker) - if err != nil { - return fmt.Errorf("finish frontier item %d: %w", id, err) - } - changed, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("read frontier finish result: %w", err) - } - if changed == 0 { - return fmt.Errorf("frontier item %d is not leased by %q", id, worker) - } - return nil -} - -const frontierSelect = ` - SELECT id, work_key, subject_kind, owner, repo, thread_kind, thread_number, - facet, priority, reason, source, attempts, max_attempts, - earliest_run_at, budget_estimate, state, lease_owner, - lease_expires_at, failure_kind, last_error, created_at, updated_at - FROM frontier_items` - -func scanFrontierItem(row rowScanner) (*FrontierItem, error) { - var item FrontierItem - var owner, repo, threadKind, facet, reason, source sql.NullString - var leaseOwner, failureKind, lastError sql.NullString - var threadNumber sql.NullInt64 - var earliest, created, updated int64 - var leaseExpires sql.NullInt64 - err := row.Scan(&item.ID, &item.WorkKey, &item.SubjectKind, &owner, &repo, - &threadKind, &threadNumber, &facet, &item.Priority, &reason, &source, - &item.Attempts, &item.MaxAttempts, &earliest, &item.BudgetEstimate, - &item.State, &leaseOwner, &leaseExpires, &failureKind, &lastError, &created, &updated) - if err != nil { - return nil, err - } - item.Owner = owner.String - item.Repo = repo.String - item.ThreadKind = threadKind.String - item.ThreadNumber = int(threadNumber.Int64) - item.Facet = facet.String - item.Reason = reason.String - item.Source = source.String - item.EarliestRunAt = scanTime(earliest) - item.LeaseOwner = leaseOwner.String - if leaseExpires.Valid { - t := scanTime(leaseExpires.Int64) - item.LeaseExpiresAt = &t - } - item.FailureKind = failureKind.String - item.LastError = lastError.String - item.CreatedAt = scanTime(created) - item.UpdatedAt = scanTime(updated) - return &item, nil -} - -func scanFrontierItems(rows *sql.Rows) ([]FrontierItem, error) { - var items []FrontierItem - for rows.Next() { - item, err := scanFrontierItem(rows) - if err != nil { - return nil, err - } - items = append(items, *item) - } - return items, rows.Err() -} diff --git a/internal/corpus/frontier_test.go b/internal/corpus/frontier_test.go deleted file mode 100644 index 192fe5e8..00000000 --- a/internal/corpus/frontier_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package corpus - -import ( - "context" - "testing" - "time" -) - -func TestFrontierDeduplicatesReplay(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - item := FrontierItem{ - WorkKey: "repository:owner/repo:metadata", SubjectKind: "repository", - Owner: "owner", Repo: "repo", Facet: "metadata", Priority: 10, - Reason: "search", Source: "github-search", MaxAttempts: 4, - } - first, inserted, err := c.EnqueueFrontierItem(ctx, item) - if err != nil || !inserted { - t.Fatalf("first enqueue = (%+v, %v, %v), want insertion", first, inserted, err) - } - item.Priority = 999 - second, inserted, err := c.EnqueueFrontierItem(ctx, item) - if err != nil || inserted { - t.Fatalf("replay enqueue = (%+v, %v, %v), want existing", second, inserted, err) - } - if second.ID != first.ID || second.Priority != 10 { - t.Fatalf("replay changed item: first=%+v second=%+v", first, second) - } -} - -func TestFrontierLeaseHonorsPriorityReadinessAndBudget(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - now := time.Unix(1_700_000_000, 123).UTC() - for _, item := range []FrontierItem{ - {WorkKey: "low", SubjectKind: "repository", Priority: 1, BudgetEstimate: 1}, - {WorkKey: "high", SubjectKind: "repository", Priority: 10, BudgetEstimate: 2}, - {WorkKey: "future", SubjectKind: "repository", Priority: 100, EarliestRunAt: now.Add(time.Hour)}, - } { - if _, _, err := c.EnqueueFrontierItem(ctx, item); err != nil { - t.Fatalf("enqueue %s: %v", item.WorkKey, err) - } - } - leased, err := c.LeaseFrontierItems(ctx, "worker-a", now, time.Minute, 10, 2) - if err != nil { - t.Fatalf("lease: %v", err) - } - if len(leased) != 1 || leased[0].WorkKey != "high" || leased[0].Attempts != 1 { - t.Fatalf("leased = %+v, want only high", leased) - } -} - -func TestFrontierExpiredLeaseCanBeReclaimed(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - now := time.Unix(1_700_000_000, 0).UTC() - if _, _, err := c.EnqueueFrontierItem(ctx, FrontierItem{WorkKey: "work", SubjectKind: "facet"}); err != nil { - t.Fatal(err) - } - first, err := c.LeaseFrontierItems(ctx, "worker-a", now, time.Minute, 1, 10) - if err != nil || len(first) != 1 { - t.Fatalf("first lease = (%+v, %v)", first, err) - } - beforeExpiry, err := c.LeaseFrontierItems(ctx, "worker-b", now.Add(30*time.Second), time.Minute, 1, 10) - if err != nil || len(beforeExpiry) != 0 { - t.Fatalf("before expiry = (%+v, %v), want none", beforeExpiry, err) - } - afterExpiry, err := c.LeaseFrontierItems(ctx, "worker-b", now.Add(2*time.Minute), time.Minute, 1, 10) - if err != nil || len(afterExpiry) != 1 || afterExpiry[0].Attempts != 2 { - t.Fatalf("after expiry = (%+v, %v), want reclaimed attempt 2", afterExpiry, err) - } - if err := c.CompleteFrontierItem(ctx, afterExpiry[0].ID, "worker-a", now.Add(3*time.Minute)); err == nil { - t.Fatal("stale worker completed reclaimed lease") - } - if err := c.CompleteFrontierItem(ctx, afterExpiry[0].ID, "worker-b", now.Add(3*time.Minute)); err != nil { - t.Fatalf("complete current lease: %v", err) - } -} - -func TestFrontierReleaseRefundsUnstartedAttempt(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - now := time.Unix(1_700_000_000, 0).UTC() - if _, _, err := c.EnqueueFrontierItem(ctx, FrontierItem{ - WorkKey: "unstarted", SubjectKind: "repository", MaxAttempts: 1, - }); err != nil { - t.Fatal(err) - } - leased, err := c.LeaseFrontierItems(ctx, "worker-a", now, time.Minute, 1, 1) - if err != nil || len(leased) != 1 || leased[0].Attempts != 1 { - t.Fatalf("lease = (%+v, %v)", leased, err) - } - if err := c.ReleaseFrontierItem(ctx, leased[0].ID, "worker-b", now); err == nil { - t.Fatal("non-owner released frontier item") - } - if err := c.ReleaseFrontierItem(ctx, leased[0].ID, "worker-a", now); err != nil { - t.Fatal(err) - } - item, err := c.GetFrontierItem(ctx, "unstarted") - if err != nil { - t.Fatal(err) - } - if item.State != FrontierQueued || item.Attempts != 0 { - t.Fatalf("released item = %+v", item) - } - again, err := c.LeaseFrontierItems(ctx, "worker-b", now, time.Minute, 1, 1) - if err != nil || len(again) != 1 || again[0].Attempts != 1 { - t.Fatalf("second lease = (%+v, %v)", again, err) - } -} - -func TestFrontierRetryIsBounded(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - now := time.Unix(1_700_000_000, 0).UTC() - if _, _, err := c.EnqueueFrontierItem(ctx, FrontierItem{ - WorkKey: "bounded", SubjectKind: "thread", MaxAttempts: 2, - }); err != nil { - t.Fatal(err) - } - for attempt := 1; attempt <= 2; attempt++ { - leased, err := c.LeaseFrontierItems(ctx, "worker", now, time.Minute, 1, 10) - if err != nil || len(leased) != 1 { - t.Fatalf("lease attempt %d = (%+v, %v)", attempt, leased, err) - } - if err := c.RetryFrontierItem(ctx, leased[0].ID, "worker", "temporary", now, now); err != nil { - t.Fatalf("retry attempt %d: %v", attempt, err) - } - } - item, err := c.GetFrontierItem(ctx, "bounded") - if err != nil { - t.Fatal(err) - } - if item.State != FrontierFailed || item.Attempts != 2 || item.FailureKind != FrontierFailureTransientExhausted || item.LastError != "temporary" { - t.Fatalf("bounded item = %+v", item) - } - leasing, err := c.LeaseFrontierItems(ctx, "worker", now, time.Minute, 1, 10) - if err != nil || len(leasing) != 0 { - t.Fatalf("terminal item leased: (%+v, %v)", leasing, err) - } -} - -func TestFrontierExpiredFinalAttemptBecomesTerminal(t *testing.T) { - t.Parallel() - c, _ := openTestCorpus(t) - ctx := context.Background() - now := time.Unix(1_700_000_000, 0).UTC() - if _, _, err := c.EnqueueFrontierItem(ctx, FrontierItem{ - WorkKey: "crashed", SubjectKind: "repository", MaxAttempts: 1, - }); err != nil { - t.Fatal(err) - } - leasing, err := c.LeaseFrontierItems(ctx, "crashing-worker", now, time.Minute, 1, 10) - if err != nil || len(leasing) != 1 { - t.Fatalf("lease = (%+v, %v)", leasing, err) - } - leasing, err = c.LeaseFrontierItems(ctx, "replacement", now.Add(2*time.Minute), time.Minute, 1, 10) - if err != nil || len(leasing) != 0 { - t.Fatalf("re-lease exhausted item = (%+v, %v)", leasing, err) - } - item, err := c.GetFrontierItem(ctx, "crashed") - if err != nil { - t.Fatal(err) - } - if item.State != FrontierFailed || item.FailureKind != FrontierFailureTransientExhausted { - t.Fatalf("expired item = %+v", item) - } -} diff --git a/internal/corpus/hydration.go b/internal/corpus/hydration.go index 4aee0cfa..e6f05c42 100644 --- a/internal/corpus/hydration.go +++ b/internal/corpus/hydration.go @@ -35,13 +35,6 @@ func (c *Corpus) ApplyFacetObservationSet(ctx context.Context, repoID int64, thr return err } -// ApplyFacetObservationSetIfNewer records a facet snapshot and reports whether -// it won the stored source ordering. Callers that maintain a derived projection -// must update it only when applied is true. -func (c *Corpus) ApplyFacetObservationSetIfNewer(ctx context.Context, repoID int64, threadID *int64, facet string, sourceUpdatedAt time.Time, pages []FacetObservationInput, complete bool, runID int64) (applied bool, err error) { - return c.applyFacetObservationSet(ctx, repoID, threadID, facet, sourceUpdatedAt, pages, complete, runID, nil) -} - // ApplyFacetObservationSetCAS atomically replaces a facet only when its current // coverage sequence still matches the sequence captured before retrieval. func (c *Corpus) ApplyFacetObservationSetCAS(ctx context.Context, repoID int64, threadID *int64, facet string, sourceUpdatedAt time.Time, pages []FacetObservationInput, complete bool, runID, expectedSequence int64) (bool, error) { diff --git a/internal/corpus/hydration_test.go b/internal/corpus/hydration_test.go index 3dee2869..60eecba9 100644 --- a/internal/corpus/hydration_test.go +++ b/internal/corpus/hydration_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestApplyFacetObservationSetReplacesWithEmptyCompleteSet(t *testing.T) { @@ -15,7 +17,7 @@ func TestApplyFacetObservationSetReplacesWithEmptyCompleteSet(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatalf("apply thread: %v", err) } @@ -67,7 +69,7 @@ func TestApplyFacetObservationSetIgnoresStaleEmptySet(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatalf("apply thread: %v", err) } @@ -110,7 +112,7 @@ func TestListFacetObservationsBoundedPreservesOrderAndReportsMore(t *testing.T) if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } @@ -150,11 +152,11 @@ func TestThreadFacetBatchReadsPreservePerFacetBounds(t *testing.T) { if err != nil { t.Fatal(err) } - first, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 1, "open", "first", "", "a", time.Unix(2, 0).UTC(), `{}`) + first, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 1, "open", "first", "", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } - second, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 2, "open", "second", "", "a", time.Unix(2, 0).UTC(), `{}`) + second, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 2, "open", "second", "", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } @@ -213,7 +215,7 @@ func TestApplyFacetObservationSetCASRejectsConcurrentEqualClockReplacement(t *te if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 1, "open", "title", "body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } diff --git a/internal/corpus/inventory.go b/internal/corpus/inventory.go index 24935291..e8b05d30 100644 --- a/internal/corpus/inventory.go +++ b/internal/corpus/inventory.go @@ -133,7 +133,7 @@ func (c *Corpus) readThreadInventoryAggregates(ctx context.Context, byRef map[st FROM repositories r LEFT JOIN threads t ON t.repository_id = r.id GROUP BY r.id, r.owner, r.name - `, ThreadKindIssue, ThreadKindPullRequest) + `, domain.IssueKind, domain.PullRequestKind) if err != nil { return fmt.Errorf("inventory aggregate threads: %w", err) } @@ -266,7 +266,7 @@ func (c *Corpus) Inventory(ctx context.Context, owner, name string) (*Repository COALESCE(SUM(CASE WHEN kind = ? THEN 1 ELSE 0 END), 0), COUNT(*) FROM threads WHERE repository_id = ? - `, ThreadKindIssue, ThreadKindPullRequest, repoID).Scan(&inv.Issues, &inv.PullRequests, &inv.Threads); err != nil { + `, domain.IssueKind, domain.PullRequestKind, repoID).Scan(&inv.Issues, &inv.PullRequests, &inv.Threads); err != nil { return nil, fmt.Errorf("inventory count threads: %w", err) } diff --git a/internal/corpus/inventory_test.go b/internal/corpus/inventory_test.go index ffd1106e..1551e728 100644 --- a/internal/corpus/inventory_test.go +++ b/internal/corpus/inventory_test.go @@ -45,13 +45,13 @@ func TestRepositoryInventoryCountsAndSizes(t *testing.T) { repo, err := c.ApplyRepositoryObservation(ctx, owner, name, "1", time.Unix(1, 0).UTC(), `{}`) requireInventorySetup(t, "apply repository", err) - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "issue one", "body", "a", time.Unix(10, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "issue one", "body", "a", time.Unix(10, 0).UTC(), `{}`); err != nil { t.Fatalf("apply issue 1: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 2, "open", "issue two", "body", "a", time.Unix(11, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 2, "open", "issue two", "body", "a", time.Unix(11, 0).UTC(), `{}`); err != nil { t.Fatalf("apply issue 2: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 3, "open", "pr one", "body", "a", time.Unix(12, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 3, "open", "pr one", "body", "a", time.Unix(12, 0).UTC(), `{}`); err != nil { t.Fatalf("apply pr: %v", err) } @@ -151,7 +151,7 @@ func TestListInventoryAggregatesEveryRepositoryScopeAndFreshness(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 1, "open", "pr", "body", "author", time.Unix(20, 0).UTC(), `{"thread":true}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 1, "open", "pr", "body", "author", time.Unix(20, 0).UTC(), `{"thread":true}`); err != nil { t.Fatal(err) } run, err := c.StartRun(ctx, "sync") @@ -211,7 +211,7 @@ func TestCodeSnapshotPrunePreservesLatestN(t *testing.T) { repo, err := c.ApplyRepositoryObservation(ctx, owner, name, "1", time.Unix(1, 0).UTC(), `{}`) requireInventorySetup(t, "apply repository", err) - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "issue", "body", "a", time.Unix(10, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "issue", "body", "a", time.Unix(10, 0).UTC(), `{}`); err != nil { t.Fatalf("apply issue: %v", err) } diff --git a/internal/corpus/jobs.go b/internal/corpus/jobs.go index 6286e81f..133f334e 100644 --- a/internal/corpus/jobs.go +++ b/internal/corpus/jobs.go @@ -99,9 +99,26 @@ func (c *Corpus) StoppedJobIDs(ctx context.Context, ids []string) (_ map[string] return stopped, nil } -// GetJobsBatch returns jobs keyed by ID in one query. When includePayload is -// false, request and result blobs are not loaded from SQLite. -func (c *Corpus) GetJobsBatch(ctx context.Context, ids []string, includePayload bool) (map[string]*Job, error) { +type jobBatchProjection uint8 + +const ( + jobSummaryProjection jobBatchProjection = iota + jobDetailedProjection +) + +// GetJobSummariesBatch returns jobs keyed by ID without loading request and +// result blobs from SQLite. +func (c *Corpus) GetJobSummariesBatch(ctx context.Context, ids []string) (map[string]*Job, error) { + return c.getJobsBatch(ctx, ids, jobSummaryProjection) +} + +// GetJobsBatch returns jobs and their stored request and result payloads keyed +// by ID in one query. +func (c *Corpus) GetJobsBatch(ctx context.Context, ids []string) (map[string]*Job, error) { + return c.getJobsBatch(ctx, ids, jobDetailedProjection) +} + +func (c *Corpus) getJobsBatch(ctx context.Context, ids []string, projection jobBatchProjection) (map[string]*Job, error) { if len(ids) > maxBatchReadItems { return nil, errors.New("job batch cannot exceed 100 items") } @@ -109,7 +126,7 @@ func (c *Corpus) GetJobsBatch(ctx context.Context, ids []string, includePayload return map[string]*Job{}, nil } selection := jobSelect - if !includePayload { + if projection == jobSummaryProjection { selection = jobSummarySelect } placeholders := sqlPlaceholders(len(ids)) @@ -138,7 +155,7 @@ func (c *Corpus) GetJobsBatch(ctx context.Context, ids []string, includePayload } // ListJobs returns recent jobs bounded by limit, optionally filtered by status. -func (c *Corpus) ListJobs(ctx context.Context, status string, limit int) ([]Job, error) { +func (c *Corpus) ListJobs(ctx context.Context, status JobStatus, limit int) ([]Job, error) { if limit <= 0 { limit = 100 } @@ -200,7 +217,7 @@ func (c *Corpus) StartJobAs(ctx context.Context, id, ownerID string) error { if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.State.Status()) { + if job.State.Status().Terminal() { return fmt.Errorf("job is already %s", job.State.Status()) } if job.State.CancellationRequested() { @@ -211,15 +228,13 @@ func (c *Corpus) StartJobAs(ctx context.Context, id, ownerID string) error { return nil } -// TransitionJob performs a safe atomic terminal transition for a job. The -// current status must match from, and cancellation requests block transitions -// to non-cancelled terminal states. Terminal transitions clear the owner. -func (c *Corpus) TransitionJob(ctx context.Context, id, from, to, result, errStr string) error { - if !isValidJobTransition(from, to) { - return fmt.Errorf("invalid job transition from %s to %s", from, to) - } - if from == to { - return nil +// TransitionJob performs one structurally valid atomic terminal transition. +// Cancellation requests block non-cancelled outcomes and every terminal +// transition clears the executor owner. +func (c *Corpus) TransitionJob(ctx context.Context, id string, transition JobTransition, result, errStr string) error { + from, to := transition.From(), transition.To() + if from == "" || to == "" { + return fmt.Errorf("invalid job transition %d", transition) } now := time.Now().UTC() res, dbErr := c.db.ExecContext(ctx, ` @@ -285,7 +300,7 @@ func (c *Corpus) UpdateJobProgress(ctx context.Context, id, progress, statistics if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.State.Status()) { + if job.State.Status().Terminal() { return fmt.Errorf("job is already %s", job.State.Status()) } if job.State.CancellationRequested() { @@ -331,7 +346,7 @@ func (c *Corpus) RequestJobCancellation(ctx context.Context, id string) error { if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.State.Status()) { + if job.State.Status().Terminal() { return fmt.Errorf("job is already %s", job.State.Status()) } return fmt.Errorf("cannot cancel job in status %s", job.State.Status()) @@ -534,23 +549,6 @@ func nullableJobTime(value sql.NullInt64) *time.Time { return &parsed } -func isTerminalJobStatus(status string) bool { - return status == JobStatusSucceeded || status == JobStatusFailed || status == JobStatusCancelled -} - -func isValidJobTransition(from, to string) bool { - if isTerminalJobStatus(from) && from != to { - return false - } - switch from { - case JobStatusQueued: - return to == JobStatusRunning || to == JobStatusCancelled || to == JobStatusFailed - case JobStatusRunning: - return to == JobStatusSucceeded || to == JobStatusFailed || to == JobStatusCancelled - } - return false -} - // RegisterJobOwner records a process owner with an explicit heartbeat time. // Calling it again for an existing owner updates its process_id and heartbeat. func (c *Corpus) RegisterJobOwner(ctx context.Context, ownerID string, processID int, t time.Time) error { diff --git a/internal/corpus/jobs_test.go b/internal/corpus/jobs_test.go index 6baf213d..585a8e43 100644 --- a/internal/corpus/jobs_test.go +++ b/internal/corpus/jobs_test.go @@ -45,11 +45,11 @@ func TestJobStateRejectsContradictoryLifecycle(t *testing.T) { status string started, completed, cancelled *time.Time }{ - {status: JobStatusQueued, started: &now}, - {status: JobStatusRunning}, - {status: JobStatusRunning, started: &now, completed: &now}, - {status: JobStatusSucceeded, completed: &now, cancelled: &now}, - {status: JobStatusCancelled, completed: &now}, + {status: JobStatusQueued.String(), started: &now}, + {status: JobStatusRunning.String()}, + {status: JobStatusRunning.String(), started: &now, completed: &now}, + {status: JobStatusSucceeded.String(), completed: &now, cancelled: &now}, + {status: JobStatusCancelled.String(), completed: &now}, {status: "invented", completed: &now}, } { if _, err := parseJobState(test.status, test.started, test.completed, test.cancelled); err == nil { @@ -73,12 +73,12 @@ func TestGetJobsBatchCanSkipPayloadBlobs(t *testing.T) { if err := c.StartJob(ctx, first.ID); err != nil { t.Fatal(err) } - if err := c.TransitionJob(ctx, first.ID, JobStatusRunning, JobStatusSucceeded, `{"large":"result"}`, ""); err != nil { + if err := c.TransitionJob(ctx, first.ID, JobRunningToSucceeded, `{"large":"result"}`, ""); err != nil { t.Fatal(err) } ids := []string{second.ID, "missing", first.ID} - summary, err := c.GetJobsBatch(ctx, ids, false) + summary, err := c.GetJobSummariesBatch(ctx, ids) if err != nil { t.Fatal(err) } @@ -88,14 +88,14 @@ func TestGetJobsBatchCanSkipPayloadBlobs(t *testing.T) { if summary[first.ID].Request != "" || summary[first.ID].Result != "" || summary[first.ID].State.Status() != JobStatusSucceeded { t.Fatalf("summary loaded payload or lost status: %+v", summary[first.ID]) } - detailed, err := c.GetJobsBatch(ctx, ids, true) + detailed, err := c.GetJobsBatch(ctx, ids) if err != nil { t.Fatal(err) } if detailed[first.ID].Request != `{"large":"request"}` || detailed[first.ID].Result != `{"large":"result"}` { t.Fatalf("detailed payload = %+v", detailed[first.ID]) } - if _, err := c.GetJobsBatch(ctx, make([]string, maxBatchReadItems+1), false); err == nil { + if _, err := c.GetJobSummariesBatch(ctx, make([]string, maxBatchReadItems+1)); err == nil { t.Fatal("job batch accepted an oversized ID set") } } @@ -173,7 +173,7 @@ func TestJobStatusTransitions(t *testing.T) { t.Fatalf("progress/statistics mismatch: %+v", job) } - if err := c.TransitionJob(ctx, job.ID, JobStatusRunning, JobStatusSucceeded, `{"done":true}`, ""); err != nil { + if err := c.TransitionJob(ctx, job.ID, JobRunningToSucceeded, `{"done":true}`, ""); err != nil { t.Fatalf("complete job: %v", err) } job, _ = c.GetJob(ctx, job.ID) @@ -185,7 +185,7 @@ func TestJobStatusTransitions(t *testing.T) { if err := c.StartJob(ctx, job.ID); err == nil { t.Fatal("expected error starting completed job") } - if err := c.TransitionJob(ctx, job.ID, JobStatusRunning, JobStatusFailed, "", "nope"); err == nil { + if err := c.TransitionJob(ctx, job.ID, JobRunningToFailed, "", "nope"); err == nil { t.Fatal("expected error transitioning completed job") } if err := c.UpdateJobProgress(ctx, job.ID, "100%", "{}"); err == nil { @@ -238,10 +238,10 @@ func TestJobCancellation(t *testing.T) { } // Completing a cancelled job as succeeded is blocked; cancelled is allowed. - if err := c.TransitionJob(ctx, running.ID, JobStatusRunning, JobStatusSucceeded, "", ""); err == nil { + if err := c.TransitionJob(ctx, running.ID, JobRunningToSucceeded, "", ""); err == nil { t.Fatal("expected error completing cancelled job as succeeded") } - if err := c.TransitionJob(ctx, running.ID, JobStatusRunning, JobStatusCancelled, "", "user cancelled"); err != nil { + if err := c.TransitionJob(ctx, running.ID, JobRunningToCancelled, "", "user cancelled"); err != nil { t.Fatalf("complete as cancelled: %v", err) } @@ -481,7 +481,7 @@ func TestConcurrentReadWhileJobRunning(t *testing.T) { } // Complete the job to leave a clean state. - if err := c.TransitionJob(ctx, job.ID, JobStatusRunning, JobStatusSucceeded, "", ""); err != nil { + if err := c.TransitionJob(ctx, job.ID, JobRunningToSucceeded, "", ""); err != nil { t.Fatal(err) } } @@ -629,7 +629,7 @@ func TestRequestJobCancellationRowsAffected(t *testing.T) { if err := c.StartJob(ctx, job.ID); err != nil { t.Fatal(err) } - if err := c.TransitionJob(ctx, job.ID, JobStatusRunning, JobStatusSucceeded, "", ""); err != nil { + if err := c.TransitionJob(ctx, job.ID, JobRunningToSucceeded, "", ""); err != nil { t.Fatal(err) } if err := c.RequestJobCancellation(ctx, job.ID); err == nil { @@ -642,7 +642,7 @@ func TestZeroRowTransitionPropagatesGetJobError(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) - corrupt := func(id, status string) { + corrupt := func(id string, status JobStatus) { t.Helper() if _, err := c.db.ExecContext(ctx, `UPDATE jobs SET status = ?, created_at = 'corrupt' WHERE id = ?`, status, id); err != nil { t.Fatalf("corrupt job: %v", err) @@ -676,7 +676,7 @@ func TestZeroRowTransitionPropagatesGetJobError(t *testing.T) { t.Fatal(err) } corrupt(transition.ID, JobStatusSucceeded) - if err := c.TransitionJob(ctx, transition.ID, JobStatusRunning, JobStatusFailed, "", ""); err == nil { + if err := c.TransitionJob(ctx, transition.ID, JobRunningToFailed, "", ""); err == nil { t.Fatal("expected error transitioning corrupt completed job") } else if strings.Contains(err.Error(), "job not found") { t.Fatalf("expected GetJob error to be propagated, got: %v", err) diff --git a/internal/corpus/lifecycle.go b/internal/corpus/lifecycle.go index fd0234c0..4dbf9b47 100644 --- a/internal/corpus/lifecycle.go +++ b/internal/corpus/lifecycle.go @@ -37,6 +37,20 @@ const ( SchemaDamaged SchemaState = "damaged" ) +// MigrationResumeStrategy describes how an interrupted migration step resumes. +type MigrationResumeStrategy string + +const MigrationRestartStep MigrationResumeStrategy = "restart_step" + +// MigrationPhase identifies a stable migration progress boundary. +type MigrationPhase string + +const ( + MigrationPending MigrationPhase = "pending" + MigrationStarted MigrationPhase = "started" + MigrationCompleted MigrationPhase = "completed" +) + // MigrationStep describes one embedded schema migration without applying it. type MigrationStep struct { Version int64 @@ -45,7 +59,7 @@ type MigrationStep struct { EstimateAvailable bool Transactional bool Resumable bool - ResumeStrategy string + ResumeStrategy MigrationResumeStrategy ProjectionRebuild bool } @@ -82,7 +96,7 @@ func (i SchemaInspection) Exists() bool { // remain owned by Goose; data-sized migrations should expose their own bounded // checkpoints rather than pretending statement-level progress is available. type MigrationProgress struct { - Phase string + Phase MigrationPhase Version int64 Name string Current int64 @@ -124,7 +138,7 @@ func backupManifestPath(path string) string { return path + ".manifest.json" } // with explicit progress, verifies connection pragmas, and closes it. Callers // own consent, backup policy, and activation of any dependent runtime. func Migrate(ctx context.Context, path string, observer MigrationObserver) (returnErr error) { - lease, err := acquireCorpusLease(path, true, "migrate corpus") + lease, err := acquireCorpusLease(path, exclusiveCorpusLease, "migrate corpus") if err != nil { return err } @@ -136,7 +150,7 @@ func Migrate(ctx context.Context, path string, observer MigrationObserver) (retu // safety backup through migration verification. An empty backup destination // explicitly opts out of backup creation. func MigrateWithBackup(ctx context.Context, path, backupDestination string, observer MigrationObserver) (_ *BackupResult, returnErr error) { - lease, err := acquireCorpusLease(path, true, "back up and migrate corpus") + lease, err := acquireCorpusLease(path, exclusiveCorpusLease, "back up and migrate corpus") if err != nil { return nil, err } @@ -326,7 +340,7 @@ func annotateMigrationSteps(_ context.Context, _ *sql.DB, steps []MigrationStep) // every version that was already committed. steps[i].Transactional = true steps[i].Resumable = true - steps[i].ResumeStrategy = "restart_step" + steps[i].ResumeStrategy = MigrationRestartStep } return nil } @@ -412,7 +426,7 @@ func (c *Corpus) ApplyMigrations(ctx context.Context, observer MigrationObserver continue } progress := MigrationProgress{ - Phase: "started", Version: status.Source.Version, + Phase: MigrationStarted, Version: status.Source.Version, Name: status.Source.Path, Current: current, Target: target, } if observer != nil { @@ -423,7 +437,7 @@ func (c *Corpus) ApplyMigrations(ctx context.Context, observer MigrationObserver } current = status.Source.Version if observer != nil { - progress.Phase = "completed" + progress.Phase = MigrationCompleted progress.Current = current observer(progress) } @@ -463,7 +477,7 @@ func Backup(ctx context.Context, source, destination string, observer func(copie if err != nil { return BackupResult{}, err } - lease, err := acquireCorpusLease(source, false, "back up corpus") + lease, err := acquireCorpusLease(source, sharedCorpusLease, "back up corpus") if err != nil { return BackupResult{}, err } diff --git a/internal/corpus/lifecycle_test.go b/internal/corpus/lifecycle_test.go index d5cec2d1..58b3800c 100644 --- a/internal/corpus/lifecycle_test.go +++ b/internal/corpus/lifecycle_test.go @@ -450,56 +450,35 @@ func TestRestoreUsesVerifiedSnapshotWhenSourceChangesDuringCopy(t *testing.T) { } } -func TestRestoreReportsCommittedResultWhenSnapshotCleanupFails(t *testing.T) { - ctx := context.Background() +func TestFinalizeCommittedRestorePreservesResultWhenSnapshotCleanupFails(t *testing.T) { dir := t.TempDir() - source := filepath.Join(dir, "source.db") - backupPath := filepath.Join(dir, "source.backup.db") destination := filepath.Join(dir, "destination.db") - c, err := Open(ctx, source) - if err != nil { + wantContent := []byte("published restore") + if err := os.WriteFile(destination, wantContent, 0o600); err != nil { t.Fatal(err) } - if _, err := c.ApplyRepositoryObservation(ctx, "owner", "committed", "external", time.Unix(1, 0), `{}`); err != nil { + snapshotPath := filepath.Join(dir, "snapshot") + if err := os.Mkdir(snapshotPath, 0o700); err != nil { t.Fatal(err) } - if err := c.Close(); err != nil { - t.Fatal(err) - } - if _, err := Backup(ctx, source, backupPath, nil); err != nil { + if err := os.WriteFile(filepath.Join(snapshotPath, "still-owned"), []byte("private"), 0o600); err != nil { t.Fatal(err) } - wantCleanupErr := errors.New("injected snapshot cleanup failure") - originalRemove := removeRestoreSnapshot - removeRestoreSnapshot = func(string) error { return wantCleanupErr } - t.Cleanup(func() { removeRestoreSnapshot = originalRemove }) - result, err := Restore(ctx, backupPath, destination, nil) + result, err := finalizeCommittedRestore(destination, snapshotPath) var committedErr *PostCommitCleanupError - if !errors.As(err, &committedErr) || !errors.Is(err, wantCleanupErr) { + if !errors.As(err, &committedErr) { t.Fatalf("Restore error = %v, want committed cleanup error", err) } if result.Path != destination || result.SizeBytes == 0 || result.SHA256 == "" { t.Fatalf("committed restore result = %+v", result) } - restored, err := OpenReadOnly(ctx, destination) - if err != nil { - t.Fatal(err) - } - defer restored.Close() - repo, err := restored.GetRepository(ctx, "owner", "committed") - if err != nil { - t.Fatal(err) - } - if repo == nil { - t.Fatal("restore cleanup failure obscured an uncommitted destination") - } - matches, err := filepath.Glob(filepath.Join(dir, ".gitcontribute-restore-source-*.db")) + gotContent, err := os.ReadFile(destination) if err != nil { t.Fatal(err) } - if len(matches) != 0 { - t.Fatalf("deferred cleanup left source snapshots: %v", matches) + if !bytes.Equal(gotContent, wantContent) { + t.Fatalf("published restore changed after cleanup failure: %q", gotContent) } } diff --git a/internal/corpus/lock.go b/internal/corpus/lock.go index fc2c90b1..5604d09f 100644 --- a/internal/corpus/lock.go +++ b/internal/corpus/lock.go @@ -24,7 +24,14 @@ type corpusLease struct { lock *flock.Flock } -func acquireCorpusLease(path string, exclusive bool, operation string) (*corpusLease, error) { +type corpusLeaseMode uint8 + +const ( + sharedCorpusLease corpusLeaseMode = iota + exclusiveCorpusLease +) + +func acquireCorpusLease(path string, mode corpusLeaseMode, operation string) (*corpusLease, error) { lockPath, ok := corpusLockPath(path) if !ok { return &corpusLease{}, nil @@ -37,10 +44,13 @@ func acquireCorpusLease(path string, exclusive bool, operation string) (*corpusL acquired bool err error ) - if exclusive { + switch mode { + case exclusiveCorpusLease: acquired, err = lock.TryLock() - } else { + case sharedCorpusLease: acquired, err = lock.TryRLock() + default: + return nil, errors.New("invalid corpus lease mode") } if err != nil { return nil, fmt.Errorf("acquire corpus lease for %s: %w", operation, err) @@ -68,17 +78,6 @@ func ensureCorpusLeaseFile(path string) error { return errors.Join(file.Close(), root.Close()) } -// CheckExclusiveAccess fails fast when another cooperating process holds a -// corpus lease. It makes no database changes and does not reserve the lease for -// later work; the mutating operation must acquire it again. -func CheckExclusiveAccess(path, operation string) error { - lease, err := acquireCorpusLease(path, true, operation) - if err != nil { - return err - } - return lease.release() -} - func corpusLockPath(path string) (string, bool) { filePath, _, inspectable, err := schemaInspectionTarget(path) if err != nil || !inspectable { diff --git a/internal/corpus/migration_test.go b/internal/corpus/migration_test.go index 7fa846e7..2b0ec493 100644 --- a/internal/corpus/migration_test.go +++ b/internal/corpus/migration_test.go @@ -6,6 +6,63 @@ import ( "time" ) +func TestRetireFrontierMigrationDropsObsoleteQueueAndRollsBackSchema(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + provider, logger, err := c.migrationProvider() + if err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 15); err != nil { + t.Fatal(err) + } + if err := logger.Err(); err != nil { + t.Fatal(err) + } + if _, err := c.db.ExecContext(ctx, ` + INSERT INTO frontier_items (work_key, subject_kind, created_at, updated_at) + VALUES ('legacy-work', 'repository', 1, 1) + `); err != nil { + t.Fatalf("seed legacy frontier: %v", err) + } + + if _, err := provider.UpTo(ctx, 16); err != nil { + t.Fatal(err) + } + if err := logger.Err(); err != nil { + t.Fatal(err) + } + exists, err := c.tableExists(ctx, "frontier_items") + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("frontier_items still exists after migration") + } + + if _, err := provider.Down(ctx); err != nil { + t.Fatal(err) + } + if err := logger.Err(); err != nil { + t.Fatal(err) + } + exists, err = c.tableExists(ctx, "frontier_items") + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("frontier_items was not recreated by schema rollback") + } + var count int + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM frontier_items`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("rolled-back frontier contains %d rows, want empty legacy schema", count) + } +} + func TestActorMigrationDeduplicatesExistingLoginsCaseInsensitively(t *testing.T) { t.Parallel() ctx := context.Background() @@ -14,10 +71,8 @@ func TestActorMigrationDeduplicatesExistingLoginsCaseInsensitively(t *testing.T) if err != nil { t.Fatal(err) } - for range 2 { - if _, err := provider.Down(ctx); err != nil { - t.Fatal(err) - } + if _, err := provider.DownTo(ctx, 13); err != nil { + t.Fatal(err) } if err := logger.Err(); err != nil { t.Fatal(err) diff --git a/internal/corpus/migrations/016_retire_crawl_frontier.sql b/internal/corpus/migrations/016_retire_crawl_frontier.sql new file mode 100644 index 00000000..e45c3fa6 --- /dev/null +++ b/internal/corpus/migrations/016_retire_crawl_frontier.sql @@ -0,0 +1,62 @@ +-- +goose Up +-- +goose StatementBegin +-- Frontier rows were scheduling hints for an executor retired before v3. They +-- are not source observations or projections, and no supported operation can +-- consume them. Retire the orphaned queue instead of reporting permanently +-- ready work. +DROP TRIGGER IF EXISTS corpus_revision_frontier_items_ai; +DROP TRIGGER IF EXISTS corpus_revision_frontier_items_au; +DROP TRIGGER IF EXISTS corpus_revision_frontier_items_ad; +DROP TABLE IF EXISTS frontier_items; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- Schema rollback recreates an empty legacy queue. Retired scheduling hints +-- cannot be reconstructed; explicit corpus migration creates a verified backup +-- before this migration is applied. +CREATE TABLE frontier_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_key TEXT NOT NULL UNIQUE, + subject_kind TEXT NOT NULL, + owner TEXT, + repo TEXT, + thread_kind TEXT, + thread_number INTEGER, + facet TEXT, + priority INTEGER NOT NULL DEFAULT 0, + reason TEXT, + source TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + earliest_run_at INTEGER NOT NULL DEFAULT 0, + budget_estimate INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL DEFAULT 'queued', + lease_owner TEXT, + lease_expires_at INTEGER, + failure_kind TEXT, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_frontier_ready + ON frontier_items (state, earliest_run_at, priority DESC, id); + +CREATE TRIGGER corpus_revision_frontier_items_ai +AFTER INSERT ON frontier_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_frontier_items_au +AFTER UPDATE ON frontier_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_frontier_items_ad +AFTER DELETE ON frontier_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; +-- +goose StatementEnd diff --git a/internal/corpus/models.go b/internal/corpus/models.go index 1efea740..fb95877a 100644 --- a/internal/corpus/models.go +++ b/internal/corpus/models.go @@ -3,6 +3,7 @@ package corpus import ( "errors" "fmt" + "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -48,9 +49,9 @@ type RepositoryObservation struct { type Thread struct { ID int64 RepositoryID int64 - Kind string + Kind domain.ThreadKind Number int - State string + State domain.ThreadState StateReason string Title string Body string @@ -102,12 +103,6 @@ type PortfolioPage struct { Truncated bool } -// ThreadKind names the thread types stored by the corpus. -const ( - ThreadKindIssue = "issue" - ThreadKindPullRequest = "pull_request" -) - // ThreadObservation is an immutable snapshot received from a source. type ThreadObservation struct { ID int64 @@ -207,15 +202,88 @@ func (s RunState) CompletedAt() (time.Time, bool) { return s.completedAt, !s.completedAt.IsZero() } +// JobStatus is a parsed durable job lifecycle value. The zero value means no +// status filter; it is never a persisted job state. +type JobStatus string + // JobStatus values for the durable job lifecycle. const ( - JobStatusQueued = "queued" - JobStatusRunning = "running" - JobStatusSucceeded = "succeeded" - JobStatusFailed = "failed" - JobStatusCancelled = "cancelled" + JobStatusQueued JobStatus = "queued" + JobStatusRunning JobStatus = "running" + JobStatusSucceeded JobStatus = "succeeded" + JobStatusFailed JobStatus = "failed" + JobStatusCancelled JobStatus = "cancelled" ) +// ParseJobStatus parses a persisted or boundary job status. +func ParseJobStatus(value string) (JobStatus, error) { + status := JobStatus(strings.TrimSpace(value)) + switch status { + case JobStatusQueued, JobStatusRunning, JobStatusSucceeded, JobStatusFailed, JobStatusCancelled: + return status, nil + default: + return "", fmt.Errorf("unknown job status %q", value) + } +} + +// ParseJobStatusFilter parses an optional boundary filter. Its zero value +// selects jobs in every status. +func ParseJobStatusFilter(value string) (JobStatus, error) { + if strings.TrimSpace(value) == "" { + return "", nil + } + return ParseJobStatus(value) +} + +// String returns the stable persisted and boundary spelling. +func (s JobStatus) String() string { return string(s) } + +// Terminal reports whether no further lifecycle transition is valid. +func (s JobStatus) Terminal() bool { + return s == JobStatusSucceeded || s == JobStatusFailed || s == JobStatusCancelled +} + +// JobTransition is one structurally valid durable lifecycle transition. +type JobTransition uint8 + +// Valid transitions performed by TransitionJob. Queued-to-running is owned by +// StartJobAs because it also claims an executor owner. +const ( + JobQueuedToCancelled JobTransition = iota + 1 + JobQueuedToFailed + JobRunningToSucceeded + JobRunningToFailed + JobRunningToCancelled +) + +// From returns the required current status, or the zero value for an invalid +// transition representation. +func (t JobTransition) From() JobStatus { + switch t { + case JobQueuedToCancelled, JobQueuedToFailed: + return JobStatusQueued + case JobRunningToSucceeded, JobRunningToFailed, JobRunningToCancelled: + return JobStatusRunning + default: + return "" + } +} + +// To returns the terminal target status, or the zero value for an invalid +// transition representation. +func (t JobTransition) To() JobStatus { + switch t { + case JobQueuedToCancelled, JobRunningToCancelled: + return JobStatusCancelled + case JobQueuedToFailed, JobRunningToFailed: + return JobStatusFailed + case JobRunningToSucceeded: + return JobStatusSucceeded + default: + return "" + } +} + // Job is a durable, cancellable unit of work. type Job struct { ID string @@ -233,14 +301,18 @@ type Job struct { // JobState binds lifecycle timestamps and cancellation requests to the statuses // in which they are meaningful. Its zero value is invalid. type JobState struct { - status string + status JobStatus startedAt time.Time completedAt time.Time cancelledAt time.Time } func parseJobState(status string, startedAt, completedAt, cancelledAt *time.Time) (JobState, error) { - state := JobState{status: status} + parsed, err := ParseJobStatus(status) + if err != nil { + return JobState{}, err + } + state := JobState{status: parsed} if startedAt != nil { state.startedAt = *startedAt } @@ -250,7 +322,7 @@ func parseJobState(status string, startedAt, completedAt, cancelledAt *time.Time if cancelledAt != nil { state.cancelledAt = *cancelledAt } - switch status { + switch parsed { case JobStatusQueued: if startedAt != nil || completedAt != nil || cancelledAt != nil { return JobState{}, errors.New("queued job cannot have lifecycle timestamps") @@ -264,22 +336,20 @@ func parseJobState(status string, startedAt, completedAt, cancelledAt *time.Time } case JobStatusSucceeded, JobStatusFailed: if completedAt == nil || completedAt.IsZero() { - return JobState{}, fmt.Errorf("%s job requires a completion time", status) + return JobState{}, fmt.Errorf("%s job requires a completion time", parsed) } if cancelledAt != nil { - return JobState{}, fmt.Errorf("%s job cannot have a cancellation time", status) + return JobState{}, fmt.Errorf("%s job cannot have a cancellation time", parsed) } case JobStatusCancelled: if completedAt == nil || completedAt.IsZero() || cancelledAt == nil || cancelledAt.IsZero() { return JobState{}, errors.New("cancelled job requires completion and cancellation times") } - default: - return JobState{}, fmt.Errorf("unknown job status %q", status) } return state, nil } -func (s JobState) Status() string { return s.status } +func (s JobState) Status() JobStatus { return s.status } func (s JobState) StartedAt() (time.Time, bool) { return s.startedAt, !s.startedAt.IsZero() } diff --git a/internal/corpus/observations.go b/internal/corpus/observations.go index b2fe2dbe..111112de 100644 --- a/internal/corpus/observations.go +++ b/internal/corpus/observations.go @@ -170,7 +170,7 @@ func (c *Corpus) ListRepositoryObservations(ctx context.Context, repoID int64) ( // ApplyThreadObservation records an immutable thread observation and updates // the current projection only when the new observation wins the ordering. -func (c *Corpus) ApplyThreadObservation(ctx context.Context, repoID int64, kind string, number int, state, title, body, author string, sourceUpdatedAt time.Time, payload string) (*Thread, error) { +func (c *Corpus) ApplyThreadObservation(ctx context.Context, repoID int64, kind domain.ThreadKind, number int, state domain.ThreadState, title, body, author string, sourceUpdatedAt time.Time, payload string) (*Thread, error) { thread := Thread{ RepositoryID: repoID, Kind: kind, @@ -190,7 +190,7 @@ func (c *Corpus) UpsertThread(ctx context.Context, thread Thread, payload string if err := parseThreadProjection(&thread); err != nil { return nil, err } - if thread.Kind != ThreadKindPullRequest && thread.Merge.Known() { + if thread.Kind != domain.PullRequestKind && thread.Merge.Known() { return nil, errors.New("only pull requests can have merge status") } tx, err := c.db.BeginTx(ctx, nil) @@ -303,7 +303,7 @@ func (c *Corpus) GetThreadByNumber(ctx context.Context, repoID int64, number int // GetThread returns the current projection of a thread, or nil if it has not // been observed. -func (c *Corpus) GetThread(ctx context.Context, repoID int64, kind string, number int) (*Thread, error) { +func (c *Corpus) GetThread(ctx context.Context, repoID int64, kind domain.ThreadKind, number int) (*Thread, error) { thread, err := scanThread(c.db.QueryRowContext(ctx, ` SELECT id, repository_id, kind, number, state, state_reason, title, body, author, author_association, labels, assignees, draft, locked, milestone, source_created_at, source_updated_at, observation_sequence, created_at, updated_at, closed_at, merged_at, merged, merged_known @@ -321,7 +321,7 @@ func (c *Corpus) GetThread(ctx context.Context, repoID int64, kind string, numbe // ListThreads returns threads for a repository, optionally filtered by kind, // ordered by source update time descending and then number descending. -func (c *Corpus) ListThreads(ctx context.Context, repoID int64, kind string, limit int) ([]Thread, error) { +func (c *Corpus) ListThreads(ctx context.Context, repoID int64, kind ThreadKindFilter, limit int) ([]Thread, error) { if limit <= 0 { limit = 1000 } @@ -334,9 +334,9 @@ func (c *Corpus) ListThreads(ctx context.Context, repoID int64, kind string, lim FROM threads WHERE repository_id = ?` args := []any{repoID} - if kind != "" { + if !kind.IsAny() { sql += ` AND kind = ?` - args = append(args, kind) + args = append(args, kind.String()) } sql += ` ORDER BY source_updated_at DESC, number DESC` sql += ` LIMIT ?` @@ -355,7 +355,7 @@ func (c *Corpus) ListThreads(ctx context.Context, repoID int64, kind string, lim // kind and state, ordered by source update time descending and then number // descending. Filtering happens at the corpus boundary before any limit is // applied, so bounded callers do not silently drop matching rows. -func (c *Corpus) ListThreadsFiltered(ctx context.Context, repoID int64, kind, state string, limit int) ([]Thread, error) { +func (c *Corpus) ListThreadsFiltered(ctx context.Context, repoID int64, kind ThreadKindFilter, state ThreadStateFilter, limit int) ([]Thread, error) { if limit <= 0 { limit = 1000 } @@ -368,13 +368,13 @@ func (c *Corpus) ListThreadsFiltered(ctx context.Context, repoID int64, kind, st FROM threads WHERE repository_id = ?` args := []any{repoID} - if kind != "" { + if !kind.IsAny() { sql += ` AND kind = ?` - args = append(args, kind) + args = append(args, kind.String()) } - if state != "" && state != "all" { + if !state.IsAny() { sql += ` AND state = ?` - args = append(args, state) + args = append(args, state.String()) } sql += ` ORDER BY source_updated_at DESC, number DESC` sql += ` LIMIT ?` @@ -391,16 +391,16 @@ func (c *Corpus) ListThreadsFiltered(ctx context.Context, repoID int64, kind, st // CountThreadsFiltered counts threads after applying the same kind and state // predicates as ListThreadsFiltered. -func (c *Corpus) CountThreadsFiltered(ctx context.Context, repoID int64, kind, state string) (int, error) { +func (c *Corpus) CountThreadsFiltered(ctx context.Context, repoID int64, kind ThreadKindFilter, state ThreadStateFilter) (int, error) { query := `SELECT COUNT(*) FROM threads WHERE repository_id = ?` args := []any{repoID} - if kind != "" { + if !kind.IsAny() { query += ` AND kind = ?` - args = append(args, kind) + args = append(args, kind.String()) } - if state != "" && state != "all" { + if !state.IsAny() { query += ` AND state = ?` - args = append(args, state) + args = append(args, state.String()) } var total int if err := c.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { @@ -433,8 +433,8 @@ func (c *Corpus) CountRepositoryThreads(ctx context.Context, repoID int64) (Repo COALESCE(SUM(CASE WHEN kind = ? AND state = 'closed' AND merged_known = 0 THEN 1 ELSE 0 END), 0) FROM threads WHERE repository_id = ? - `, ThreadKindIssue, ThreadKindIssue, ThreadKindPullRequest, ThreadKindPullRequest, - ThreadKindPullRequest, ThreadKindPullRequest, repoID).Scan( + `, domain.IssueKind, domain.IssueKind, domain.PullRequestKind, domain.PullRequestKind, + domain.PullRequestKind, domain.PullRequestKind, repoID).Scan( &counts.OpenIssues, &counts.ClosedIssues, &counts.OpenPullRequests, @@ -450,24 +450,30 @@ func (c *Corpus) CountRepositoryThreads(ctx context.Context, repoID int64) (Repo // ListThreadsByStateAndMerge returns every matching thread when limit is // non-positive. Positive limits apply after all predicates. -func (c *Corpus) ListThreadsByStateAndMerge(ctx context.Context, repoID int64, kind, state string, merged *bool, limit int) (_ []Thread, returnErr error) { +func (c *Corpus) ListThreadsByStateAndMerge(ctx context.Context, repoID int64, kind ThreadKindFilter, state ThreadStateFilter, merge MergeFilter, limit int) (_ []Thread, returnErr error) { query := ` SELECT id, repository_id, kind, number, state, state_reason, title, body, author, author_association, labels, assignees, draft, locked, milestone, source_created_at, source_updated_at, observation_sequence, created_at, updated_at, closed_at, merged_at, merged, merged_known FROM threads WHERE repository_id = ?` args := []any{repoID} - if kind != "" { + if !kind.IsAny() { query += ` AND kind = ?` - args = append(args, kind) + args = append(args, kind.String()) } - if state != "" && state != "all" { + if !state.IsAny() { query += ` AND state = ?` - args = append(args, state) - } - if merged != nil && kind == ThreadKindPullRequest { - query += ` AND merged_known = 1 AND merged = ?` - args = append(args, *merged) + args = append(args, state.String()) + } + if !merge.IsAny() && kind.String() == string(domain.PullRequestKind) { + switch { + case merge.IsMerged(): + query += ` AND merged_known = 1 AND merged = 1` + case merge.IsUnmerged(): + query += ` AND merged_known = 1 AND merged = 0` + case merge.IsUnknown(): + query += ` AND merged_known = 0` + } } query += ` ORDER BY source_updated_at DESC, number DESC` if limit > 0 { @@ -618,18 +624,18 @@ func parseThreadProjection(thread *Thread) error { if thread == nil { return errors.New("thread is required") } - kind, err := domain.ParseThreadKind(thread.Kind) + kind, err := domain.ParseThreadKind(string(thread.Kind)) if err != nil { return err } - state, err := domain.ParseThreadState(thread.State) + state, err := domain.ParseThreadState(string(thread.State)) if err != nil { return err } if thread.RepositoryID <= 0 || thread.Number <= 0 { return errors.New("thread repository and positive number are required") } - thread.Kind, thread.State = string(kind), string(state) + thread.Kind, thread.State = kind, state return nil } diff --git a/internal/corpus/observations_test.go b/internal/corpus/observations_test.go index a79c258b..d6a69283 100644 --- a/internal/corpus/observations_test.go +++ b/internal/corpus/observations_test.go @@ -24,7 +24,7 @@ func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { base := time.Unix(1000, 0).UTC() threads := []struct { number int - state string + state domain.ThreadState when time.Time }{ {1, "closed", base.Add(3 * time.Second)}, @@ -34,7 +34,7 @@ func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { for _, th := range threads { if _, err := c.UpsertThread(ctx, Thread{ RepositoryID: repo.ID, - Kind: ThreadKindIssue, + Kind: domain.IssueKind, Number: th.number, State: th.state, Title: "title", @@ -50,7 +50,7 @@ func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { // A limit of 1 applied before the state filter would return nothing, // because the most recently updated row is closed. Filtering first // should return the most recently updated open thread (#3). - listed, err := c.ListThreadsFiltered(ctx, repo.ID, ThreadKindIssue, "open", 1) + listed, err := c.ListThreadsFiltered(ctx, repo.ID, IssueThreadKind(), OpenThreadState(), 1) if err != nil { t.Fatalf("list threads filtered: %v", err) } @@ -59,14 +59,14 @@ func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { } // Limit 2 should still return only the open threads and respect the bound. - listed, err = c.ListThreadsFiltered(ctx, repo.ID, ThreadKindIssue, "open", 2) + listed, err = c.ListThreadsFiltered(ctx, repo.ID, IssueThreadKind(), OpenThreadState(), 2) if err != nil { t.Fatalf("list threads filtered: %v", err) } if len(listed) != 1 || listed[0].Number != 3 { t.Fatalf("got %+v, want one open thread with number 3", listed) } - total, err := c.CountThreadsFiltered(ctx, repo.ID, ThreadKindIssue, "open") + total, err := c.CountThreadsFiltered(ctx, repo.ID, IssueThreadKind(), OpenThreadState()) if err != nil { t.Fatalf("count threads filtered: %v", err) } @@ -84,7 +84,7 @@ func TestUpsertThreadRejectsUnknownKindAndStateBeforeWrite(t *testing.T) { } for name, thread := range map[string]Thread{ "kind": {RepositoryID: repo.ID, Kind: "discussion", Number: 1, State: "open"}, - "state": {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "draft"}, + "state": {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "draft"}, } { t.Run(name, func(t *testing.T) { if _, err := c.UpsertThread(ctx, thread, `{}`); err == nil { @@ -92,7 +92,7 @@ func TestUpsertThreadRejectsUnknownKindAndStateBeforeWrite(t *testing.T) { } }) } - threads, err := c.ListThreads(ctx, repo.ID, "", 10) + threads, err := c.ListThreads(ctx, repo.ID, AnyThreadKind(), 10) if err != nil { t.Fatal(err) } @@ -111,23 +111,23 @@ func TestListThreadsByStateAndMergeIgnoresMergedOutsidePullRequests(t *testing.T } merged := true for _, thread := range []Thread{ - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "open", Title: "issue", SourceUpdatedAt: time.Unix(3, 0).UTC()}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 2, State: "closed", Title: "merged", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(2, 0).UTC()}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "closed", Title: "unmerged", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(1, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "issue", SourceUpdatedAt: time.Unix(3, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "merged", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(2, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "unmerged", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(1, 0).UTC()}, } { if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) } } - issues, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, ThreadKindIssue, "open", &merged, 1) + issues, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, IssueThreadKind(), OpenThreadState(), MergeFilterFromPointer(&merged), 1) if err != nil { t.Fatal(err) } if len(issues) != 1 || issues[0].Number != 1 { t.Fatalf("issues = %+v", issues) } - pullRequests, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, ThreadKindPullRequest, "closed", &merged, 1) + pullRequests, err := c.ListThreadsByStateAndMerge(ctx, repo.ID, PullRequestThreadKind(), ClosedThreadState(), MergeFilterFromPointer(&merged), 1) if err != nil { t.Fatal(err) } @@ -147,10 +147,10 @@ func TestListPullRequestPortfolioFiltersByAuthorAndState(t *testing.T) { } when := time.Unix(1000, 0).UTC() threads := []Thread{ - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, State: "open", Author: "Alice", Title: "alice open", SourceUpdatedAt: when}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 2, State: "closed", Author: "alice", Title: "alice closed", SourceUpdatedAt: when.Add(time.Second)}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "open", Author: "bob", Title: "bob open", SourceUpdatedAt: when.Add(2 * time.Second)}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 4, State: "open", Author: "alice", Title: "not a pull request", SourceUpdatedAt: when.Add(3 * time.Second)}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "open", Author: "Alice", Title: "alice open", SourceUpdatedAt: when}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 2, State: "closed", Author: "alice", Title: "alice closed", SourceUpdatedAt: when.Add(time.Second)}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "open", Author: "bob", Title: "bob open", SourceUpdatedAt: when.Add(2 * time.Second)}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 4, State: "open", Author: "alice", Title: "not a pull request", SourceUpdatedAt: when.Add(3 * time.Second)}, } for _, thread := range threads { if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { @@ -158,7 +158,7 @@ func TestListPullRequestPortfolioFiltersByAuthorAndState(t *testing.T) { } } - got, err := c.ListPullRequestPortfolio(ctx, "ALICE", "OPEN", nil, 10) + got, err := c.ListPullRequestPortfolio(ctx, "ALICE", OpenThreadState(), nil, 10) if err != nil { t.Fatalf("list pull request portfolio: %v", err) } @@ -169,14 +169,14 @@ func TestListPullRequestPortfolioFiltersByAuthorAndState(t *testing.T) { t.Fatalf("portfolio item = %+v, want owner/repo#1", got[0]) } - got, err = c.ListPullRequestPortfolio(ctx, "alice", "all", nil, 10) + got, err = c.ListPullRequestPortfolio(ctx, "alice", AnyThreadState(), nil, 10) if err != nil { t.Fatalf("list pull request portfolio for all states: %v", err) } if len(got) != 2 || got[0].Thread.Number != 2 || got[1].Thread.Number != 1 { t.Fatalf("all-state portfolio = %+v, want #2 then #1", got) } - page, err := c.ListPullRequestPortfolioPage(ctx, "alice", "all", nil, 1) + page, err := c.ListPullRequestPortfolioPage(ctx, "alice", AnyThreadState(), nil, 1) if err != nil { t.Fatal(err) } @@ -208,7 +208,7 @@ func TestListPullRequestPortfolioUsesDeterministicGlobalOrder(t *testing.T) { } if _, err := c.UpsertThread(ctx, Thread{ RepositoryID: repo.ID, - Kind: ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: number, State: "open", Author: "alice", @@ -220,7 +220,7 @@ func TestListPullRequestPortfolioUsesDeterministicGlobalOrder(t *testing.T) { } } - got, err := c.ListPullRequestPortfolio(ctx, "", "", nil, 100) + got, err := c.ListPullRequestPortfolio(ctx, "", AnyThreadState(), nil, 100) if err != nil { t.Fatalf("list pull request portfolio: %v", err) } @@ -253,7 +253,7 @@ func TestUpsertThreadPersistsMetadataAndDeterministicAssignees(t *testing.T) { want := Thread{ RepositoryID: repo.ID, - Kind: ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", StateReason: "completed", @@ -281,7 +281,7 @@ func TestUpsertThreadPersistsMetadataAndDeterministicAssignees(t *testing.T) { t.Fatalf("upsert mismatch (-want +got):\n%s", diff) } - fetched, err := c.GetThread(ctx, repo.ID, ThreadKindIssue, 1) + fetched, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) if err != nil { t.Fatalf("get thread: %v", err) } @@ -289,7 +289,7 @@ func TestUpsertThreadPersistsMetadataAndDeterministicAssignees(t *testing.T) { t.Fatalf("get thread mismatch (-want +got):\n%s", diff) } - listed, err := c.ListThreads(ctx, repo.ID, "", 10) + listed, err := c.ListThreads(ctx, repo.ID, AnyThreadKind(), 10) if err != nil { t.Fatalf("list threads: %v", err) } @@ -321,7 +321,7 @@ func TestUpsertThreadUnknownMergeStateDoesNotEraseKnownState(t *testing.T) { at := time.Unix(100, 0).UTC() mergedAt := at.Add(-time.Hour) known, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "details", Merge: domain.MergedStatus(mergedAt), SourceUpdatedAt: at, }, `{"Merged":true}`) if err != nil { @@ -332,7 +332,7 @@ func TestUpsertThreadUnknownMergeStateDoesNotEraseKnownState(t *testing.T) { } got, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "newer header", SourceUpdatedAt: at.Add(time.Second), }, `{"Kind":"pull_request"}`) if err != nil { @@ -343,7 +343,7 @@ func TestUpsertThreadUnknownMergeStateDoesNotEraseKnownState(t *testing.T) { } got, err = c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "observed false", Merge: domain.UnmergedStatus(), SourceUpdatedAt: at.Add(2 * time.Second), }, `{"Merged":false}`) diff --git a/internal/corpus/organize.go b/internal/corpus/organize.go index 87803bd1..0af552ca 100644 --- a/internal/corpus/organize.go +++ b/internal/corpus/organize.go @@ -9,16 +9,15 @@ import ( "strings" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/lens" ) const ( - maxSavedNameLength = 128 - maxCollectionBatchSize = 1000 - maxCollectionRefLength = 2048 - lensListLimit = 1000 - collectionListLimit = 1000 - collectionMemberListLimit = 10000 + maxSavedNameLength = 128 + maxCollectionBatchSize = 1000 + lensListLimit = 1000 + collectionListLimit = 1000 ) // LensRecord is a durable, reusable ranking definition. @@ -53,18 +52,90 @@ type CollectionList struct { // CollectionMember is one typed stable reference in a collection. type CollectionMember struct { - Ref string - Kind string - AddedAt time.Time + kind collectionMemberKind + ref string } -// CollectionMemberList is one bounded, stable page of collection members. -type CollectionMemberList struct { - Members []CollectionMember - Total int - Truncated bool +type collectionMemberKind uint8 + +const ( + collectionRepositoryMember collectionMemberKind = iota + 1 + collectionIssueMember + collectionPullRequestMember + collectionThreadMember + collectionOpportunityMember + collectionInvestigationMember +) + +func NewRepositoryCollectionMember(ref domain.RepoRef) (CollectionMember, error) { + if !ref.IsValid() { + return CollectionMember{}, errors.New("collection repository reference is not parsed") + } + return CollectionMember{kind: collectionRepositoryMember, ref: ref.String()}, nil +} + +func NewThreadCollectionMember(kind domain.ThreadKind, ref domain.RepoRef, number int) (CollectionMember, error) { + if !ref.IsValid() || number <= 0 { + return CollectionMember{}, errors.New("collection thread reference is invalid") + } + var memberKind collectionMemberKind + switch kind { + case domain.IssueKind: + memberKind = collectionIssueMember + case domain.PullRequestKind: + memberKind = collectionPullRequestMember + default: + return CollectionMember{}, errors.New("collection thread kind must be issue or pull_request") + } + return CollectionMember{kind: memberKind, ref: fmt.Sprintf("%s#%d", ref, number)}, nil +} + +func NewAnyThreadCollectionMember(ref domain.RepoRef, number int) (CollectionMember, error) { + if !ref.IsValid() || number <= 0 { + return CollectionMember{}, errors.New("collection thread reference is invalid") + } + return CollectionMember{kind: collectionThreadMember, ref: fmt.Sprintf("%s#%d", ref, number)}, nil +} + +func NewOpportunityCollectionMember(id string) (CollectionMember, error) { + return newWorkflowCollectionMember(collectionOpportunityMember, id) +} + +func NewInvestigationCollectionMember(id string) (CollectionMember, error) { + return newWorkflowCollectionMember(collectionInvestigationMember, id) +} + +func newWorkflowCollectionMember(kind collectionMemberKind, id string) (CollectionMember, error) { + id, err := validateSavedText("collection workflow reference", id, 64) + if err != nil { + return CollectionMember{}, err + } + return CollectionMember{kind: kind, ref: id}, nil +} + +func (m CollectionMember) Kind() string { + switch m.kind { + case collectionRepositoryMember: + return "repository" + case collectionIssueMember: + return "issue" + case collectionPullRequestMember: + return "pull_request" + case collectionThreadMember: + return "thread" + case collectionOpportunityMember: + return "opportunity" + case collectionInvestigationMember: + return "investigation" + default: + return "" + } } +func (m CollectionMember) Ref() string { return m.ref } + +func (m CollectionMember) valid() bool { return m.Kind() != "" && m.ref != "" } + // SaveLens creates or replaces a named lens after validating its scoring // contract. Existing creation time is retained. func (c *Corpus) SaveLens(ctx context.Context, definition lens.Definition) (*LensRecord, error) { @@ -219,17 +290,10 @@ func (c *Corpus) AddCollectionMembers(ctx context.Context, collectionName string if len(members) > maxCollectionBatchSize { return fmt.Errorf("collection batch exceeds %d members", maxCollectionBatchSize) } - validated := make([]CollectionMember, len(members)) - for i, member := range members { - ref, err := validateSavedText("collection reference", member.Ref, maxCollectionRefLength) - if err != nil { - return err - } - kind, err := validateSavedText("collection member kind", member.Kind, maxSavedNameLength) - if err != nil { - return err + for _, member := range members { + if !member.valid() { + return errors.New("collection member is not parsed") } - validated[i] = CollectionMember{Ref: ref, Kind: kind} } tx, err := c.db.BeginTx(ctx, nil) @@ -245,11 +309,11 @@ func (c *Corpus) AddCollectionMembers(ctx context.Context, collectionName string return fmt.Errorf("get collection identity: %w", err) } now := encodeTime(time.Now()) - for _, member := range validated { + for _, member := range members { if _, err := tx.ExecContext(ctx, ` INSERT INTO collection_members (collection_id, ref, kind, added_at) VALUES (?, ?, ?, ?) ON CONFLICT (collection_id, kind, ref) DO NOTHING - `, collectionID, member.Ref, member.Kind, now); err != nil { + `, collectionID, member.Ref(), member.Kind(), now); err != nil { return fmt.Errorf("add collection member: %w", err) } } @@ -262,39 +326,6 @@ func (c *Corpus) AddCollectionMembers(ctx context.Context, collectionName string return nil } -// ListCollectionMembers returns a bounded member page in stable kind and -// reference order. -func (c *Corpus) ListCollectionMembers(ctx context.Context, collectionName string) (CollectionMemberList, error) { - name := strings.TrimSpace(collectionName) - rows, err := c.db.QueryContext(ctx, ` - SELECT m.ref, m.kind, m.added_at, - (SELECT COUNT(*) FROM collection_members cm - JOIN collections cc ON cc.id=cm.collection_id WHERE cc.name=?) - FROM collection_members m - JOIN collections c ON c.id=m.collection_id - WHERE c.name=? ORDER BY m.kind, m.ref LIMIT ? - `, name, name, collectionMemberListLimit) - if err != nil { - return CollectionMemberList{}, fmt.Errorf("list collection members: %w", err) - } - defer func() { _ = rows.Close() }() - var result CollectionMemberList - for rows.Next() { - var member CollectionMember - var addedAt int64 - if err := rows.Scan(&member.Ref, &member.Kind, &addedAt, &result.Total); err != nil { - return CollectionMemberList{}, err - } - member.AddedAt = scanTime(addedAt) - result.Members = append(result.Members, member) - } - if err := rows.Err(); err != nil { - return CollectionMemberList{}, err - } - result.Truncated = result.Total > len(result.Members) - return result, nil -} - func validateSavedText(field, value string, limit int) (string, error) { value = strings.TrimSpace(value) if value == "" { diff --git a/internal/corpus/organize_test.go b/internal/corpus/organize_test.go index d413e672..5d5cbdee 100644 --- a/internal/corpus/organize_test.go +++ b/internal/corpus/organize_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/lens" ) @@ -77,15 +78,15 @@ func TestCollectionsDeduplicateTypedReferences(t *testing.T) { t.Fatal(err) } members := []CollectionMember{ - {Kind: "repository", Ref: "octocat/hello-world"}, - {Kind: "issue", Ref: "octocat/hello-world#12"}, + mustRepositoryCollectionMember(t, "octocat/hello-world"), + mustThreadCollectionMember(t, domain.IssueKind, "octocat/hello-world", 12), } if err := c.AddCollectionMembers(ctx, "favorites", members); err != nil { t.Fatal(err) } if err := c.AddCollectionMembers(ctx, "favorites", []CollectionMember{ - {Kind: "issue", Ref: "octocat/hello-world#12"}, - {Kind: "pull_request", Ref: "octocat/hello-world#12"}, + mustThreadCollectionMember(t, domain.IssueKind, "octocat/hello-world", 12), + mustThreadCollectionMember(t, domain.PullRequestKind, "octocat/hello-world", 12), }); err != nil { t.Fatal(err) } @@ -97,13 +98,6 @@ func TestCollectionsDeduplicateTypedReferences(t *testing.T) { if stored.ID != created.ID || stored.MemberCount != 3 { t.Fatalf("collection = %+v", stored) } - got, err := c.ListCollectionMembers(ctx, "favorites") - if err != nil { - t.Fatal(err) - } - if len(got.Members) != 3 || got.Members[0].Kind != "issue" || got.Members[1].Kind != "pull_request" || got.Members[2].Kind != "repository" || got.Total != 3 || got.Truncated { - t.Fatalf("members = %+v", got) - } collections, err := c.ListCollections(ctx) if err != nil || len(collections.Collections) != 1 || collections.Collections[0].MemberCount != 3 || collections.Total != 1 || collections.Truncated { t.Fatalf("collections = %+v, err = %v", collections, err) @@ -114,7 +108,7 @@ func TestAddCollectionMembersRequiresExistingBoundedCollection(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - if err := c.AddCollectionMembers(ctx, "missing", []CollectionMember{{Kind: "repository", Ref: "o/r"}}); err == nil { + if err := c.AddCollectionMembers(ctx, "missing", []CollectionMember{mustRepositoryCollectionMember(t, "o/r")}); err == nil { t.Fatal("expected missing collection error") } if _, err := c.SaveCollection(ctx, "saved"); err != nil { @@ -145,19 +139,6 @@ func TestOrganizeListsExposeHardCapTruncation(t *testing.T) { t.Fatal(err) } } - result, err := tx.ExecContext(ctx, `INSERT INTO collections (name, created_at, updated_at) VALUES ('members', ?, ?)`, now, now) - if err != nil { - t.Fatal(err) - } - collectionID, err := result.LastInsertId() - if err != nil { - t.Fatal(err) - } - for i := 0; i <= collectionMemberListLimit; i++ { - if _, err := tx.ExecContext(ctx, `INSERT INTO collection_members (collection_id, ref, kind, added_at) VALUES (?, ?, 'issue', ?)`, collectionID, fmt.Sprintf("owner/repo#%05d", i), now); err != nil { - t.Fatal(err) - } - } if err := tx.Commit(); err != nil { t.Fatal(err) } @@ -173,14 +154,33 @@ func TestOrganizeListsExposeHardCapTruncation(t *testing.T) { if err != nil { t.Fatal(err) } - if len(collections.Collections) != collectionListLimit || collections.Total != collectionListLimit+2 || !collections.Truncated { + if len(collections.Collections) != collectionListLimit || collections.Total != collectionListLimit+1 || !collections.Truncated { t.Fatalf("collections = returned:%d total:%d truncated:%v", len(collections.Collections), collections.Total, collections.Truncated) } - members, err := c.ListCollectionMembers(ctx, "members") +} + +func mustRepositoryCollectionMember(t *testing.T, value string) CollectionMember { + t.Helper() + ref, err := domain.ParseRepoRef(value) + if err != nil { + t.Fatal(err) + } + member, err := NewRepositoryCollectionMember(ref) + if err != nil { + t.Fatal(err) + } + return member +} + +func mustThreadCollectionMember(t *testing.T, kind domain.ThreadKind, repository string, number int) CollectionMember { + t.Helper() + ref, err := domain.ParseRepoRef(repository) if err != nil { t.Fatal(err) } - if len(members.Members) != collectionMemberListLimit || members.Total != collectionMemberListLimit+1 || !members.Truncated { - t.Fatalf("members = returned:%d total:%d truncated:%v", len(members.Members), members.Total, members.Truncated) + member, err := NewThreadCollectionMember(kind, ref, number) + if err != nil { + t.Fatal(err) } + return member } diff --git a/internal/corpus/portfolio.go b/internal/corpus/portfolio.go index d711159f..d974ecc2 100644 --- a/internal/corpus/portfolio.go +++ b/internal/corpus/portfolio.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "github.com/morluto/gitcontribute/internal/domain" ) @@ -15,7 +14,7 @@ import ( // state "all" is equivalent to no state filter. The read is bounded and // deterministic so callers can build portfolio views without repository-level // N+1 queries. -func (c *Corpus) ListPullRequestPortfolio(ctx context.Context, author, state string, repository *RepositoryKey, limit int) (_ []PortfolioPullRequest, err error) { +func (c *Corpus) ListPullRequestPortfolio(ctx context.Context, author string, state ThreadStateFilter, repository *RepositoryKey, limit int) (_ []PortfolioPullRequest, err error) { page, err := c.ListPullRequestPortfolioPage(ctx, author, state, repository, limit) if err != nil { return nil, err @@ -25,7 +24,7 @@ func (c *Corpus) ListPullRequestPortfolio(ctx context.Context, author, state str // ListPullRequestPortfolioPage returns a bounded portfolio and the exact // matching population so callers never mistake the page size for the total. -func (c *Corpus) ListPullRequestPortfolioPage(ctx context.Context, author, state string, repository *RepositoryKey, limit int) (_ PortfolioPage, err error) { +func (c *Corpus) ListPullRequestPortfolioPage(ctx context.Context, author string, state ThreadStateFilter, repository *RepositoryKey, limit int) (_ PortfolioPage, err error) { if limit <= 0 { limit = 1000 } @@ -45,7 +44,7 @@ func (c *Corpus) ListPullRequestPortfolioPage(ctx context.Context, author, state FROM threads t JOIN repositories r ON r.id = t.repository_id WHERE t.kind = ?` - args := []any{ThreadKindPullRequest} + args := []any{domain.PullRequestKind} if author != "" { query += ` AND lower(t.author) = lower(?)` args = append(args, author) @@ -54,12 +53,12 @@ func (c *Corpus) ListPullRequestPortfolioPage(ctx context.Context, author, state query += ` AND lower(r.owner) = lower(?) AND lower(r.name) = lower(?)` args = append(args, repository.Owner, repository.Name) } - if state != "" && !strings.EqualFold(state, "all") { + if !state.IsAny() { query += ` AND lower(t.state) = lower(?)` - args = append(args, state) + args = append(args, state.String()) } countQuery := `SELECT COUNT(*) FROM threads t JOIN repositories r ON r.id = t.repository_id WHERE t.kind = ?` - countArgs := []any{ThreadKindPullRequest} + countArgs := []any{domain.PullRequestKind} if author != "" { countQuery += ` AND lower(t.author) = lower(?)` countArgs = append(countArgs, author) @@ -68,9 +67,9 @@ func (c *Corpus) ListPullRequestPortfolioPage(ctx context.Context, author, state countQuery += ` AND lower(r.owner) = lower(?) AND lower(r.name) = lower(?)` countArgs = append(countArgs, repository.Owner, repository.Name) } - if state != "" && !strings.EqualFold(state, "all") { + if !state.IsAny() { countQuery += ` AND lower(t.state) = lower(?)` - countArgs = append(countArgs, state) + countArgs = append(countArgs, state.String()) } var total int if err := tx.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { diff --git a/internal/corpus/portfolio_relationships.go b/internal/corpus/portfolio_relationships.go index 574e7faa..b892ae13 100644 --- a/internal/corpus/portfolio_relationships.go +++ b/internal/corpus/portfolio_relationships.go @@ -11,6 +11,8 @@ import ( "strconv" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) const ( @@ -51,15 +53,179 @@ var portfolioFacets = []string{ // local portfolio or resolution fact. Kind is product-owned (for example, // thread or facet) and ID is the corresponding corpus observation identity. type ObservationRef struct { - Kind string `json:"kind"` - ID int64 `json:"id"` + kind observationRefKind + id int64 +} + +type observationRefKind uint8 + +const ( + threadObservationRef observationRefKind = iota + 1 + facetObservationRef + portfolioLinkObservationRef +) + +func ParseObservationRef(kind string, id int64) (ObservationRef, error) { + if id <= 0 { + return ObservationRef{}, errors.New("observation reference id must be positive") + } + switch strings.TrimSpace(kind) { + case "thread": + return ObservationRef{kind: threadObservationRef, id: id}, nil + case "facet": + return ObservationRef{kind: facetObservationRef, id: id}, nil + case "portfolio_link": + return ObservationRef{kind: portfolioLinkObservationRef, id: id}, nil + default: + return ObservationRef{}, errors.New("unknown observation reference kind") + } +} + +func NewThreadObservationRef(id int64) (ObservationRef, error) { + return ParseObservationRef("thread", id) +} + +func NewFacetObservationRef(id int64) (ObservationRef, error) { + return ParseObservationRef("facet", id) +} + +func newPortfolioLinkObservationRef(id int64) (ObservationRef, error) { + return ParseObservationRef("portfolio_link", id) +} + +func (r ObservationRef) Kind() string { + switch r.kind { + case threadObservationRef: + return "thread" + case facetObservationRef: + return "facet" + case portfolioLinkObservationRef: + return "portfolio_link" + default: + return "" + } +} + +func (r ObservationRef) ID() int64 { return r.id } + +func (r ObservationRef) valid() bool { return r.Kind() != "" && r.id > 0 } + +func (r ObservationRef) MarshalJSON() ([]byte, error) { + if !r.valid() { + return nil, errors.New("invalid observation reference") + } + return json.Marshal(struct { + Kind string `json:"kind"` + ID int64 `json:"id"` + }{Kind: r.Kind(), ID: r.ID()}) +} + +func (r *ObservationRef) UnmarshalJSON(data []byte) error { + var input struct { + Kind string `json:"kind"` + ID int64 `json:"id"` + } + if err := json.Unmarshal(data, &input); err != nil { + return err + } + parsed, err := ParseObservationRef(input.Kind, input.ID) + if err != nil { + return err + } + *r = parsed + return nil } // PortfolioSubject is a stable local identity. Pull-request references are -// decimal corpus thread IDs; opportunity and workspace references are IDs. +// canonical decimal corpus thread IDs; opportunity and workspace references +// are trimmed local IDs. Construct subjects with ParsePortfolioSubject or +// NewPullRequestPortfolioSubject so storage and comparison share one identity. type PortfolioSubject struct { - Kind string `json:"kind"` - Ref string `json:"ref"` + kind portfolioSubjectKind + ref string +} + +type portfolioSubjectKind uint8 + +const ( + portfolioPullRequestSubject portfolioSubjectKind = iota + 1 + portfolioOpportunitySubject + portfolioWorkspaceSubject +) + +// ParsePortfolioSubject consumes the transport/storage spelling for one local +// portfolio identity and returns its canonical representation. +func ParsePortfolioSubject(kind, ref string) (PortfolioSubject, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return PortfolioSubject{}, errors.New("portfolio subject reference is required") + } + switch strings.TrimSpace(kind) { + case PortfolioSubjectPullRequest: + id, err := strconv.ParseInt(ref, 10, 64) + if err != nil || id <= 0 { + return PortfolioSubject{}, errors.New("pull request subject reference must be a positive corpus thread id") + } + return PortfolioSubject{kind: portfolioPullRequestSubject, ref: strconv.FormatInt(id, 10)}, nil + case PortfolioSubjectOpportunity: + return PortfolioSubject{kind: portfolioOpportunitySubject, ref: ref}, nil + case PortfolioSubjectWorkspace: + return PortfolioSubject{kind: portfolioWorkspaceSubject, ref: ref}, nil + default: + return PortfolioSubject{}, errors.New("unknown portfolio subject kind") + } +} + +// NewPullRequestPortfolioSubject constructs an identity from an authoritative +// corpus thread ID without a string round trip at the call site. +func NewPullRequestPortfolioSubject(threadID int64) (PortfolioSubject, error) { + if threadID <= 0 { + return PortfolioSubject{}, errors.New("pull request subject reference must be a positive corpus thread id") + } + return PortfolioSubject{kind: portfolioPullRequestSubject, ref: strconv.FormatInt(threadID, 10)}, nil +} + +func (s PortfolioSubject) Kind() string { + switch s.kind { + case portfolioPullRequestSubject: + return PortfolioSubjectPullRequest + case portfolioOpportunitySubject: + return PortfolioSubjectOpportunity + case portfolioWorkspaceSubject: + return PortfolioSubjectWorkspace + default: + return "" + } +} + +func (s PortfolioSubject) Ref() string { return s.ref } + +func (s PortfolioSubject) valid() bool { return s.Kind() != "" && s.ref != "" } + +func (s PortfolioSubject) MarshalJSON() ([]byte, error) { + if !s.valid() { + return nil, errors.New("invalid portfolio subject") + } + return json.Marshal(struct { + Kind string `json:"kind"` + Ref string `json:"ref"` + }{Kind: s.Kind(), Ref: s.Ref()}) +} + +func (s *PortfolioSubject) UnmarshalJSON(data []byte) error { + var input struct { + Kind string `json:"kind"` + Ref string `json:"ref"` + } + if err := json.Unmarshal(data, &input); err != nil { + return err + } + parsed, err := ParsePortfolioSubject(input.Kind, input.Ref) + if err != nil { + return err + } + *s = parsed + return nil } // PortfolioLink explicitly associates an authored PR with local workflow @@ -72,14 +238,128 @@ type PortfolioLink struct { CreatedAt time.Time `json:"created_at"` } -// PortfolioSignal is one normalized overlap input. Similarity signals name a -// target subject and carry a score; path and linked-issue signals use Value. +// PortfolioSignal is one normalized overlap input. Constructors seal the +// scalar path/issue variants apart from scored pull-request similarity. type PortfolioSignal struct { - Kind string `json:"kind"` - Value string `json:"value"` - TargetKind string `json:"target_kind,omitempty"` - TargetRef string `json:"target_ref,omitempty"` - Score float64 `json:"score,omitempty"` + kind portfolioSignalKind + value string + target PortfolioSubject + score float64 +} + +type portfolioSignalKind uint8 + +const ( + portfolioFilePathSignal portfolioSignalKind = iota + 1 + portfolioLinkedIssueSignal + portfolioOpportunitySimilaritySignal +) + +func NewPortfolioFilePathSignal(value string) (PortfolioSignal, error) { + value = strings.TrimSpace(value) + if value == "" { + return PortfolioSignal{}, errors.New("portfolio signal value is required") + } + value = path.Clean(strings.ReplaceAll(value, `\`, "/")) + return PortfolioSignal{kind: portfolioFilePathSignal, value: value}, nil +} + +func NewPortfolioLinkedIssueSignal(value string) (PortfolioSignal, error) { + value = strings.TrimSpace(value) + if value == "" { + return PortfolioSignal{}, errors.New("portfolio signal value is required") + } + return PortfolioSignal{kind: portfolioLinkedIssueSignal, value: value}, nil +} + +func NewPortfolioOpportunitySimilaritySignal(target PortfolioSubject, score float64) (PortfolioSignal, error) { + if target.Kind() != PortfolioSubjectPullRequest || !(score >= 0 && score <= 1) { + return PortfolioSignal{}, errors.New("opportunity similarity requires a pull request target and score between zero and one") + } + return PortfolioSignal{kind: portfolioOpportunitySimilaritySignal, target: target, score: score}, nil +} + +func (s PortfolioSignal) Kind() string { + switch s.kind { + case portfolioFilePathSignal: + return PortfolioSignalFilePath + case portfolioLinkedIssueSignal: + return PortfolioSignalLinkedIssue + case portfolioOpportunitySimilaritySignal: + return PortfolioSignalOpportunitySimilarity + default: + return "" + } +} + +func (s PortfolioSignal) Value() string { return s.value } + +func (s PortfolioSignal) Target() (PortfolioSubject, bool) { + return s.target, s.kind == portfolioOpportunitySimilaritySignal +} + +func (s PortfolioSignal) Score() float64 { return s.score } + +func parsePortfolioSignal(kind, value, targetKind, targetRef string, score float64) (PortfolioSignal, error) { + switch strings.TrimSpace(kind) { + case PortfolioSignalFilePath: + if strings.TrimSpace(targetKind) != "" || strings.TrimSpace(targetRef) != "" || score != 0 { + return PortfolioSignal{}, errors.New("file path signal cannot carry a target or score") + } + return NewPortfolioFilePathSignal(value) + case PortfolioSignalLinkedIssue: + if strings.TrimSpace(targetKind) != "" || strings.TrimSpace(targetRef) != "" || score != 0 { + return PortfolioSignal{}, errors.New("linked issue signal cannot carry a target or score") + } + return NewPortfolioLinkedIssueSignal(value) + case PortfolioSignalOpportunitySimilarity: + if strings.TrimSpace(value) != "" { + return PortfolioSignal{}, errors.New("opportunity similarity signal cannot carry a scalar value") + } + target, err := ParsePortfolioSubject(targetKind, targetRef) + if err != nil { + return PortfolioSignal{}, err + } + return NewPortfolioOpportunitySimilaritySignal(target, score) + default: + return PortfolioSignal{}, errors.New("unknown portfolio signal kind") + } +} + +func (s PortfolioSignal) MarshalJSON() ([]byte, error) { + if s.Kind() == "" { + return nil, errors.New("invalid portfolio signal") + } + output := struct { + Kind string `json:"kind"` + Value string `json:"value"` + TargetKind string `json:"target_kind,omitempty"` + TargetRef string `json:"target_ref,omitempty"` + Score float64 `json:"score,omitempty"` + }{Kind: s.Kind(), Value: s.Value(), Score: s.Score()} + if target, ok := s.Target(); ok { + output.TargetKind, output.TargetRef = target.Kind(), target.Ref() + } + return json.Marshal(output) +} + +func (s *PortfolioSignal) UnmarshalJSON(data []byte) error { + var input struct { + Kind string `json:"kind"` + Value string `json:"value"` + TargetKind string `json:"target_kind"` + TargetRef string `json:"target_ref"` + Score float64 `json:"score"` + } + if err := json.Unmarshal(data, &input); err != nil { + return err + } + parsed, err := parsePortfolioSignal(input.Kind, input.Value, input.TargetKind, input.TargetRef, input.Score) + if err != nil { + return err + } + *s = parsed + return nil } // PortfolioSignalSnapshot is one complete, immutable facet replacement. @@ -124,10 +404,51 @@ type PortfolioOverlapMatch struct { // no_overlap, or unknown. A no_overlap result requires complete coverage of // every overlap facet for both the candidate and every compared PR. type PortfolioOverlapResult struct { - Candidate PortfolioSubject `json:"candidate"` - Status string `json:"status"` - Coverage map[string]string `json:"coverage"` - Matches []PortfolioOverlapMatch `json:"matches"` + Candidate PortfolioSubject + status portfolioOverlapStatus + coverage map[string]bool + Matches []PortfolioOverlapMatch +} + +type portfolioOverlapStatus uint8 + +const ( + portfolioOverlapUnknown portfolioOverlapStatus = iota + 1 + portfolioOverlapFound + portfolioNoOverlap +) + +func (s portfolioOverlapStatus) String() string { + switch s { + case portfolioOverlapUnknown: + return "unknown" + case portfolioOverlapFound: + return "overlap" + case portfolioNoOverlap: + return "no_overlap" + default: + return "" + } +} + +// Status returns the stable wire representation of the computed outcome. +func (r PortfolioOverlapResult) Status() string { return r.status.String() } + +// Unknown reports whether incomplete facet coverage prevents a negative result. +func (r PortfolioOverlapResult) Unknown() bool { return r.status == portfolioOverlapUnknown } + +// Coverage returns the stable wire representation of each required facet's +// observed completeness. +func (r PortfolioOverlapResult) Coverage() map[string]string { + coverage := make(map[string]string, len(r.coverage)) + for facet, complete := range r.coverage { + if complete { + coverage[facet] = "complete" + } else { + coverage[facet] = "missing" + } + } + return coverage } // SavePortfolioLink idempotently records an explicit local workflow link. @@ -139,7 +460,7 @@ func (c *Corpus) SavePortfolioLink(ctx context.Context, link PortfolioLink) (*Po if err := c.db.QueryRowContext(ctx, `SELECT kind FROM threads WHERE id=?`, link.PullRequestThreadID).Scan(&kind); err != nil { return nil, fmt.Errorf("resolve portfolio pull request: %w", err) } - if kind != ThreadKindPullRequest { + if kind != string(domain.PullRequestKind) { return nil, errors.New("portfolio link thread is not a pull request") } if link.CreatedAt.IsZero() { @@ -230,7 +551,7 @@ func (c *Corpus) ReplacePortfolioSignals(ctx context.Context, snapshot Portfolio INSERT INTO portfolio_signal_snapshots (subject_kind, subject_ref, facet, source_updated_at, observation_sequence, source_observation_refs, observed_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, snapshot.Subject.Kind, snapshot.Subject.Ref, snapshot.Facet, encodeTime(snapshot.SourceUpdatedAt), snapshot.ObservationSequence, string(refs), encodeTime(snapshot.ObservedAt)) + `, snapshot.Subject.Kind(), snapshot.Subject.Ref(), snapshot.Facet, encodeTime(snapshot.SourceUpdatedAt), snapshot.ObservationSequence, string(refs), encodeTime(snapshot.ObservedAt)) if err != nil { return nil, fmt.Errorf("insert portfolio signal snapshot: %w", err) } @@ -238,11 +559,16 @@ func (c *Corpus) ReplacePortfolioSignals(ctx context.Context, snapshot Portfolio if err != nil { return nil, fmt.Errorf("read portfolio signal snapshot id: %w", err) } - for position, signal := range canonicalPortfolioSignals(snapshot.Signals) { + snapshot.Signals = canonicalPortfolioSignals(snapshot.Signals) + for position, signal := range snapshot.Signals { + targetKind, targetRef := "", "" + if target, ok := signal.Target(); ok { + targetKind, targetRef = target.Kind(), target.Ref() + } if _, err := tx.ExecContext(ctx, ` INSERT INTO portfolio_signals (snapshot_id, position, kind, value, target_kind, target_ref, score) VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?) - `, snapshot.ID, position, signal.Kind, signal.Value, signal.TargetKind, signal.TargetRef, signal.Score); err != nil { + `, snapshot.ID, position, signal.Kind(), signal.Value(), targetKind, targetRef, signal.Score()); err != nil { return nil, fmt.Errorf("insert portfolio signal: %w", err) } } @@ -257,7 +583,7 @@ func (c *Corpus) ReplacePortfolioSignals(ctx context.Context, snapshot Portfolio WHERE portfolio_signal_projections.source_updated_at < excluded.source_updated_at OR (portfolio_signal_projections.source_updated_at = excluded.source_updated_at AND portfolio_signal_projections.observation_sequence < excluded.observation_sequence) - `, snapshot.Subject.Kind, snapshot.Subject.Ref, snapshot.Facet, snapshot.ID, encodeTime(snapshot.SourceUpdatedAt), snapshot.ObservationSequence); err != nil { + `, snapshot.Subject.Kind(), snapshot.Subject.Ref(), snapshot.Facet, snapshot.ID, encodeTime(snapshot.SourceUpdatedAt), snapshot.ObservationSequence); err != nil { return nil, fmt.Errorf("advance portfolio signal projection: %w", err) } if err := tx.Commit(); err != nil { @@ -270,19 +596,19 @@ func validateObservationRefsTx(ctx context.Context, tx *sql.Tx, refs []Observati for _, ref := range refs { var exists int var err error - switch ref.Kind { + switch ref.Kind() { case "thread": - err = tx.QueryRowContext(ctx, `SELECT 1 FROM thread_observations WHERE id=?`, ref.ID).Scan(&exists) + err = tx.QueryRowContext(ctx, `SELECT 1 FROM thread_observations WHERE id=?`, ref.ID()).Scan(&exists) case "facet": - err = tx.QueryRowContext(ctx, `SELECT 1 FROM facet_observations WHERE id=?`, ref.ID).Scan(&exists) + err = tx.QueryRowContext(ctx, `SELECT 1 FROM facet_observations WHERE id=?`, ref.ID()).Scan(&exists) default: - return fmt.Errorf("unsupported source observation kind %q", ref.Kind) + return fmt.Errorf("unsupported source observation kind %q", ref.Kind()) } if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("source observation %s:%d does not exist", ref.Kind, ref.ID) + return fmt.Errorf("source observation %s:%d does not exist", ref.Kind(), ref.ID()) } if err != nil { - return fmt.Errorf("validate source observation %s:%d: %w", ref.Kind, ref.ID, err) + return fmt.Errorf("validate source observation %s:%d: %w", ref.Kind(), ref.ID(), err) } } return nil @@ -304,36 +630,20 @@ func validatePortfolioSnapshot(snapshot PortfolioSignalSnapshot) error { return errors.New("portfolio signal source time and observation refs are required") } for _, ref := range snapshot.SourceObservationRefs { - if strings.TrimSpace(ref.Kind) == "" || ref.ID <= 0 { + if !ref.valid() { return errors.New("invalid portfolio source observation reference") } } for _, signal := range snapshot.Signals { - if signal.Kind != wantKind { - return fmt.Errorf("signal kind %q does not belong to facet %q", signal.Kind, snapshot.Facet) - } - if strings.TrimSpace(signal.Value) == "" && signal.Kind != PortfolioSignalOpportunitySimilarity { - return errors.New("portfolio signal value is required") - } - if signal.Kind == PortfolioSignalOpportunitySimilarity && (signal.TargetKind != PortfolioSubjectPullRequest || signal.TargetRef == "" || signal.Score < 0 || signal.Score > 1) { - return errors.New("opportunity similarity requires a pull request target and score between zero and one") + if signal.Kind() != wantKind { + return fmt.Errorf("signal kind %q does not belong to facet %q", signal.Kind(), snapshot.Facet) } } return nil } func validatePortfolioSubject(subject PortfolioSubject) error { - if strings.TrimSpace(subject.Ref) == "" { - return errors.New("portfolio subject reference is required") - } - switch subject.Kind { - case PortfolioSubjectPullRequest: - id, err := strconv.ParseInt(subject.Ref, 10, 64) - if err != nil || id <= 0 { - return errors.New("pull request subject reference must be a positive corpus thread id") - } - case PortfolioSubjectOpportunity, PortfolioSubjectWorkspace: - default: + if !subject.valid() { return errors.New("unknown portfolio subject kind") } return nil @@ -341,27 +651,23 @@ func validatePortfolioSubject(subject PortfolioSubject) error { func canonicalPortfolioSignals(signals []PortfolioSignal) []PortfolioSignal { out := append([]PortfolioSignal(nil), signals...) - for i := range out { - out[i].Value = strings.TrimSpace(out[i].Value) - if out[i].Kind == PortfolioSignalFilePath { - out[i].Value = path.Clean(strings.ReplaceAll(out[i].Value, `\`, "/")) - } - } sort.SliceStable(out, func(i, j int) bool { a, b := out[i], out[j] - if a.Kind != b.Kind { - return a.Kind < b.Kind + if a.Kind() != b.Kind() { + return a.Kind() < b.Kind() } - if a.Value != b.Value { - return a.Value < b.Value + if a.Value() != b.Value() { + return a.Value() < b.Value() } - if a.TargetKind != b.TargetKind { - return a.TargetKind < b.TargetKind + aTarget, _ := a.Target() + bTarget, _ := b.Target() + if aTarget.Kind() != bTarget.Kind() { + return aTarget.Kind() < bTarget.Kind() } - if a.TargetRef != b.TargetRef { - return a.TargetRef < b.TargetRef + if aTarget.Ref() != bTarget.Ref() { + return aTarget.Ref() < bTarget.Ref() } - return a.Score < b.Score + return a.Score() < b.Score() }) return out } @@ -379,7 +685,7 @@ func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, FROM portfolio_signal_projections p JOIN portfolio_signal_snapshots s ON s.id=p.snapshot_id WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? - `, subject.Kind, subject.Ref, facet).Scan(&refs) + `, subject.Kind(), subject.Ref(), facet).Scan(&refs) if errors.Is(err, sql.ErrNoRows) { return out, nil } @@ -396,7 +702,7 @@ func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, JOIN portfolio_signals s ON s.snapshot_id=p.snapshot_id WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? ORDER BY s.position - `, subject.Kind, subject.Ref, facet) + `, subject.Kind(), subject.Ref(), facet) if err != nil { return projectedPortfolioSignals{}, err } @@ -407,10 +713,15 @@ func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, } }() for rows.Next() { - var signal PortfolioSignal - if err := rows.Scan(&signal.Kind, &signal.Value, &signal.TargetKind, &signal.TargetRef, &signal.Score); err != nil { + var kind, value, targetKind, targetRef string + var score float64 + if err := rows.Scan(&kind, &value, &targetKind, &targetRef, &score); err != nil { return out, err } + signal, err := parsePortfolioSignal(kind, value, targetKind, targetRef, score) + if err != nil { + return out, fmt.Errorf("parse stored portfolio signal: %w", err) + } out.signals = append(out.signals, signal) } if err := rows.Err(); err != nil { @@ -422,7 +733,7 @@ func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, // ListPullRequestIssueLinks returns a bounded, deterministic offline view of // authoritative closing-issue relationships for stored pull requests. It // performs one corpus query and preserves selected-thread ordering. -func (c *Corpus) ListPullRequestIssueLinks(ctx context.Context, repoID int64, state string, limit int) (out []PullRequestIssueLinks, capped bool, err error) { +func (c *Corpus) ListPullRequestIssueLinks(ctx context.Context, repoID int64, state ThreadStateFilter, limit int) (out []PullRequestIssueLinks, capped bool, err error) { if repoID <= 0 { return nil, false, errors.New("repository id must be positive") } @@ -430,10 +741,10 @@ func (c *Corpus) ListPullRequestIssueLinks(ctx context.Context, repoID int64, st return nil, false, errors.New("pull request issue-link limit must be between 1 and 10000") } stateFilter := "" - args := []any{repoID, ThreadKindPullRequest} - if state != "" && state != "all" { + args := []any{repoID, domain.PullRequestKind} + if !state.IsAny() { stateFilter = " AND state = ?" - args = append(args, state) + args = append(args, state.String()) } args = append(args, limit+1, PortfolioSubjectPullRequest, PortfolioFacetLinkedIssues) rows, err := c.db.QueryContext(ctx, ` @@ -526,8 +837,8 @@ func (c *Corpus) findCandidateOverlaps(ctx context.Context, candidate PortfolioS if err := validatePortfolioSubject(candidate); err != nil { return PortfolioOverlapResult{}, err } - result := PortfolioOverlapResult{Candidate: candidate, Status: "unknown", Coverage: make(map[string]string)} - candidateFacets, allCovered, err := c.loadCandidateFacets(ctx, candidate, result.Coverage) + result := PortfolioOverlapResult{Candidate: candidate, status: portfolioOverlapUnknown, coverage: make(map[string]bool)} + candidateFacets, allCovered, err := c.loadCandidateFacets(ctx, candidate, result.coverage) if err != nil { return PortfolioOverlapResult{}, err } @@ -539,14 +850,14 @@ func (c *Corpus) findCandidateOverlaps(ctx context.Context, candidate PortfolioS allCovered = allCovered && covered } if len(result.Matches) > 0 { - result.Status = "overlap" + result.status = portfolioOverlapFound } else if allCovered { - result.Status = "no_overlap" + result.status = portfolioNoOverlap } return result, nil } -func (c *Corpus) loadCandidateFacets(ctx context.Context, candidate PortfolioSubject, coverage map[string]string) (map[string]projectedPortfolioSignals, bool, error) { +func (c *Corpus) loadCandidateFacets(ctx context.Context, candidate PortfolioSubject, coverage map[string]bool) (map[string]projectedPortfolioSignals, bool, error) { facets := make(map[string]projectedPortfolioSignals) allCovered := true for _, facet := range requiredPortfolioFacets(candidate) { @@ -555,14 +866,17 @@ func (c *Corpus) loadCandidateFacets(ctx context.Context, candidate PortfolioSub return nil, false, err } facets[facet] = projected - coverage["candidate."+facet] = coverageStatus(projected.covered) + coverage["candidate."+facet] = projected.covered allCovered = allCovered && projected.covered } return facets, allCovered, nil } func (c *Corpus) comparePortfolioPullRequest(ctx context.Context, candidate PortfolioSubject, candidateFacets map[string]projectedPortfolioSignals, prID int64, result *PortfolioOverlapResult) (bool, error) { - pr := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(prID, 10)} + pr, err := NewPullRequestPortfolioSubject(prID) + if err != nil { + return false, err + } evidence, err := c.explicitPortfolioEvidence(ctx, candidate, prID) if err != nil { return false, err @@ -573,7 +887,7 @@ func (c *Corpus) comparePortfolioPullRequest(ctx context.Context, candidate Port if err != nil { return false, err } - result.Coverage["pull_request."+pr.Ref+"."+facet] = coverageStatus(projected.covered) + result.coverage["pull_request."+pr.Ref()+"."+facet] = projected.covered allCovered = allCovered && projected.covered evidence = append(evidence, overlapEvidence(candidate, pr, candidateFacets[facet], projected)...) } @@ -591,15 +905,8 @@ func (c *Corpus) comparePortfolioPullRequest(ctx context.Context, candidate Port return allCovered, nil } -func coverageStatus(covered bool) string { - if covered { - return "complete" - } - return "missing" -} - func requiredPortfolioFacets(subject PortfolioSubject) []string { - if subject.Kind == PortfolioSubjectPullRequest { + if subject.Kind() == PortfolioSubjectPullRequest { return []string{PortfolioFacetChangedFiles, PortfolioFacetLinkedIssues} } return portfolioFacets @@ -607,7 +914,7 @@ func requiredPortfolioFacets(subject PortfolioSubject) []string { func (c *Corpus) explicitPortfolioLink(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) (*PortfolioOverlapEvidence, error) { column := "" - switch candidate.Kind { + switch candidate.Kind() { case PortfolioSubjectOpportunity: column = "opportunity_id" case PortfolioSubjectWorkspace: @@ -616,14 +923,18 @@ func (c *Corpus) explicitPortfolioLink(ctx context.Context, candidate PortfolioS return nil, errPortfolioLinkNotApplicable } var linkID int64 - err := c.db.QueryRowContext(ctx, `SELECT id FROM portfolio_links WHERE pull_request_thread_id=? AND `+column+`=? ORDER BY id LIMIT 1`, pullRequestThreadID, candidate.Ref).Scan(&linkID) + err := c.db.QueryRowContext(ctx, `SELECT id FROM portfolio_links WHERE pull_request_thread_id=? AND `+column+`=? ORDER BY id LIMIT 1`, pullRequestThreadID, candidate.Ref()).Scan(&linkID) if errors.Is(err, sql.ErrNoRows) { return nil, errPortfolioLinkNotFound } if err != nil { return nil, fmt.Errorf("read explicit portfolio link: %w", err) } - return &PortfolioOverlapEvidence{Kind: "explicit_link", Value: candidate.Ref + "->" + strconv.FormatInt(pullRequestThreadID, 10), SourceObservationRefs: []ObservationRef{{Kind: "portfolio_link", ID: linkID}}}, nil + ref, err := newPortfolioLinkObservationRef(linkID) + if err != nil { + return nil, err + } + return &PortfolioOverlapEvidence{Kind: "explicit_link", Value: candidate.Ref() + "->" + strconv.FormatInt(pullRequestThreadID, 10), SourceObservationRefs: []ObservationRef{ref}}, nil } func (c *Corpus) explicitPortfolioEvidence(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) ([]PortfolioOverlapEvidence, error) { @@ -641,25 +952,21 @@ func overlapEvidence(candidate, pr PortfolioSubject, candidateSignals, prSignals var out []PortfolioOverlapEvidence values := make(map[string]struct{}, len(prSignals.signals)) for _, signal := range prSignals.signals { - values[signal.Kind+"\x00"+signal.Value] = struct{}{} + values[signal.Kind()+"\x00"+signal.Value()] = struct{}{} } for _, signal := range candidateSignals.signals { - switch signal.Kind { + switch signal.Kind() { case PortfolioSignalFilePath, PortfolioSignalLinkedIssue: - if _, ok := values[signal.Kind+"\x00"+signal.Value]; ok { - out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind, Value: signal.Value, SourceObservationRefs: mergeObservationRefs(candidateSignals.refs, prSignals.refs)}) + if _, ok := values[signal.Kind()+"\x00"+signal.Value()]; ok { + out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: signal.Value(), SourceObservationRefs: mergeObservationRefs(candidateSignals.refs, prSignals.refs)}) } case PortfolioSignalOpportunitySimilarity: - if signal.TargetKind == pr.Kind && signal.TargetRef == pr.Ref { - out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind, Value: candidate.Ref + "->" + pr.Ref, Score: signal.Score, SourceObservationRefs: candidateSignals.refs}) + target, _ := signal.Target() + if target == pr { + out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: candidate.Ref() + "->" + pr.Ref(), Score: signal.Score(), SourceObservationRefs: candidateSignals.refs}) } } } - for _, signal := range prSignals.signals { - if signal.Kind == PortfolioSignalOpportunitySimilarity && signal.TargetKind == candidate.Kind && signal.TargetRef == candidate.Ref { - out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind, Value: pr.Ref + "->" + candidate.Ref, Score: signal.Score, SourceObservationRefs: prSignals.refs}) - } - } return out } @@ -676,10 +983,10 @@ func mergeObservationRefs(first, second []ObservationRef) []ObservationRef { } } sort.Slice(out, func(i, j int) bool { - if out[i].Kind != out[j].Kind { - return out[i].Kind < out[j].Kind + if out[i].Kind() != out[j].Kind() { + return out[i].Kind() < out[j].Kind() } - return out[i].ID < out[j].ID + return out[i].ID() < out[j].ID() }) return out } diff --git a/internal/corpus/portfolio_relationships_test.go b/internal/corpus/portfolio_relationships_test.go index 3c750ee7..fe4357a9 100644 --- a/internal/corpus/portfolio_relationships_test.go +++ b/internal/corpus/portfolio_relationships_test.go @@ -2,12 +2,61 @@ package corpus import ( "context" - "strconv" + "encoding/json" "strings" "testing" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) +func TestPortfolioSubjectCanonicalizesBoundaryIdentity(t *testing.T) { + t.Parallel() + subject, err := ParsePortfolioSubject(" pull_request ", " 001 ") + if err != nil { + t.Fatal(err) + } + if subject.Kind() != PortfolioSubjectPullRequest || subject.Ref() != "1" { + t.Fatalf("subject = %s:%s", subject.Kind(), subject.Ref()) + } + encoded, err := json.Marshal(subject) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"kind":"pull_request","ref":"1"}` { + t.Fatalf("encoded subject = %s", encoded) + } + var decoded PortfolioSubject + if err := json.Unmarshal([]byte(`{"kind":"opportunity","ref":" opp-1 "}`), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Kind() != PortfolioSubjectOpportunity || decoded.Ref() != "opp-1" { + t.Fatalf("decoded subject = %s:%s", decoded.Kind(), decoded.Ref()) + } + if _, err := ParsePortfolioSubject(PortfolioSubjectPullRequest, "0"); err == nil { + t.Fatal("expected invalid pull-request identity to be rejected") + } +} + +func TestPortfolioSignalVariantsRejectMixedRepresentations(t *testing.T) { + t.Parallel() + pathSignal, err := NewPortfolioFilePathSignal(` internal\store\record.go `) + if err != nil { + t.Fatal(err) + } + if pathSignal.Kind() != PortfolioSignalFilePath || pathSignal.Value() != "internal/store/record.go" { + t.Fatalf("path signal = %s:%s", pathSignal.Kind(), pathSignal.Value()) + } + var mixed PortfolioSignal + if err := json.Unmarshal([]byte(`{"kind":"file_path","value":"main.go","target_kind":"pull_request","target_ref":"1"}`), &mixed); err == nil { + t.Fatal("expected mixed scalar and target signal to be rejected") + } + opportunity := mustPortfolioSubject(t, PortfolioSubjectOpportunity, "opp-1") + if _, err := NewPortfolioOpportunitySimilaritySignal(opportunity, 0.8); err == nil { + t.Fatal("expected similarity target outside the pull-request domain to be rejected") + } +} + func TestPortfolioLinksAreExplicitAndDeterministic(t *testing.T) { t.Parallel() ctx := context.Background() @@ -38,11 +87,11 @@ func TestPortfolioLinksAreExplicitAndDeterministic(t *testing.T) { if len(links) != 1 || links[0].PullRequestThreadID != prID || links[0].OpportunityID != "opp-1" || links[0].WorkspaceID != "ws-1" { t.Fatalf("links = %#v", links) } - results, err := c.FindPortfolioOverlaps(ctx, []PortfolioSubject{{Kind: PortfolioSubjectOpportunity, Ref: "opp-1"}}, []int64{prID}) + results, err := c.FindPortfolioOverlaps(ctx, []PortfolioSubject{mustPortfolioSubject(t, PortfolioSubjectOpportunity, "opp-1")}, []int64{prID}) if err != nil { t.Fatal(err) } - if len(results) != 1 || results[0].Status != "overlap" || len(results[0].Matches) != 1 || results[0].Matches[0].Evidence[0].Kind != "explicit_link" { + if len(results) != 1 || results[0].Status() != "overlap" || len(results[0].Matches) != 1 || results[0].Matches[0].Evidence[0].Kind != "explicit_link" { t.Fatalf("explicit link overlap = %#v", results) } } @@ -53,9 +102,9 @@ func TestPortfolioSignalsRejectMissingSourceObservation(t *testing.T) { c, _ := openTestCorpus(t) prID := insertPortfolioFixture(t, ctx, c) _, err := c.ReplacePortfolioSignals(ctx, PortfolioSignalSnapshot{ - Subject: PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(prID, 10)}, Facet: PortfolioFacetChangedFiles, - Signals: []PortfolioSignal{{Kind: PortfolioSignalFilePath, Value: "main.go"}}, SourceUpdatedAt: time.Unix(300, 0).UTC(), - SourceObservationRefs: []ObservationRef{{Kind: "facet", ID: 999999}}, + Subject: mustPullRequestPortfolioSubject(t, prID), Facet: PortfolioFacetChangedFiles, + Signals: []PortfolioSignal{mustPortfolioFilePathSignal(t, "main.go")}, SourceUpdatedAt: time.Unix(300, 0).UTC(), + SourceObservationRefs: []ObservationRef{mustObservationRef(t, "facet", 999999)}, }) if err == nil || !strings.Contains(err.Error(), "does not exist") { t.Fatalf("missing observation error = %v", err) @@ -67,25 +116,25 @@ func TestFindPortfolioOverlapsUsesOnlyCoveredObservedSignals(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) prID := insertPortfolioFixture(t, ctx, c) - pr := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(prID, 10)} - candidate := PortfolioSubject{Kind: PortfolioSubjectOpportunity, Ref: "opp-1"} - unknown := PortfolioSubject{Kind: PortfolioSubjectOpportunity, Ref: "opp-missing"} + pr := mustPullRequestPortfolioSubject(t, prID) + candidate := mustPortfolioSubject(t, PortfolioSubjectOpportunity, "opp-1") + unknown := mustPortfolioSubject(t, PortfolioSubjectOpportunity, "opp-missing") newer := time.Unix(300, 0).UTC() replacePortfolioFixture(t, ctx, c, candidate, PortfolioFacetChangedFiles, newer, - PortfolioSignal{Kind: PortfolioSignalFilePath, Value: `internal\\store\\record.go`}) + mustPortfolioFilePathSignal(t, `internal\\store\\record.go`)) replacePortfolioFixture(t, ctx, c, candidate, PortfolioFacetLinkedIssues, newer, - PortfolioSignal{Kind: PortfolioSignalLinkedIssue, Value: "owner/repo#7"}) + mustPortfolioLinkedIssueSignal(t, "owner/repo#7")) replacePortfolioFixture(t, ctx, c, candidate, PortfolioFacetOpportunitySimilarity, newer, - PortfolioSignal{Kind: PortfolioSignalOpportunitySimilarity, TargetKind: PortfolioSubjectPullRequest, TargetRef: pr.Ref, Score: 0.86}) + mustPortfolioSimilaritySignal(t, pr, 0.86)) replacePortfolioFixture(t, ctx, c, pr, PortfolioFacetChangedFiles, newer, - PortfolioSignal{Kind: PortfolioSignalFilePath, Value: "internal/store/record.go"}) + mustPortfolioFilePathSignal(t, "internal/store/record.go")) replacePortfolioFixture(t, ctx, c, pr, PortfolioFacetLinkedIssues, newer) // The stale snapshot remains immutable history but cannot replace the newer // path projection used by offline overlap reads. replacePortfolioFixture(t, ctx, c, candidate, PortfolioFacetChangedFiles, newer.Add(-time.Hour), - PortfolioSignal{Kind: PortfolioSignalFilePath, Value: "unrelated.go"}) + mustPortfolioFilePathSignal(t, "unrelated.go")) results, err := c.FindPortfolioOverlaps(ctx, []PortfolioSubject{candidate, unknown}, []int64{prID}) if err != nil { @@ -94,19 +143,19 @@ func TestFindPortfolioOverlapsUsesOnlyCoveredObservedSignals(t *testing.T) { if len(results) != 2 || results[0].Candidate != candidate || results[1].Candidate != unknown { t.Fatalf("input ordering not preserved: %#v", results) } - if results[0].Status != "overlap" || len(results[0].Matches) != 1 { + if results[0].Status() != "overlap" || len(results[0].Matches) != 1 { t.Fatalf("covered overlap = %#v", results[0]) } evidence := results[0].Matches[0].Evidence if len(evidence) != 2 || evidence[0].Kind != PortfolioSignalFilePath || evidence[0].Value != "internal/store/record.go" || evidence[1].Kind != PortfolioSignalOpportunitySimilarity { t.Fatalf("overlap evidence = %#v", evidence) } - if results[1].Status != "unknown" || results[1].Coverage["candidate.changed_files"] != "missing" { + if results[1].Status() != "unknown" || results[1].Coverage()["candidate.changed_files"] != "missing" { t.Fatalf("missing coverage converted to no-overlap: %#v", results[1]) } var snapshots int - if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM portfolio_signal_snapshots WHERE subject_kind=? AND subject_ref=? AND facet=?`, candidate.Kind, candidate.Ref, PortfolioFacetChangedFiles).Scan(&snapshots); err != nil { + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM portfolio_signal_snapshots WHERE subject_kind=? AND subject_ref=? AND facet=?`, candidate.Kind(), candidate.Ref(), PortfolioFacetChangedFiles).Scan(&snapshots); err != nil { t.Fatalf("count signal snapshots: %v", err) } if snapshots != 2 { @@ -119,8 +168,8 @@ func TestFindPortfolioOverlapsRequiresCompleteNegativeCoverage(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) prID := insertPortfolioFixture(t, ctx, c) - pr := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(prID, 10)} - candidate := PortfolioSubject{Kind: PortfolioSubjectOpportunity, Ref: "opp-1"} + pr := mustPullRequestPortfolioSubject(t, prID) + candidate := mustPortfolioSubject(t, PortfolioSubjectOpportunity, "opp-1") at := time.Unix(300, 0).UTC() for _, facet := range portfolioFacets { replacePortfolioFixture(t, ctx, c, candidate, facet, at) @@ -132,7 +181,7 @@ func TestFindPortfolioOverlapsRequiresCompleteNegativeCoverage(t *testing.T) { if err != nil { t.Fatalf("find portfolio overlaps: %v", err) } - if results[0].Status != "no_overlap" { + if results[0].Status() != "no_overlap" { t.Fatalf("fully covered negative = %#v", results[0]) } } @@ -146,12 +195,12 @@ func TestFindPortfolioOverlapsPullRequestNegativeDoesNotRequireSimilarityFacet(t if err := c.db.QueryRowContext(ctx, `SELECT repository_id FROM threads WHERE id=?`, firstID).Scan(&repositoryID); err != nil { t.Fatal(err) } - second, err := c.ApplyThreadObservation(ctx, repositoryID, ThreadKindPullRequest, 4, "open", "second PR", "body", "author", time.Unix(201, 0).UTC(), `{}`) + second, err := c.ApplyThreadObservation(ctx, repositoryID, domain.PullRequestKind, 4, "open", "second PR", "body", "author", time.Unix(201, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } - first := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(firstID, 10)} - secondSubject := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(second.ID, 10)} + first := mustPullRequestPortfolioSubject(t, firstID) + secondSubject := mustPullRequestPortfolioSubject(t, second.ID) for _, subject := range []PortfolioSubject{first, secondSubject} { replacePortfolioFixture(t, ctx, c, subject, PortfolioFacetChangedFiles, time.Unix(300, 0).UTC()) replacePortfolioFixture(t, ctx, c, subject, PortfolioFacetLinkedIssues, time.Unix(300, 0).UTC()) @@ -160,7 +209,7 @@ func TestFindPortfolioOverlapsPullRequestNegativeDoesNotRequireSimilarityFacet(t if err != nil { t.Fatal(err) } - if len(results) != 1 || results[0].Status != "no_overlap" { + if len(results) != 1 || results[0].Status() != "no_overlap" { t.Fatalf("pull-request no-overlap = %#v", results) } } @@ -174,17 +223,17 @@ func TestListPullRequestIssueLinksDistinguishesCoveredEmptyAndBoundsPopulation(t if err := c.db.QueryRowContext(ctx, `SELECT repository_id FROM threads WHERE id=?`, firstID).Scan(&repoID); err != nil { t.Fatal(err) } - second, err := c.ApplyThreadObservation(ctx, repoID, ThreadKindPullRequest, 4, "open", "newer", "body", "author", time.Unix(201, 0).UTC(), `{}`) + second, err := c.ApplyThreadObservation(ctx, repoID, domain.PullRequestKind, 4, "open", "newer", "body", "author", time.Unix(201, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } - first := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(firstID, 10)} - secondSubject := PortfolioSubject{Kind: PortfolioSubjectPullRequest, Ref: strconv.FormatInt(second.ID, 10)} + first := mustPullRequestPortfolioSubject(t, firstID) + secondSubject := mustPullRequestPortfolioSubject(t, second.ID) replacePortfolioFixture(t, ctx, c, first, PortfolioFacetLinkedIssues, time.Unix(300, 0).UTC(), - PortfolioSignal{Kind: PortfolioSignalLinkedIssue, Value: "owner/repo#7"}) + mustPortfolioLinkedIssueSignal(t, "owner/repo#7")) replacePortfolioFixture(t, ctx, c, secondSubject, PortfolioFacetLinkedIssues, time.Unix(301, 0).UTC()) - links, capped, err := c.ListPullRequestIssueLinks(ctx, repoID, "open", 2) + links, capped, err := c.ListPullRequestIssueLinks(ctx, repoID, OpenThreadState(), 2) if err != nil { t.Fatal(err) } @@ -194,7 +243,7 @@ func TestListPullRequestIssueLinksDistinguishesCoveredEmptyAndBoundsPopulation(t if links[1].Number != 3 || !links[1].Covered || len(links[1].LinkedIssues) != 1 || links[1].LinkedIssues[0] != "owner/repo#7" { t.Fatalf("linked projection = %+v", links[1]) } - bounded, capped, err := c.ListPullRequestIssueLinks(ctx, repoID, "open", 1) + bounded, capped, err := c.ListPullRequestIssueLinks(ctx, repoID, OpenThreadState(), 1) if err != nil || !capped || len(bounded) != 1 || bounded[0].Number != 4 { t.Fatalf("bounded = %+v, capped=%v, err=%v", bounded, capped, err) } @@ -209,10 +258,64 @@ func replacePortfolioFixture(t *testing.T, ctx context.Context, c *Corpus, subje } if _, err := c.ReplacePortfolioSignals(ctx, PortfolioSignalSnapshot{ Subject: subject, Facet: facet, Signals: signals, SourceUpdatedAt: at, - SourceObservationRefs: []ObservationRef{{Kind: "thread", ID: observationID}}, + SourceObservationRefs: []ObservationRef{mustObservationRef(t, "thread", observationID)}, }); err != nil { - t.Fatalf("replace %s/%s signals: %v", subject.Ref, facet, err) + t.Fatalf("replace %s/%s signals: %v", subject.Ref(), facet, err) + } +} + +func mustPortfolioSubject(t *testing.T, kind, ref string) PortfolioSubject { + t.Helper() + subject, err := ParsePortfolioSubject(kind, ref) + if err != nil { + t.Fatal(err) + } + return subject +} + +func mustPullRequestPortfolioSubject(t *testing.T, threadID int64) PortfolioSubject { + t.Helper() + subject, err := NewPullRequestPortfolioSubject(threadID) + if err != nil { + t.Fatal(err) + } + return subject +} + +func mustPortfolioFilePathSignal(t *testing.T, value string) PortfolioSignal { + t.Helper() + signal, err := NewPortfolioFilePathSignal(value) + if err != nil { + t.Fatal(err) + } + return signal +} + +func mustPortfolioLinkedIssueSignal(t *testing.T, value string) PortfolioSignal { + t.Helper() + signal, err := NewPortfolioLinkedIssueSignal(value) + if err != nil { + t.Fatal(err) + } + return signal +} + +func mustPortfolioSimilaritySignal(t *testing.T, target PortfolioSubject, score float64) PortfolioSignal { + t.Helper() + signal, err := NewPortfolioOpportunitySimilaritySignal(target, score) + if err != nil { + t.Fatal(err) + } + return signal +} + +func mustObservationRef(t *testing.T, kind string, id int64) ObservationRef { + t.Helper() + ref, err := ParseObservationRef(kind, id) + if err != nil { + t.Fatal(err) } + return ref } //nolint:revive // Test helpers conventionally put *testing.T first. @@ -222,7 +325,7 @@ func insertPortfolioFixture(t *testing.T, ctx context.Context, c *Corpus) int64 if err != nil { t.Fatalf("insert repository: %v", err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindPullRequest, 3, "open", "PR", "body", "author", time.Unix(200, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.PullRequestKind, 3, "open", "PR", "body", "author", time.Unix(200, 0).UTC(), `{}`) if err != nil { t.Fatalf("insert pull request: %v", err) } diff --git a/internal/corpus/portfolio_test.go b/internal/corpus/portfolio_test.go index fe99b222..433b8a71 100644 --- a/internal/corpus/portfolio_test.go +++ b/internal/corpus/portfolio_test.go @@ -18,7 +18,7 @@ func TestPortfolioReadRejectsInvalidStoredThreadProjection(t *testing.T) { t.Fatal(err) } thread, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, State: string(domain.OpenState), + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 1, State: domain.OpenState, Title: "invalid projection fixture", SourceCreatedAt: now, SourceUpdatedAt: now, }, `{}`) if err != nil { @@ -27,7 +27,7 @@ func TestPortfolioReadRejectsInvalidStoredThreadProjection(t *testing.T) { if _, err := c.db.ExecContext(ctx, `UPDATE threads SET state = 'invented' WHERE id = ?`, thread.ID); err != nil { t.Fatal(err) } - if _, err := c.ListPullRequestPortfolioPage(ctx, "", "all", nil, 10); err == nil { + if _, err := c.ListPullRequestPortfolioPage(ctx, "", AnyThreadState(), nil, 10); err == nil { t.Fatal("portfolio read accepted an invalid stored thread projection") } } diff --git a/internal/corpus/precedent_test.go b/internal/corpus/precedent_test.go index 8115b6c8..e2564c23 100644 --- a/internal/corpus/precedent_test.go +++ b/internal/corpus/precedent_test.go @@ -18,9 +18,9 @@ func TestLoadPrecedentRepositoriesGroupsSourcesByRepository(t *testing.T) { t.Fatal(err) } for _, thread := range []Thread{ - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "open", Title: "source one", SourceUpdatedAt: time.Unix(3, 0)}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 2, State: "open", Title: "source two", SourceUpdatedAt: time.Unix(2, 0)}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "closed", Title: "history", SourceUpdatedAt: time.Unix(1, 0)}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "source one", SourceUpdatedAt: time.Unix(3, 0)}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 2, State: "open", Title: "source two", SourceUpdatedAt: time.Unix(2, 0)}, + {RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "history", SourceUpdatedAt: time.Unix(1, 0)}, } { if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) diff --git a/internal/corpus/projections.go b/internal/corpus/projections.go index ba64e009..d06b88db 100644 --- a/internal/corpus/projections.go +++ b/internal/corpus/projections.go @@ -10,6 +10,7 @@ import ( "fmt" "hash" "strconv" + "strings" "time" ) @@ -22,6 +23,25 @@ const ( ProjectionNamePullRequestFeedbackFTS = "pull_request_feedback_fts" ) +// ParseProjectionStatus converts durable text into a supported projection +// lifecycle state. +func ParseProjectionStatus(value string) (ProjectionStatus, error) { + switch ProjectionStatus(strings.TrimSpace(value)) { + case ProjectionStatusAbsent: + return ProjectionStatusAbsent, nil + case ProjectionStatusBuilding: + return ProjectionStatusBuilding, nil + case ProjectionStatusCurrent: + return ProjectionStatusCurrent, nil + case ProjectionStatusStale: + return ProjectionStatusStale, nil + case ProjectionStatusFailed: + return ProjectionStatusFailed, nil + default: + return "", fmt.Errorf("unsupported projection status %q", value) + } +} + // Product-owned versions for derived SQLite search projections. const ( ProjectionVersionThreadsFTS = "threads-fts-v3" @@ -31,6 +51,23 @@ const ( ProjectionVersionPullRequestFeedbackFTS = "pull-request-feedback-fts-v2" ) +// ParseProjectionAttemptStatus converts durable text into a supported rebuild +// attempt state. Empty is the historical representation of no attempt. +func ParseProjectionAttemptStatus(value string) (ProjectionAttemptStatus, error) { + switch ProjectionAttemptStatus(strings.TrimSpace(value)) { + case ProjectionAttemptNone: + return ProjectionAttemptNone, nil + case ProjectionAttemptBuilding: + return ProjectionAttemptBuilding, nil + case ProjectionAttemptSucceeded: + return ProjectionAttemptSucceeded, nil + case ProjectionAttemptFailed: + return ProjectionAttemptFailed, nil + default: + return "", fmt.Errorf("unsupported projection attempt status %q", value) + } +} + // ProjectionStatus describes the durability state of a derived projection. type ProjectionStatus string @@ -122,6 +159,7 @@ func (c *Corpus) RequireFreshProjection(ctx context.Context, name, version strin // GetProjectionState returns the durable state for one derived projection. func (c *Corpus) GetProjectionState(ctx context.Context, name string) (ProjectionState, error) { var state ProjectionState + var status, attemptStatus string var refreshed, attemptStarted, attemptFinished sql.NullInt64 err := c.db.QueryRowContext(ctx, ` SELECT name, version, status, refreshed_at, row_count, @@ -129,8 +167,8 @@ func (c *Corpus) GetProjectionState(ctx context.Context, name string) (Projectio attempt_started_at, attempt_finished_at, attempt_error FROM projection_states WHERE name = ? - `, name).Scan(&state.Name, &state.Version, &state.Status, &refreshed, &state.RowCount, - &state.SourceRevision, &state.ContentHash, &state.AttemptStatus, + `, name).Scan(&state.Name, &state.Version, &status, &refreshed, &state.RowCount, + &state.SourceRevision, &state.ContentHash, &attemptStatus, &attemptStarted, &attemptFinished, &state.AttemptError) if errors.Is(err, sql.ErrNoRows) { if isSearchProjection(name) { @@ -141,6 +179,14 @@ func (c *Corpus) GetProjectionState(ctx context.Context, name string) (Projectio if err != nil { return ProjectionState{}, fmt.Errorf("get projection state %s: %w", name, err) } + state.Status, err = ParseProjectionStatus(status) + if err != nil { + return ProjectionState{}, fmt.Errorf("get projection state %s: %w", name, err) + } + state.AttemptStatus, err = ParseProjectionAttemptStatus(attemptStatus) + if err != nil { + return ProjectionState{}, fmt.Errorf("get projection state %s: %w", name, err) + } setProjectionTimes(&state, refreshed, attemptStarted, attemptFinished) return state, nil } @@ -162,12 +208,21 @@ func (c *Corpus) ListProjectionStates(ctx context.Context) (_ []ProjectionState, var out []ProjectionState for rows.Next() { var state ProjectionState + var status, attemptStatus string var refreshed, attemptStarted, attemptFinished sql.NullInt64 - if err := rows.Scan(&state.Name, &state.Version, &state.Status, &refreshed, &state.RowCount, - &state.SourceRevision, &state.ContentHash, &state.AttemptStatus, + if err := rows.Scan(&state.Name, &state.Version, &status, &refreshed, &state.RowCount, + &state.SourceRevision, &state.ContentHash, &attemptStatus, &attemptStarted, &attemptFinished, &state.AttemptError); err != nil { return nil, err } + state.Status, err = ParseProjectionStatus(status) + if err != nil { + return nil, fmt.Errorf("list projection state %s: %w", state.Name, err) + } + state.AttemptStatus, err = ParseProjectionAttemptStatus(attemptStatus) + if err != nil { + return nil, fmt.Errorf("list projection state %s: %w", state.Name, err) + } setProjectionTimes(&state, refreshed, attemptStarted, attemptFinished) out = append(out, state) } @@ -180,11 +235,6 @@ func (c *Corpus) RebuildThreadSearchProjection(ctx context.Context) (ProjectionS return c.rebuildSearchProjection(ctx, ProjectionNameThreadsFTS, ProjectionVersionThreadsFTS) } -// RebuildRepositorySearchProjection atomically rebuilds repository search. -func (c *Corpus) RebuildRepositorySearchProjection(ctx context.Context) (ProjectionState, error) { - return c.rebuildSearchProjection(ctx, ProjectionNameRepositoriesFTS, ProjectionVersionRepositoriesFTS) -} - // RebuildCodeSearchProjection atomically rebuilds the code_documents_fts index // and advances the durable projection state. It is explicit: search never calls it. func (c *Corpus) RebuildCodeSearchProjection(ctx context.Context) (ProjectionState, error) { diff --git a/internal/corpus/projections_test.go b/internal/corpus/projections_test.go index 99ab7109..24b5828a 100644 --- a/internal/corpus/projections_test.go +++ b/internal/corpus/projections_test.go @@ -54,6 +54,28 @@ func TestGetProjectionStateMissing(t *testing.T) { } } +func TestProjectionReadsRejectCorruptLifecycleValues(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + + if _, err := c.db.ExecContext(ctx, `UPDATE projection_states SET status='invented' WHERE name=?`, ProjectionNameThreadsFTS); err != nil { + t.Fatal(err) + } + if _, err := c.GetProjectionState(ctx, ProjectionNameThreadsFTS); err == nil { + t.Fatal("projection read accepted an invalid stored status") + } + if _, err := c.ListProjectionStates(ctx); err == nil { + t.Fatal("projection list accepted an invalid stored status") + } + if _, err := c.db.ExecContext(ctx, `UPDATE projection_states SET status=?, attempt_status='invented' WHERE name=?`, ProjectionStatusCurrent, ProjectionNameThreadsFTS); err != nil { + t.Fatal(err) + } + if _, err := c.GetProjectionState(ctx, ProjectionNameThreadsFTS); err == nil { + t.Fatal("projection read accepted an invalid stored attempt status") + } +} + func TestGetProjectionStateReportsKnownAbsentProjection(t *testing.T) { t.Parallel() ctx := context.Background() @@ -100,7 +122,7 @@ func TestRebuildThreadSearchProjectionIsAtomicAndSetsState(t *testing.T) { repo, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "id", time.Unix(1, 0).UTC(), `{}`) requireProjectionSetup(t, "apply repository", err) - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "searchable title", "body text", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "searchable title", "body text", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply thread: %v", err) } @@ -152,7 +174,7 @@ func TestRebuildThreadSearchProjectionIsAtomicAndSetsState(t *testing.T) { if state.SourceRevision == "" || state.ContentHash == "" || state.AttemptStatus != ProjectionAttemptSucceeded || state.AttemptFinishedAt.IsZero() { t.Fatalf("rebuild metadata = %+v", state) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "updated title", "body text", "a", time.Unix(3, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "updated title", "body text", "a", time.Unix(3, 0).UTC(), `{}`); err != nil { t.Fatalf("update thread source: %v", err) } changed, err := c.GetProjectionState(ctx, ProjectionNameThreadsFTS) @@ -184,7 +206,7 @@ func TestBuildingAndFailedAttemptKeepLastCompleteProjectionReadable(t *testing.T if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "durable result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "durable result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatal(err) } complete, err := c.RebuildThreadSearchProjection(ctx) @@ -221,7 +243,7 @@ func TestFailedRebuildRollsBackIndexAndPreservesLastCompleteMetadata(t *testing. if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "preserved result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "preserved result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatal(err) } complete, err := c.RebuildThreadSearchProjection(ctx) @@ -270,7 +292,7 @@ func TestCancelledRebuildPreservesLastCompleteProjection(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "cancel-safe result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "cancel-safe result", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatal(err) } complete, err := c.RebuildThreadSearchProjection(ctx) @@ -336,7 +358,7 @@ func TestRebuildCodeSearchProjectionIsAtomicAndSetsState(t *testing.T) { t.Fatalf("row_count = %d, want 2", state.RowCount) } - page, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 10}) + page, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Page: mustSearchPage(t, 10)}) if err != nil { t.Fatalf("search code: %v", err) } @@ -354,7 +376,7 @@ func TestSearchDoesNotSilentlyRebuildProjections(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "rebuild term", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "rebuild term", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply thread: %v", err) } @@ -406,7 +428,7 @@ func TestRebuildThreadSearchProjectionRestoresClearedIndex(t *testing.T) { if err != nil { t.Fatalf("apply repository: %v", err) } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "lost term", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "lost term", "body", "a", time.Unix(2, 0).UTC(), `{}`); err != nil { t.Fatalf("apply thread: %v", err) } diff --git a/internal/corpus/pull_request_feedback.go b/internal/corpus/pull_request_feedback.go index 12bd72ca..597e0965 100644 --- a/internal/corpus/pull_request_feedback.go +++ b/internal/corpus/pull_request_feedback.go @@ -13,17 +13,15 @@ const ( feedbackFacetReviewThreads = "pr_feedback_review_threads" ) -var feedbackChannels = []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"} - -func feedbackFacetForChannel(channel string) string { +func feedbackFacet(channel FeedbackChannel) string { switch channel { - case "issue_comments": + case FeedbackIssueComments: return feedbackFacetIssueComments - case "submitted_reviews": + case FeedbackSubmittedReviews: return feedbackFacetReviews - case "inline_comments": + case FeedbackInlineComments: return feedbackFacetInlineComments - case "review_threads": + case FeedbackReviewThreads: return feedbackFacetReviewThreads default: return "" @@ -51,19 +49,36 @@ func feedbackChannelForFacet(facet string) string { type FeedbackDiscovery struct { RepositoryID int64 Generation int64 - State string NextPage int - Complete bool - Truncated bool + State FeedbackDiscoveryState DiscoveredPullRequests int Requests int - Channels []string - ThreadState string + Selection FeedbackSelection LastError string SourceUpdatedAt time.Time UpdatedAt time.Time } +// FeedbackDiscoveryState is the mutually exclusive state of repository-wide +// pull-request discovery. +type FeedbackDiscoveryState string + +const ( + FeedbackDiscoveryPending FeedbackDiscoveryState = "" + FeedbackDiscoveryComplete FeedbackDiscoveryState = "complete" + FeedbackDiscoveryTruncated FeedbackDiscoveryState = "truncated" +) + +// IsComplete reports whether repository-wide discovery reached its last page. +func (d FeedbackDiscovery) IsComplete() bool { return d.State == FeedbackDiscoveryComplete } + +// IsTruncated reports whether discovery stopped with a resumable next page. +func (d FeedbackDiscovery) IsTruncated() bool { return d.State == FeedbackDiscoveryTruncated } + +func (s FeedbackDiscoveryState) valid() bool { + return s == FeedbackDiscoveryPending || s == FeedbackDiscoveryComplete || s == FeedbackDiscoveryTruncated +} + // PullRequestFeedbackProjection is one normalized, queryable feedback item. // The raw facet observation remains canonical; this row is rebuildable. type PullRequestFeedbackProjection struct { @@ -99,26 +114,6 @@ type PullRequestFeedbackProjection struct { SourceObservationSequence int64 } -// FeedbackSearchFilter scopes an offline normalized feedback search. -type FeedbackSearchFilter struct { - RepositoryID int64 - FeedbackAuthor string - PullRequestAuthor string - State string - Merged string - ThreadState string - Channel string - Text string - CreatedAfter time.Time - CreatedBefore time.Time - UpdatedAfter time.Time - UpdatedBefore time.Time - Sort string - Order string - Limit int - Cursor string -} - type FeedbackSearchPage struct { Items []PullRequestFeedbackProjection UnknownMergePullRequests []int @@ -128,10 +123,42 @@ type FeedbackSearchPage struct { Coverage FeedbackCoverageSummary } +// FeedbackCoverageState is the complete set of valid relationships between +// repository discovery and per-pull-request facet coverage. +type FeedbackCoverageState uint8 + +const ( + FeedbackCoverageUnknown FeedbackCoverageState = iota + FeedbackCoveragePartialDiscovery + FeedbackCoveragePartialFacets + FeedbackCoverageComplete +) + type FeedbackCoverageSummary struct { - Status string - DiscoveryComplete bool + State FeedbackCoverageState IncompletePRs int TotalPullRequests int Channels []string } + +// Status returns the stable wire status derived from the coverage state. +func (c FeedbackCoverageSummary) Status() string { + switch c.State { + case FeedbackCoverageComplete: + return "complete" + case FeedbackCoveragePartialDiscovery, FeedbackCoveragePartialFacets: + return "partial" + default: + return "unknown" + } +} + +// DiscoveryComplete reports whether repository discovery covers the requested +// channel and thread-state selection. +func (c FeedbackCoverageSummary) DiscoveryComplete() bool { + return c.State == FeedbackCoveragePartialFacets || c.State == FeedbackCoverageComplete +} + +// Complete reports whether both discovery and every requested facet are +// complete. +func (c FeedbackCoverageSummary) Complete() bool { return c.State == FeedbackCoverageComplete } diff --git a/internal/corpus/pull_request_feedback_coverage.go b/internal/corpus/pull_request_feedback_coverage.go index d064bd37..9b814407 100644 --- a/internal/corpus/pull_request_feedback_coverage.go +++ b/internal/corpus/pull_request_feedback_coverage.go @@ -8,44 +8,44 @@ import ( "fmt" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) -func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryID int64, selectedChannel, threadState string) (FeedbackCoverageSummary, error) { - coverage := FeedbackCoverageSummary{Status: "unknown", Channels: append([]string(nil), feedbackChannels...)} - var discovery FeedbackDiscovery - var channelsJSON string - var complete, truncated int - var source, updated int64 - err := 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).Scan(&discovery.RepositoryID, &discovery.Generation, &discovery.State, &discovery.NextPage, &complete, &truncated, &discovery.DiscoveredPullRequests, &discovery.Requests, &channelsJSON, &discovery.ThreadState, &discovery.LastError, &source, &updated) +func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryID int64, selectedChannel FeedbackChannel, threadState feedbackThreadState) (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) { return coverage, nil } if err != nil { return coverage, err } - discovery.Complete, discovery.Truncated = complete != 0, truncated != 0 - discovery.SourceUpdatedAt, discovery.UpdatedAt = scanTime(source), scanTime(updated) - if err := json.Unmarshal([]byte(channelsJSON), &discovery.Channels); err != nil { - return coverage, err - } - if selectedChannel != "" { - coverage.Channels = []string{selectedChannel} + if selectedChannel != 0 { + coverage.Channels = []string{selectedChannel.String()} } var total int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM threads WHERE repository_id=? AND kind=?`, repositoryID, ThreadKindPullRequest).Scan(&total); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM threads WHERE repository_id=? AND kind=?`, repositoryID, domain.PullRequestKind).Scan(&total); err != nil { return coverage, err } coverage.TotalPullRequests = total - coverage.DiscoveryComplete = discovery.Complete + discoveryComplete := discovery.IsComplete() + if (threadState.isAny() || threadState.isResolved()) && discovery.Selection.threadState != AllFeedbackThreads { + discoveryComplete = false + } channels := coverage.Channels var incomplete int predicates := make([]string, 0, len(channels)) - coverageArgs := []any{repositoryID, ThreadKindPullRequest} - for _, channel := range channels { - predicate, args := feedbackCoveragePredicate(channel, threadState) - if predicate == "" { - continue + coverageArgs := []any{repositoryID, domain.PullRequestKind} + for _, value := range channels { + channel, err := ParseFeedbackChannel(value) + if err != nil { + return coverage, err + } + if !discovery.Selection.Includes(channel) { + discoveryComplete = false } + predicate, args := feedbackCoveragePredicate(channel, threadState) predicates = append(predicates, predicate) coverageArgs = append(coverageArgs, args...) } @@ -56,10 +56,14 @@ func (c *Corpus) feedbackCoverageTx(ctx context.Context, tx *sql.Tx, repositoryI } } coverage.IncompletePRs = incomplete - if discovery.Complete && incomplete == 0 { - coverage.Status = "complete" - } else if discovery.Complete || discovery.DiscoveredPullRequests > 0 { - coverage.Status = "partial" + if discoveryComplete && incomplete == 0 { + coverage.State = FeedbackCoverageComplete + } else if discovery.IsComplete() || discovery.DiscoveredPullRequests > 0 { + if discoveryComplete { + coverage.State = FeedbackCoveragePartialFacets + } else { + coverage.State = FeedbackCoveragePartialDiscovery + } } return coverage, nil } @@ -75,15 +79,25 @@ func (c *Corpus) ListPullRequestsWithIncompleteFeedback(ctx context.Context, rep return nil, errors.New("incomplete feedback limit cannot exceed 1000") } if len(channels) == 0 { - channels = feedbackChannels + channels = AllFeedbackSelection().Channels() + } + parsedThreadState, err := parseFeedbackThreadState(threadState) + if err != nil { + return nil, err } var predicates []string - args := []any{repositoryID, ThreadKindPullRequest} - for _, channel := range channels { - predicate, predicateArgs := feedbackCoveragePredicate(channel, threadState) - if predicate == "" { - continue + args := []any{repositoryID, domain.PullRequestKind} + seen := make(map[FeedbackChannel]struct{}, len(channels)) + for _, value := range channels { + channel, err := ParseFeedbackChannel(value) + if err != nil { + return nil, err } + if _, exists := seen[channel]; exists { + return nil, fmt.Errorf("duplicate feedback channel %q", value) + } + seen[channel] = struct{}{} + predicate, predicateArgs := feedbackCoveragePredicate(channel, parsedThreadState) predicates = append(predicates, predicate) args = append(args, predicateArgs...) } @@ -103,16 +117,13 @@ func (c *Corpus) ListPullRequestsWithIncompleteFeedback(ctx context.Context, rep return scanThreads(rows) } -func feedbackCoveragePredicate(channel, threadState string) (string, []any) { - facet := feedbackFacetForChannel(channel) - if facet == "" { - return "", nil - } +func feedbackCoveragePredicate(channel FeedbackChannel, threadState feedbackThreadState) (string, []any) { + facet := feedbackFacet(channel) predicate := "NOT EXISTS (SELECT 1 FROM facet_coverage fc WHERE fc.thread_id=t.id AND fc.facet=? AND fc.complete=1" args := []any{facet} - if channel == "review_threads" { + if channel == FeedbackReviewThreads { selections := []string{"all"} - if threadState == "unresolved" { + if !threadState.isAny() && !threadState.isResolved() { selections = append(selections, "unresolved") } placeholders := sqlPlaceholders(len(selections)) @@ -132,19 +143,19 @@ func feedbackCoveragePredicate(channel, threadState string) (string, []any) { // UpsertFeedbackDiscovery persists a checkpoint and the coverage fact used by // offline search. It is deliberately idempotent for a replayed provider page. func (c *Corpus) UpsertFeedbackDiscovery(ctx context.Context, value FeedbackDiscovery) error { - if value.RepositoryID == 0 { + if value.RepositoryID <= 0 { return errors.New("feedback discovery requires a repository") } - if value.NextPage < 1 { - value.NextPage = 1 + if !value.Selection.Valid() { + return errors.New("feedback discovery requires a parsed selection") } - if value.State == "" { - value.State = "all" + if !value.State.valid() { + return fmt.Errorf("feedback discovery has unsupported state %q", value.State) } - if value.ThreadState == "" { - value.ThreadState = "all" + if value.NextPage < 1 { + value.NextPage = 1 } - channels, err := json.Marshal(value.Channels) + channels, err := json.Marshal(value.Selection.Channels()) if err != nil { return fmt.Errorf("encode feedback discovery channels: %w", err) } @@ -166,8 +177,8 @@ 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, value.State, value.NextPage, boolToInt(value.Complete), boolToInt(value.Truncated), - value.DiscoveredPullRequests, value.Requests, string(channels), value.ThreadState, value.LastError, + `, value.RepositoryID, value.Generation, "all", 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 { return fmt.Errorf("upsert feedback discovery: %w", err) @@ -177,33 +188,58 @@ func (c *Corpus) UpsertFeedbackDiscovery(ctx context.Context, value FeedbackDisc } else if affected == 0 { return nil } - if err := c.AdvanceFacet(ctx, value.RepositoryID, nil, "pull_request_feedback_discovery", value.SourceUpdatedAt, value.Complete, 0); err != nil { + if err := c.AdvanceFacet(ctx, value.RepositoryID, nil, "pull_request_feedback_discovery", value.SourceUpdatedAt, value.IsComplete(), 0); err != nil { return fmt.Errorf("advance feedback discovery coverage: %w", err) } return nil } func (c *Corpus) GetFeedbackDiscovery(ctx context.Context, repositoryID int64) (*FeedbackDiscovery, error) { - var value FeedbackDiscovery - var channels string - var complete, truncated int - var source, updated int64 - err := c.db.QueryRowContext(ctx, ` + value, err := scanFeedbackDiscovery(c.db.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).Scan(&value.RepositoryID, &value.Generation, &value.State, &value.NextPage, &complete, &truncated, - &value.DiscoveredPullRequests, &value.Requests, &channels, &value.ThreadState, &value.LastError, &source, &updated) + `, repositoryID)) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get feedback discovery: %w", err) } - value.Complete, value.Truncated = complete != 0, truncated != 0 - value.SourceUpdatedAt, value.UpdatedAt = scanTime(source), scanTime(updated) - if err := json.Unmarshal([]byte(channels), &value.Channels); err != nil { + return value, nil +} + +func scanFeedbackDiscovery(row rowScanner) (*FeedbackDiscovery, error) { + var value FeedbackDiscovery + var state, channels, threadState string + var complete, truncated int + var source, updated int64 + if err := row.Scan(&value.RepositoryID, &value.Generation, &state, &value.NextPage, &complete, &truncated, + &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) + } + var channelValues []string + if err := json.Unmarshal([]byte(channels), &channelValues); err != nil { return nil, fmt.Errorf("decode feedback discovery channels: %w", err) } + selection, err := ParseFeedbackSelection(channelValues, threadState) + if err != nil { + return nil, fmt.Errorf("parse stored feedback discovery selection: %w", err) + } + value.Selection = selection + switch { + case complete == 1 && truncated == 0: + value.State = FeedbackDiscoveryComplete + case complete == 0 && truncated == 1: + value.State = FeedbackDiscoveryTruncated + case complete == 0 && truncated == 0: + value.State = FeedbackDiscoveryPending + default: + return nil, fmt.Errorf("parse stored feedback discovery: invalid complete/truncated state %d/%d", complete, truncated) + } + value.SourceUpdatedAt, value.UpdatedAt = scanTime(source), scanTime(updated) return &value, nil } diff --git a/internal/corpus/pull_request_feedback_search.go b/internal/corpus/pull_request_feedback_search.go index a1b487dc..b1d0d736 100644 --- a/internal/corpus/pull_request_feedback_search.go +++ b/internal/corpus/pull_request_feedback_search.go @@ -59,82 +59,39 @@ func decodeFeedbackCursor(value, filter string) (int, error) { // SearchPullRequestFeedback performs a deterministic local read over the // normalized feedback projection. It never refreshes facets or contacts GitHub. -func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, filter FeedbackSearchFilter) (FeedbackSearchPage, error) { - if filter.Limit == 0 { - filter.Limit = 20 - } - if filter.Limit < 1 || filter.Limit > 100 { - return FeedbackSearchPage{}, errors.New("feedback search limit must be between 1 and 100") - } - if filter.State == "" { - filter.State = "all" - } - if filter.State != "open" && filter.State != "closed" && filter.State != "all" { - return FeedbackSearchPage{}, errors.New("feedback search state must be open, closed, or all") - } - if filter.Merged == "" { - filter.Merged = "any" - } - if filter.Merged != "true" && filter.Merged != "false" && filter.Merged != "unknown" && filter.Merged != "any" { - return FeedbackSearchPage{}, errors.New("feedback search merged must be true, false, unknown, or any") - } - if filter.ThreadState == "" { - filter.ThreadState = "all" - } - if filter.ThreadState != "resolved" && filter.ThreadState != "unresolved" && filter.ThreadState != "all" { - return FeedbackSearchPage{}, errors.New("feedback search thread_state must be resolved, unresolved, or all") - } - if filter.Sort == "" { - filter.Sort = "updated" - } - switch filter.Sort { - case "feedback_author", "pull_request_state", "merge_state", "created", "updated", "pull_request_number": - default: - return FeedbackSearchPage{}, errors.New("feedback search sort is unsupported") - } - if filter.Order == "" { - filter.Order = "desc" - } - if filter.Order != "asc" && filter.Order != "desc" { - return FeedbackSearchPage{}, errors.New("feedback search order must be asc or desc") - } - if filter.Channel != "" && !validFeedbackChannel(filter.Channel) { - return FeedbackSearchPage{}, fmt.Errorf("unsupported feedback channel %q", filter.Channel) - } +func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, request FeedbackSearchRequest) (FeedbackSearchPage, error) { + query := request.query if err := c.RequireFreshProjection(ctx, ProjectionNamePullRequestFeedbackFTS, ProjectionVersionPullRequestFeedbackFTS); err != nil { return FeedbackSearchPage{}, err } filterKeyBytes, _ := json.Marshal(feedbackSearchFilterKey{ - RepositoryID: filter.RepositoryID, FeedbackAuthor: filter.FeedbackAuthor, - PullRequestAuthor: filter.PullRequestAuthor, State: filter.State, Merged: filter.Merged, - ThreadState: filter.ThreadState, Channel: filter.Channel, Text: filter.Text, - CreatedAfter: encodeTime(filter.CreatedAfter), CreatedBefore: encodeTime(filter.CreatedBefore), - UpdatedAfter: encodeTime(filter.UpdatedAfter), UpdatedBefore: encodeTime(filter.UpdatedBefore), - Sort: filter.Sort, Order: filter.Order, Limit: filter.Limit, + RepositoryID: request.repositoryID, FeedbackAuthor: query.feedbackAuthor, + PullRequestAuthor: query.pullRequestAuthor, State: query.state.String(), Merged: query.merge.BooleanString(), + ThreadState: query.threadState.String(), Channel: query.channel.String(), Text: query.text, + CreatedAfter: encodeTime(query.createdAfter), CreatedBefore: encodeTime(query.createdBefore), + UpdatedAfter: encodeTime(query.updatedAfter), UpdatedBefore: encodeTime(query.updatedBefore), + Sort: query.sort.String(), Order: query.order.String(), Limit: query.page.Limit(), }) - offset, err := decodeFeedbackCursor(filter.Cursor, string(filterKeyBytes)) + offset, err := decodeFeedbackCursor(query.page.Cursor(), string(filterKeyBytes)) if err != nil { return FeedbackSearchPage{}, err } - where, args, ftsQuery := feedbackSearchWhere(filter, true) + where, args, ftsQuery := feedbackSearchWhere(request) joinFTS := "" if ftsQuery != "" { joinFTS = " JOIN pull_request_feedback_fts ON pull_request_feedback_fts.rowid = p.id" args = append([]any{ftsQuery}, args...) } from := ` FROM pull_request_feedback_projection p JOIN threads t ON t.id = p.thread_id` + joinFTS - orderExpr := feedbackSortExpression(filter.Sort) - direction := "ASC" - if filter.Order == "desc" { - direction = "DESC" - } + orderExpr := query.sort.expression() + direction := query.order.sqlDirection() statement := `SELECT p.id, p.repository_id, p.thread_id, t.number, t.author, t.state, t.merged, t.merged_known, p.channel, p.feedback_id, p.feedback_node_id, p.thread_external_id, p.in_reply_to_id, p.author, p.body, p.path, p.line, p.start_line, p.side, p.start_side, p.commit_oid, p.review_state, p.created_at, p.updated_at, p.resolved_known, p.resolved, p.resolved_by, p.outdated, p.head_sha, p.source_updated_at, p.source_observation_sequence, p.source_observation_id` + from + where + ` ORDER BY ` + orderExpr + ` ` + direction + `, p.id ` + direction + ` LIMIT ? OFFSET ?` queryArgs := append([]any(nil), args...) - queryArgs = append(queryArgs, filter.Limit+1, offset) + queryArgs = append(queryArgs, query.page.Limit()+1, offset) tx, err := c.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return FeedbackSearchPage{}, fmt.Errorf("begin feedback search: %w", err) @@ -155,8 +112,10 @@ func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, filter FeedbackS return FeedbackSearchPage{}, fmt.Errorf("count pull-request feedback: %w", err) } page := FeedbackSearchPage{Items: items, Total: total} - if filter.Merged == "true" || filter.Merged == "false" { - unknownWhere, unknownArgs, unknownFTSQuery := feedbackSearchWhere(filter, false) + if query.merge.IsMerged() || query.merge.IsUnmerged() { + unknownRequest := request + unknownRequest.query.merge = AnyMergeState() + unknownWhere, unknownArgs, unknownFTSQuery := feedbackSearchWhere(unknownRequest) unknownWhere += " AND t.merged_known = 0" if unknownFTSQuery != "" { unknownArgs = append([]any{unknownFTSQuery}, unknownArgs...) @@ -177,12 +136,12 @@ func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, filter FeedbackS return FeedbackSearchPage{}, fmt.Errorf("iterate unknown pull-request merge states: %w", err) } } - if len(items) > filter.Limit { - page.Items = items[:filter.Limit] - page.NextCursor = encodeFeedbackCursor(feedbackSearchCursor{Scope: "pull_request_feedback", Filter: string(filterKeyBytes), Offset: offset + filter.Limit}) + if len(items) > query.page.Limit() { + page.Items = items[:query.page.Limit()] + 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, filter.RepositoryID, filter.Channel, filter.ThreadState) + page.Coverage, err = c.feedbackCoverageTx(ctx, tx, request.repositoryID, query.channel, query.threadState) if err != nil { return FeedbackSearchPage{}, err } @@ -195,7 +154,7 @@ func (c *Corpus) SearchPullRequestFeedback(ctx context.Context, filter FeedbackS // GetPullRequestFeedbackItem returns one exact normalized feedback record. // It is an offline read over the same projection used by search; callers do // not need to parse a raw facet payload to follow a search match. -func (c *Corpus) GetPullRequestFeedbackItem(ctx context.Context, repositoryID int64, number int, channel, feedbackID string) (*PullRequestFeedbackProjection, error) { +func (c *Corpus) GetPullRequestFeedbackItem(ctx context.Context, repositoryID int64, number int, channel FeedbackChannel, feedbackID string) (*PullRequestFeedbackProjection, error) { if err := c.RequireFreshProjection(ctx, ProjectionNamePullRequestFeedbackFTS, ProjectionVersionPullRequestFeedbackFTS); err != nil { return nil, err } @@ -205,7 +164,7 @@ func (c *Corpus) GetPullRequestFeedbackItem(ctx context.Context, repositoryID in p.source_observation_sequence, p.source_observation_id FROM pull_request_feedback_projection p JOIN threads t ON t.id = p.thread_id WHERE p.repository_id = ? AND t.number = ? AND p.channel = ? AND p.feedback_id = ? - ORDER BY p.id LIMIT 1`, repositoryID, number, channel, feedbackID) + ORDER BY p.id LIMIT 1`, repositoryID, number, channel.String(), feedbackID) if err != nil { return nil, fmt.Errorf("get pull-request feedback item: %w", err) } @@ -220,89 +179,65 @@ func (c *Corpus) GetPullRequestFeedbackItem(ctx context.Context, repositoryID in return &items[0], nil } -func validFeedbackChannel(value string) bool { - return feedbackFacetForChannel(value) != "" -} - -func feedbackSearchWhere(filter FeedbackSearchFilter, applyMerge bool) (string, []any, string) { +func feedbackSearchWhere(request FeedbackSearchRequest) (string, []any, string) { + query := request.query where := " WHERE 1=1" args := make([]any, 0, 12) - if filter.RepositoryID != 0 { - where += " AND p.repository_id = ?" - args = append(args, filter.RepositoryID) - } - if filter.FeedbackAuthor != "" { + where += " AND p.repository_id = ?" + args = append(args, request.repositoryID) + if query.feedbackAuthor != "" { where += " AND lower(p.author) = lower(?)" - args = append(args, filter.FeedbackAuthor) + args = append(args, query.feedbackAuthor) } - if filter.PullRequestAuthor != "" { + if query.pullRequestAuthor != "" { where += " AND lower(t.author) = lower(?)" - args = append(args, filter.PullRequestAuthor) + args = append(args, query.pullRequestAuthor) } - if filter.State != "all" { + if !query.state.IsAny() { where += " AND t.state = ?" - args = append(args, filter.State) - } - if applyMerge { - switch filter.Merged { - case "true": - where += " AND t.merged_known = 1 AND t.merged = 1" - case "false": - where += " AND t.merged_known = 1 AND t.merged = 0" - case "unknown": - where += " AND t.merged_known = 0" - } + args = append(args, query.state.String()) + } + switch { + case query.merge.IsMerged(): + where += " AND t.merged_known = 1 AND t.merged = 1" + case query.merge.IsUnmerged(): + where += " AND t.merged_known = 1 AND t.merged = 0" + case query.merge.IsUnknown(): + where += " AND t.merged_known = 0" } - if filter.ThreadState == "resolved" { + if query.threadState.isResolved() { where += " AND p.resolved_known = 1 AND p.resolved = 1" } - if filter.ThreadState == "unresolved" { + if !query.threadState.isAny() && !query.threadState.isResolved() { where += " AND p.resolved_known = 1 AND p.resolved = 0" } - if filter.Channel != "" { + if query.channel != 0 { where += " AND p.channel = ?" - args = append(args, filter.Channel) + args = append(args, query.channel.String()) } - if !filter.CreatedAfter.IsZero() { + if !query.createdAfter.IsZero() { where += " AND p.created_at >= ?" - args = append(args, encodeTime(filter.CreatedAfter)) + args = append(args, encodeTime(query.createdAfter)) } - if !filter.CreatedBefore.IsZero() { + if !query.createdBefore.IsZero() { where += " AND p.created_at <= ?" - args = append(args, encodeTime(filter.CreatedBefore)) + args = append(args, encodeTime(query.createdBefore)) } - if !filter.UpdatedAfter.IsZero() { + if !query.updatedAfter.IsZero() { where += " AND p.updated_at >= ?" - args = append(args, encodeTime(filter.UpdatedAfter)) + args = append(args, encodeTime(query.updatedAfter)) } - if !filter.UpdatedBefore.IsZero() { + if !query.updatedBefore.IsZero() { where += " AND p.updated_at <= ?" - args = append(args, encodeTime(filter.UpdatedBefore)) + args = append(args, encodeTime(query.updatedBefore)) } - ftsQuery := literalFTSQueryMode(filter.Text, "all") + ftsQuery := literalFTSQueryMode(query.text, MatchAllTerms()) if ftsQuery != "" { where = " WHERE pull_request_feedback_fts MATCH ?" + strings.TrimPrefix(where, " WHERE 1=1") } return where, args, ftsQuery } -func feedbackSortExpression(sort string) string { - switch sort { - case "feedback_author": - return "lower(p.author)" - case "pull_request_state": - return "t.state" - case "merge_state": - return "CASE WHEN t.merged_known = 0 THEN 0 WHEN t.merged = 0 THEN 1 ELSE 2 END" - case "created": - return "p.created_at" - case "pull_request_number": - return "t.number" - default: - return "p.updated_at" - } -} - func scanFeedbackProjectionRows(rows *sql.Rows) ([]PullRequestFeedbackProjection, error) { var out []PullRequestFeedbackProjection for rows.Next() { diff --git a/internal/corpus/pull_request_feedback_test.go b/internal/corpus/pull_request_feedback_test.go index 71acea94..da48b79b 100644 --- a/internal/corpus/pull_request_feedback_test.go +++ b/internal/corpus/pull_request_feedback_test.go @@ -10,6 +10,28 @@ import ( "github.com/morluto/gitcontribute/internal/domain" ) +func mustFeedbackSearch(t *testing.T, repositoryID int64, input FeedbackSearchInput) FeedbackSearchRequest { + t.Helper() + query, err := ParseFeedbackSearchQuery(input) + if err != nil { + t.Fatalf("parse feedback search: %v", err) + } + request, err := query.InRepository(repositoryID) + if err != nil { + t.Fatalf("bind feedback search: %v", err) + } + return request +} + +func mustFeedbackSelection(t *testing.T, channels []string, threadState string) FeedbackSelection { + t.Helper() + selection, err := ParseFeedbackSelection(channels, threadState) + if err != nil { + t.Fatalf("parse feedback selection: %v", err) + } + return selection +} + func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { t.Parallel() ctx := context.Background() @@ -19,7 +41,7 @@ func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { if err != nil { t.Fatal(err) } - pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 7, State: "closed", Author: "submitter", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: now}, `{}`) + pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 7, State: "closed", Author: "submitter", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -38,20 +60,20 @@ func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, repo.ID, &pr.ID, feedbackFacetReviewThreads, now, []FacetObservationInput{{SourceUpdatedAt: now, Payload: completePayload(`[{"id":"thread-7","resolved":true,"resolved_by":"maintainer","path":"main.go","line":12,"comments":[{"id":14,"node_id":"PRT_node","in_reply_to_id":13,"author":"reviewer","body":"thread body","created_at":"2026-07-31T10:05:00Z"}]}]`)}}, true, 0); err != nil { t.Fatal(err) } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, State: "all", NextPage: 1, Complete: true, DiscoveredPullRequests: 1, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: now}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, NextPage: 1, State: FeedbackDiscoveryComplete, DiscoveredPullRequests: 1, Selection: AllFeedbackSelection(), SourceUpdatedAt: now}); err != nil { t.Fatal(err) } if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { t.Fatal(err) } - page, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Text: "latency", FeedbackAuthor: "alice", Merged: "true", State: "closed", Limit: 10}) + page, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Text: "latency", FeedbackAuthor: "alice", Merged: "true", State: "closed", Limit: 10})) if err != nil { t.Fatal(err) } - if len(page.Items) != 1 || page.Items[0].FeedbackID != "11" || page.Items[0].PullRequestNumber != 7 || !page.Items[0].PullRequestMerge.IsMerged() || page.Coverage.Status != "complete" { + if len(page.Items) != 1 || page.Items[0].FeedbackID != "11" || page.Items[0].PullRequestNumber != 7 || !page.Items[0].PullRequestMerge.IsMerged() || !page.Coverage.Complete() { t.Fatalf("feedback page = %+v", page) } - exact, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, FeedbackAuthor: "reviewer", Limit: 10}) + exact, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{FeedbackAuthor: "reviewer", Limit: 10})) if err != nil { t.Fatal(err) } @@ -74,7 +96,7 @@ func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, repo.ID, &pr.ID, feedbackFacetIssueComments, now.Add(time.Hour), []FacetObservationInput{{SourceUpdatedAt: now.Add(time.Hour), Payload: completePayload(`[{"id":12,"author":"alice","body":"new feedback"}]`)}}, true, 0); err != nil { t.Fatal(err) } - if _, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Limit: 10}); !errors.Is(err, ErrProjectionStale) { + if _, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Limit: 10})); !errors.Is(err, ErrProjectionStale) { t.Fatalf("search after raw feedback replacement error = %v, want ErrProjectionStale", err) } } @@ -88,7 +110,7 @@ func TestPullRequestFeedbackIncompleteRefreshPreservesCompleteProjection(t *test if err != nil { t.Fatal(err) } - pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 8, State: "open", SourceUpdatedAt: first}, `{}`) + pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 8, State: "open", SourceUpdatedAt: first}, `{}`) if err != nil { t.Fatal(err) } @@ -101,7 +123,7 @@ func TestPullRequestFeedbackIncompleteRefreshPreservesCompleteProjection(t *test t.Fatal(err) } } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, State: "all", NextPage: 1, Complete: true, DiscoveredPullRequests: 1, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: first}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, NextPage: 1, State: FeedbackDiscoveryComplete, DiscoveredPullRequests: 1, Selection: AllFeedbackSelection(), SourceUpdatedAt: first}); err != nil { t.Fatal(err) } if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { @@ -114,11 +136,11 @@ func TestPullRequestFeedbackIncompleteRefreshPreservesCompleteProjection(t *test if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { t.Fatal(err) } - page, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Text: "old", Channel: "issue_comments", Limit: 10}) + page, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Text: "old", Channel: "issue_comments", Limit: 10})) if err != nil { t.Fatal(err) } - if len(page.Items) != 1 || page.Items[0].Body != "old review" || page.Coverage.Status != "partial" || page.Coverage.IncompletePRs != 1 { + if len(page.Items) != 1 || page.Items[0].Body != "old review" || page.Coverage.Status() != "partial" || page.Coverage.IncompletePRs != 1 { t.Fatalf("preserved feedback page = %+v", page) } } @@ -132,7 +154,7 @@ func TestPullRequestFeedbackCoverageRespectsThreadSelection(t *testing.T) { if err != nil { t.Fatal(err) } - pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 9, State: "open", SourceUpdatedAt: now}, `{}`) + pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 9, State: "open", SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -145,38 +167,70 @@ func TestPullRequestFeedbackCoverageRespectsThreadSelection(t *testing.T) { t.Fatal(err) } } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, State: "all", NextPage: 1, Complete: true, DiscoveredPullRequests: 1, Channels: feedbackChannels, ThreadState: "unresolved", SourceUpdatedAt: now}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, NextPage: 1, State: FeedbackDiscoveryComplete, DiscoveredPullRequests: 1, Selection: mustFeedbackSelection(t, AllFeedbackSelection().Channels(), "unresolved"), SourceUpdatedAt: now}); err != nil { t.Fatal(err) } if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { t.Fatal(err) } - unresolved, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "unresolved", Limit: 10}) + unresolved, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "review_threads", ThreadState: "unresolved", Limit: 10})) if err != nil { t.Fatal(err) } - if len(unresolved.Items) != 1 || unresolved.Coverage.Status != "complete" { + if len(unresolved.Items) != 1 || !unresolved.Coverage.Complete() { t.Fatalf("unresolved search = %+v", unresolved) } - all, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "all", Limit: 10}) + all, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "review_threads", ThreadState: "all", Limit: 10})) if err != nil { t.Fatal(err) } - if all.Coverage.Status != "partial" || all.Coverage.IncompletePRs != 1 { + if all.Coverage.Status() != "partial" || all.Coverage.IncompletePRs != 1 { t.Fatalf("all-thread coverage = %+v", all.Coverage) } - resolved, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "resolved", Limit: 10}) + resolved, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "review_threads", ThreadState: "resolved", Limit: 10})) if err != nil { t.Fatal(err) } - if len(resolved.Items) != 0 || resolved.Coverage.Status != "partial" || resolved.Coverage.IncompletePRs != 1 { + if len(resolved.Items) != 0 || resolved.Coverage.Status() != "partial" || resolved.Coverage.IncompletePRs != 1 { t.Fatalf("resolved-thread coverage = %+v", resolved) } } +func TestPullRequestFeedbackCoverageDoesNotOverclaimUnselectedEmptyChannel(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + now := time.Date(2026, 7, 31, 11, 30, 0, 0, time.UTC) + repo, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "empty", SourceUpdatedAt: now}, `{}`) + if err != nil { + t.Fatal(err) + } + selection := mustFeedbackSelection(t, []string{"issue_comments"}, "all") + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, NextPage: 1, State: FeedbackDiscoveryComplete, Selection: selection, SourceUpdatedAt: now}); err != nil { + t.Fatal(err) + } + if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { + t.Fatal(err) + } + selected, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "issue_comments"})) + if err != nil { + t.Fatal(err) + } + if !selected.Coverage.Complete() || !selected.Coverage.DiscoveryComplete() { + t.Fatalf("selected channel coverage = %+v", selected.Coverage) + } + unselected, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "submitted_reviews"})) + if err != nil { + t.Fatal(err) + } + if unselected.Coverage.Status() != "partial" || unselected.Coverage.DiscoveryComplete() { + t.Fatalf("unselected channel coverage = %+v", unselected.Coverage) + } +} + func TestFeedbackDiscoveryDoesNotRegressCheckpoint(t *testing.T) { t.Parallel() ctx := context.Background() @@ -186,31 +240,59 @@ func TestFeedbackDiscoveryDoesNotRegressCheckpoint(t *testing.T) { if err != nil { t.Fatal(err) } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, State: "all", NextPage: 4, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: first}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, NextPage: 4, Selection: AllFeedbackSelection(), SourceUpdatedAt: first}); err != nil { t.Fatal(err) } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, State: "all", NextPage: 2, Complete: true, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: first.Add(time.Minute)}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 1, NextPage: 2, State: FeedbackDiscoveryComplete, Selection: AllFeedbackSelection(), SourceUpdatedAt: first.Add(time.Minute)}); err != nil { t.Fatal(err) } got, err := c.GetFeedbackDiscovery(ctx, repo.ID) if err != nil { t.Fatal(err) } - if got == nil || got.Generation != 1 || got.NextPage != 4 || got.Complete { + if got == nil || got.Generation != 1 || got.NextPage != 4 || got.IsComplete() { t.Fatalf("discovery checkpoint = %+v, want page 4 incomplete", got) } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 2, State: "all", NextPage: 1, Complete: true, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: first.Add(2 * time.Minute)}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, Generation: 2, NextPage: 1, State: FeedbackDiscoveryComplete, Selection: AllFeedbackSelection(), SourceUpdatedAt: first.Add(2 * time.Minute)}); err != nil { t.Fatal(err) } got, err = c.GetFeedbackDiscovery(ctx, repo.ID) if err != nil { t.Fatal(err) } - if got == nil || got.Generation != 2 || got.NextPage != 1 || !got.Complete { + if got == nil || got.Generation != 2 || got.NextPage != 1 || !got.IsComplete() { t.Fatalf("new discovery generation = %+v, want page 1 complete", got) } } +func TestFeedbackDiscoveryRejectsImpossibleState(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + repo, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "state", SourceUpdatedAt: now}, `{}`) + if err != nil { + t.Fatal(err) + } + invalid := FeedbackDiscovery{ + RepositoryID: repo.ID, State: "impossible", Selection: AllFeedbackSelection(), SourceUpdatedAt: now, + } + if err := c.UpsertFeedbackDiscovery(ctx, invalid); err == nil { + t.Fatal("unsupported in-memory discovery state was accepted") + } + valid := FeedbackDiscovery{ + RepositoryID: repo.ID, State: FeedbackDiscoveryComplete, Selection: AllFeedbackSelection(), SourceUpdatedAt: now, + } + if err := c.UpsertFeedbackDiscovery(ctx, valid); err != nil { + t.Fatal(err) + } + if _, err := c.db.ExecContext(ctx, `UPDATE pull_request_feedback_discovery SET complete=1, truncated=1 WHERE repository_id=?`, repo.ID); err != nil { + t.Fatal(err) + } + if _, err := c.GetFeedbackDiscovery(ctx, repo.ID); err == nil { + t.Fatal("contradictory stored discovery flags were accepted") + } +} + func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { t.Parallel() ctx := context.Background() @@ -222,7 +304,7 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { } type pullRequestCase struct { number int - state string + state domain.ThreadState mergedKnown bool merged bool feedbackID int @@ -243,7 +325,7 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { merge = domain.MergedStatus(time.Time{}) } } - thread, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: value.number, State: value.state, Author: fmt.Sprintf("pr-author-%d", value.number), Merge: merge, SourceUpdatedAt: at}, `{}`) + thread, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: value.number, State: value.state, Author: fmt.Sprintf("pr-author-%d", value.number), Merge: merge, SourceUpdatedAt: at}, `{}`) if err != nil { t.Fatal(err) } @@ -265,21 +347,21 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { t.Fatal(err) } } - if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, State: "all", NextPage: 1, Complete: true, DiscoveredPullRequests: 3, Channels: feedbackChannels, ThreadState: "all", SourceUpdatedAt: now}); err != nil { + if err := c.UpsertFeedbackDiscovery(ctx, FeedbackDiscovery{RepositoryID: repo.ID, NextPage: 1, State: FeedbackDiscoveryComplete, DiscoveredPullRequests: 3, Selection: AllFeedbackSelection(), SourceUpdatedAt: now}); err != nil { t.Fatal(err) } if _, err := c.RebuildPullRequestFeedbackProjection(ctx); err != nil { t.Fatal(err) } - first, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Sort: "feedback_author", Order: "asc", Limit: 1}) + first, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Sort: "feedback_author", Order: "asc", Limit: 1})) if err != nil { t.Fatal(err) } if first.Total != 4 || len(first.Items) != 1 || first.Items[0].Author != "alice" || first.Items[0].PullRequestNumber != 1 || first.NextCursor == "" { t.Fatalf("first sorted page = %+v", first) } - second, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Sort: "feedback_author", Order: "asc", Limit: 1, Cursor: first.NextCursor}) + second, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Sort: "feedback_author", Order: "asc", Limit: 1, Cursor: first.NextCursor})) if err != nil { t.Fatal(err) } @@ -287,7 +369,7 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { t.Fatalf("second sorted page = %+v", second) } - filtered, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, State: "closed", Merged: "false", Text: "resolved body", Limit: 10}) + filtered, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{State: "closed", Merged: "false", Text: "resolved body", Limit: 10})) if err != nil { t.Fatal(err) } @@ -298,14 +380,14 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { if filtered.Items[0].Channel != "review_threads" || !known || !resolved { t.Fatalf("filtered feedback = %+v", filtered) } - unknown, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Merged: "true", Text: "latency discussion", Limit: 10}) + unknown, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Merged: "true", Text: "latency discussion", Limit: 10})) if err != nil { t.Fatal(err) } if len(unknown.Items) != 0 || len(unknown.UnknownMergePullRequests) != 1 || unknown.UnknownMergePullRequests[0] != 1 { t.Fatalf("unknown merge candidates = %+v", unknown) } - resolvedPage, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "resolved", Limit: 10}) + resolvedPage, err := c.SearchPullRequestFeedback(ctx, mustFeedbackSearch(t, repo.ID, FeedbackSearchInput{Channel: "review_threads", ThreadState: "resolved", Limit: 10})) if err != nil { t.Fatal(err) } diff --git a/internal/corpus/read_snapshot.go b/internal/corpus/read_snapshot.go index 1d9f34ed..8adfb8e8 100644 --- a/internal/corpus/read_snapshot.go +++ b/internal/corpus/read_snapshot.go @@ -19,13 +19,59 @@ var ( ) type SnapshotMaterialization struct { - Kind string - Scope any - SourceManifest any - DerivedVersions any - Completeness any - Provenance any - Payload any + kind string + scope json.RawMessage + sourceManifest json.RawMessage + derivedVersions json.RawMessage + completeness json.RawMessage + provenance json.RawMessage + payload json.RawMessage +} + +// NewSnapshotMaterialization serializes one typed workflow result before it +// reaches the storage transaction. Downstream token and artifact logic only +// accepts this encoded form, so it cannot accidentally mix fields from +// different loose maps or discover an encoding failure after writes begin. +func NewSnapshotMaterialization[S, M, D, C, P, V any](kind string, scope S, sourceManifest M, derivedVersions D, completeness C, provenance P, payload V) (SnapshotMaterialization, error) { + if kind == "" { + return SnapshotMaterialization{}, errors.New("snapshot artifact kind is required") + } + encodedScope, err := encodeSnapshotPart("scope", scope) + if err != nil { + return SnapshotMaterialization{}, err + } + encodedSource, err := encodeSnapshotPart("source manifest", sourceManifest) + if err != nil { + return SnapshotMaterialization{}, err + } + encodedDerived, err := encodeSnapshotPart("derived versions", derivedVersions) + if err != nil { + return SnapshotMaterialization{}, err + } + encodedCompleteness, err := encodeSnapshotPart("completeness", completeness) + if err != nil { + return SnapshotMaterialization{}, err + } + encodedProvenance, err := encodeSnapshotPart("provenance", provenance) + if err != nil { + return SnapshotMaterialization{}, err + } + encodedPayload, err := encodeSnapshotPart("payload", payload) + if err != nil { + return SnapshotMaterialization{}, err + } + return SnapshotMaterialization{ + kind: kind, scope: encodedScope, sourceManifest: encodedSource, derivedVersions: encodedDerived, + completeness: encodedCompleteness, provenance: encodedProvenance, payload: encodedPayload, + }, nil +} + +func encodeSnapshotPart[T any](name string, value T) (json.RawMessage, error) { + out, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode snapshot %s: %w", name, err) + } + return out, nil } type ReadSnapshotArtifact struct { @@ -44,41 +90,13 @@ type ReadSnapshotArtifact struct { } func (c *Corpus) MaterializeReadSnapshot(ctx context.Context, in SnapshotMaterialization) (ReadSnapshotArtifact, error) { - if in.Kind == "" { + if in.kind == "" { return ReadSnapshotArtifact{}, errors.New("snapshot artifact kind is required") } - marshal := func(name string, value any) ([]byte, error) { - out, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode snapshot %s: %w", name, err) - } - return out, nil - } - scope, err := marshal("scope", in.Scope) - if err != nil { - return ReadSnapshotArtifact{}, err - } - source, err := marshal("source manifest", in.SourceManifest) - if err != nil { - return ReadSnapshotArtifact{}, err - } - derived, err := marshal("derived versions", in.DerivedVersions) - if err != nil { - return ReadSnapshotArtifact{}, err - } - complete, err := marshal("completeness", in.Completeness) - if err != nil { - return ReadSnapshotArtifact{}, err - } - provenance, err := marshal("provenance", in.Provenance) - if err != nil { - return ReadSnapshotArtifact{}, err - } - payload, err := marshal("payload", in.Payload) - if err != nil { - return ReadSnapshotArtifact{}, err + if len(in.scope) == 0 || len(in.sourceManifest) == 0 || len(in.derivedVersions) == 0 || len(in.completeness) == 0 || len(in.provenance) == 0 || len(in.payload) == 0 { + return ReadSnapshotArtifact{}, errors.New("snapshot materialization is not parsed") } - sourceHash, artifactHash := sha256.Sum256(source), sha256.Sum256(append([]byte(in.Kind+"\x00"), payload...)) + sourceHash, artifactHash := sha256.Sum256(in.sourceManifest), sha256.Sum256(append([]byte(in.kind+"\x00"), in.payload...)) sourceDigest, artifactDigest := hex.EncodeToString(sourceHash[:]), hex.EncodeToString(artifactHash[:]) tx, err := c.db.BeginTx(ctx, nil) if err != nil { @@ -89,17 +107,17 @@ func (c *Corpus) MaterializeReadSnapshot(ctx context.Context, in SnapshotMateria if err != nil { return ReadSnapshotArtifact{}, err } - tokenBody, err := json.Marshal([]any{ReadSnapshotContractVersion, watermark, json.RawMessage(scope), sourceDigest, json.RawMessage(derived), json.RawMessage(complete), artifactDigest}) + tokenBody, err := json.Marshal([]any{ReadSnapshotContractVersion, watermark, in.scope, sourceDigest, in.derivedVersions, in.completeness, artifactDigest}) if err != nil { return ReadSnapshotArtifact{}, fmt.Errorf("encode snapshot token: %w", err) } tokenHash := sha256.Sum256(tokenBody) token := hex.EncodeToString(tokenHash[:]) created := time.Now().UTC() - if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO corpus_read_artifacts (digest, kind, payload_json, created_at) VALUES (?, ?, ?, ?)`, artifactDigest, in.Kind, string(payload), encodeTime(created)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO corpus_read_artifacts (digest, kind, payload_json, created_at) VALUES (?, ?, ?, ?)`, artifactDigest, in.kind, string(in.payload), encodeTime(created)); err != nil { return ReadSnapshotArtifact{}, fmt.Errorf("store read artifact: %w", err) } - if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO corpus_snapshot_tokens (token, contract_version, observation_watermark, scope_json, source_manifest_sha256, derived_versions_json, completeness_json, provenance_json, artifact_kind, artifact_digest, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, token, ReadSnapshotContractVersion, watermark, string(scope), sourceDigest, string(derived), string(complete), string(provenance), in.Kind, artifactDigest, encodeTime(created)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO corpus_snapshot_tokens (token, contract_version, observation_watermark, scope_json, source_manifest_sha256, derived_versions_json, completeness_json, provenance_json, artifact_kind, artifact_digest, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, token, ReadSnapshotContractVersion, watermark, string(in.scope), sourceDigest, string(in.derivedVersions), string(in.completeness), string(in.provenance), in.kind, artifactDigest, encodeTime(created)); err != nil { return ReadSnapshotArtifact{}, fmt.Errorf("store snapshot token: %w", err) } if err := tx.Commit(); err != nil { diff --git a/internal/corpus/read_snapshot_test.go b/internal/corpus/read_snapshot_test.go index 6a2fe280..367ae145 100644 --- a/internal/corpus/read_snapshot_test.go +++ b/internal/corpus/read_snapshot_test.go @@ -10,14 +10,14 @@ func TestReadSnapshotIsImmutableAndUnavailableNeverFallsBack(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - first, err := c.MaterializeReadSnapshot(ctx, SnapshotMaterialization{Kind: "coverage", Scope: map[string]string{"repository": "acme/rocket"}, SourceManifest: map[string]int{"observation": 1}, DerivedVersions: map[string]string{"coverage": "v1"}, Completeness: map[string]bool{"complete": true}, Provenance: map[string]string{"producer": "test"}, Payload: map[string]any{"facets": []string{"metadata"}}}) + first, err := c.MaterializeReadSnapshot(ctx, mustSnapshotMaterialization(t, "coverage", map[string]string{"repository": "acme/rocket"}, map[string]int{"observation": 1}, map[string]string{"coverage": "v1"}, map[string]bool{"complete": true}, map[string]string{"producer": "test"}, map[string]any{"facets": []string{"metadata"}})) if err != nil { t.Fatal(err) } if _, err := c.db.ExecContext(ctx, `UPDATE corpus_state SET revision=revision+1 WHERE id=1`); err != nil { t.Fatal(err) } - second, err := c.MaterializeReadSnapshot(ctx, SnapshotMaterialization{Kind: "coverage", Scope: map[string]string{"repository": "acme/rocket"}, SourceManifest: map[string]int{"observation": 2}, DerivedVersions: map[string]string{"coverage": "v1"}, Completeness: map[string]bool{"complete": false}, Provenance: map[string]string{"producer": "test"}, Payload: map[string]any{"facets": []string{"metadata", "threads"}}}) + second, err := c.MaterializeReadSnapshot(ctx, mustSnapshotMaterialization(t, "coverage", map[string]string{"repository": "acme/rocket"}, map[string]int{"observation": 2}, map[string]string{"coverage": "v1"}, map[string]bool{"complete": false}, map[string]string{"producer": "test"}, map[string]any{"facets": []string{"metadata", "threads"}})) if err != nil { t.Fatal(err) } @@ -37,7 +37,7 @@ func TestReadSnapshotRejectsInconsistentArtifact(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - snapshot, err := c.MaterializeReadSnapshot(ctx, SnapshotMaterialization{Kind: "coverage", Scope: "scope", SourceManifest: "source", DerivedVersions: map[string]string{}, Completeness: map[string]bool{}, Provenance: map[string]string{}, Payload: map[string]string{"value": "original"}}) + snapshot, err := c.MaterializeReadSnapshot(ctx, mustSnapshotMaterialization(t, "coverage", "scope", "source", map[string]string{}, map[string]bool{}, map[string]string{}, map[string]string{"value": "original"})) if err != nil { t.Fatal(err) } @@ -49,15 +49,25 @@ func TestReadSnapshotRejectsInconsistentArtifact(t *testing.T) { } } +func TestReadSnapshotRequiresParsedMaterialization(t *testing.T) { + t.Parallel() + c, _ := openTestCorpus(t) + if _, err := c.MaterializeReadSnapshot(context.Background(), SnapshotMaterialization{kind: "coverage"}); err == nil || err.Error() != "snapshot materialization is not parsed" { + t.Fatalf("unparsed materialization error = %v", err) + } + if _, err := NewSnapshotMaterialization("coverage", "scope", "source", struct{}{}, struct{}{}, struct{}{}, make(chan int)); err == nil { + t.Fatal("unencodable snapshot payload was accepted") + } +} + func TestResolveReadArtifactUsesExactKindAndDigestWithoutProjectionFallback(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - want, err := c.MaterializeReadSnapshot(ctx, SnapshotMaterialization{ - Kind: "source-bundle.v1", Scope: "acme/rocket", SourceManifest: "manifest", - DerivedVersions: map[string]string{"source_bundle": "v1"}, Completeness: map[string]bool{"complete": true}, - Provenance: map[string]string{"provider": "github"}, Payload: map[string]any{"commit_sha": "abc", "items": []string{"README.md"}}, - }) + want, err := c.MaterializeReadSnapshot(ctx, mustSnapshotMaterialization( + t, "source-bundle.v1", "acme/rocket", "manifest", map[string]string{"source_bundle": "v1"}, map[string]bool{"complete": true}, + map[string]string{"provider": "github"}, map[string]any{"commit_sha": "abc", "items": []string{"README.md"}}, + )) if err != nil { t.Fatal(err) } @@ -75,3 +85,12 @@ func TestResolveReadArtifactUsesExactKindAndDigestWithoutProjectionFallback(t *t t.Fatalf("wrong-kind artifact error = %v", err) } } + +func mustSnapshotMaterialization[S, M, D, C, P, V any](t *testing.T, kind string, scope S, source M, derived D, completeness C, provenance P, payload V) SnapshotMaterialization { + t.Helper() + materialization, err := NewSnapshotMaterialization(kind, scope, source, derived, completeness, provenance, payload) + if err != nil { + t.Fatal(err) + } + return materialization +} diff --git a/internal/corpus/repository_removal.go b/internal/corpus/repository_removal.go index 8cfe02db..c82313e7 100644 --- a/internal/corpus/repository_removal.go +++ b/internal/corpus/repository_removal.go @@ -30,7 +30,6 @@ type RepositoryRemovalPlan struct { Dossiers int ClusterRuns int Clusters int - FrontierItems int DetachedTriageEvents int RemovedPortfolioLinks int RemovedResolutionRecords int @@ -136,7 +135,6 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref {&plan.Dossiers, `SELECT COUNT(*) FROM dossiers WHERE repository_id = ?`, []any{plan.RepositoryID}}, {&plan.ClusterRuns, `SELECT COUNT(*) FROM cluster_runs WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, {&plan.Clusters, `SELECT COUNT(*) FROM clusters WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, - {&plan.FrontierItems, `SELECT COUNT(*) FROM frontier_items WHERE owner = ? AND repo = ?`, []any{ref.Owner(), ref.Repo()}}, {&plan.DetachedTriageEvents, `SELECT COUNT(*) FROM triage_events WHERE repository_id = ? OR thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{plan.RepositoryID, plan.RepositoryID}}, {&plan.RemovedPortfolioLinks, `SELECT COUNT(*) FROM portfolio_links WHERE pull_request_thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{plan.RepositoryID}}, {&plan.RemovedResolutionRecords, `SELECT COUNT(*) FROM resolution_records WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{plan.RepositoryID}}, @@ -189,7 +187,6 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref {"cluster members", `SELECT * FROM cluster_members WHERE cluster_id IN (SELECT id FROM clusters WHERE repo_owner = ? AND repo_name = ?) OR thread_id IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY id`, []any{ref.Owner(), ref.Repo(), plan.RepositoryID}}, {"cluster overrides", `SELECT * FROM cluster_overrides WHERE cluster_id IN (SELECT id FROM clusters WHERE repo_owner = ? AND repo_name = ?) OR target_cluster_id IN (SELECT id FROM clusters WHERE repo_owner = ? AND repo_name = ?) ORDER BY id`, []any{ref.Owner(), ref.Repo(), ref.Owner(), ref.Repo()}}, {"cluster projection", `SELECT * FROM cluster_projection_state WHERE repo_owner = ? AND repo_name = ? ORDER BY repo_owner, repo_name`, []any{ref.Owner(), ref.Repo()}}, - {"frontier items", `SELECT * FROM frontier_items WHERE owner = ? AND repo = ? ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, {"triage events", `SELECT * FROM triage_events WHERE repository_id = ? OR thread_id IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY id`, []any{plan.RepositoryID, plan.RepositoryID}}, {"portfolio links", `SELECT * FROM portfolio_links WHERE pull_request_thread_id IN (SELECT id FROM threads WHERE repository_id = ?) OR opportunity_id IN (SELECT id FROM opportunities WHERE investigation_id IN (SELECT id FROM investigations WHERE repo_owner = ? AND repo_name = ?)) OR workspace_id IN (SELECT id FROM workspaces WHERE investigation_id IN (SELECT id FROM investigations WHERE repo_owner = ? AND repo_name = ?)) ORDER BY id`, []any{plan.RepositoryID, ref.Owner(), ref.Repo(), ref.Owner(), ref.Repo()}}, {"signal snapshots", `SELECT * FROM portfolio_signal_snapshots WHERE subject_kind = ? AND CAST(subject_ref AS INTEGER) IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY id`, []any{PortfolioSubjectPullRequest, plan.RepositoryID}}, @@ -270,7 +267,6 @@ func deleteRepositoryScope(ctx context.Context, tx *sql.Tx, plan *RepositoryRemo {"delete repository clusters", `DELETE FROM clusters WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, {"delete repository cluster runs", `DELETE FROM cluster_runs WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, {"delete code snapshots", `DELETE FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, - {"delete frontier items", `DELETE FROM frontier_items WHERE owner = ? AND repo = ?`, []any{ref.Owner(), ref.Repo()}}, {"delete facet observations", `DELETE FROM facet_observations WHERE repository_id = ?`, []any{repoID}}, {"delete facet coverage", `DELETE FROM facet_coverage WHERE repository_id = ?`, []any{repoID}}, {"delete thread observations", `DELETE FROM thread_observations WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{repoID}}, diff --git a/internal/corpus/repository_removal_test.go b/internal/corpus/repository_removal_test.go index 82b1f351..7634e3e5 100644 --- a/internal/corpus/repository_removal_test.go +++ b/internal/corpus/repository_removal_test.go @@ -98,7 +98,7 @@ func TestRepositoryRemovalRejectsStalePlan(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.ApplyThreadObservation(ctx, repoID, ThreadKindIssue, 2, "open", "new", "", "author", time.Unix(3, 0), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repoID, domain.IssueKind, 2, "open", "new", "", "author", time.Unix(3, 0), `{}`); err != nil { t.Fatal(err) } if _, err := c.ApplyRepositoryRemoval(ctx, ref, plan); !errors.Is(err, ErrRepositoryRemovalPlanStale) { @@ -162,7 +162,7 @@ func seedRemovalRepository(ctx context.Context, t *testing.T, c *Corpus, ref dom if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, number, "open", ref.String(), "body", "author", time.Unix(int64(number+1), 0), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, number, "open", ref.String(), "body", "author", time.Unix(int64(number+1), 0), `{}`) if err != nil { t.Fatal(err) } diff --git a/internal/corpus/repository_search.go b/internal/corpus/repository_search.go index 053d5c0e..bbd2c6b8 100644 --- a/internal/corpus/repository_search.go +++ b/internal/corpus/repository_search.go @@ -10,9 +10,8 @@ import ( // RepositorySearchOptions scopes a paginated repository search. type RepositorySearchOptions struct { - Limit int - Cursor string - Sort string + Page SearchPage + Order SearchOrder } // RepositorySearchPage is a paginated result of a repository keyword search. @@ -25,7 +24,11 @@ type RepositorySearchPage struct { // ListRepositories returns repositories matching an optional name query. // An empty query lists all repositories ordered by most recently updated. func (c *Corpus) ListRepositories(ctx context.Context, query string, limit int) ([]Repository, error) { - page, err := c.ListRepositoriesWithOptions(ctx, query, RepositorySearchOptions{Limit: limit}) + request, err := ParseSearchPage(limit, "") + if err != nil { + return nil, err + } + page, err := c.ListRepositoriesWithOptions(ctx, query, RepositorySearchOptions{Page: request}) if err != nil { return nil, err } @@ -53,15 +56,15 @@ func (c *Corpus) ListRepositoriesWithOptions(ctx context.Context, query string, } page := RepositorySearchPage{Repositories: out} - if len(out) > opts.Limit { - page.Repositories = out[:opts.Limit] + if len(out) > opts.Page.Limit() { + page.Repositories = out[:opts.Page.Limit()] last := page.Repositories[len(page.Repositories)-1] page.NextCursor = encodeCursor(searchCursor{ - Scope: "repos", Query: ftsQuery, Kind: "repo", Filter: opts.Sort, + Scope: "repos", Query: ftsQuery, Kind: "repo", Filter: opts.Order.String(), Rank: last.Rank, UpdatedAt: encodeTime(last.SourceUpdatedAt), ID: last.ID, }) } - if len(out) > opts.Limit || opts.Cursor != "" { + if len(out) > opts.Page.Limit() || opts.Page.Cursor() != "" { page.Total, err = c.countRepositories(ctx, ftsQuery) if err != nil { return RepositorySearchPage{}, err @@ -73,25 +76,13 @@ func (c *Corpus) ListRepositoriesWithOptions(ctx context.Context, query string, } func (c *Corpus) prepareRepositorySearch(ctx context.Context, query string, opts RepositorySearchOptions) (RepositorySearchOptions, string, *searchCursor, error) { - if opts.Limit <= 0 { - opts.Limit = 20 - } - if opts.Limit > 100 { - return opts, "", nil, errors.New("repository list limit cannot exceed 100") - } - if opts.Sort == "" { - opts.Sort = "relevance" - } - if opts.Sort != "relevance" && opts.Sort != "updated" { - return opts, "", nil, errors.New("repository sort must be relevance or updated") - } ftsQuery := repositoryFTSQuery(query) if ftsQuery != "" { if err := c.RequireProjection(ctx, ProjectionNameRepositoriesFTS, ProjectionVersionRepositoriesFTS); err != nil { return opts, "", nil, err } } - cursor, err := c.decodeRepoCursor(opts.Cursor, ftsQuery, opts.Sort) + cursor, err := c.decodeRepoCursor(opts.Page.Cursor(), ftsQuery, opts.Order.String()) return opts, ftsQuery, cursor, err } @@ -112,7 +103,7 @@ func repositorySearchStatement(ftsQuery string, opts RepositorySearchOptions, cu } else { where += ` AND ` } - if ftsQuery != "" && opts.Sort == "relevance" { + if ftsQuery != "" && !opts.Order.IsUpdated() { where += `(` + rankSelect + ` > ? OR (` + rankSelect + ` = ? AND (repositories.source_updated_at < ? OR (repositories.source_updated_at = ? AND repositories.id > ?))))` args = append(args, cursor.Rank, cursor.Rank, cursor.UpdatedAt, cursor.UpdatedAt, cursor.ID) } else { @@ -124,9 +115,9 @@ func repositorySearchStatement(ftsQuery string, opts RepositorySearchOptions, cu SELECT ` + rankSelect + `, repositories.id, repositories.owner, repositories.name, repositories.external_id, repositories.description, repositories.default_branch, repositories.language, repositories.license, repositories.topics, repositories.stars, repositories.watchers, repositories.forks, repositories.open_issues, repositories.archived, repositories.fork, repositories.source_created_at, repositories.source_updated_at, repositories.observation_sequence, repositories.created_at, repositories.updated_at ` + from + ` ` + where + ` - ORDER BY ` + repositoryOrder(ftsQuery, opts.Sort) + ` + ORDER BY ` + repositoryOrder(ftsQuery, opts.Order) + ` LIMIT ?` - return statement, append(args, opts.Limit+1) + return statement, append(args, opts.Page.Limit()+1) } func scanRepositorySearchRows(rows *sql.Rows) ([]Repository, error) { @@ -151,8 +142,8 @@ func scanRepositorySearchRows(rows *sql.Rows) ([]Repository, error) { return out, rows.Err() } -func repositoryOrder(ftsQuery, sort string) string { - if ftsQuery != "" && sort == "relevance" { +func repositoryOrder(ftsQuery string, order SearchOrder) string { + if ftsQuery != "" && !order.IsUpdated() { return "bm25(repositories_fts, 10.0, 10.0, 5.0, 2.0), repositories.source_updated_at DESC, repositories.id" } return "repositories.source_updated_at DESC, repositories.id DESC" diff --git a/internal/corpus/resolutions.go b/internal/corpus/resolutions.go index f148d900..28bc822a 100644 --- a/internal/corpus/resolutions.go +++ b/internal/corpus/resolutions.go @@ -34,7 +34,7 @@ func (c *Corpus) SaveResolutionRecord(ctx context.Context, record ResolutionReco return nil, errors.New("resolution thread, kind, rule version, source time, and observation refs are required") } for _, ref := range record.SourceObservationRefs { - if strings.TrimSpace(ref.Kind) == "" || ref.ID <= 0 { + if !ref.valid() { return nil, errors.New("invalid resolution source observation reference") } } diff --git a/internal/corpus/resolutions_test.go b/internal/corpus/resolutions_test.go index 6beabb5e..f6288390 100644 --- a/internal/corpus/resolutions_test.go +++ b/internal/corpus/resolutions_test.go @@ -29,7 +29,7 @@ func TestResolutionRecordsAreAppendOnlyAndStaleSafe(t *testing.T) { newer := ResolutionRecord{ ThreadID: threadID, Kind: "fixed", Summary: "observed closing PR", RuleVersion: "resolution-v2", SourceUpdatedAt: time.Unix(500, 0).UTC(), - SourceObservationRefs: []ObservationRef{{Kind: "thread", ID: threadObservation.ID}, {Kind: "facet", ID: facetObservations[0].ID}}, + SourceObservationRefs: []ObservationRef{mustObservationRef(t, "thread", threadObservation.ID), mustObservationRef(t, "facet", facetObservations[0].ID)}, } if _, err := c.SaveResolutionRecord(ctx, newer); err != nil { t.Fatalf("save newer resolution: %v", err) diff --git a/internal/corpus/restore.go b/internal/corpus/restore.go index ca04b00d..c11b95df 100644 --- a/internal/corpus/restore.go +++ b/internal/corpus/restore.go @@ -15,8 +15,6 @@ import ( "strings" ) -var removeRestoreSnapshot = removeFile - // PostCommitCleanupError indicates that restore committed successfully but a // private staging artifact could not be cleaned up. type PostCommitCleanupError struct { @@ -44,7 +42,7 @@ func Restore(ctx context.Context, source, destination string, observer func(copi if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { return BackupResult{}, fmt.Errorf("create restore directory: %w", err) } - lease, err := acquireCorpusLease(destination, true, "restore corpus") + lease, err := acquireCorpusLease(destination, exclusiveCorpusLease, "restore corpus") if err != nil { return BackupResult{}, err } @@ -74,7 +72,7 @@ func RestoreWithSafetyBackup(ctx context.Context, source, destination, safetyDes if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { return nil, BackupResult{}, fmt.Errorf("create restore directory: %w", err) } - lease, err := acquireCorpusLease(destination, true, "back up and restore corpus") + lease, err := acquireCorpusLease(destination, exclusiveCorpusLease, "back up and restore corpus") if err != nil { return nil, BackupResult{}, err } @@ -142,14 +140,22 @@ func restoreWithLease(ctx context.Context, source, destination string, observer return BackupResult{}, fmt.Errorf("publish restored corpus: %w", err) } cleanup = false + result, err := finalizeCommittedRestore(destination, snapshotPath) + if err != nil { + return result, err + } + snapshotCleanup = false + return result, nil +} + +func finalizeCommittedRestore(destination, snapshotPath string) (BackupResult, error) { result, err := summarizeSQLiteFile(destination) if err != nil { return BackupResult{Path: destination}, &PostCommitCleanupError{Err: fmt.Errorf("summarize restored corpus: %w", err)} } - if err := removeRestoreSnapshot(snapshotPath); err != nil { + if err := removeFile(snapshotPath); err != nil { return result, &PostCommitCleanupError{Err: fmt.Errorf("remove restore source snapshot: %w", err)} } - snapshotCleanup = false return result, nil } diff --git a/internal/corpus/schema_inspect.go b/internal/corpus/schema_inspect.go index fc37e099..7533bbda 100644 --- a/internal/corpus/schema_inspect.go +++ b/internal/corpus/schema_inspect.go @@ -178,7 +178,7 @@ func CheckWriteAccessAtPath(ctx context.Context, path string) (returnErr error) if _, err := os.Stat(filePath); err != nil { return fmt.Errorf("inspect corpus for write access: %w", err) } - lease, err := acquireCorpusLease(path, false, "check corpus write access") + lease, err := acquireCorpusLease(path, sharedCorpusLease, "check corpus write access") if err != nil { return err } diff --git a/internal/corpus/search.go b/internal/corpus/search.go index a2c37799..85343f0c 100644 --- a/internal/corpus/search.go +++ b/internal/corpus/search.go @@ -17,22 +17,20 @@ import ( // SearchFilter scopes a thread keyword search. type SearchFilter struct { - RepoID int64 - Repo string - Kind string - State string - StateReason string - Merged *bool + Repository ThreadRepositoryScope + Kind ThreadKindFilter + State ThreadStateFilter + StateReason ThreadStateReason + Merge MergeFilter Author string Association string Assignee string Labels []string UpdatedAfter time.Time UpdatedBefore time.Time - Limit int - Cursor string - Sort string - MatchMode string + Page SearchPage + Order SearchOrder + TermMatch TermMatch } // ThreadSearchPage is a paginated result of a thread keyword search. @@ -91,17 +89,11 @@ func threadSearchArguments(ftsQuery string) []any { // It returns matching threads ordered by FTS5 rank and limited to at most limit // results. No network access occurs. func (c *Corpus) SearchThreads(ctx context.Context, query string, limit int) ([]Thread, error) { - page, err := c.SearchThreadsPage(ctx, query, SearchFilter{Limit: limit}) + request, err := ParseSearchPage(limit, "") if err != nil { return nil, err } - return page.Threads, nil -} - -// SearchThreadsWithFilter performs the same search as SearchThreads but -// supports filtering to a repository and thread kind. -func (c *Corpus) SearchThreadsWithFilter(ctx context.Context, query string, filter SearchFilter) ([]Thread, error) { - page, err := c.SearchThreadsPage(ctx, query, filter) + page, err := c.SearchThreadsPage(ctx, query, SearchFilter{Page: request}) if err != nil { return nil, err } @@ -113,26 +105,8 @@ func (c *Corpus) SearchThreadsWithFilter(ctx context.Context, query string, filt // ascending, so the same cursor always returns the same next page on an // unchanged corpus. No network access occurs. func (c *Corpus) SearchThreadsPage(ctx context.Context, query string, filter SearchFilter) (ThreadSearchPage, error) { - if filter.Limit <= 0 { - filter.Limit = 20 - } - if filter.Limit > 100 { - return ThreadSearchPage{}, errors.New("search limit cannot exceed 100") - } - if filter.Sort == "" { - filter.Sort = "relevance" - } - if filter.Sort != "relevance" && filter.Sort != "updated" { - return ThreadSearchPage{}, errors.New("search sort must be relevance or updated") - } - if filter.MatchMode == "" { - filter.MatchMode = "all" - } - if filter.MatchMode != "all" && filter.MatchMode != "any" { - return ThreadSearchPage{}, errors.New("search match mode must be all or any") - } - - ftsQuery := literalFTSQueryMode(query, filter.MatchMode) + limit := filter.Page.Limit() + ftsQuery := literalFTSQueryMode(query, filter.TermMatch) if ftsQuery == "" { return ThreadSearchPage{}, nil } @@ -144,7 +118,7 @@ func (c *Corpus) SearchThreadsPage(ctx context.Context, query string, filter Sea } filterKey := threadFilterKey(filter) - cursor, err := c.decodeThreadCursor(filter.Cursor, query, filter.Repo, filter.Kind, filterKey) + cursor, err := c.decodeThreadCursor(filter.Page.Cursor(), query, filter.Repository.String(), filter.Kind.String(), filterKey) if err != nil { return ThreadSearchPage{}, err } @@ -157,17 +131,17 @@ func (c *Corpus) SearchThreadsPage(ctx context.Context, query string, filter Sea JOIN threads t ON t.id = m.thread_id WHERE 1 = 1` args := threadSearchArguments(ftsQuery) - if filter.RepoID != 0 { + if filter.Repository.IsScoped() { statement += ` AND t.repository_id = ?` - args = append(args, filter.RepoID) + args = append(args, filter.Repository.ID()) } - if filter.Kind != "" { + if !filter.Kind.IsAny() { statement += ` AND t.kind = ?` - args = append(args, filter.Kind) + args = append(args, filter.Kind.String()) } statement, args = appendThreadMetadataFilters(statement, args, filter) if cursor != nil { - if filter.Sort == "updated" { + if filter.Order.IsUpdated() { statement += ` AND (t.source_updated_at < ? OR (t.source_updated_at = ? AND t.id < ?))` args = append(args, cursor.UpdatedAt, cursor.UpdatedAt, cursor.ID) } else { @@ -175,12 +149,12 @@ func (c *Corpus) SearchThreadsPage(ctx context.Context, query string, filter Sea args = append(args, cursor.Rank, cursor.Rank, cursor.UpdatedAt, cursor.UpdatedAt, cursor.ID) } } - if filter.Sort == "updated" { + if filter.Order.IsUpdated() { statement += ` ORDER BY t.source_updated_at DESC, t.id DESC LIMIT ?` } else { statement += ` ORDER BY m.rank, t.source_updated_at DESC, t.id LIMIT ?` } - args = append(args, filter.Limit+1) + args = append(args, limit+1) tx, err := c.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { @@ -204,21 +178,21 @@ func (c *Corpus) SearchThreadsPage(ctx context.Context, query string, filter Sea } page := ThreadSearchPage{Threads: threads} - if len(threads) > filter.Limit { - page.Threads = threads[:filter.Limit] + if len(threads) > limit { + page.Threads = threads[:limit] last := page.Threads[len(page.Threads)-1] page.NextCursor = encodeCursor(searchCursor{ Scope: "threads", Query: query, - Repo: filter.Repo, - Kind: filter.Kind, + Repo: filter.Repository.String(), + Kind: filter.Kind.String(), Filter: filterKey, Rank: last.Rank, UpdatedAt: encodeTime(last.SourceUpdatedAt), ID: last.ID, }) } - if filter.Merged == nil && len(threads) <= filter.Limit && cursor == nil { + if filter.Merge.IsAny() && len(threads) <= limit && cursor == nil { page.Total = len(threads) } else { page.Total, page.UnknownMergeCount, err = countThreadMatchSummary(ctx, tx, ftsQuery, filter) @@ -243,14 +217,14 @@ func countThreadMatchSummary(ctx context.Context, tx *sql.Tx, ftsQuery string, f ) SELECT ` args := []any{ftsQuery} - if filter.Merged == nil { + if filter.Merge.IsAny() { statement += `COUNT(*)` } else { statement += `COALESCE(SUM(CASE WHEN t.kind = 'pull_request' AND t.merged_known = 1 AND t.merged = ? THEN 1 ELSE 0 END), 0)` - if *filter.Merged { + if filter.Merge.IsMerged() { args = append(args, 1) } else { args = append(args, 0) @@ -264,21 +238,21 @@ func countThreadMatchSummary(ctx context.Context, tx *sql.Tx, ftsQuery string, f FROM matching_threads m JOIN threads t ON t.id = m.thread_id WHERE 1 = 1` - if filter.Merged == nil { + if filter.Merge.IsAny() { args = append(args, 0) } else { args = append(args, 1) } - if filter.RepoID != 0 { + if filter.Repository.IsScoped() { statement += ` AND t.repository_id = ?` - args = append(args, filter.RepoID) + args = append(args, filter.Repository.ID()) } - if filter.Kind != "" { + if !filter.Kind.IsAny() { statement += ` AND t.kind = ?` - args = append(args, filter.Kind) + args = append(args, filter.Kind.String()) } summaryFilter := filter - summaryFilter.Merged = nil + summaryFilter.Merge = AnyMergeState() statement, args = appendThreadMetadataFilters(statement, args, summaryFilter) var total, unknownMerge int if err := tx.QueryRowContext(ctx, statement, args...).Scan(&total, &unknownMerge); err != nil { @@ -331,17 +305,17 @@ func (c *Corpus) decodeThreadCursor(cursor, query, repo, kind, filter string) (* } func appendThreadMetadataFilters(query string, args []any, filter SearchFilter) (string, []any) { - if filter.State != "" && filter.State != "all" { + if !filter.State.IsAny() { query += ` AND t.state = ?` - args = append(args, filter.State) + args = append(args, filter.State.String()) } - if filter.StateReason != "" { + if !filter.StateReason.IsAny() { query += ` AND t.state_reason = ?` - args = append(args, filter.StateReason) + args = append(args, filter.StateReason.String()) } - if filter.Merged != nil { + if !filter.Merge.IsAny() { merged := 0 - if *filter.Merged { + if filter.Merge.IsMerged() { merged = 1 } query += ` AND t.merged = ? AND t.merged_known = 1` @@ -383,8 +357,8 @@ func threadFilterKey(filter SearchFilter) string { } slices.Sort(labels) return strings.Join([]string{ - strings.ToLower(filter.State), strings.ToLower(filter.StateReason), fmt.Sprint(filter.Merged), strings.ToLower(filter.Author), strings.ToLower(filter.Association), strings.ToLower(filter.Assignee), strings.Join(labels, ","), - strconv.FormatInt(encodeTime(filter.UpdatedAfter), 10), strconv.FormatInt(encodeTime(filter.UpdatedBefore), 10), filter.Sort, filter.MatchMode, + filter.State.String(), filter.StateReason.String(), filter.Merge.String(), strings.ToLower(filter.Author), strings.ToLower(filter.Association), strings.ToLower(filter.Assignee), strings.Join(labels, ","), + strconv.FormatInt(encodeTime(filter.UpdatedAfter), 10), strconv.FormatInt(encodeTime(filter.UpdatedBefore), 10), filter.Order.String(), filter.TermMatch.String(), }, "|") } @@ -438,15 +412,15 @@ func scanThreadsWithRank(rows *sql.Rows) ([]Thread, error) { // literalFTSQuery treats user input as terms rather than exposing FTS5 query // operators. This keeps ordinary punctuation and unmatched quotes searchable. func literalFTSQuery(query string) string { - return literalFTSQueryMode(query, "all") + return literalFTSQueryMode(query, MatchAllTerms()) } -func literalFTSQueryMode(query, mode string) string { +func literalFTSQueryMode(query string, mode TermMatch) string { terms := strings.Fields(query) for i, term := range terms { terms[i] = quoteFTSTerm(term) } - if mode == "any" { + if mode.IsAny() { return strings.Join(terms, " OR ") } return strings.Join(terms, " ") diff --git a/internal/corpus/search_modes.go b/internal/corpus/search_modes.go new file mode 100644 index 00000000..ee589c3d --- /dev/null +++ b/internal/corpus/search_modes.go @@ -0,0 +1,347 @@ +package corpus + +import ( + "errors" + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/domain" +) + +const ( + // DefaultSearchPageSize is used when a boundary omits its page size. + DefaultSearchPageSize = 20 + // MaximumSearchPageSize bounds one local FTS query. + MaximumSearchPageSize = 100 +) + +// SearchPage is a parsed bounded page request. Its private fields prevent an +// invalid limit from reaching a query, while the zero value is the default +// first page. +type SearchPage struct { + limit int + cursor string +} + +// ParseSearchPage parses a boundary limit and opaque cursor. +func ParseSearchPage(limit int, cursor string) (SearchPage, error) { + if limit == 0 { + limit = DefaultSearchPageSize + } + if limit < 1 { + return SearchPage{}, errors.New("search limit must be positive") + } + if limit > MaximumSearchPageSize { + return SearchPage{}, errors.New("search limit cannot exceed 100") + } + return SearchPage{limit: limit, cursor: cursor}, nil +} + +// MaximumSearchPage returns the largest supported first page. +func MaximumSearchPage() SearchPage { return SearchPage{limit: MaximumSearchPageSize} } + +// Limit returns the bounded page size. +func (p SearchPage) Limit() int { + if p.limit == 0 { + return DefaultSearchPageSize + } + return p.limit +} + +// Cursor returns the opaque continuation cursor, or empty for the first page. +func (p SearchPage) Cursor() string { return p.cursor } + +// WithCursor returns the same bounded page size at the supplied continuation. +func (p SearchPage) WithCursor(cursor string) SearchPage { + p.cursor = cursor + return p +} + +// ThreadRepositoryScope binds the stored repository row used by SQL to the +// parsed repository identity embedded in cursor scope. Its zero value searches +// every repository. +type ThreadRepositoryScope struct { + ref domain.RepoRef + id int64 +} + +// NewThreadRepositoryScope constructs an exact stored-repository restriction. +func NewThreadRepositoryScope(ref domain.RepoRef, id int64) (ThreadRepositoryScope, error) { + if !ref.IsValid() { + return ThreadRepositoryScope{}, errors.New("repository reference is not parsed") + } + if id <= 0 { + return ThreadRepositoryScope{}, fmt.Errorf("repository id must be positive, got %d", id) + } + return ThreadRepositoryScope{ref: ref, id: id}, nil +} + +// AllThreadRepositories returns an unrestricted repository scope. +func AllThreadRepositories() ThreadRepositoryScope { return ThreadRepositoryScope{} } + +// IsScoped reports whether one exact stored repository is selected. +func (s ThreadRepositoryScope) IsScoped() bool { return s.id != 0 } + +// ID returns the selected stored repository row, or zero for every repository. +func (s ThreadRepositoryScope) ID() int64 { return s.id } + +// Repository returns the selected parsed identity, or the zero value for every repository. +func (s ThreadRepositoryScope) Repository() domain.RepoRef { return s.ref } + +// String returns the cursor-scope spelling, or empty for every repository. +func (s ThreadRepositoryScope) String() string { return s.ref.String() } + +// SearchOrder is a parsed ordering for the corpus FTS indexes. Its private +// representation leaves relevance as the valid zero value. +type SearchOrder struct { + updated bool +} + +// ParseSearchOrder parses the only supported corpus search orderings. +func ParseSearchOrder(value string) (SearchOrder, error) { + switch strings.TrimSpace(value) { + case "", "relevance": + return SearchOrder{}, nil + case "updated": + return SearchOrder{updated: true}, nil + default: + return SearchOrder{}, errors.New("search sort must be relevance or updated") + } +} + +// RelevanceSearchOrder returns the default weighted-FTS ordering. +func RelevanceSearchOrder() SearchOrder { return SearchOrder{} } + +// UpdatedSearchOrder returns newest-source-update ordering. +func UpdatedSearchOrder() SearchOrder { return SearchOrder{updated: true} } + +// IsUpdated reports whether newest-source-update ordering was selected. +func (o SearchOrder) IsUpdated() bool { return o.updated } + +// String returns the boundary spelling of the parsed order. +func (o SearchOrder) String() string { + if o.updated { + return "updated" + } + return "relevance" +} + +// TermMatch is a parsed FTS term-combination rule. Its private representation +// leaves all-term matching as the valid zero value. +type TermMatch struct { + any bool +} + +// ParseTermMatch parses the only supported term-combination rules. +func ParseTermMatch(value string) (TermMatch, error) { + switch strings.TrimSpace(value) { + case "", "all": + return TermMatch{}, nil + case "any": + return TermMatch{any: true}, nil + default: + return TermMatch{}, errors.New("search match mode must be all or any") + } +} + +// MatchAllTerms returns the default conjunctive term rule. +func MatchAllTerms() TermMatch { return TermMatch{} } + +// MatchAnyTerm returns the disjunctive term rule. +func MatchAnyTerm() TermMatch { return TermMatch{any: true} } + +// IsAny reports whether at least one search term may match. +func (m TermMatch) IsAny() bool { return m.any } + +// String returns the boundary spelling of the parsed term rule. +func (m TermMatch) String() string { + if m.any { + return "any" + } + return "all" +} + +// ThreadKindFilter is an optional parsed issue-or-pull-request restriction. +// Its zero value includes both kinds. +type ThreadKindFilter struct { + kind domain.ThreadKind +} + +// ParseThreadKindFilter parses a canonical optional thread kind. +func ParseThreadKindFilter(value string) (ThreadKindFilter, error) { + if strings.TrimSpace(value) == "" { + return ThreadKindFilter{}, nil + } + kind, err := domain.ParseThreadKind(value) + if err != nil { + return ThreadKindFilter{}, err + } + return ThreadKindFilter{kind: kind}, nil +} + +// AnyThreadKind includes issues and pull requests. +func AnyThreadKind() ThreadKindFilter { return ThreadKindFilter{} } + +// IssueThreadKind restricts a search to issues. +func IssueThreadKind() ThreadKindFilter { return ThreadKindFilter{kind: domain.IssueKind} } + +// PullRequestThreadKind restricts a search to pull requests. +func PullRequestThreadKind() ThreadKindFilter { + return ThreadKindFilter{kind: domain.PullRequestKind} +} + +// IsAny reports whether both thread kinds are included. +func (f ThreadKindFilter) IsAny() bool { return f.kind == "" } + +// String returns the canonical stored thread-kind spelling, or empty for both. +func (f ThreadKindFilter) String() string { return string(f.kind) } + +// ThreadStateFilter is an optional parsed open-or-closed restriction. Its zero +// value includes both states. +type ThreadStateFilter struct { + state domain.ThreadState +} + +// ParseThreadStateFilter parses a canonical optional thread state. +func ParseThreadStateFilter(value string) (ThreadStateFilter, error) { + if value = strings.TrimSpace(value); value == "" || value == "all" { + return ThreadStateFilter{}, nil + } + state, err := domain.ParseThreadState(value) + if err != nil { + return ThreadStateFilter{}, err + } + return ThreadStateFilter{state: state}, nil +} + +// AnyThreadState includes open and closed threads. +func AnyThreadState() ThreadStateFilter { return ThreadStateFilter{} } + +// OpenThreadState restricts a search to open threads. +func OpenThreadState() ThreadStateFilter { return ThreadStateFilter{state: domain.OpenState} } + +// ClosedThreadState restricts a search to closed threads. +func ClosedThreadState() ThreadStateFilter { return ThreadStateFilter{state: domain.ClosedState} } + +// IsAny reports whether both thread states are included. +func (f ThreadStateFilter) IsAny() bool { return f.state == "" } + +// IsOpen reports whether the filter selects only open threads. +func (f ThreadStateFilter) IsOpen() bool { return f.state == domain.OpenState } + +// String returns the canonical stored thread-state spelling, or empty for both. +func (f ThreadStateFilter) String() string { return string(f.state) } + +// ThreadStateReason is an optional parsed GitHub close reason. Its zero value +// includes every reason. +type ThreadStateReason struct { + value uint8 +} + +// ParseThreadStateReason parses the supported close reasons. +func ParseThreadStateReason(value string) (ThreadStateReason, error) { + switch strings.TrimSpace(value) { + case "": + return ThreadStateReason{}, nil + case "completed": + return ThreadStateReason{value: 1}, nil + case "not_planned": + return ThreadStateReason{value: 2}, nil + default: + return ThreadStateReason{}, errors.New("search state reason must be completed or not_planned") + } +} + +// IsAny reports whether every close reason is included. +func (r ThreadStateReason) IsAny() bool { return r.value == 0 } + +// String returns the canonical stored close reason, or empty for every reason. +func (r ThreadStateReason) String() string { + switch r.value { + case 1: + return "completed" + case 2: + return "not_planned" + default: + return "" + } +} + +// MergeFilter is the three-way merged, unmerged, or unrestricted predicate. +// Its zero value is unrestricted. +type MergeFilter struct { + value uint8 +} + +// ParseMergeFilter parses boolean and domain spellings used by search +// boundaries. Unknown is distinct from unrestricted so callers never need a +// second "known" flag. +func ParseMergeFilter(value string) (MergeFilter, error) { + switch strings.TrimSpace(value) { + case "", "any": + return MergeFilter{}, nil + case "true", "merged": + return MergeFilter{value: 1}, nil + case "false", "unmerged": + return MergeFilter{value: 2}, nil + case "unknown": + return MergeFilter{value: 3}, nil + default: + return MergeFilter{}, errors.New("merge filter must be merged, unmerged, unknown, or any") + } +} + +// MergeFilterFromPointer parses the optional boolean boundary representation. +func MergeFilterFromPointer(value *bool) MergeFilter { + if value == nil { + return MergeFilter{} + } + if *value { + return MergeFilter{value: 1} + } + return MergeFilter{value: 2} +} + +// AnyMergeState includes known merged, known unmerged, and unknown results. +func AnyMergeState() MergeFilter { return MergeFilter{} } + +// IsAny reports whether merge state is unrestricted. +func (f MergeFilter) IsAny() bool { return f.value == 0 } + +// IsMerged reports whether only known merged pull requests are selected. +func (f MergeFilter) IsMerged() bool { return f.value == 1 } + +// IsUnmerged reports whether only known unmerged pull requests are selected. +func (f MergeFilter) IsUnmerged() bool { return f.value == 2 } + +// IsUnknown reports whether only pull requests without an observed merge +// state are selected. +func (f MergeFilter) IsUnknown() bool { return f.value == 3 } + +// String returns the stable cursor-key spelling of the filter. +func (f MergeFilter) String() string { + switch f.value { + case 1: + return "merged" + case 2: + return "unmerged" + case 3: + return "unknown" + default: + return "any" + } +} + +// BooleanString returns the legacy feedback-boundary spelling. +func (f MergeFilter) BooleanString() string { + switch f.value { + case 1: + return "true" + case 2: + return "false" + case 3: + return "unknown" + default: + return "any" + } +} diff --git a/internal/corpus/search_test.go b/internal/corpus/search_test.go index 4d0e3ada..52acdd9c 100644 --- a/internal/corpus/search_test.go +++ b/internal/corpus/search_test.go @@ -13,6 +13,19 @@ import ( "github.com/morluto/gitcontribute/internal/domain" ) +func mustSearchPage(t *testing.T, limit int, cursor ...string) SearchPage { + t.Helper() + value := "" + if len(cursor) > 0 { + value = cursor[0] + } + page, err := ParseSearchPage(limit, value) + if err != nil { + t.Fatal(err) + } + return page +} + func TestSearchThreadsPageReturnsNextCursorAndTotal(t *testing.T) { t.Parallel() ctx := context.Background() @@ -25,12 +38,12 @@ func TestSearchThreadsPageReturnsNextCursorAndTotal(t *testing.T) { for i := 1; i <= 5; i++ { title := "shared term" - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, i, "open", title, "body", "a", time.Unix(int64(i), 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, i, "open", title, "body", "a", time.Unix(int64(i), 0).UTC(), `{}`); err != nil { t.Fatalf("apply thread %d: %v", i, err) } } - first, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Limit: 2}) + first, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Page: mustSearchPage(t, 2)}) if err != nil { t.Fatalf("first page: %v", err) } @@ -44,7 +57,7 @@ func TestSearchThreadsPageReturnsNextCursorAndTotal(t *testing.T) { t.Fatal("first page next_cursor is empty") } - second, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Limit: 2, Cursor: first.NextCursor}) + second, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Page: mustSearchPage(t, 2, first.NextCursor)}) if err != nil { t.Fatalf("second page: %v", err) } @@ -55,7 +68,7 @@ func TestSearchThreadsPageReturnsNextCursorAndTotal(t *testing.T) { t.Fatalf("second page total = %d, want 5", second.Total) } - third, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Limit: 2, Cursor: second.NextCursor}) + third, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Page: mustSearchPage(t, 2, second.NextCursor)}) if err != nil { t.Fatalf("third page: %v", err) } @@ -89,7 +102,7 @@ func TestSearchThreadsPageMalformedCursorRejected(t *testing.T) { c, _ := openTestCorpus(t) for _, cursor := range []string{"not-base64", "e30=", encodeCursor(searchCursor{Scope: "code"})} { - _, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Limit: 10, Cursor: cursor}) + _, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Page: mustSearchPage(t, 10, cursor)}) if err == nil { t.Fatalf("cursor %q should be rejected", cursor) } @@ -105,20 +118,20 @@ func TestSearchThreadsPageSupportsAnyTermModeAndBindsCursor(t *testing.T) { t.Fatal(err) } for number, title := range []string{"alpha only", "beta only", "alpha beta"} { - if _, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, number+1, "open", title, "", "a", time.Unix(int64(number+2), 0).UTC(), `{}`); err != nil { + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, number+1, "open", title, "", "a", time.Unix(int64(number+2), 0).UTC(), `{}`); err != nil { t.Fatal(err) } } - all, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Limit: 10, MatchMode: "all"}) + all, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || all.Total != 1 { t.Fatalf("all-term search = (%+v, %v)", all, err) } - any, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Limit: 1, MatchMode: "any"}) + any, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Page: mustSearchPage(t, 1), TermMatch: MatchAnyTerm()}) if err != nil || any.Total != 3 || any.NextCursor == "" { t.Fatalf("any-term search = (%+v, %v)", any, err) } - if _, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Limit: 1, MatchMode: "all", Cursor: any.NextCursor}); err == nil { + if _, err := c.SearchThreadsPage(ctx, "alpha beta", SearchFilter{Page: mustSearchPage(t, 1, any.NextCursor)}); err == nil { t.Fatal("cursor created for any-term search was accepted by all-term search") } } @@ -127,7 +140,7 @@ func TestSearchThreadsPageHonorsHardMax(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - _, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Limit: 101}) + _, err := c.SearchThreads(ctx, "term", 101) if err == nil || err.Error() != "search limit cannot exceed 100" { t.Fatalf("unexpected error = %v", err) } @@ -142,9 +155,9 @@ func TestSearchThreadsWeightsTitleLabelsAndSupportsNewestSort(t *testing.T) { t.Fatal(err) } threads := []Thread{ - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 1, State: "open", Title: "music playback fails", Body: "short", SourceUpdatedAt: time.Unix(100, 0).UTC()}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 2, State: "open", Title: "unrelated request", Body: "music music music music", SourceUpdatedAt: time.Unix(300, 0).UTC()}, - {RepositoryID: repo.ID, Kind: ThreadKindIssue, Number: 3, State: "open", Title: "label-only request", Labels: []string{"music"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 1, State: "open", Title: "music playback fails", Body: "short", SourceUpdatedAt: time.Unix(100, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 2, State: "open", Title: "unrelated request", Body: "music music music music", SourceUpdatedAt: time.Unix(300, 0).UTC()}, + {RepositoryID: repo.ID, Kind: domain.IssueKind, Number: 3, State: "open", Title: "label-only request", Labels: []string{"music"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, } for _, thread := range threads { thread.SourceCreatedAt = thread.SourceUpdatedAt @@ -153,7 +166,7 @@ func TestSearchThreadsWeightsTitleLabelsAndSupportsNewestSort(t *testing.T) { } } - relevance, err := c.SearchThreadsPage(ctx, "music", SearchFilter{Limit: 10}) + relevance, err := c.SearchThreadsPage(ctx, "music", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil { t.Fatal(err) } @@ -161,7 +174,7 @@ func TestSearchThreadsWeightsTitleLabelsAndSupportsNewestSort(t *testing.T) { t.Fatalf("weighted relevance order = %+v", relevance.Threads) } - newest, err := c.SearchThreadsPage(ctx, "music", SearchFilter{Limit: 10, Sort: "updated"}) + newest, err := c.SearchThreadsPage(ctx, "music", SearchFilter{Page: mustSearchPage(t, 10), Order: UpdatedSearchOrder()}) if err != nil { t.Fatal(err) } @@ -179,9 +192,9 @@ func TestSearchThreadsPageAppliesMetadataFiltersAndBindsCursor(t *testing.T) { t.Fatal(err) } for i, thread := range []Thread{ - {Kind: ThreadKindIssue, Number: 1, State: "open", Title: "shared term", Author: "Alice", Labels: []string{"bug", "help wanted"}, SourceUpdatedAt: time.Unix(100, 0).UTC()}, - {Kind: ThreadKindIssue, Number: 2, State: "closed", Title: "shared term", Author: "alice", Labels: []string{"bug"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, - {Kind: ThreadKindIssue, Number: 3, State: "open", Title: "shared term", Author: "bob", Labels: []string{"bug"}, SourceUpdatedAt: time.Unix(300, 0).UTC()}, + {Kind: domain.IssueKind, Number: 1, State: "open", Title: "shared term", Author: "Alice", Labels: []string{"bug", "help wanted"}, SourceUpdatedAt: time.Unix(100, 0).UTC()}, + {Kind: domain.IssueKind, Number: 2, State: "closed", Title: "shared term", Author: "alice", Labels: []string{"bug"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, + {Kind: domain.IssueKind, Number: 3, State: "open", Title: "shared term", Author: "bob", Labels: []string{"bug"}, SourceUpdatedAt: time.Unix(300, 0).UTC()}, } { thread.RepositoryID = repo.ID thread.SourceCreatedAt = thread.SourceUpdatedAt @@ -189,7 +202,7 @@ func TestSearchThreadsPageAppliesMetadataFiltersAndBindsCursor(t *testing.T) { t.Fatalf("seed thread %d: %v", i, err) } } - filter := SearchFilter{State: "open", Labels: []string{"bug"}, UpdatedAfter: time.Unix(50, 0).UTC(), Limit: 1} + filter := SearchFilter{State: OpenThreadState(), Labels: []string{"bug"}, UpdatedAfter: time.Unix(50, 0).UTC(), Page: mustSearchPage(t, 1)} page, err := c.SearchThreadsPage(ctx, "term", filter) if err != nil { t.Fatalf("search: %v", err) @@ -197,12 +210,12 @@ func TestSearchThreadsPageAppliesMetadataFiltersAndBindsCursor(t *testing.T) { if len(page.Threads) != 1 || page.Threads[0].Number != 3 || page.Total != 2 || page.NextCursor == "" { t.Fatalf("page = %+v", page) } - filter.Cursor = page.NextCursor + filter.Page = filter.Page.WithCursor(page.NextCursor) filter.Author = "bob" if _, err := c.SearchThreadsPage(ctx, "term", filter); err == nil { t.Fatal("cursor should be rejected when metadata filters change") } - authorPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Author: "ALICE", Limit: 10}) + authorPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Author: "ALICE", Page: mustSearchPage(t, 10)}) if err != nil || len(authorPage.Threads) != 2 { t.Fatalf("case-insensitive author filter = %+v, err=%v", authorPage, err) } @@ -217,9 +230,9 @@ func TestSearchThreadsPageDoesNotTreatUnknownMergeStateAsFalse(t *testing.T) { t.Fatal(err) } for _, thread := range []Thread{ - {Kind: ThreadKindPullRequest, Number: 1, State: "closed", Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(10, 0).UTC()}, - {Kind: ThreadKindPullRequest, Number: 2, State: "closed", Title: "shared term", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(20, 0).UTC()}, - {Kind: ThreadKindPullRequest, Number: 3, State: "closed", Title: "shared term", SourceUpdatedAt: time.Unix(30, 0).UTC()}, + {Kind: domain.PullRequestKind, Number: 1, State: "closed", Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(10, 0).UTC()}, + {Kind: domain.PullRequestKind, Number: 2, State: "closed", Title: "shared term", Merge: domain.UnmergedStatus(), SourceUpdatedAt: time.Unix(20, 0).UTC()}, + {Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "shared term", SourceUpdatedAt: time.Unix(30, 0).UTC()}, } { thread.RepositoryID = repo.ID if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { @@ -227,32 +240,43 @@ func TestSearchThreadsPageDoesNotTreatUnknownMergeStateAsFalse(t *testing.T) { } } merged, unmerged := true, false - mergedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merged: &merged, Limit: 10}) + mergedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merge: MergeFilterFromPointer(&merged), Page: mustSearchPage(t, 10)}) if err != nil || len(mergedPage.Threads) != 1 || mergedPage.Threads[0].Number != 1 || mergedPage.UnknownMergeCount != 1 { t.Fatalf("merged page = %+v, %v", mergedPage, err) } - unmergedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merged: &unmerged, Limit: 10}) + unmergedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merge: MergeFilterFromPointer(&unmerged), Page: mustSearchPage(t, 10)}) if err != nil || len(unmergedPage.Threads) != 1 || unmergedPage.Threads[0].Number != 2 || unmergedPage.UnknownMergeCount != 1 { t.Fatalf("unmerged page = %+v, %v", unmergedPage, err) } if _, err := c.UpsertThread(ctx, Thread{ - RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "closed", + RepositoryID: repo.ID, Kind: domain.PullRequestKind, Number: 3, State: "closed", Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(40, 0).UTC(), }, `{"Merged":true}`); err != nil { t.Fatal(err) } - hydratedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merged: &merged, Limit: 10}) + hydratedPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merge: MergeFilterFromPointer(&merged), Page: mustSearchPage(t, 10)}) if err != nil || len(hydratedPage.Threads) != 2 || hydratedPage.UnknownMergeCount != 0 { t.Fatalf("hydrated page = %+v, %v", hydratedPage, err) } + first, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Merge: MergeFilterFromPointer(&merged), Page: mustSearchPage(t, 1)}) + if err != nil || first.NextCursor == "" { + t.Fatalf("first merged page = %+v, %v", first, err) + } + sameMergedFilter := true + second, err := c.SearchThreadsPage(ctx, "term", SearchFilter{ + Merge: MergeFilterFromPointer(&sameMergedFilter), Page: mustSearchPage(t, 1, first.NextCursor), + }) + if err != nil || len(second.Threads) != 1 || second.Threads[0].ID == first.Threads[0].ID { + t.Fatalf("second merged page = %+v, %v", second, err) + } } func TestSearchThreadsPageIncludesAtomicFacetEvidence(t *testing.T) { t.Parallel() ctx, c, thread, newer := seedFacetSearch(t) - page, err := c.SearchThreadsPage(ctx, "transport invariant", SearchFilter{Limit: 10}) + page, err := c.SearchThreadsPage(ctx, "transport invariant", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil { t.Fatal(err) } @@ -262,7 +286,7 @@ func TestSearchThreadsPageIncludesAtomicFacetEvidence(t *testing.T) { if page.Threads[0].MatchSource != "issue_comments" || !strings.Contains(page.Threads[0].MatchExcerpt, "transport") { t.Fatalf("facet match evidence = %+v", page.Threads[0]) } - duplicatePage, err := c.SearchThreadsPage(ctx, "plain", SearchFilter{Limit: 10}) + duplicatePage, err := c.SearchThreadsPage(ctx, "plain", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || duplicatePage.Total != 1 || len(duplicatePage.Threads) != 1 { t.Fatalf("thread/facet duplicate search = %+v, err=%v", duplicatePage, err) } @@ -282,7 +306,7 @@ func TestSearchableFacetReplacementHonorsSourceOrdering(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, thread.RepositoryID, &thread.ID, "issue_comments", older, []FacetObservationInput{{SourceUpdatedAt: older, Payload: `[]`, SearchText: "stale replacement"}}, true, 0); err != nil { t.Fatal(err) } - page, err := c.SearchThreadsPage(ctx, "transport invariant", SearchFilter{Limit: 10}) + page, err := c.SearchThreadsPage(ctx, "transport invariant", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(page.Threads) != 1 { t.Fatalf("stale replacement changed search projection: page=%+v err=%v", page, err) } @@ -291,11 +315,11 @@ func TestSearchableFacetReplacementHonorsSourceOrdering(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, thread.RepositoryID, &thread.ID, "issue_comments", latest, []FacetObservationInput{{SourceUpdatedAt: latest, Payload: `[]`, SearchText: "replacement evidence"}}, true, 0); err != nil { t.Fatal(err) } - oldPage, err := c.SearchThreadsPage(ctx, "transport", SearchFilter{Limit: 10}) + oldPage, err := c.SearchThreadsPage(ctx, "transport", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(oldPage.Threads) != 0 { t.Fatalf("old facet term remains searchable: page=%+v err=%v", oldPage, err) } - newPage, err := c.SearchThreadsPage(ctx, "replacement", SearchFilter{Limit: 10}) + newPage, err := c.SearchThreadsPage(ctx, "replacement", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(newPage.Threads) != 1 { t.Fatalf("replacement facet term missing: page=%+v err=%v", newPage, err) } @@ -303,7 +327,7 @@ func TestSearchableFacetReplacementHonorsSourceOrdering(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, thread.RepositoryID, &thread.ID, "issue_comments", time.Unix(40, 0).UTC(), nil, true, 0); err != nil { t.Fatal(err) } - emptyPage, err := c.SearchThreadsPage(ctx, "replacement", SearchFilter{Limit: 10}) + emptyPage, err := c.SearchThreadsPage(ctx, "replacement", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(emptyPage.Threads) != 0 { t.Fatalf("empty facet replacement remains searchable: page=%+v err=%v", emptyPage, err) } @@ -317,7 +341,7 @@ func TestThreadSearchReportsBoundedHydratedDocument(t *testing.T) { if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "titlematch", "plain", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "titlematch", "plain", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } @@ -325,15 +349,15 @@ func TestThreadSearchReportsBoundedHydratedDocument(t *testing.T) { if err := c.ApplyFacetObservationSet(ctx, repo.ID, &thread.ID, "issue_comments", time.Unix(3, 0).UTC(), []FacetObservationInput{{SourceUpdatedAt: time.Unix(3, 0).UTC(), SearchText: searchText}}, true, 0); err != nil { t.Fatal(err) } - page, err := c.SearchThreadsPage(ctx, "insideboundary", SearchFilter{Limit: 10}) + page, err := c.SearchThreadsPage(ctx, "insideboundary", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(page.Threads) != 1 || !page.Threads[0].MatchTruncated || page.Threads[0].MatchSource != "hydrated_facets" { t.Fatalf("bounded search page = %+v, err=%v", page, err) } - titlePage, err := c.SearchThreadsPage(ctx, "titlematch", SearchFilter{Limit: 10}) + titlePage, err := c.SearchThreadsPage(ctx, "titlematch", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || len(titlePage.Threads) != 1 || titlePage.Threads[0].MatchSource != "thread" { t.Fatalf("truncated facet must not replace title attribution: page=%+v err=%v", titlePage, err) } - omitted, err := c.SearchThreadsPage(ctx, "outsideboundary", SearchFilter{Limit: 10}) + omitted, err := c.SearchThreadsPage(ctx, "outsideboundary", SearchFilter{Page: mustSearchPage(t, 10)}) if err != nil || omitted.Total != 0 { t.Fatalf("omitted suffix search = %+v, err=%v", omitted, err) } @@ -347,7 +371,7 @@ func seedFacetSearch(t *testing.T) (context.Context, *Corpus, *Thread, time.Time if err != nil { t.Fatal(err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "plain title", "plain body", "a", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "plain title", "plain body", "a", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatal(err) } @@ -375,9 +399,9 @@ func TestSearchThreadsPageFiltersByAssociationAndAssignee(t *testing.T) { t.Fatal(err) } for i, thread := range []Thread{ - {Kind: ThreadKindIssue, Number: 1, State: "open", Title: "shared term", Author: "Alice", AuthorAssociation: "OWNER", Assignees: []string{"alice"}, SourceUpdatedAt: time.Unix(100, 0).UTC()}, - {Kind: ThreadKindIssue, Number: 2, State: "open", Title: "shared term", Author: "bob", AuthorAssociation: "CONTRIBUTOR", Assignees: []string{"alice", "bob"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, - {Kind: ThreadKindIssue, Number: 3, State: "open", Title: "shared term", Author: "charlie", AuthorAssociation: "NONE", Assignees: []string{"bob"}, SourceUpdatedAt: time.Unix(300, 0).UTC()}, + {Kind: domain.IssueKind, Number: 1, State: "open", Title: "shared term", Author: "Alice", AuthorAssociation: "OWNER", Assignees: []string{"alice"}, SourceUpdatedAt: time.Unix(100, 0).UTC()}, + {Kind: domain.IssueKind, Number: 2, State: "open", Title: "shared term", Author: "bob", AuthorAssociation: "CONTRIBUTOR", Assignees: []string{"alice", "bob"}, SourceUpdatedAt: time.Unix(200, 0).UTC()}, + {Kind: domain.IssueKind, Number: 3, State: "open", Title: "shared term", Author: "charlie", AuthorAssociation: "NONE", Assignees: []string{"bob"}, SourceUpdatedAt: time.Unix(300, 0).UTC()}, } { thread.RepositoryID = repo.ID thread.SourceCreatedAt = thread.SourceUpdatedAt @@ -386,12 +410,12 @@ func TestSearchThreadsPageFiltersByAssociationAndAssignee(t *testing.T) { } } - assocPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Association: "owner", Limit: 10}) + assocPage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Association: "owner", Page: mustSearchPage(t, 10)}) if err != nil || len(assocPage.Threads) != 1 || assocPage.Threads[0].Number != 1 { t.Fatalf("association filter = %+v, err=%v", assocPage, err) } - assigneePage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Assignee: "ALICE", Limit: 10}) + assigneePage, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Assignee: "ALICE", Page: mustSearchPage(t, 10)}) if err != nil || len(assigneePage.Threads) != 2 { t.Fatalf("assignee filter = %+v, err=%v", assigneePage, err) } @@ -401,7 +425,7 @@ func TestSearchThreadsPageFiltersByAssociationAndAssignee(t *testing.T) { } } - combined, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Association: "contributor", Assignee: "bob", Limit: 10}) + combined, err := c.SearchThreadsPage(ctx, "term", SearchFilter{Association: "contributor", Assignee: "bob", Page: mustSearchPage(t, 10)}) if err != nil || len(combined.Threads) != 1 || combined.Threads[0].Number != 2 { t.Fatalf("combined filter = %+v, err=%v", combined, err) } @@ -419,7 +443,7 @@ func TestListRepositoriesPageReturnsNextCursorAndTotal(t *testing.T) { } } - first, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Limit: 2}) + first, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Page: mustSearchPage(t, 2)}) if err != nil { t.Fatalf("first page: %v", err) } @@ -432,12 +456,12 @@ func TestListRepositoriesPageReturnsNextCursorAndTotal(t *testing.T) { if first.NextCursor == "" { t.Fatal("first page next_cursor is empty") } - blank, err := c.ListRepositoriesWithOptions(ctx, " \t ", RepositorySearchOptions{Limit: 10}) + blank, err := c.ListRepositoriesWithOptions(ctx, " \t ", RepositorySearchOptions{Page: mustSearchPage(t, 10)}) if err != nil || len(blank.Repositories) != 5 { t.Fatalf("whitespace-only query = %+v, err=%v", blank, err) } - second, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Limit: 2, Cursor: first.NextCursor}) + second, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Page: mustSearchPage(t, 2, first.NextCursor)}) if err != nil { t.Fatalf("second page: %v", err) } @@ -445,7 +469,7 @@ func TestListRepositoriesPageReturnsNextCursorAndTotal(t *testing.T) { t.Fatalf("second page repositories = %d, want 2", len(second.Repositories)) } - third, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Limit: 2, Cursor: second.NextCursor}) + third, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Page: mustSearchPage(t, 2, second.NextCursor)}) if err != nil { t.Fatalf("third page: %v", err) } @@ -481,7 +505,7 @@ func TestListRepositoriesQueryWithCursorParenthesizesOR(t *testing.T) { t.Fatalf("seed description match 2: %v", err) } - first, err := c.ListRepositoriesWithOptions(ctx, "match", RepositorySearchOptions{Limit: 1}) + first, err := c.ListRepositoriesWithOptions(ctx, "match", RepositorySearchOptions{Page: mustSearchPage(t, 1)}) if err != nil { t.Fatalf("first page: %v", err) } @@ -489,7 +513,7 @@ func TestListRepositoriesQueryWithCursorParenthesizesOR(t *testing.T) { t.Fatalf("first page = %+v", first.Repositories) } - second, err := c.ListRepositoriesWithOptions(ctx, "match", RepositorySearchOptions{Limit: 2, Cursor: first.NextCursor}) + second, err := c.ListRepositoriesWithOptions(ctx, "match", RepositorySearchOptions{Page: mustSearchPage(t, 2, first.NextCursor)}) if err != nil { t.Fatalf("second page: %v", err) } @@ -507,7 +531,7 @@ func TestListRepositoriesPageMalformedCursorRejected(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - _, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Limit: 10, Cursor: "bad-cursor"}) + _, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Page: mustSearchPage(t, 10, "bad-cursor")}) if err == nil { t.Fatal("expected malformed cursor error") } @@ -517,8 +541,8 @@ func TestListRepositoriesPageHonorsHardMax(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - _, err := c.ListRepositoriesWithOptions(ctx, "", RepositorySearchOptions{Limit: 101}) - if err == nil || err.Error() != "repository list limit cannot exceed 100" { + _, err := c.ListRepositories(ctx, "", 101) + if err == nil || err.Error() != "search limit cannot exceed 100" { t.Fatalf("unexpected error = %v", err) } } @@ -538,7 +562,7 @@ func TestRepositorySearchWeightsNameTopicsDescriptionAndSupportsNewestSort(t *te } } - relevance, err := c.ListRepositoriesWithOptions(ctx, "music", RepositorySearchOptions{Limit: 10}) + relevance, err := c.ListRepositoriesWithOptions(ctx, "music", RepositorySearchOptions{Page: mustSearchPage(t, 10)}) if err != nil { t.Fatal(err) } @@ -546,7 +570,7 @@ func TestRepositorySearchWeightsNameTopicsDescriptionAndSupportsNewestSort(t *te t.Fatalf("weighted repository order = %v", got) } - newest, err := c.ListRepositoriesWithOptions(ctx, "music", RepositorySearchOptions{Limit: 10, Sort: "updated"}) + newest, err := c.ListRepositoriesWithOptions(ctx, "music", RepositorySearchOptions{Page: mustSearchPage(t, 10), Order: UpdatedSearchOrder()}) if err != nil { t.Fatal(err) } @@ -566,7 +590,7 @@ func TestRepositorySearchMatchesCanonicalSlug(t *testing.T) { t.Fatal(err) } - page, err := c.ListRepositoriesWithOptions(ctx, "acme/rocket", RepositorySearchOptions{Limit: 10}) + page, err := c.ListRepositoriesWithOptions(ctx, "acme/rocket", RepositorySearchOptions{Page: mustSearchPage(t, 10)}) if err != nil { t.Fatal(err) } @@ -595,7 +619,7 @@ func TestSearchCodePageReturnsNextCursorAndTotal(t *testing.T) { t.Fatalf("store snapshot: %v", err) } - first, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 2}) + first, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Page: mustSearchPage(t, 2)}) if err != nil { t.Fatalf("first page: %v", err) } @@ -609,7 +633,7 @@ func TestSearchCodePageReturnsNextCursorAndTotal(t *testing.T) { t.Fatal("first page next_cursor is empty") } - second, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 2, Cursor: first.NextCursor}) + second, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Page: mustSearchPage(t, 2, first.NextCursor)}) if err != nil { t.Fatalf("second page: %v", err) } @@ -646,7 +670,7 @@ func TestSearchCodePageMalformedCursorRejected(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) ref := domain.MustRepoRef("owner", "repo") - _, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 10, Cursor: "invalid"}) + _, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Page: mustSearchPage(t, 10, "invalid")}) if err == nil { t.Fatal("expected malformed cursor error") } @@ -657,8 +681,8 @@ func TestSearchCodePageHonorsHardMax(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) ref := domain.MustRepoRef("owner", "repo") - _, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 101}) - if err == nil || err.Error() != "code search limit cannot exceed 100" { + _, err := c.SearchCode(ctx, "term", ref, 101) + if err == nil || err.Error() != "search limit cannot exceed 100" { t.Fatalf("unexpected error = %v", err) } } diff --git a/internal/corpus/tracking.go b/internal/corpus/tracking.go index 7a35fdfc..563061e5 100644 --- a/internal/corpus/tracking.go +++ b/internal/corpus/tracking.go @@ -164,12 +164,12 @@ func resolveTriageLinks(ctx context.Context, c *Corpus, e *tracking.TriageEvent) if ok { if repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()); err == nil && repo != nil { e.RepositoryID = &repo.ID - kind := "" + var kind domain.ThreadKind switch e.TargetKind { case tracking.TargetIssue: - kind = ThreadKindIssue + kind = domain.IssueKind case tracking.TargetPullRequest: - kind = ThreadKindPullRequest + kind = domain.PullRequestKind } if kind != "" { if thread, err := c.GetThread(ctx, repo.ID, kind, number); err == nil && thread != nil { @@ -256,16 +256,24 @@ func (c *Corpus) ListTriageEvents(ctx context.Context, filter tracking.TriageEve query := `SELECT id, target_kind, target_ref, outcome, reason, lens, source_event_at, created_at, updated_at, repository_id, thread_id, investigation_id, opportunity_id FROM triage_events WHERE 1=1` var args []any if filter.TargetKind != "" { + parsed, err := tracking.ParseTargetKind(string(filter.TargetKind)) + if err != nil { + return nil, fmt.Errorf("parse triage target kind filter: %w", err) + } query += ` AND target_kind=?` - args = append(args, string(filter.TargetKind)) + args = append(args, string(parsed)) } if filter.TargetRef != "" { query += ` AND target_ref=?` args = append(args, filter.TargetRef) } if filter.Outcome != "" { + parsed, err := tracking.ParseOutcome(string(filter.Outcome)) + if err != nil { + return nil, fmt.Errorf("parse triage outcome filter: %w", err) + } query += ` AND outcome=?` - args = append(args, string(filter.Outcome)) + args = append(args, string(parsed)) } if filter.Lens != "" { query += ` AND lens=?` @@ -295,13 +303,22 @@ func scanTriageEvent(rows interface { Scan(dest ...any) error }) (*tracking.TriageEvent, error) { var e tracking.TriageEvent + var targetKind, outcome string var sourceEventAt, createdAt, updatedAt int64 var repositoryID, threadID sql.NullInt64 var investigationID, opportunityID sql.NullString - err := rows.Scan(&e.ID, &e.TargetKind, &e.TargetRef, &e.Outcome, &e.Reason, &e.Lens, &sourceEventAt, &createdAt, &updatedAt, &repositoryID, &threadID, &investigationID, &opportunityID) + err := rows.Scan(&e.ID, &targetKind, &e.TargetRef, &outcome, &e.Reason, &e.Lens, &sourceEventAt, &createdAt, &updatedAt, &repositoryID, &threadID, &investigationID, &opportunityID) if err != nil { return nil, err } + e.TargetKind, err = tracking.ParseTargetKind(targetKind) + if err != nil { + return nil, fmt.Errorf("parse stored triage target kind: %w", err) + } + e.Outcome, err = tracking.ParseOutcome(outcome) + if err != nil { + return nil, fmt.Errorf("parse stored triage outcome: %w", err) + } e.SourceEventAt = scanTime(sourceEventAt) e.CreatedAt = scanTime(createdAt) e.UpdatedAt = scanTime(updatedAt) @@ -460,9 +477,9 @@ func (c *Corpus) ListContributions(ctx context.Context, filter tracking.Contribu query += ` AND opportunity_id=?` args = append(args, filter.OpportunityID) } - if filter.Kind != "" { + if !filter.Kind.IsAny() { query += ` AND kind=?` - args = append(args, filter.Kind) + args = append(args, filter.Kind.String()) } query += ` ORDER BY prepared_at, id LIMIT ?` args = append(args, limit) @@ -490,11 +507,15 @@ func scanContribution(scanner interface { var item tracking.Contribution var preparedAt, createdAt, updatedAt int64 var submittedAt sql.NullInt64 - var payload string - err := scanner.Scan(&item.ID, &item.OpportunityID, &item.Kind, &item.Title, &item.Body, &item.Reference, &item.ReferenceURL, &preparedAt, &submittedAt, &createdAt, &updatedAt, &payload) + var kind, payload string + err := scanner.Scan(&item.ID, &item.OpportunityID, &kind, &item.Title, &item.Body, &item.Reference, &item.ReferenceURL, &preparedAt, &submittedAt, &createdAt, &updatedAt, &payload) if err != nil { return nil, err } + item.Kind, err = domain.ParseThreadKind(kind) + if err != nil { + return nil, fmt.Errorf("parse stored contribution kind: %w", err) + } item.PreparedAt = scanTime(preparedAt) item.CreatedAt = scanTime(createdAt) item.UpdatedAt = scanTime(updatedAt) @@ -603,10 +624,18 @@ func (c *Corpus) ListContributionOutcomes(ctx context.Context, contributionID st var out []*tracking.ContributionOutcome for rows.Next() { var o tracking.ContributionOutcome + var outcome string var sourceEventAt, createdAt int64 - if err := rows.Scan(&o.ID, &o.ContributionID, &o.Outcome, &o.Reason, &sourceEventAt, &createdAt); err != nil { + if err := rows.Scan(&o.ID, &o.ContributionID, &outcome, &o.Reason, &sourceEventAt, &createdAt); err != nil { return nil, err } + o.Outcome, err = tracking.ParseOutcome(outcome) + if err != nil { + return nil, fmt.Errorf("parse stored contribution outcome: %w", err) + } + if !isStoredContributionOutcome(o.Outcome) { + return nil, fmt.Errorf("stored contribution outcome %q is not a contribution lifecycle outcome", outcome) + } o.SourceEventAt = scanTime(sourceEventAt) o.CreatedAt = scanTime(createdAt) out = append(out, &o) @@ -614,6 +643,15 @@ func (c *Corpus) ListContributionOutcomes(ctx context.Context, contributionID st return out, rows.Err() } +func isStoredContributionOutcome(outcome tracking.Outcome) bool { + switch outcome { + case tracking.OutcomeSubmitted, tracking.OutcomeMerged, tracking.OutcomeRejected, tracking.OutcomeAbandoned: + return true + default: + return false + } +} + // ExportLocalMetadata returns a redacted, deterministic snapshot of tracking // metadata bounded by opts.Limit. func (c *Corpus) ExportLocalMetadata(ctx context.Context, opts tracking.ExportOptions) (*tracking.Bundle, error) { diff --git a/internal/corpus/tracking_outcomes.go b/internal/corpus/tracking_outcomes.go index 92917dfa..9f283599 100644 --- a/internal/corpus/tracking_outcomes.go +++ b/internal/corpus/tracking_outcomes.go @@ -25,10 +25,18 @@ func (c *Corpus) exportContributionOutcomes(ctx context.Context, db dbQueryer, b for rows.Next() { var o tracking.ContributionOutcome + var outcome string var sourceEventAt, createdAt int64 - if err := rows.Scan(&o.ID, &o.ContributionID, &o.Outcome, &o.Reason, &sourceEventAt, &createdAt); err != nil { + if err := rows.Scan(&o.ID, &o.ContributionID, &outcome, &o.Reason, &sourceEventAt, &createdAt); err != nil { return err } + o.Outcome, err = tracking.ParseOutcome(outcome) + if err != nil { + return fmt.Errorf("parse stored contribution outcome: %w", err) + } + if !isStoredContributionOutcome(o.Outcome) { + return fmt.Errorf("stored contribution outcome %q is not a contribution lifecycle outcome", outcome) + } o.SourceEventAt = scanTime(sourceEventAt) o.CreatedAt = scanTime(createdAt) bundle.ContributionOutcomes = append(bundle.ContributionOutcomes, &o) diff --git a/internal/corpus/tracking_test.go b/internal/corpus/tracking_test.go index fa02432e..19eac520 100644 --- a/internal/corpus/tracking_test.go +++ b/internal/corpus/tracking_test.go @@ -81,7 +81,7 @@ func TestTriageEventPersistsWithOptionalForeignKeyLinks(t *testing.T) { if err != nil { t.Fatalf("seed repository: %v", err) } - thread, err := c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "bug", "body", "alice", time.Unix(2, 0).UTC(), `{}`) + thread, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "bug", "body", "alice", time.Unix(2, 0).UTC(), `{}`) if err != nil { t.Fatalf("seed thread: %v", err) } @@ -163,6 +163,25 @@ func TestTriageEventOrderingIsDeterministic(t *testing.T) { t.Fatalf("events not ordered by source_event_at: %v after %v", events[i], events[i+1]) } } + if _, err := c.db.ExecContext(ctx, `UPDATE triage_events SET target_kind='invented' WHERE id=?`, ids[0]); err != nil { + t.Fatal(err) + } + if _, err := svc.ListTriageEvents(ctx, tracking.TriageEventFilter{}); err == nil { + t.Fatal("triage read accepted an invalid stored target kind") + } +} + +func TestTriageFiltersRejectUnknownTypedCasts(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + svc := tracking.NewService(c) + if _, err := svc.ListTriageEvents(ctx, tracking.TriageEventFilter{TargetKind: tracking.TargetKind("invented")}); err == nil { + t.Fatal("unknown target kind filter was treated as an empty result") + } + if _, err := svc.ListTriageEvents(ctx, tracking.TriageEventFilter{Outcome: tracking.Outcome("invented")}); err == nil { + t.Fatal("unknown outcome filter was treated as an empty result") + } } func TestContributionLifecyclePersists(t *testing.T) { @@ -228,6 +247,18 @@ func TestContributionLifecyclePersists(t *testing.T) { if len(outcomes) != 1 || outcomes[0].Outcome != tracking.OutcomeSubmitted { t.Fatalf("unexpected outcomes: %+v", outcomes) } + if _, err := c.db.ExecContext(ctx, `UPDATE contribution_outcomes SET outcome='invented' WHERE id=?`, outcome.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.ListContributionOutcomes(ctx, contribution.ID); err == nil { + t.Fatal("contribution outcome read accepted an invalid stored outcome") + } + if _, err := c.db.ExecContext(ctx, `UPDATE contribution_outcomes SET outcome=? WHERE id=?`, tracking.OutcomeViewed, outcome.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.ListContributionOutcomes(ctx, contribution.ID); err == nil { + t.Fatal("contribution outcome read accepted a triage-only outcome") + } } func TestContributionRequiresExistingOpportunity(t *testing.T) { @@ -257,8 +288,12 @@ func TestExportImportLocalMetadataIsIdempotent(t *testing.T) { svc := tracking.NewService(c) repo, _ := c.ApplyRepositoryObservation(ctx, "owner", "repo", "123", time.Unix(1, 0).UTC(), `{}`) - c.ApplyThreadObservation(ctx, repo.ID, ThreadKindIssue, 1, "open", "bug", "body", "alice", time.Unix(2, 0).UTC(), `{}`) - source, err := c.CurrentSourceRevision(ctx, evidence.SourceSubject{Kind: evidence.SourceSubjectRepository, Owner: "owner", Repo: "repo"}) + c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "bug", "body", "alice", time.Unix(2, 0).UTC(), `{}`) + subject, err := evidence.NewRepositorySourceSubject(domain.MustRepoRef("owner", "repo")) + if err != nil { + t.Fatal(err) + } + source, err := c.CurrentSourceRevision(ctx, subject) if err != nil || source == nil { t.Fatalf("current repository source = (%+v, %v)", source, err) } diff --git a/internal/corpus/workflow.go b/internal/corpus/workflow.go index 09fdacf8..cf48f898 100644 --- a/internal/corpus/workflow.go +++ b/internal/corpus/workflow.go @@ -349,7 +349,7 @@ func (c *Corpus) SaveOpportunity(ctx context.Context, item *investigation.Opport // UpdateOpportunity conditionally replaces the exact revision read by the // caller. For advancing transitions, the same SQL statement also rejects any // contradicting evidence visible when the status write is serialized. -func (c *Corpus) UpdateOpportunity(ctx context.Context, previous, next *investigation.Opportunity, blockContradicting bool) error { +func (c *Corpus) UpdateOpportunity(ctx context.Context, previous, next *investigation.Opportunity, constraint investigation.OpportunityUpdateConstraint) error { if previous == nil || next == nil || previous.ID == "" || previous.ID != next.ID { return errors.New("matching opportunity revisions are required") } @@ -362,7 +362,7 @@ func (c *Corpus) UpdateOpportunity(ctx context.Context, previous, next *investig return err } block := 0 - if blockContradicting { + if constraint.BlocksContradictingEvidence() { block = 1 } result, err := c.db.ExecContext(ctx, ` @@ -385,7 +385,7 @@ func (c *Corpus) UpdateOpportunity(ctx context.Context, previous, next *investig if changed == 1 { return nil } - if blockContradicting { + if constraint.BlocksContradictingEvidence() { var exists int if err := c.db.QueryRowContext(ctx, ` SELECT EXISTS( diff --git a/internal/corpus/workflow_test.go b/internal/corpus/workflow_test.go index 76bf0832..9540c6e7 100644 --- a/internal/corpus/workflow_test.go +++ b/internal/corpus/workflow_test.go @@ -2,8 +2,10 @@ package corpus import ( "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "path/filepath" "sync" "testing" @@ -233,6 +235,30 @@ func TestContributionWorkflowPersistsAcrossReopen(t *testing.T) { } } +func TestContributionDraftRevisionRejectsPayloadThatDisagreesWithImmutableMetadata(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + draft := &contribution.IssueDraft{ + OpportunityID: "opp", Title: "original title", Body: "original body", RenderedAt: time.Unix(10, 0).UTC(), + } + if err := c.SaveIssueDraft(ctx, draft); err != nil { + t.Fatal(err) + } + draft.Body = "tampered but internally coherent body" + draft.BodyBytes = len([]byte(draft.Body)) + draft.BodySHA256 = fmt.Sprintf("%x", sha256.Sum256([]byte(draft.Body))) + payload, err := marshalWorkflow(draft) + if err != nil { + t.Fatal(err) + } + if _, err := c.db.ExecContext(ctx, `UPDATE contribution_draft_revisions SET payload=? WHERE draft_id=? AND revision=?`, payload, draft.ID, draft.Revision); err != nil { + t.Fatal(err) + } + if _, err := c.GetContributionDraftRevision(ctx, draft.ID, draft.Revision); err == nil { + t.Fatal("revision payload that disagreed with immutable metadata was accepted") + } +} + func TestFindRelatedUsesRepositoryAndCategory(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/corpus/workflow_validation.go b/internal/corpus/workflow_validation.go index 48c0f7a0..71a549f7 100644 --- a/internal/corpus/workflow_validation.go +++ b/internal/corpus/workflow_validation.go @@ -275,16 +275,23 @@ func (c *Corpus) SaveIssueDraft(ctx context.Context, item *contribution.IssueDra if item == nil { return errors.New("issue draft is required") } - contribution.EnsureDraftIdentity(&item.DraftIdentity, item.Repository, "issue", item.Title, item.Body) - return c.saveDraft(ctx, item.OpportunityID, "issue", &item.DraftIdentity, item, item.RenderedAt) + contribution.EnsureDraftIdentity(&item.DraftIdentity, item.Repository, domain.IssueKind, item.Title, item.Body) + return c.saveDraft(ctx, item.OpportunityID, domain.IssueKind, &item.DraftIdentity, item, item.RenderedAt) } // GetIssueDraft returns the issue draft for an opportunity, or nil when absent. func (c *Corpus) GetIssueDraft(ctx context.Context, opportunityID string) (*contribution.IssueDraft, error) { + payload, err := c.readDraftPayload(ctx, opportunityID, domain.IssueKind) + if err != nil { + return nil, err + } var item contribution.IssueDraft - if err := c.getDraft(ctx, opportunityID, "issue", &item); err != nil { + if err := unmarshalWorkflow(payload, &item); err != nil { return nil, err } + if item.OpportunityID != opportunityID { + return nil, fmt.Errorf("stored issue draft opportunity does not match its lookup key") + } return &item, nil } @@ -293,20 +300,27 @@ func (c *Corpus) SavePullRequestDraft(ctx context.Context, item *contribution.Pu if item == nil { return errors.New("pull request draft is required") } - contribution.EnsureDraftIdentity(&item.DraftIdentity, item.Repository, "pull_request", item.Title, item.Body) - return c.saveDraft(ctx, item.OpportunityID, "pull_request", &item.DraftIdentity, item, item.RenderedAt) + contribution.EnsureDraftIdentity(&item.DraftIdentity, item.Repository, domain.PullRequestKind, item.Title, item.Body) + return c.saveDraft(ctx, item.OpportunityID, domain.PullRequestKind, &item.DraftIdentity, item, item.RenderedAt) } // GetPullRequestDraft returns the pull-request draft for an opportunity, or nil when absent. func (c *Corpus) GetPullRequestDraft(ctx context.Context, opportunityID string) (*contribution.PullRequestDraft, error) { + payload, err := c.readDraftPayload(ctx, opportunityID, domain.PullRequestKind) + if err != nil { + return nil, err + } var item contribution.PullRequestDraft - if err := c.getDraft(ctx, opportunityID, "pull_request", &item); err != nil { + if err := unmarshalWorkflow(payload, &item); err != nil { return nil, err } + if item.OpportunityID != opportunityID { + return nil, fmt.Errorf("stored pull-request draft opportunity does not match its lookup key") + } return &item, nil } -func (c *Corpus) saveDraft(ctx context.Context, opportunityID, kind string, identity *contribution.DraftIdentity, item any, renderedAt time.Time) error { +func (c *Corpus) saveDraft(ctx context.Context, opportunityID string, kind domain.ThreadKind, identity *contribution.DraftIdentity, item any, renderedAt time.Time) error { if opportunityID == "" { return errors.New("draft opportunity id is required") } @@ -320,10 +334,15 @@ func (c *Corpus) saveDraft(ctx context.Context, opportunityID, kind string, iden defer func() { _ = tx.Rollback() }() if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(revision), 0) + 1 FROM contribution_draft_revisions WHERE opportunity_id=? AND kind=?`, - opportunityID, kind, + opportunityID, string(kind), ).Scan(&identity.Revision); err != nil { return fmt.Errorf("select contribution draft revision: %w", err) } + if record, ok := item.(interface{ ParseStored() error }); ok { + if err := record.ParseStored(); err != nil { + return fmt.Errorf("validate contribution draft: %w", err) + } + } payload, err := marshalWorkflow(item) if err != nil { return err @@ -332,28 +351,28 @@ func (c *Corpus) saveDraft(ctx context.Context, opportunityID, kind string, iden INSERT INTO contribution_draft_revisions (draft_id, opportunity_id, kind, revision, title_sha256, body_sha256, payload, rendered_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - identity.ID, opportunityID, kind, identity.Revision, identity.TitleSHA256, identity.BodySHA256, payload, encodeTime(renderedAt), + identity.ID, opportunityID, string(kind), identity.Revision, identity.TitleSHA256, identity.BodySHA256, payload, encodeTime(renderedAt), ) if err != nil { return fmt.Errorf("save contribution draft revision: %w", err) } - _, err = tx.ExecContext(ctx, `INSERT INTO contribution_drafts (opportunity_id, kind, payload, rendered_at) VALUES (?, ?, ?, ?) ON CONFLICT (opportunity_id, kind) DO UPDATE SET payload=excluded.payload, rendered_at=excluded.rendered_at`, opportunityID, kind, payload, encodeTime(renderedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO contribution_drafts (opportunity_id, kind, payload, rendered_at) VALUES (?, ?, ?, ?) ON CONFLICT (opportunity_id, kind) DO UPDATE SET payload=excluded.payload, rendered_at=excluded.rendered_at`, opportunityID, string(kind), payload, encodeTime(renderedAt)) if err != nil { return fmt.Errorf("save latest contribution draft: %w", err) } return tx.Commit() } -func (c *Corpus) getDraft(ctx context.Context, opportunityID, kind string, target any) error { +func (c *Corpus) readDraftPayload(ctx context.Context, opportunityID string, kind domain.ThreadKind) (string, error) { var payload string - err := c.db.QueryRowContext(ctx, `SELECT payload FROM contribution_drafts WHERE opportunity_id=? AND kind=?`, opportunityID, kind).Scan(&payload) + err := c.db.QueryRowContext(ctx, `SELECT payload FROM contribution_drafts WHERE opportunity_id=? AND kind=?`, opportunityID, string(kind)).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { - return contribution.ErrNotFound + return "", contribution.ErrNotFound } if err != nil { - return fmt.Errorf("get contribution draft: %w", err) + return "", fmt.Errorf("get contribution draft: %w", err) } - return unmarshalWorkflow(payload, target) + return payload, nil } // GetContributionDraftRevision returns one immutable stored draft revision. @@ -361,39 +380,50 @@ func (c *Corpus) GetContributionDraftRevision(ctx context.Context, draftID strin if draftID == "" || revision < 1 { return nil, errors.New("draft id and positive revision are required") } - var kind, payload string + var opportunityID, storedKind, titleSHA256, bodySHA256, payload string + var renderedAt int64 err := c.db.QueryRowContext(ctx, - `SELECT kind, payload FROM contribution_draft_revisions WHERE draft_id=? AND revision=?`, + `SELECT opportunity_id, kind, title_sha256, body_sha256, payload, rendered_at + FROM contribution_draft_revisions WHERE draft_id=? AND revision=?`, draftID, revision, - ).Scan(&kind, &payload) + ).Scan(&opportunityID, &storedKind, &titleSHA256, &bodySHA256, &payload, &renderedAt) if errors.Is(err, sql.ErrNoRows) { return nil, contribution.ErrNotFound } if err != nil { return nil, fmt.Errorf("get contribution draft revision: %w", err) } + kind, err := domain.ParseThreadKind(storedKind) + if err != nil { + return nil, fmt.Errorf("parse stored draft kind: %w", err) + } + var artifact contribution.DraftArtifact switch kind { - case "issue": + case domain.IssueKind: var draft contribution.IssueDraft if err := unmarshalWorkflow(payload, &draft); err != nil { return nil, err } - return &contribution.DraftArtifact{ + artifact = contribution.DraftArtifact{ DraftIdentity: draft.DraftIdentity, OpportunityID: draft.OpportunityID, Title: draft.Title, Body: draft.Body, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID, - }, nil - case "pull_request": + } + case domain.PullRequestKind: var draft contribution.PullRequestDraft if err := unmarshalWorkflow(payload, &draft); err != nil { return nil, err } - return &contribution.DraftArtifact{ + artifact = contribution.DraftArtifact{ DraftIdentity: draft.DraftIdentity, OpportunityID: draft.OpportunityID, Title: draft.Title, Body: draft.Body, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID, - }, nil - default: - return nil, fmt.Errorf("unsupported stored draft kind %q", kind) + } + } + if artifact.ID != draftID || artifact.Revision != revision || artifact.OpportunityID != opportunityID || + artifact.Kind != kind || artifact.TitleSHA256 != titleSHA256 || artifact.BodySHA256 != bodySHA256 || + !artifact.RenderedAt.Equal(scanTime(renderedAt)) { + return nil, fmt.Errorf("stored draft revision metadata does not match its payload") } + return &artifact, nil } // SaveContributionManifest persists one deterministic evidence statement. diff --git a/internal/deepwiki/client.go b/internal/deepwiki/client.go index 5d6eec08..1f03d40e 100644 --- a/internal/deepwiki/client.go +++ b/internal/deepwiki/client.go @@ -15,15 +15,6 @@ import ( // DefaultEndpoint is DeepWiki's unauthenticated public Streamable HTTP MCP endpoint. const DefaultEndpoint = "https://mcp.deepwiki.com/mcp" -// Request selects one public DeepWiki read action. Structure and contents use -// Repository; question uses Repositories and Question. -type Request struct { - Action string - Repository string - Repositories []string - Question string -} - type responseState uint8 const ( @@ -128,27 +119,22 @@ func callDeepWikiTool(ctx context.Context, endpoint, name string, arguments map[ } func toolCall(req Request) (string, map[string]any, error) { - switch req.Action { - case "structure": - if req.Repository == "" { - return "", nil, errors.New("repository is required") - } - return "read_wiki_structure", map[string]any{"repoName": req.Repository}, nil - case "contents": - if req.Repository == "" { - return "", nil, errors.New("repository is required") - } - return "read_wiki_contents", map[string]any{"repoName": req.Repository}, nil - case "question": - if len(req.Repositories) == 0 || req.Question == "" { - return "", nil, errors.New("repositories and question are required") - } - var repoName any = req.Repositories - if len(req.Repositories) == 1 { - repoName = req.Repositories[0] + if req == nil { + return "", nil, errors.New("DeepWiki request is required") + } + repositories := req.Repositories() + switch req.Action() { + case Structure: + return "read_wiki_structure", map[string]any{"repoName": repositories[0]}, nil + case Contents: + return "read_wiki_contents", map[string]any{"repoName": repositories[0]}, nil + case Question: + var repoName any = repositories + if len(repositories) == 1 { + repoName = repositories[0] } - return "ask_question", map[string]any{"repoName": repoName, "question": req.Question}, nil + return "ask_question", map[string]any{"repoName": repoName, "question": req.Question()}, nil default: - return "", nil, fmt.Errorf("unsupported DeepWiki action %q", req.Action) + return "", nil, errors.New("invalid parsed DeepWiki request") } } diff --git a/internal/deepwiki/client_test.go b/internal/deepwiki/client_test.go index c4987648..c1308d70 100644 --- a/internal/deepwiki/client_test.go +++ b/internal/deepwiki/client_test.go @@ -37,7 +37,8 @@ func TestClientReadRoutesRequests(t *testing.T) { name, args = gotName, gotArgs return &mcp.CallToolResult{}, nil }) - if _, err := client.Read(context.Background(), Request{Action: tt.action, Repository: tt.repository, Repositories: tt.repositories, Question: tt.question}); err != nil { + request := mustParseRequest(t, tt.action, tt.repository, tt.repositories, tt.question) + if _, err := client.Read(context.Background(), request); err != nil { t.Fatal(err) } mu.Lock() @@ -57,12 +58,10 @@ func TestClientReadRoutesRequests(t *testing.T) { } } -func TestClientReadRejectsMissingAndUnsupportedInputs(t *testing.T) { +func TestClientReadRejectsMissingParsedRequest(t *testing.T) { t.Parallel() - for _, req := range []Request{{Action: "structure"}, {Action: "contents"}, {Action: "question"}, {Action: "unknown"}} { - if _, err := (&Client{}).Read(context.Background(), req); err == nil { - t.Fatalf("Read(%+v) accepted invalid input", req) - } + if _, err := (&Client{}).Read(context.Background(), nil); err == nil { + t.Fatal("Read accepted a missing parsed request") } } @@ -74,7 +73,7 @@ func TestClientReadMapsResponse(t *testing.T) { } return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "first"}, &mcp.TextContent{Text: "https://deepwiki.com/owner/repo#topic"}}}, nil }) - got, err := client.Read(context.Background(), Request{Action: "contents", Repository: "owner/repo"}) + got, err := client.Read(context.Background(), mustParseRequest(t, "contents", "owner/repo", nil, "")) if err != nil { t.Fatal(err) } @@ -88,7 +87,7 @@ func TestClientReadHandlesProviderAndTransportFailures(t *testing.T) { provider := newTestClient(t, func(string, map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{IsError: true}, nil }) - got, err := provider.Read(context.Background(), Request{Action: "structure", Repository: "owner/repo"}) + got, err := provider.Read(context.Background(), mustParseRequest(t, "structure", "owner/repo", nil, "")) if err != nil || got.Available() { t.Fatalf("provider error = %+v, %v", got, err) } @@ -96,7 +95,7 @@ func TestClientReadHandlesProviderAndTransportFailures(t *testing.T) { transportServer := httptest.NewServer(http.NotFoundHandler()) t.Cleanup(transportServer.Close) transport := &Client{Endpoint: transportServer.URL} - _, err = transport.Read(context.Background(), Request{Action: "structure", Repository: "owner/repo"}) + _, err = transport.Read(context.Background(), mustParseRequest(t, "structure", "owner/repo", nil, "")) if err == nil || !strings.Contains(err.Error(), "call DeepWiki read_wiki_structure:") { t.Fatalf("transport error = %v", err) } @@ -126,11 +125,8 @@ func TestClientReadClassifiesProviderErrorTextAsUnavailable(t *testing.T) { client := newTestClient(t, func(string, map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: tt.text}}}, nil }) - got, err := client.Read(context.Background(), Request{ - Action: "question", - Repositories: []string{"indexed/repo", "missing/repo"}, - Question: "Compare them.", - }) + request := mustParseRequest(t, "question", "", []string{"indexed/repo", "missing/repo"}, "Compare them.") + got, err := client.Read(context.Background(), request) if err != nil || got.Available() || got.Text() != tt.text { t.Fatalf("provider error text = %+v, %v", got, err) } @@ -144,11 +140,8 @@ func TestClientReadKeepsNormalMultiRepositoryAnswerAvailable(t *testing.T) { client := newTestClient(t, func(string, map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: answer}}}, nil }) - got, err := client.Read(context.Background(), Request{ - Action: "question", - Repositories: []string{"indexed/repo", "other/repo"}, - Question: "Compare them.", - }) + request := mustParseRequest(t, "question", "", []string{"indexed/repo", "other/repo"}, "Compare them.") + got, err := client.Read(context.Background(), request) if err != nil || !got.Available() || got.Text() != answer { t.Fatalf("normal answer = %+v, %v", got, err) } @@ -159,7 +152,7 @@ func TestClientReadAcceptsEmptySuccessfulResponse(t *testing.T) { client := newTestClient(t, func(string, map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{}, nil }) - got, err := client.Read(context.Background(), Request{Action: "structure", Repository: "owner/repo"}) + got, err := client.Read(context.Background(), mustParseRequest(t, "structure", "owner/repo", nil, "")) if err != nil || !got.Available() || got.Text() != "" || got.SourceURL() != "" { t.Fatalf("empty response = %+v, %v", got, err) } @@ -174,6 +167,15 @@ func (e *unexpectedToolCallError) Error() string { return "unexpected DeepWiki tool call " + e.name } +func mustParseRequest(t *testing.T, action, repository string, repositories []string, question string) Request { + t.Helper() + request, err := ParseRequest(action, repository, repositories, question) + if err != nil { + t.Fatalf("ParseRequest: %v", err) + } + return request +} + func newTestClient(t *testing.T, respond func(string, map[string]any) (*mcp.CallToolResult, error)) *Client { t.Helper() server := mcp.NewServer(&mcp.Implementation{Name: "deepwiki-fixture", Version: "1"}, nil) diff --git a/internal/deepwiki/request.go b/internal/deepwiki/request.go new file mode 100644 index 00000000..63a53907 --- /dev/null +++ b/internal/deepwiki/request.go @@ -0,0 +1,124 @@ +package deepwiki + +import ( + "errors" + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/domain" +) + +// MaxRepositories is the provider-supported bound for one cross-repository +// question. +const MaxRepositories = 10 + +// Action identifies the concrete DeepWiki read represented by a Request. +type Action uint8 + +const ( + Structure Action = iota + 1 + Contents + Question +) + +// String returns the protocol spelling for the action. +func (a Action) String() string { + switch a { + case Structure: + return "structure" + case Contents: + return "contents" + case Question: + return "question" + default: + return "" + } +} + +// Request is a parsed DeepWiki read. Its implementations are sealed so +// repository reads cannot carry question-only fields and questions cannot +// carry the single-repository representation. +type Request interface { + Action() Action + Repositories() []string + Question() string + isRequest() +} + +type repositoryRequest struct { + action Action + repository domain.RepoRef +} + +func (r repositoryRequest) Action() Action { return r.action } +func (r repositoryRequest) Repositories() []string { return []string{r.repository.String()} } +func (repositoryRequest) Question() string { return "" } +func (repositoryRequest) isRequest() {} + +type questionRequest struct { + repositories []domain.RepoRef + question string +} + +func (questionRequest) Action() Action { return Question } +func (r questionRequest) Repositories() []string { + values := make([]string, len(r.repositories)) + for i, repository := range r.repositories { + values[i] = repository.String() + } + return values +} +func (r questionRequest) Question() string { return r.question } +func (questionRequest) isRequest() {} + +// ParseRequest turns the loose protocol fields into one concrete DeepWiki +// operation. Successful callers pass the returned Request inward instead of +// retaining or revalidating the original field bag. +func ParseRequest(action, repository string, repositories []string, question string) (Request, error) { + switch strings.TrimSpace(action) { + case "structure": + return parseRepositoryRequest(Structure, repository, repositories, question) + case "contents": + return parseRepositoryRequest(Contents, repository, repositories, question) + case "question": + return parseQuestionRequest(repository, repositories, question) + default: + return nil, errors.New("action must be structure, contents, or question") + } +} + +func parseRepositoryRequest(action Action, repository string, repositories []string, question string) (Request, error) { + if len(repositories) > 0 || strings.TrimSpace(question) != "" { + return nil, fmt.Errorf("%s accepts repository only", action) + } + parsed, err := domain.ParseRepoRef(repository) + if err != nil { + return nil, fmt.Errorf("invalid DeepWiki repository: %w", err) + } + return repositoryRequest{action: action, repository: parsed}, nil +} + +func parseQuestionRequest(repository string, repositories []string, question string) (Request, error) { + if strings.TrimSpace(repository) != "" { + return nil, errors.New("question accepts repositories only") + } + if len(repositories) == 0 { + return nil, errors.New("repositories are required for question") + } + if len(repositories) > MaxRepositories { + return nil, fmt.Errorf("DeepWiki supports at most %d repositories", MaxRepositories) + } + question = strings.TrimSpace(question) + if question == "" { + return nil, errors.New("question is required") + } + parsed := make([]domain.RepoRef, len(repositories)) + for i, repository := range repositories { + ref, err := domain.ParseRepoRef(repository) + if err != nil { + return nil, fmt.Errorf("invalid DeepWiki repository at index %d: %w", i, err) + } + parsed[i] = ref + } + return questionRequest{repositories: parsed, question: question}, nil +} diff --git a/internal/deepwiki/request_test.go b/internal/deepwiki/request_test.go new file mode 100644 index 00000000..592a22c8 --- /dev/null +++ b/internal/deepwiki/request_test.go @@ -0,0 +1,80 @@ +package deepwiki + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseRequestConstructsConcreteOperations(t *testing.T) { + t.Parallel() + tests := []struct { + name string + action string + repository string + repositories []string + question string + wantAction Action + wantRepos []string + wantQuestion string + }{ + {name: "structure", action: " structure ", repository: " owner/repo ", wantAction: Structure, wantRepos: []string{"owner/repo"}}, + {name: "contents", action: "contents", repository: "owner/repo", wantAction: Contents, wantRepos: []string{"owner/repo"}}, + {name: "question", action: "question", repositories: []string{" one/repo ", "two/repo"}, question: " Compare them. ", wantAction: Question, wantRepos: []string{"one/repo", "two/repo"}, wantQuestion: "Compare them."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + request, err := ParseRequest(tt.action, tt.repository, tt.repositories, tt.question) + if err != nil { + t.Fatal(err) + } + if request.Action() != tt.wantAction || !reflect.DeepEqual(request.Repositories(), tt.wantRepos) || request.Question() != tt.wantQuestion { + t.Fatalf("request = (%s, %v, %q), want (%s, %v, %q)", request.Action(), request.Repositories(), request.Question(), tt.wantAction, tt.wantRepos, tt.wantQuestion) + } + }) + } +} + +func TestParseRequestRejectsContradictoryAndMalformedInputs(t *testing.T) { + t.Parallel() + tooMany := make([]string, MaxRepositories+1) + for i := range tooMany { + tooMany[i] = "owner/repo" + } + tests := []struct { + name string + action string + repository string + repositories []string + question string + }{ + {name: "unknown action", action: "unknown"}, + {name: "missing repository", action: "structure"}, + {name: "malformed repository", action: "contents", repository: "owner/repo/extra"}, + {name: "repository mode with question fields", action: "structure", repository: "owner/repo", repositories: []string{"other/repo"}}, + {name: "question with repository field", action: "question", repository: "owner/repo", repositories: []string{"other/repo"}, question: "why"}, + {name: "question without repositories", action: "question", question: "why"}, + {name: "question without text", action: "question", repositories: []string{"owner/repo"}, question: " "}, + {name: "question with malformed repository", action: "question", repositories: []string{"owner/repo", "bad"}, question: "why"}, + {name: "too many repositories", action: "question", repositories: tooMany, question: "why"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if request, err := ParseRequest(tt.action, tt.repository, tt.repositories, tt.question); err == nil { + t.Fatalf("ParseRequest returned %v", request) + } + }) + } +} + +func TestRequestRepositoryProjectionDoesNotExposeMutableState(t *testing.T) { + t.Parallel() + request := mustParseRequest(t, "question", "", []string{"one/repo", "two/repo"}, "Compare") + repositories := request.Repositories() + repositories[0] = strings.Repeat("x", 20) + if got := request.Repositories(); !reflect.DeepEqual(got, []string{"one/repo", "two/repo"}) { + t.Fatalf("request repositories mutated through projection: %v", got) + } +} diff --git a/internal/discovery/checkpoint.go b/internal/discovery/checkpoint.go index 5e8c98ce..56806bf6 100644 --- a/internal/discovery/checkpoint.go +++ b/internal/discovery/checkpoint.go @@ -2,7 +2,6 @@ package discovery import ( "context" - "sync" "time" ) @@ -24,63 +23,3 @@ type CheckpointStore interface { // MarkImported records the given GH Archive hour as imported. MarkImported(ctx context.Context, hour string) error } - -// MemoryCheckpointStore is an in-memory CheckpointStore for tests and -// short-lived local use. -type MemoryCheckpointStore struct { - mu sync.Mutex - times map[string]time.Time - hours map[string]struct{} -} - -// NewMemoryCheckpointStore returns a new in-memory checkpoint store. -func NewMemoryCheckpointStore() *MemoryCheckpointStore { - return &MemoryCheckpointStore{ - times: make(map[string]time.Time), - hours: make(map[string]struct{}), - } -} - -// GetTime returns the timestamp checkpoint for key. -func (m *MemoryCheckpointStore) GetTime(ctx context.Context, key string) (time.Time, bool, error) { - if err := ctx.Err(); err != nil { - return time.Time{}, false, err - } - m.mu.Lock() - defer m.mu.Unlock() - t, ok := m.times[key] - return t, ok, nil -} - -// SetTime stores a timestamp checkpoint for key. -func (m *MemoryCheckpointStore) SetTime(ctx context.Context, key string, t time.Time) error { - if err := ctx.Err(); err != nil { - return err - } - m.mu.Lock() - defer m.mu.Unlock() - m.times[key] = t - return nil -} - -// IsImported reports whether the given hour has already been imported. -func (m *MemoryCheckpointStore) IsImported(ctx context.Context, hour string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - m.mu.Lock() - defer m.mu.Unlock() - _, ok := m.hours[hour] - return ok, nil -} - -// MarkImported records the given hour as imported. -func (m *MemoryCheckpointStore) MarkImported(ctx context.Context, hour string) error { - if err := ctx.Err(); err != nil { - return err - } - m.mu.Lock() - defer m.mu.Unlock() - m.hours[hour] = struct{}{} - return nil -} diff --git a/internal/discovery/checkpoint_store_test.go b/internal/discovery/checkpoint_store_test.go new file mode 100644 index 00000000..8598dc68 --- /dev/null +++ b/internal/discovery/checkpoint_store_test.go @@ -0,0 +1,60 @@ +package discovery + +import ( + "context" + "sync" + "time" +) + +type MemoryCheckpointStore struct { + mu sync.Mutex + times map[string]time.Time + hours map[string]struct{} +} + +func NewMemoryCheckpointStore() *MemoryCheckpointStore { + return &MemoryCheckpointStore{ + times: make(map[string]time.Time), + hours: make(map[string]struct{}), + } +} + +func (m *MemoryCheckpointStore) GetTime(ctx context.Context, key string) (time.Time, bool, error) { + if err := ctx.Err(); err != nil { + return time.Time{}, false, err + } + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.times[key] + return t, ok, nil +} + +func (m *MemoryCheckpointStore) SetTime(ctx context.Context, key string, value time.Time) error { + if err := ctx.Err(); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + m.times[key] = value + return nil +} + +func (m *MemoryCheckpointStore) IsImported(ctx context.Context, hour string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.hours[hour] + return ok, nil +} + +func (m *MemoryCheckpointStore) MarkImported(ctx context.Context, hour string) error { + if err := ctx.Err(); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + m.hours[hour] = struct{}{} + return nil +} diff --git a/internal/discovery/gharchive.go b/internal/discovery/gharchive.go index bb41136f..7af55b75 100644 --- a/internal/discovery/gharchive.go +++ b/internal/discovery/gharchive.go @@ -23,25 +23,6 @@ const defaultMaxEventBytes = 4 << 20 // exceeds ArchiveReader.MaxTotalBytes. var ErrDecompressedTooLarge = errors.New("decompressed archive exceeds size limit") -var knownEventTypes = map[string]bool{ - string(PushEvent): true, - string(IssuesEvent): true, - string(PullRequestEvent): true, - string(IssueCommentEvent): true, - string(PullRequestReviewEvent): true, - string(PullRequestReviewCommentEvent): true, - string(ReleaseEvent): true, - string(WatchEvent): true, - string(ForkEvent): true, - string(DiscussionEvent): true, - string(DiscussionCommentEvent): true, -} - -// IsKnownEventType reports whether t is a recognized GH Archive event type. -func IsKnownEventType(t string) bool { - return knownEventTypes[t] -} - // ArchiveHourRange returns the inclusive hourly bounds for a --since crawl. // The latest complete hour is the hour before the current hour, because the // current hour's file may not yet be published. @@ -73,6 +54,37 @@ const ( DiscussionCommentEvent EventType = "DiscussionCommentEvent" ) +// ParseEventType converts a raw GH Archive discriminator into one supported +// event kind. +func ParseEventType(value string) (EventType, error) { + switch EventType(value) { + case PushEvent: + return PushEvent, nil + case IssuesEvent: + return IssuesEvent, nil + case PullRequestEvent: + return PullRequestEvent, nil + case IssueCommentEvent: + return IssueCommentEvent, nil + case PullRequestReviewEvent: + return PullRequestReviewEvent, nil + case PullRequestReviewCommentEvent: + return PullRequestReviewCommentEvent, nil + case ReleaseEvent: + return ReleaseEvent, nil + case WatchEvent: + return WatchEvent, nil + case ForkEvent: + return ForkEvent, nil + case DiscussionEvent: + return DiscussionEvent, nil + case DiscussionCommentEvent: + return DiscussionCommentEvent, nil + default: + return "", fmt.Errorf("unsupported GH Archive event type %q", value) + } +} + // Signal is a normalized, product-owned discovery signal emitted from GH Archive // events. Not all fields are populated for every event kind. type Signal struct { @@ -99,7 +111,7 @@ type Signal struct { // ArchiveReader streams an hourly GH Archive gzip file line by line, retains // only configured event types, and emits normalized repository/thread signals. type ArchiveReader struct { - Include map[string]bool + include map[EventType]struct{} Store CheckpointStore MaxEventBytes int // MaxTotalBytes bounds the total decompressed bytes for an hour. Zero @@ -109,12 +121,16 @@ type ArchiveReader struct { // NewArchiveReader creates a reader that retains the given event types. An // empty include list retains all events. -func NewArchiveReader(include []string, store CheckpointStore) *ArchiveReader { - m := make(map[string]bool, len(include)) - for _, t := range include { - m[t] = true +func NewArchiveReader(include []string, store CheckpointStore) (*ArchiveReader, error) { + parsed := make(map[EventType]struct{}, len(include)) + for _, raw := range include { + eventType, err := ParseEventType(raw) + if err != nil { + return nil, err + } + parsed[eventType] = struct{}{} } - return &ArchiveReader{Include: m, Store: store, MaxEventBytes: defaultMaxEventBytes} + return &ArchiveReader{include: parsed, Store: store, MaxEventBytes: defaultMaxEventBytes}, nil } // Read decompresses the hourly gzip stream, parses JSON lines, and emits a @@ -171,11 +187,12 @@ func (r *ArchiveReader) Read(ctx context.Context, hour time.Time, in io.Reader, if err := json.Unmarshal(line, &ev); err != nil { continue } - if !r.shouldInclude(ev.Type) { + eventType, err := ParseEventType(ev.Type) + if err != nil || !r.shouldInclude(eventType) { continue } - sig, ok := normalizeEvent(ev, hour) + sig, ok := normalizeEvent(ev, hour, eventType) if !ok { continue } @@ -196,11 +213,12 @@ func (r *ArchiveReader) Read(ctx context.Context, hour time.Time, in io.Reader, return nil } -func (r *ArchiveReader) shouldInclude(t string) bool { - if len(r.Include) == 0 { +func (r *ArchiveReader) shouldInclude(eventType EventType) bool { + if len(r.include) == 0 { return true } - return r.Include[t] + _, ok := r.include[eventType] + return ok } // HourKey returns a stable, UTC hour identifier for checkpoint storage. @@ -277,6 +295,10 @@ type releasePayload struct { } `json:"release"` } +type discussionPayload struct { + Action string `json:"action"` +} + type commentPayload struct { Action string `json:"action"` Issue issueObj `json:"issue"` @@ -308,7 +330,7 @@ type reviewCommentPayload struct { } `json:"comment"` } -func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { +func normalizeEvent(ev rawEvent, hour time.Time, eventType EventType) (Signal, bool) { observed, err := time.Parse(time.RFC3339, ev.CreatedAt) if err != nil { return Signal{}, false @@ -318,19 +340,18 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { if !ok { return Signal{}, false } - sig := Signal{ Source: "gharchive", Hour: hour.UTC().Truncate(time.Hour), ObservedAt: observed, - EventType: EventType(ev.Type), + EventType: eventType, Repo: ref, RepoID: ev.Repo.ID, Actor: ev.Actor.Login, } - switch ev.Type { - case string(PushEvent): + switch eventType { + case PushEvent: var p pushPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -344,7 +365,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { } return sig, true - case string(IssuesEvent): + case IssuesEvent: var p issuePayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -353,7 +374,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(PullRequestEvent): + case PullRequestEvent: var p pullRequestPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -366,7 +387,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(IssueCommentEvent): + case IssueCommentEvent: var p commentPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -379,7 +400,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(PullRequestReviewEvent): + case PullRequestReviewEvent: var p reviewPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -388,7 +409,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(PullRequestReviewCommentEvent): + case PullRequestReviewCommentEvent: var p reviewCommentPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -397,7 +418,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(WatchEvent): + case WatchEvent: var p watchPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -405,7 +426,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.Action = p.Action return sig, true - case string(ForkEvent): + case ForkEvent: var p forkPayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -414,7 +435,7 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { _ = p.Forkee.ID return sig, true - case string(ReleaseEvent): + case ReleaseEvent: var p releasePayload if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false @@ -423,14 +444,12 @@ func normalizeEvent(ev rawEvent, hour time.Time) (Signal, bool) { sig.TagName = p.Release.TagName return sig, true - case string(DiscussionEvent), string(DiscussionCommentEvent): - var m map[string]any - if err := json.Unmarshal(ev.Payload, &m); err != nil { + case DiscussionEvent, DiscussionCommentEvent: + var p discussionPayload + if err := json.Unmarshal(ev.Payload, &p); err != nil { return Signal{}, false } - if a, ok := m["action"].(string); ok { - sig.Action = a - } + sig.Action = p.Action return sig, true default: @@ -443,23 +462,14 @@ func fillIssueSignal(sig *Signal, issue issueObj, kind domain.ThreadKind) { sig.ThreadNumber = issue.Number sig.ThreadTitle = issue.Title sig.ThreadAuthor = issue.User.Login - sig.ThreadState = mapState(issue.State) + if state, err := domain.ParseThreadState(issue.State); err == nil { + sig.ThreadState = state + } if kind == domain.PullRequestKind { sig.Merged = issue.Merged } } -func mapState(state string) domain.ThreadState { - switch state { - case "open": - return domain.OpenState - case "closed": - return domain.ClosedState - default: - return "" - } -} - func parseRepoRef(name string) (domain.RepoRef, bool) { ref, err := domain.ParseRepoRef(name) if err != nil { diff --git a/internal/discovery/gharchive_fetcher.go b/internal/discovery/gharchive_fetcher.go index 894cf8e6..63ab97e1 100644 --- a/internal/discovery/gharchive_fetcher.go +++ b/internal/discovery/gharchive_fetcher.go @@ -36,8 +36,8 @@ type ArchiveFetcher interface { Fetch(ctx context.Context, hour time.Time) (io.ReadCloser, error) } -// ArchiveClient is a context-aware, bounded HTTP fetcher for GH Archive. -type ArchiveClient struct { +// archiveClient is the product's context-aware, bounded GH Archive fetcher. +type archiveClient struct { baseURL string httpClient *http.Client timeout time.Duration @@ -46,7 +46,7 @@ type ArchiveClient struct { // NewArchiveClient returns an ArchiveFetcher with sensible production defaults. func NewArchiveClient() ArchiveFetcher { - return NewArchiveClientWithOptions( + return newArchiveClient( DefaultArchiveBaseURL, &http.Client{Timeout: DefaultArchiveTimeout}, DefaultArchiveTimeout, @@ -54,14 +54,11 @@ func NewArchiveClient() ArchiveFetcher { ) } -// NewArchiveClientWithOptions returns a fetcher using the supplied parameters. -// It is intended for tests and advanced configuration; callers should avoid -// exposing arbitrary base URLs to untrusted input. -func NewArchiveClientWithOptions(baseURL string, client *http.Client, timeout time.Duration, maxBytes int64) *ArchiveClient { +func newArchiveClient(baseURL string, client *http.Client, timeout time.Duration, maxBytes int64) *archiveClient { if client == nil { client = &http.Client{Timeout: timeout} } - return &ArchiveClient{ + return &archiveClient{ baseURL: baseURL, httpClient: client, timeout: timeout, @@ -72,7 +69,7 @@ func NewArchiveClientWithOptions(baseURL string, client *http.Client, timeout ti // Fetch builds the canonical https://data.gharchive.org/YYYY-MM-DD-H.json.gz // URL, applies a per-request timeout, checks status and response size, and // returns a ReadCloser that enforces maxBytes while streaming. -func (c *ArchiveClient) Fetch(ctx context.Context, hour time.Time) (io.ReadCloser, error) { +func (c *archiveClient) Fetch(ctx context.Context, hour time.Time) (io.ReadCloser, error) { hour = hour.UTC() url := fmt.Sprintf("%s/%04d-%02d-%02d-%d.json.gz", c.baseURL, hour.Year(), hour.Month(), hour.Day(), hour.Hour()) @@ -119,6 +116,8 @@ func (c *ArchiveClient) Fetch(ctx context.Context, hour time.Time) (io.ReadClose return NewLimitedReader(resp.Body, c.maxBytes, closeBody, ErrResponseTooLarge), nil } +var _ ArchiveFetcher = (*archiveClient)(nil) + type ownedReadCloser struct { io.Reader closeFunc func() error diff --git a/internal/discovery/gharchive_fetcher_support_test.go b/internal/discovery/gharchive_fetcher_support_test.go new file mode 100644 index 00000000..cb0e348c --- /dev/null +++ b/internal/discovery/gharchive_fetcher_support_test.go @@ -0,0 +1,10 @@ +package discovery + +import ( + "net/http" + "time" +) + +func NewArchiveClientWithOptions(baseURL string, client *http.Client, timeout time.Duration, maxBytes int64) *archiveClient { + return newArchiveClient(baseURL, client, timeout, maxBytes) +} diff --git a/internal/discovery/gharchive_fetcher_test.go b/internal/discovery/gharchive_fetcher_test.go index 781c4d6d..06587934 100644 --- a/internal/discovery/gharchive_fetcher_test.go +++ b/internal/discovery/gharchive_fetcher_test.go @@ -212,9 +212,9 @@ func TestArchiveClientFetchContextCancellation(t *testing.T) { func TestArchiveClientDefaultUsesProductionURL(t *testing.T) { f := NewArchiveClient() - client, ok := f.(*ArchiveClient) + client, ok := f.(*archiveClient) if !ok { - t.Fatalf("expected *ArchiveClient, got %T", f) + t.Fatalf("expected *archiveClient, got %T", f) } if client.baseURL != DefaultArchiveBaseURL { t.Fatalf("baseURL = %q, want %q", client.baseURL, DefaultArchiveBaseURL) diff --git a/internal/discovery/gharchive_test.go b/internal/discovery/gharchive_test.go index fd9d82c9..ccbc3bfb 100644 --- a/internal/discovery/gharchive_test.go +++ b/internal/discovery/gharchive_test.go @@ -38,6 +38,22 @@ func eventLine(t string, payload map[string]any) []byte { return b } +func mustArchiveReader(t *testing.T, include []string, store CheckpointStore) *ArchiveReader { + t.Helper() + reader, err := NewArchiveReader(include, store) + if err != nil { + t.Fatalf("NewArchiveReader: %v", err) + } + return reader +} + +func TestNewArchiveReaderRejectsUnknownEventType(t *testing.T) { + reader, err := NewArchiveReader([]string{"UnknownEvent"}, nil) + if err == nil { + t.Fatalf("NewArchiveReader() = %v, want unsupported event error", reader) + } +} + func TestArchiveReaderSkipsMalformedLines(t *testing.T) { lines := [][]byte{ eventLine("PushEvent", map[string]any{"ref": "refs/heads/main", "head": "abc", "size": 1}), @@ -45,7 +61,7 @@ func TestArchiveReaderSkipsMalformedLines(t *testing.T) { []byte(`{"type":"IssuesEvent",`), []byte(""), } - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) var got []Signal err := reader.Read(context.Background(), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gzipLines(lines...)), func(s Signal) error { got = append(got, s) @@ -68,7 +84,7 @@ func TestArchiveReaderFiltersEventTypes(t *testing.T) { eventLine("IssuesEvent", map[string]any{"action": "opened", "issue": map[string]any{"number": 1, "title": "x", "state": "open", "user": map[string]any{"login": "u"}}}), eventLine("WatchEvent", map[string]any{"action": "started"}), } - reader := NewArchiveReader([]string{"IssuesEvent", "PullRequestEvent"}, nil) + reader := mustArchiveReader(t, []string{"IssuesEvent", "PullRequestEvent"}, nil) var got []Signal err := reader.Read(context.Background(), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gzipLines(lines...)), func(s Signal) error { got = append(got, s) @@ -90,7 +106,7 @@ func TestArchiveReaderDuplicateHour(t *testing.T) { hour := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) _ = store.MarkImported(context.Background(), HourKey(hour)) - reader := NewArchiveReader(nil, store) + reader := mustArchiveReader(t, nil, store) var got []Signal err := reader.Read(context.Background(), hour, bytes.NewReader(gzipLines(eventLine("PushEvent", map[string]any{}))), func(s Signal) error { got = append(got, s) @@ -108,7 +124,7 @@ func TestArchiveReaderContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) err := reader.Read(ctx, time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gzipLines(eventLine("PushEvent", map[string]any{}))), func(_ Signal) error { return nil }) @@ -135,7 +151,7 @@ func TestArchiveReaderRepresentativeEvents(t *testing.T) { "issue": map[string]any{ "number": 42, "title": "A bug", - "state": "open", + "state": " open ", "user": map[string]any{"login": "bob"}, }, }), @@ -153,7 +169,7 @@ func TestArchiveReaderRepresentativeEvents(t *testing.T) { eventLine("ForkEvent", map[string]any{"forkee": map[string]any{"full_name": "forker/repo", "id": 99}}), } - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) var got []Signal err := reader.Read(context.Background(), hour, bytes.NewReader(gzipLines(lines...)), func(s Signal) error { got = append(got, s) @@ -200,7 +216,7 @@ func TestHourKey(t *testing.T) { } func TestArchiveReaderBoundsEventLines(t *testing.T) { - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) reader.MaxEventBytes = 128 line := eventLine("PushEvent", map[string]any{"head": strings.Repeat("a", 256)}) err := reader.Read(context.Background(), time.Now(), bytes.NewReader(gzipLines(line)), func(Signal) error { return nil }) @@ -230,7 +246,7 @@ func TestArchiveReaderRejectsDecompressionBomb(t *testing.T) { gw.Write(uncompressed.Bytes()) gw.Close() - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) reader.MaxTotalBytes = 256 var count int err := reader.Read(context.Background(), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gz.Bytes()), func(Signal) error { @@ -252,7 +268,7 @@ func TestArchiveReaderSkipsMalformedAndUnknownEvents(t *testing.T) { malformed := []byte(`this is not json`) incomplete := []byte(`{"type":"IssuesEvent",`) - reader := NewArchiveReader(nil, nil) + reader := mustArchiveReader(t, nil, nil) var got []Signal err := reader.Read(context.Background(), hour, bytes.NewReader(gzipLines(valid, unknown, malformed, incomplete)), func(s Signal) error { got = append(got, s) diff --git a/internal/domain/types.go b/internal/domain/types.go index 5310e7fd..2e473624 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -166,6 +166,30 @@ func ParseThreadState(value string) (ThreadState, error) { } } +// ContributionKind identifies one GitHub contribution category. The +// vocabulary is intentionally open because GitHub may add categories, while +// the representation is still parsed so empty or non-canonical values cannot +// enter stored contribution records. +type ContributionKind string + +const ( + CommitContributionKind ContributionKind = "commit" + IssueContributionKind ContributionKind = "issue" + PullRequestContributionKind ContributionKind = "pull_request" + PullRequestReviewContributionKind ContributionKind = "pull_request_review" + RepositoryContributionKind ContributionKind = "repository" +) + +func ParseContributionKind(value string) (ContributionKind, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "", errors.New("contribution kind is required") + } + return ContributionKind(value), nil +} + +func (k ContributionKind) String() string { return string(k) } + // Thread is a product-owned model for an issue or pull request. // It carries no vendor-specific API types. type Thread struct { @@ -225,16 +249,7 @@ type Comment struct { // PullRequestDetails contains PR-specific facets. type PullRequestDetails struct { - HeadRef string - BaseRef string - HeadSHA string - BaseSHA string - Merge MergeStatus - MergeCommitSHA string - Additions int - Deletions int - ChangedFiles int - CIStatus string + Merge MergeStatus } // Repository is a product-owned snapshot of repository metadata and counts. @@ -360,15 +375,6 @@ func NewFacetCoverage(facet string, complete bool, asOf time.Time, count int) (F return FacetCoverage{facet: facet, complete: complete, asOf: asOf, count: count}, nil } -// MustFacetCoverage constructs static and test fixture coverage. -func MustFacetCoverage(facet string, complete bool, asOf time.Time, count int) FacetCoverage { - coverage, err := NewFacetCoverage(facet, complete, asOf, count) - if err != nil { - panic(err) - } - return coverage -} - func (c FacetCoverage) Facet() string { return c.facet } func (c FacetCoverage) Complete() bool { return c.complete } func (c FacetCoverage) AsOf() time.Time { return c.asOf } diff --git a/internal/domain/types_test.go b/internal/domain/types_test.go index 9112104f..98a84c3e 100644 --- a/internal/domain/types_test.go +++ b/internal/domain/types_test.go @@ -156,3 +156,17 @@ func TestParseThreadKindAndStateRejectUnknownVariants(t *testing.T) { t.Fatal("unknown thread state was accepted") } } + +func TestParseContributionKindCanonicalizesOpenVocabulary(t *testing.T) { + t.Parallel() + kind, err := ParseContributionKind(" Future_Category ") + if err != nil { + t.Fatal(err) + } + if kind.String() != "future_category" { + t.Fatalf("contribution kind = %q, want future_category", kind) + } + if _, err := ParseContributionKind(" \t "); err == nil { + t.Fatal("empty contribution kind was accepted") + } +} diff --git a/internal/dossier/builder_test.go b/internal/dossier/builder_test.go index 1009bf09..cda900df 100644 --- a/internal/dossier/builder_test.go +++ b/internal/dossier/builder_test.go @@ -27,6 +27,14 @@ type threadKey struct { merged string } +func mustFacetCoverage(facet string, complete bool, asOf time.Time, count int) domain.FacetCoverage { + coverage, err := domain.NewFacetCoverage(facet, complete, asOf, count) + if err != nil { + panic(err) + } + return coverage +} + func (f *fakeReader) ReadRepository(_ context.Context, ref domain.RepoRef) (domain.Repository, []domain.SourceRef, error) { if !ref.IsValid() { return domain.Repository{}, nil, errors.New("repository reference is not parsed") @@ -95,8 +103,8 @@ func TestBuilderBuild(t *testing.T) { coverage := domain.Coverage{ AsOf: now, Facets: []domain.FacetCoverage{ - domain.MustFacetCoverage("metadata", true, now, 0), - domain.MustFacetCoverage("threads", false, now.Add(-time.Hour), 0), + mustFacetCoverage("metadata", true, now, 0), + mustFacetCoverage("threads", false, now.Add(-time.Hour), 0), }, } guidance := "Please open an issue first." diff --git a/internal/evidence/compare.go b/internal/evidence/compare.go index b13fe41b..2ea4c295 100644 --- a/internal/evidence/compare.go +++ b/internal/evidence/compare.go @@ -32,7 +32,7 @@ func inconclusiveComparison(base, candidate *ValidationRun, explanation string) } func incompatibleRunIdentity(base, candidate *ValidationRun) string { - if base.ExecutionOrigin == "external" || candidate.ExecutionOrigin == "external" { + if base.ExecutionOrigin == ExecutionOriginExternal || candidate.ExecutionOrigin == ExecutionOriginExternal { if base.ExecutionOrigin != candidate.ExecutionOrigin { return "external and locally executed observations cannot establish a confirmatory comparison" } @@ -52,7 +52,7 @@ func incompatibleRunIdentity(base, candidate *ValidationRun) string { } } for _, run := range []*ValidationRun{base, candidate} { - if run.WorkspaceBindingStatus == "stale" || run.WorkspaceBindingStatus == "incompatible" { + if run.WorkspaceBindingStatus == WorkspaceBindingStale || run.WorkspaceBindingStatus == WorkspaceBindingIncompatible { return fmt.Sprintf("%s workspace binding is %s", run.Kind, run.WorkspaceBindingStatus) } } diff --git a/internal/evidence/external_manifest.go b/internal/evidence/external_manifest.go index 7d9ac212..a75d3a55 100644 --- a/internal/evidence/external_manifest.go +++ b/internal/evidence/external_manifest.go @@ -19,24 +19,65 @@ const ExternalEvidenceManifestSchemaV1 = "gitcontribute.external-evidence.v1" const maxExternalEvidenceManifestBytes = 2 << 20 const maxExternalEvidenceSharedMetadataBytes = 16 << 10 +// ExternalEvidenceCompleteness is the producer's bounded coverage claim. +type ExternalEvidenceCompleteness string + +const ( + ExternalEvidenceComplete ExternalEvidenceCompleteness = "complete" + ExternalEvidenceIncomplete ExternalEvidenceCompleteness = "incomplete" + ExternalEvidenceUnknown ExternalEvidenceCompleteness = "unknown" +) + +// ParseExternalEvidenceCompleteness converts an exact producer value into a supported claim. +func ParseExternalEvidenceCompleteness(value string) (ExternalEvidenceCompleteness, error) { + completeness := ExternalEvidenceCompleteness(value) + switch completeness { + case ExternalEvidenceComplete, ExternalEvidenceIncomplete, ExternalEvidenceUnknown: + return completeness, nil + default: + return "", fmt.Errorf("unsupported external evidence completeness %q", value) + } +} + +// ExternalEvidenceIntegrity is the producer's integrity claim. Invalid claims +// parse successfully so import policy can reject them explicitly. +type ExternalEvidenceIntegrity string + +const ( + ExternalEvidenceVerified ExternalEvidenceIntegrity = "verified" + ExternalEvidenceUnverified ExternalEvidenceIntegrity = "unverified" + ExternalEvidenceInvalid ExternalEvidenceIntegrity = "invalid" +) + +// ParseExternalEvidenceIntegrity converts an exact producer value into a supported claim. +func ParseExternalEvidenceIntegrity(value string) (ExternalEvidenceIntegrity, error) { + integrity := ExternalEvidenceIntegrity(value) + switch integrity { + case ExternalEvidenceVerified, ExternalEvidenceUnverified, ExternalEvidenceInvalid: + return integrity, nil + default: + return "", fmt.Errorf("unsupported external evidence integrity %q", value) + } +} + // ExternalEvidenceManifest is a bounded, producer-neutral handoff. It is // imported as evidence only; no producer command, path, or reference is run. type ExternalEvidenceManifest struct { - SchemaVersion string `json:"schema_version"` - Producer string `json:"producer"` - InvestigationID string `json:"investigation_id"` - HypothesisID string `json:"hypothesis_id,omitempty"` - OpportunityID string `json:"opportunity_id,omitempty"` - Repository string `json:"repository"` - Revision string `json:"revision"` - ArtifactSHA256 string `json:"artifact_sha256,omitempty"` - ObservedAt time.Time `json:"observed_at"` - Environment map[string]string `json:"environment,omitempty"` - Completeness string `json:"completeness"` - Integrity string `json:"integrity"` - Limitations []string `json:"limitations,omitempty"` - Claims []ExternalEvidenceClaim `json:"claims"` - ManifestSHA256 string `json:"manifest_sha256"` + SchemaVersion string `json:"schema_version"` + Producer string `json:"producer"` + InvestigationID string `json:"investigation_id"` + HypothesisID string `json:"hypothesis_id,omitempty"` + OpportunityID string `json:"opportunity_id,omitempty"` + Repository string `json:"repository"` + Revision string `json:"revision"` + ArtifactSHA256 string `json:"artifact_sha256,omitempty"` + ObservedAt time.Time `json:"observed_at"` + Environment map[string]string `json:"environment,omitempty"` + Completeness ExternalEvidenceCompleteness `json:"completeness"` + Integrity ExternalEvidenceIntegrity `json:"integrity"` + Limitations []string `json:"limitations,omitempty"` + Claims []ExternalEvidenceClaim `json:"claims"` + ManifestSHA256 string `json:"manifest_sha256"` } type ExternalEvidenceClaim struct { @@ -68,21 +109,21 @@ func DigestExternalEvidenceManifest(item ExternalEvidenceManifest) (string, erro func canonicalExternalEvidenceManifest(item ExternalEvidenceManifest) ([]byte, error) { item.ManifestSHA256 = "" canonical := struct { - SchemaVersion string `json:"schema_version"` - Producer string `json:"producer"` - InvestigationID string `json:"investigation_id"` - HypothesisID string `json:"hypothesis_id,omitempty"` - OpportunityID string `json:"opportunity_id,omitempty"` - Repository string `json:"repository"` - Revision string `json:"revision"` - ArtifactSHA256 string `json:"artifact_sha256,omitempty"` - ObservedAt string `json:"observed_at"` - Environment map[string]string `json:"environment,omitempty"` - Completeness string `json:"completeness"` - Integrity string `json:"integrity"` - Limitations []string `json:"limitations,omitempty"` - Claims []ExternalEvidenceClaim `json:"claims"` - ManifestSHA256 string `json:"manifest_sha256"` + SchemaVersion string `json:"schema_version"` + Producer string `json:"producer"` + InvestigationID string `json:"investigation_id"` + HypothesisID string `json:"hypothesis_id,omitempty"` + OpportunityID string `json:"opportunity_id,omitempty"` + Repository string `json:"repository"` + Revision string `json:"revision"` + ArtifactSHA256 string `json:"artifact_sha256,omitempty"` + ObservedAt string `json:"observed_at"` + Environment map[string]string `json:"environment,omitempty"` + Completeness ExternalEvidenceCompleteness `json:"completeness"` + Integrity ExternalEvidenceIntegrity `json:"integrity"` + Limitations []string `json:"limitations,omitempty"` + Claims []ExternalEvidenceClaim `json:"claims"` + ManifestSHA256 string `json:"manifest_sha256"` }{item.SchemaVersion, item.Producer, item.InvestigationID, item.HypothesisID, item.OpportunityID, item.Repository, item.Revision, item.ArtifactSHA256, item.ObservedAt.UTC().Format(time.RFC3339Nano), item.Environment, item.Completeness, item.Integrity, item.Limitations, item.Claims, ""} var payload bytes.Buffer encoder := json.NewEncoder(&payload) @@ -128,7 +169,7 @@ func (s *Service) ImportExternalEvidenceManifest(ctx context.Context, item Exter if err := s.repo.SaveEvidenceBatch(ctx, items); err != nil { return nil, fmt.Errorf("save imported external evidence: %w", err) } - return &ImportedExternalEvidence{EvidenceID: firstEvidenceID, Producer: item.Producer, ManifestSHA256: digest, ClaimCount: len(item.Claims), Incomplete: item.Completeness != "complete" || item.Integrity != "verified"}, nil + return &ImportedExternalEvidence{EvidenceID: firstEvidenceID, Producer: item.Producer, ManifestSHA256: digest, ClaimCount: len(item.Claims), Incomplete: item.Completeness != ExternalEvidenceComplete || item.Integrity != ExternalEvidenceVerified}, nil } func externalClaimSourceRefs(refs []string, item ExternalEvidenceManifest) []domain.SourceRef { @@ -165,13 +206,13 @@ func validateExternalEvidenceManifest(item ExternalEvidenceManifest) error { if item.ObservedAt.IsZero() || len(item.Claims) == 0 || len(item.Claims) > 1000 { return errors.New("external evidence manifest must contain 1 to 1000 claims and an observation time") } - if item.Completeness != "complete" && item.Completeness != "incomplete" && item.Completeness != "unknown" { - return fmt.Errorf("unsupported external evidence completeness %q", item.Completeness) + if _, err := ParseExternalEvidenceCompleteness(string(item.Completeness)); err != nil { + return err } - if item.Integrity != "verified" && item.Integrity != "unverified" && item.Integrity != "invalid" { - return fmt.Errorf("unsupported external evidence integrity %q", item.Integrity) + if _, err := ParseExternalEvidenceIntegrity(string(item.Integrity)); err != nil { + return err } - if item.Integrity == "invalid" { + if item.Integrity == ExternalEvidenceInvalid { return errors.New("external evidence manifest with invalid integrity cannot be imported") } if item.ArtifactSHA256 != "" { diff --git a/internal/evidence/external_receipt.go b/internal/evidence/external_receipt.go index 2ad4ded2..ea5b60a1 100644 --- a/internal/evidence/external_receipt.go +++ b/internal/evidence/external_receipt.go @@ -114,7 +114,7 @@ func (s *Service) AttachExternalReceipt(ctx context.Context, receipt ExternalRec OpportunityID: receipt.OpportunityID, Kind: receipt.Kind, StartedAt: receipt.StartedAt.UTC(), CompletedAt: receipt.CompletedAt.UTC(), ExitCode: receipt.ExitCode, Stdout: receipt.Stdout, Stderr: receipt.Stderr, Truncated: receipt.Truncated, Classification: receipt.Classification, - ObservationStatus: ObservationNotEvaluated, ExecutionOrigin: "external", + ObservationStatus: ObservationNotEvaluated, ExecutionOrigin: ExecutionOriginExternal, External: &ExternalReceiptProvenance{ SchemaVersion: receipt.SchemaVersion, Producer: receipt.Producer, ValidationID: receipt.ValidationID, ReceiptSHA256: digest, Repository: receipt.Repository, Revision: receipt.Revision, ArtifactSHA256: receipt.ArtifactSHA256, diff --git a/internal/evidence/freshness.go b/internal/evidence/freshness.go index fe6c5cca..26b4e5b3 100644 --- a/internal/evidence/freshness.go +++ b/internal/evidence/freshness.go @@ -2,6 +2,7 @@ package evidence import ( "context" + "encoding/json" "errors" "fmt" "sort" @@ -13,16 +14,32 @@ import ( // SourceSubjectKind identifies the independent corpus projection whose // revision an evidence record used. -type SourceSubjectKind string +type SourceSubjectKind uint8 // Source subject kinds supported by evidence provenance. const ( - SourceSubjectRepository SourceSubjectKind = "repository" - SourceSubjectThread SourceSubjectKind = "thread" - SourceSubjectFacet SourceSubjectKind = "facet" - SourceSubjectGuidance SourceSubjectKind = "guidance" + SourceSubjectRepository SourceSubjectKind = iota + 1 + SourceSubjectThread + SourceSubjectFacet + SourceSubjectGuidance ) +// String returns the stable boundary spelling of a source subject kind. +func (k SourceSubjectKind) String() string { + switch k { + case SourceSubjectRepository: + return "repository" + case SourceSubjectThread: + return "thread" + case SourceSubjectFacet: + return "facet" + case SourceSubjectGuidance: + return "guidance" + default: + return "" + } +} + // GuidanceFacet is the repository-level facet used by contribution guidance. const GuidanceFacet = "contribution_guidance" @@ -38,19 +55,148 @@ const ( FreshnessNotApplicable FreshnessStatus = "not_applicable" ) +// ParseFreshnessStatus parses a durable evaluated freshness outcome. +func ParseFreshnessStatus(value string) (FreshnessStatus, error) { + switch FreshnessStatus(value) { + case FreshnessFresh: + return FreshnessFresh, nil + case FreshnessStale: + return FreshnessStale, nil + case FreshnessUnknown: + return FreshnessUnknown, nil + case FreshnessNotApplicable: + return FreshnessNotApplicable, nil + default: + return "", fmt.Errorf("unsupported evidence freshness status %q", value) + } +} + // ErrSourceRevisionUnavailable means a reader cannot find the current local // projection for a recorded source subject. var ErrSourceRevisionUnavailable = errors.New("evidence: source revision unavailable") -// SourceSubject is a vendor-neutral identity for a repository, thread, or -// independently refreshed facet. +// SourceSubject is a parsed vendor-neutral identity for exactly one repository, +// thread, or independently refreshed facet. Its private representation keeps +// fields belonging to other subject variants out of downstream code. type SourceSubject struct { - Kind SourceSubjectKind `json:"kind"` - Owner string `json:"owner"` - Repo string `json:"repo"` - ThreadKind string `json:"thread_kind,omitempty"` - Number int `json:"number,omitempty"` - Facet string `json:"facet,omitempty"` + kind SourceSubjectKind + repository domain.RepoRef + threadKind domain.ThreadKind + number int + facet string +} + +type sourceSubjectJSON struct { + Kind string `json:"kind"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ThreadKind string `json:"thread_kind,omitempty"` + Number int `json:"number,omitempty"` + Facet string `json:"facet,omitempty"` +} + +// ParseSourceSubject converts one broad storage or API representation into a +// canonical source subject. Guidance accepts its historical explicit facet but +// stores it implicitly so it has only one representation. +func ParseSourceSubject(kind, owner, repo, threadKind string, number int, facet string) (SourceSubject, error) { + repository, err := domain.NewRepoRef(owner, repo) + if err != nil { + return SourceSubject{}, fmt.Errorf("invalid source repository: %w", err) + } + threadKind = strings.TrimSpace(threadKind) + facet = strings.TrimSpace(facet) + threadScoped := threadKind != "" || number != 0 + var parsedThreadKind domain.ThreadKind + if threadScoped { + if threadKind == "" || number <= 0 { + return SourceSubject{}, errors.New("thread kind and positive number must be provided together") + } + parsedThreadKind, err = domain.ParseThreadKind(threadKind) + if err != nil { + return SourceSubject{}, err + } + } + + var parsedKind SourceSubjectKind + switch strings.TrimSpace(kind) { + case "repository": + parsedKind = SourceSubjectRepository + if threadScoped || facet != "" { + return SourceSubject{}, errors.New("repository subject cannot include thread or facet fields") + } + case "thread": + parsedKind = SourceSubjectThread + if !threadScoped || facet != "" { + return SourceSubject{}, errors.New("thread subject requires a thread and no facet") + } + case "facet": + parsedKind = SourceSubjectFacet + if facet == "" { + return SourceSubject{}, errors.New("facet subject requires a facet name") + } + case "guidance": + parsedKind = SourceSubjectGuidance + if threadScoped || (facet != "" && facet != GuidanceFacet) { + return SourceSubject{}, errors.New("guidance subject cannot include thread fields or another facet") + } + facet = "" + default: + return SourceSubject{}, fmt.Errorf("unsupported source subject kind %q", kind) + } + return SourceSubject{ + kind: parsedKind, repository: repository, threadKind: parsedThreadKind, + number: number, facet: facet, + }, nil +} + +// NewRepositorySourceSubject returns a repository-scoped source subject. +func NewRepositorySourceSubject(repository domain.RepoRef) (SourceSubject, error) { + return ParseSourceSubject("repository", repository.Owner(), repository.Repo(), "", 0, "") +} + +// NewThreadSourceSubject returns a source subject for one issue or pull request. +func NewThreadSourceSubject(repository domain.RepoRef, kind domain.ThreadKind, number int) (SourceSubject, error) { + return ParseSourceSubject("thread", repository.Owner(), repository.Repo(), string(kind), number, "") +} + +// Kind returns the sealed subject variant. +func (s SourceSubject) Kind() SourceSubjectKind { return s.kind } + +// Repository returns the parsed repository identity shared by every variant. +func (s SourceSubject) Repository() domain.RepoRef { return s.repository } + +// Thread returns the subject's thread identity when it is thread-scoped. +func (s SourceSubject) Thread() (domain.ThreadKind, int, bool) { + return s.threadKind, s.number, s.threadKind != "" +} + +// Facet returns the facet name for facet subjects. Guidance has an implied +// contribution-guidance facet and therefore returns an empty string here. +func (s SourceSubject) Facet() string { return s.facet } + +// MarshalJSON preserves the durable source-provenance object representation. +func (s SourceSubject) MarshalJSON() ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + return json.Marshal(sourceSubjectJSON{ + Kind: s.kind.String(), Owner: s.repository.Owner(), Repo: s.repository.Repo(), + ThreadKind: string(s.threadKind), Number: s.number, Facet: s.facet, + }) +} + +// UnmarshalJSON parses durable source provenance before it enters the domain. +func (s *SourceSubject) UnmarshalJSON(data []byte) error { + var raw sourceSubjectJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + parsed, err := ParseSourceSubject(raw.Kind, raw.Owner, raw.Repo, raw.ThreadKind, raw.Number, raw.Facet) + if err != nil { + return err + } + *s = parsed + return nil } // SourceRevision records the exact winning source order used by evidence. @@ -147,10 +293,6 @@ func NormalizeSourceRevisions(revisions []SourceRevision) ([]SourceRevision, err out := make([]SourceRevision, len(revisions)) seen := make(map[string]struct{}, len(revisions)) for i, revision := range revisions { - revision.Subject.Owner = strings.TrimSpace(revision.Subject.Owner) - revision.Subject.Repo = strings.TrimSpace(revision.Subject.Repo) - revision.Subject.ThreadKind = strings.TrimSpace(revision.Subject.ThreadKind) - revision.Subject.Facet = strings.TrimSpace(revision.Subject.Facet) if !revision.SourceUpdatedAt.IsZero() { revision.SourceUpdatedAt = revision.SourceUpdatedAt.UTC() } @@ -185,63 +327,37 @@ func (r SourceRevision) Validate() error { return nil } -// Validate checks the shape required by each subject kind. +// Validate rejects the invalid zero value. Variant-specific fields are parsed +// together by ParseSourceSubject and cannot be modified independently. func (s SourceSubject) Validate() error { - if _, err := domain.NewRepoRef(s.Owner, s.Repo); err != nil { - return fmt.Errorf("invalid source repository: %w", err) - } - threadScoped := s.ThreadKind != "" || s.Number != 0 - if threadScoped && (s.ThreadKind == "" || s.Number <= 0) { - return errors.New("thread kind and positive number must be provided together") - } - if s.ThreadKind != "" && s.ThreadKind != string(domain.IssueKind) && s.ThreadKind != string(domain.PullRequestKind) { - return fmt.Errorf("unsupported thread kind %q", s.ThreadKind) - } - switch s.Kind { - case SourceSubjectRepository: - if threadScoped || s.Facet != "" { - return errors.New("repository subject cannot include thread or facet fields") - } - case SourceSubjectThread: - if !threadScoped || s.Facet != "" { - return errors.New("thread subject requires a thread and no facet") - } - case SourceSubjectFacet: - if s.Facet == "" { - return errors.New("facet subject requires a facet name") - } - case SourceSubjectGuidance: - if threadScoped || (s.Facet != "" && s.Facet != GuidanceFacet) { - return errors.New("guidance subject cannot include thread fields or another facet") - } - default: - return fmt.Errorf("unsupported source subject kind %q", s.Kind) + if !s.repository.IsValid() || s.kind.String() == "" { + return errors.New("source subject is not parsed") } return nil } // Key returns a stable case-insensitive subject identity. func (s SourceSubject) Key() string { - return strings.ToLower(fmt.Sprintf("%s:%s/%s:%s:%d:%s", s.Kind, s.Owner, s.Repo, s.ThreadKind, s.Number, s.Facet)) + return strings.ToLower(fmt.Sprintf("%s:%s:%s:%d:%s", s.kind, s.repository, s.threadKind, s.number, s.facet)) } func (s SourceSubject) String() string { - repo := s.Owner + "/" + s.Repo - thread := fmt.Sprintf("%s:%s#%d", s.ThreadKind, repo, s.Number) - switch s.Kind { + repo := s.repository.String() + thread := fmt.Sprintf("%s:%s#%d", s.threadKind, repo, s.number) + switch s.kind { case SourceSubjectRepository: return "repository " + repo case SourceSubjectThread: return "thread " + thread case SourceSubjectFacet: - if s.ThreadKind != "" { - return fmt.Sprintf("facet %s on %s", s.Facet, thread) + if s.threadKind != "" { + return fmt.Sprintf("facet %s on %s", s.facet, thread) } - return fmt.Sprintf("facet %s on %s", s.Facet, repo) + return fmt.Sprintf("facet %s on %s", s.facet, repo) case SourceSubjectGuidance: return "guidance " + repo default: - return string(s.Kind) + " " + repo + return "invalid source subject" } } diff --git a/internal/evidence/freshness_test.go b/internal/evidence/freshness_test.go index f0b872d8..befa35d0 100644 --- a/internal/evidence/freshness_test.go +++ b/internal/evidence/freshness_test.go @@ -2,6 +2,7 @@ package evidence import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -55,7 +56,7 @@ func TestFreshnessEvaluatorReasonIsDeterministic(t *testing.T) { b := testSourceRevision(SourceSubjectFacet, "issue", 1, "pr_reviews", time.Unix(100, 0).UTC(), 5) reader := revisionReaderFunc(func(_ context.Context, subject SourceSubject) (*SourceRevision, error) { current := a - if subject.Facet == b.Subject.Facet { + if subject.Facet() == b.Subject.Facet() { current = b } current.ObservationSequence += 10 @@ -90,6 +91,56 @@ func TestNormalizeSourceRevisionsRejectsInvalidAndDuplicateSubjects(t *testing.T } } +func TestParseSourceSubjectRejectsMixedVariants(t *testing.T) { + t.Parallel() + tests := []struct { + name string + kind, threadKind, facet string + number int + }{ + {name: "repository thread", kind: "repository", threadKind: "issue", number: 1}, + {name: "thread without number", kind: "thread", threadKind: "issue"}, + {name: "facet without name", kind: "facet"}, + {name: "guidance thread", kind: "guidance", threadKind: "issue", number: 1}, + {name: "unknown kind", kind: "branch"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if _, err := ParseSourceSubject(tt.kind, "owner", "repo", tt.threadKind, tt.number, tt.facet); err == nil { + t.Fatal("mixed or unsupported source subject accepted") + } + }) + } + if err := (SourceSubject{}).Validate(); err == nil { + t.Fatal("unparsed source subject accepted") + } +} + +func TestSourceSubjectJSONParsesAndCanonicalizes(t *testing.T) { + t.Parallel() + var thread SourceSubject + if err := json.Unmarshal([]byte(`{"kind":"thread","owner":" Owner ","repo":" Repo ","thread_kind":" issue ","number":42}`), &thread); err != nil { + t.Fatal(err) + } + kind, number, ok := thread.Thread() + if thread.Kind() != SourceSubjectThread || thread.Repository().String() != "Owner/Repo" || !ok || kind != "issue" || number != 42 { + t.Fatalf("parsed thread subject = %v", thread) + } + + var guidance SourceSubject + if err := json.Unmarshal([]byte(`{"kind":"guidance","owner":"Owner","repo":"Repo","facet":"contribution_guidance"}`), &guidance); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(guidance) + if err != nil { + t.Fatal(err) + } + if got, want := string(encoded), `{"kind":"guidance","owner":"Owner","repo":"Repo"}`; got != want { + t.Fatalf("canonical guidance JSON = %s, want %s", got, want) + } +} + func TestFreshnessEvaluatorHonorsCancellation(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) @@ -108,10 +159,12 @@ func TestFreshnessEvaluatorHonorsCancellation(t *testing.T) { } func testSourceRevision(kind SourceSubjectKind, threadKind string, number int, facet string, updated time.Time, sequence int64) SourceRevision { + subject, err := ParseSourceSubject(kind.String(), "Owner", "Repo", threadKind, number, facet) + if err != nil { + panic(err) + } return SourceRevision{ - Subject: SourceSubject{ - Kind: kind, Owner: "Owner", Repo: "Repo", ThreadKind: threadKind, Number: number, Facet: facet, - }, + Subject: subject, SourceUpdatedAt: updated, ObservationSequence: sequence, ObservedAt: time.Unix(200, 0).UTC(), } } diff --git a/internal/evidence/junit.go b/internal/evidence/junit.go index 9740073b..114878ae 100644 --- a/internal/evidence/junit.go +++ b/internal/evidence/junit.go @@ -23,6 +23,25 @@ const ( maxJUnitParseErrorBytes = 512 ) +// ParseJUnitTestStatus converts a durable testcase outcome into the normalized +// status vocabulary. +func ParseJUnitTestStatus(value string) (JUnitTestStatus, error) { + switch JUnitTestStatus(value) { + case JUnitTestPassed: + return JUnitTestPassed, nil + case JUnitTestFailed: + return JUnitTestFailed, nil + case JUnitTestSkipped: + return JUnitTestSkipped, nil + case JUnitTestErrored: + return JUnitTestErrored, nil + case JUnitTestUnknown: + return JUnitTestUnknown, nil + default: + return "", fmt.Errorf("unsupported JUnit testcase status %q", value) + } +} + var ( ErrJUnitReportTooLarge = errors.New("evidence: JUnit report exceeds the input bound") ErrJUnitTestCaseLimit = errors.New("evidence: JUnit report exceeds the test-case bound") @@ -83,6 +102,58 @@ type JUnitReport struct { RawSHA256 string } +// ValidateSummary verifies the portable structured report independently of +// whether the raw XML accompanies it. +func (r *JUnitReport) ValidateSummary() error { + if r == nil { + return errors.New("JUnit report is required") + } + if r.SchemaVersion != JUnitReportSchemaV1 { + return fmt.Errorf("unsupported JUnit report schema %q", r.SchemaVersion) + } + if r.Incomplete != (r.ParseError != "") { + return errors.New("JUnit incomplete state contradicts its parse diagnostic") + } + if r.Counts.Total < 0 || r.Counts.Passed < 0 || r.Counts.Failed < 0 || r.Counts.Skipped < 0 || r.Counts.Errored < 0 || r.Counts.Unknown < 0 { + return errors.New("JUnit counts cannot be negative") + } + derived := JUnitCounts{} + for i := range r.TestCases { + status, err := ParseJUnitTestStatus(string(r.TestCases[i].Status)) + if err != nil { + return fmt.Errorf("JUnit testcase %d: %w", i, err) + } + r.TestCases[i].Status = status + addJUnitTestCase(&derived, status) + } + if derived != r.Counts || r.Counts.Total != len(r.TestCases) { + return errors.New("JUnit counts do not match testcase outcomes") + } + if r.RawSHA256 != "" { + digest, err := hex.DecodeString(r.RawSHA256) + if err != nil || len(digest) != sha256.Size { + return errors.New("JUnit raw digest must be a 64-character hexadecimal SHA-256") + } + } + return nil +} + +// ParseStored validates a durable report and verifies its raw content identity +// when the XML is present. +func (r *JUnitReport) ParseStored() error { + if err := r.ValidateSummary(); err != nil { + return err + } + if r.RawXML == "" { + return nil + } + digest := sha256.Sum256([]byte(r.RawXML)) + if r.RawSHA256 != hex.EncodeToString(digest[:]) { + return errors.New("JUnit raw digest does not match its XML") + } + return nil +} + type junitSuiteFrame struct { Declared int DeclaredFailures int diff --git a/internal/evidence/junit_test.go b/internal/evidence/junit_test.go index 23f6a973..dc432d67 100644 --- a/internal/evidence/junit_test.go +++ b/internal/evidence/junit_test.go @@ -37,6 +37,44 @@ func TestParseJUnitReportCountsCasesAndPreservesIdentity(t *testing.T) { } } +func TestJUnitReportValidationRejectsContradictoryStoredSummary(t *testing.T) { + report, err := ParseJUnitReport(strings.NewReader(``), JUnitParseOptions{}) + if err != nil { + t.Fatal(err) + } + if err := report.ParseStored(); err != nil { + t.Fatalf("valid stored report: %v", err) + } + + tests := map[string]func(*JUnitReport){ + "schema": func(report *JUnitReport) { + report.SchemaVersion = "future" + }, + "incomplete": func(report *JUnitReport) { + report.Incomplete = true + }, + "status": func(report *JUnitReport) { + report.TestCases[0].Status = JUnitTestStatus("flaky") + }, + "counts": func(report *JUnitReport) { + report.Counts.Passed = 0 + }, + "digest": func(report *JUnitReport) { + report.RawSHA256 = strings.Repeat("a", 64) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := report + candidate.TestCases = append([]JUnitTestCase(nil), report.TestCases...) + mutate(&candidate) + if err := candidate.ParseStored(); err == nil { + t.Fatal("contradictory stored JUnit report was accepted") + } + }) + } +} + func TestParseJUnitReportMarksMalformedInputIncompleteWithPartialCounts(t *testing.T) { report, err := ParseJUnitReport(strings.NewReader(` maxRepeatRuns { return opts, fmt.Errorf("repeat run count must be between 1 and %d", maxRepeatRuns) } if len(opts.Kinds) == 0 || len(opts.Kinds) > 2 { return opts, errors.New("one or two validation kinds are required") } - seen := map[RunKind]bool{} + seen := map[RunKind]struct{}{} for _, kind := range opts.Kinds { - if (kind != RunKindBase && kind != RunKindCandidate) || seen[kind] { + if kind != RunKindBase && kind != RunKindCandidate { + return opts, ErrMissingRunKind + } + if _, duplicate := seen[kind]; duplicate { return opts, ErrMissingRunKind } - seen[kind] = true + seen[kind] = struct{}{} } total := len(opts.Kinds) * opts.RunCount if opts.Concurrency < 1 || opts.Concurrency > maxRepeatConcurrency || opts.Concurrency > total { return opts, fmt.Errorf("repeat concurrency must be between 1 and %d and no greater than total attempts", maxRepeatConcurrency) } + if opts.PerRunTimeout != 0 && (opts.PerRunTimeout < 0 || opts.PerRunTimeout > maxValidationTimeout) { + return opts, ErrInvalidTimeout + } + if opts.SampleInterval != 0 && (opts.SampleInterval < minSampleInterval || opts.SampleInterval > maxSampleInterval) { + return opts, fmt.Errorf("sample interval must be between %s and %s", minSampleInterval, maxSampleInterval) + } + if opts.OverallTimeout != 0 && (opts.OverallTimeout < 0 || opts.OverallTimeout > maxValidationTimeout) { + return opts, ErrInvalidTimeout + } + opts.Kinds = append([]RunKind(nil), opts.Kinds...) + return opts, nil +} + +func normalizeRepeatOptions(def *ValidationDefinition, opts RepeatValidationOptions) (RepeatValidationOptions, error) { + parsed, err := ParseRepeatValidationOptions(opts) + if err != nil { + return opts, err + } + opts = parsed if opts.PerRunTimeout == 0 { opts.PerRunTimeout = def.Timeout } @@ -116,6 +140,7 @@ func normalizeRepeatOptions(def *ValidationDefinition, opts RepeatValidationOpti return opts, fmt.Errorf("sample interval must be between %s and %s", minSampleInterval, maxSampleInterval) } if opts.OverallTimeout == 0 { + total := len(opts.Kinds) * opts.RunCount waves := (total + opts.Concurrency - 1) / opts.Concurrency opts.OverallTimeout = time.Duration(waves) * opts.PerRunTimeout } @@ -195,16 +220,16 @@ func (s *Service) executeValidationTask(ctx context.Context, def *ValidationDefi func aggregateAttempts(attempts []ValidationAttempt, kinds []RunKind, requested int, hasObservations bool) []ValidationAggregate { aggregates := make([]ValidationAggregate, 0, len(kinds)) for _, kind := range kinds { - aggregate := ValidationAggregate{Kind: kind, Requested: requested, ResourceClassification: "available"} + aggregate := ValidationAggregate{Kind: kind, Requested: requested, ResourceClassification: ResourceAvailable} for _, attempt := range attempts { if attempt.Kind != kind { continue } aggregate.Completed++ - if attempt.Cleanup.Status == "failed" { - aggregate.ResourceClassification = "cleanup_failed" - } else if aggregate.ResourceClassification == "available" && resourcesUnavailable(attempt.Resources) { - aggregate.ResourceClassification = "inconclusive" + if attempt.Cleanup.Status == CleanupFailed { + aggregate.ResourceClassification = ResourceCleanupFailed + } else if aggregate.ResourceClassification == ResourceAvailable && resourcesUnavailable(attempt.Resources) { + aggregate.ResourceClassification = ResourceInconclusive } switch semanticAttempt(attempt, hasObservations) { case RunGroupStablePass: diff --git a/internal/evidence/runner.go b/internal/evidence/runner.go index cd0806c9..8c75c1e5 100644 --- a/internal/evidence/runner.go +++ b/internal/evidence/runner.go @@ -60,9 +60,9 @@ func (r *ExecRunner) Run(ctx context.Context, req RunRequest) (*RunResult, error if err := cmd.Start(); err != nil { if ctx.Err() != nil { completed := time.Now().UTC() - timeoutPhase := "" + timeoutPhase := ValidationPhaseNone if errors.Is(ctx.Err(), context.DeadlineExceeded) { - timeoutPhase = "startup" + timeoutPhase = ValidationPhaseStartup } return &RunResult{ ExitCode: -1, @@ -72,16 +72,16 @@ func (r *ExecRunner) Run(ctx context.Context, req RunRequest) (*RunResult, error Classification: RunClassificationCancelled, Phases: phases, TimeoutPhase: timeoutPhase, - FailurePhase: "startup", - Cleanup: CleanupResult{Status: "unavailable", Reason: "process did not start", CheckedAt: completed}, + FailurePhase: ValidationPhaseStartup, + Cleanup: CleanupResult{Status: CleanupUnavailable, Reason: "process did not start", CheckedAt: completed}, }, nil } completed := time.Now().UTC() return &RunResult{ ExitCode: -1, StartedAt: started, CompletedAt: completed, Error: fmt.Sprintf("runner: start: %v", err), Classification: RunClassificationError, - Phases: phases, FailurePhase: "startup", - Cleanup: CleanupResult{Status: "unavailable", Reason: "process did not start", CheckedAt: completed}, + Phases: phases, FailurePhase: ValidationPhaseStartup, + Cleanup: CleanupResult{Status: CleanupUnavailable, Reason: "process did not start", CheckedAt: completed}, }, nil } phases.ProcessStartedAt = time.Now().UTC() @@ -96,9 +96,9 @@ func (r *ExecRunner) Run(ctx context.Context, req RunRequest) (*RunResult, error phases.ShutdownCheckedAt = sampled.cleanup.CheckedAt if ctx.Err() != nil { - timeoutPhase := "" + timeoutPhase := ValidationPhaseNone if errors.Is(ctx.Err(), context.DeadlineExceeded) { - timeoutPhase = "execution" + timeoutPhase = ValidationPhaseExecution } return &RunResult{ ExitCode: -1, @@ -112,7 +112,7 @@ func (r *ExecRunner) Run(ctx context.Context, req RunRequest) (*RunResult, error Process: sampled.identity, Phases: phases, TimeoutPhase: timeoutPhase, - FailurePhase: "execution", + FailurePhase: ValidationPhaseExecution, Resources: sampled.telemetry, Cleanup: sampled.cleanup, }, nil @@ -134,16 +134,16 @@ func (r *ExecRunner) Run(ctx context.Context, req RunRequest) (*RunResult, error } runErrStr = runErr.Error() } - timeoutPhase := "" + timeoutPhase := ValidationPhaseNone if errors.Is(runErr, exec.ErrWaitDelay) { - timeoutPhase = "shutdown" + timeoutPhase = ValidationPhaseShutdown } - failurePhase := "" + failurePhase := ValidationPhaseNone if runErr != nil { - failurePhase = "execution" + failurePhase = ValidationPhaseExecution } - if timeoutPhase == "shutdown" { - failurePhase = "shutdown" + if timeoutPhase == ValidationPhaseShutdown { + failurePhase = ValidationPhaseShutdown } return &RunResult{ diff --git a/internal/evidence/telemetry.go b/internal/evidence/telemetry.go index f6a61f60..036ce6f6 100644 --- a/internal/evidence/telemetry.go +++ b/internal/evidence/telemetry.go @@ -211,9 +211,9 @@ func (s *samplerState) result(parent context.Context) samplerResult { return survivors[i].CreateTimeUnixMilli < survivors[j].CreateTimeUnixMilli }) checkedAt := time.Now().UTC() - cleanup := CleanupResult{Status: "clean", CheckedAt: checkedAt} + cleanup := CleanupResult{Status: CleanupClean, CheckedAt: checkedAt} if len(survivors) > 0 { - cleanup.Status, cleanup.Reason, cleanup.Survivors = "failed", "sampled descendants survived shutdown", survivors + cleanup.Status, cleanup.Reason, cleanup.Survivors = CleanupFailed, "sampled descendants survived shutdown", survivors } telemetry := ResourceTelemetry{ Provider: "gopsutil/v4", Platform: runtime.GOOS + "/" + runtime.GOARCH, @@ -222,10 +222,10 @@ func (s *samplerState) result(parent context.Context) samplerResult { PeakRSSBytes: metricUint64(s.peakRSS, unavailableMetricError(s.memoryAvailable, s.memoryErr)), PeakChildCount: metricInt64(s.peakChildren, unavailableMetricError(s.childrenAvailable, s.childrenErr)), } - if s.rootCreateAt == 0 && cleanup.Status == "clean" { - cleanup.Status, cleanup.Reason = "unavailable", "root process identity was unavailable" - } else if s.trackingErr != nil && cleanup.Status == "clean" { - cleanup.Status, cleanup.Reason = "unavailable", "process tree tracking incomplete: "+s.trackingErr.Error() + if s.rootCreateAt == 0 && cleanup.Status == CleanupClean { + cleanup.Status, cleanup.Reason = CleanupUnavailable, "root process identity was unavailable" + } else if s.trackingErr != nil && cleanup.Status == CleanupClean { + cleanup.Status, cleanup.Reason = CleanupUnavailable, "process tree tracking incomplete: "+s.trackingErr.Error() } return samplerResult{identity: ProcessIdentity{PID: s.rootPID, CreateTimeUnixMilli: s.rootCreateAt}, telemetry: telemetry, cleanup: cleanup} } diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index 086ffca8..69f3e4a6 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -17,6 +17,14 @@ var ( syntheticGitHubTokenB = "ghp_" + strings.Repeat("0", 36) ) +func mustFacetCoverage(facet string, complete bool, asOf time.Time, count int) domain.FacetCoverage { + coverage, err := domain.NewFacetCoverage(facet, complete, asOf, count) + if err != nil { + panic(err) + } + return coverage +} + func sampleDossier() *domain.Dossier { return &domain.Dossier{ Repo: domain.MustRepoRef("owner", "repo"), @@ -45,8 +53,8 @@ func sampleDossier() *domain.Dossier { Coverage: domain.Coverage{ AsOf: now, Facets: []domain.FacetCoverage{ - domain.MustFacetCoverage("threads", false, now.Add(-time.Hour), 0), - domain.MustFacetCoverage("metadata", true, now, 0), + mustFacetCoverage("threads", false, now.Add(-time.Hour), 0), + mustFacetCoverage("metadata", true, now, 0), }, }, RecentMergedPullRequests: []domain.DossierThread{ diff --git a/internal/facets/facets.go b/internal/facets/facets.go index 18d7d795..8b2fc07f 100644 --- a/internal/facets/facets.go +++ b/internal/facets/facets.go @@ -3,10 +3,11 @@ // repeating facet names or default sets. package facets -// Thread kinds used by facet selection policy. -const ( - IssueKind = "issue" - PullRequestKind = "pull_request" +import ( + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/domain" ) // Facet names are stable corpus keys. Health-only facets are included here so @@ -26,48 +27,141 @@ const ( ) type definition struct { - name string - hydratable bool - defaultForIssue bool - defaultForPullRequest bool - explicitOnly bool + name Name + policy hydrationPolicy } +type hydrationPolicy uint8 + +const ( + healthOnly hydrationPolicy = iota + defaultForAllThreads + defaultForPullRequests + explicitForAllThreads +) + var catalog = [...]definition{ - {name: IssueComments, hydratable: true, defaultForIssue: true, defaultForPullRequest: true}, - {name: PRDetails, hydratable: true, defaultForPullRequest: true}, - {name: PRReviews, hydratable: true, defaultForPullRequest: true}, - {name: PRReviewComments, hydratable: true, defaultForPullRequest: true}, - {name: PRChecks}, - {name: PRReviewThreads}, - {name: PRMergeState}, - {name: PRMergeQueue}, - {name: PRClosingIssues}, - {name: PRFiles}, - {name: IssueTimeline, hydratable: true, explicitOnly: true}, + {name: Name(IssueComments), policy: defaultForAllThreads}, + {name: Name(PRDetails), policy: defaultForPullRequests}, + {name: Name(PRReviews), policy: defaultForPullRequests}, + {name: Name(PRReviewComments), policy: defaultForPullRequests}, + {name: Name(PRChecks)}, + {name: Name(PRReviewThreads)}, + {name: Name(PRMergeState)}, + {name: Name(PRMergeQueue)}, + {name: Name(PRClosingIssues)}, + {name: Name(PRFiles)}, + {name: Name(IssueTimeline), policy: explicitForAllThreads}, } -// DefaultFor returns the default hydration facets for a thread kind. -func DefaultFor(kind string) []string { - result := make([]string, 0, len(catalog)) +// Name is a parsed catalog-backed facet name. +type Name string + +// String returns the stable corpus and protocol key. +func (n Name) String() string { return string(n) } + +// Selection is an immutable parsed hydration selection. Its zero value means +// the default facets for the resolved thread kind. +type Selection struct { + requested []Name +} + +// ParseSelection parses and deduplicates selectable hydration facets while +// preserving caller order. Applicability is checked after the thread kind is +// resolved. +func ParseSelection(values []string) (Selection, error) { + requested := make([]Name, 0, len(values)) + seen := make(map[Name]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + definition, ok := definitionByName(value) + if !ok || definition.policy == healthOnly { + return Selection{}, fmt.Errorf("unknown facet %q", value) + } + if _, duplicate := seen[definition.name]; duplicate { + continue + } + seen[definition.name] = struct{}{} + requested = append(requested, definition.name) + } + return Selection{requested: requested}, nil +} + +// Explicit reports whether the caller selected facets rather than requesting +// the defaults for each thread kind. +func (s Selection) Explicit() bool { return len(s.requested) > 0 } + +// For returns the selected facets for one exact thread kind. An explicit facet +// that cannot apply to that kind is rejected. +func (s Selection) For(kind domain.ThreadKind) ([]Name, error) { + if !s.Explicit() { + return defaultNamesFor(kind), nil + } + result := make([]Name, 0, len(s.requested)) + for _, name := range s.requested { + definition, _ := definitionByName(name.String()) + if !selectableFor(definition, kind) { + return nil, fmt.Errorf("facet %q is not applicable to %s threads", name, kind) + } + result = append(result, name) + } + return result, nil +} + +func definitionByName(name string) (definition, bool) { + for _, facet := range catalog { + if facet.name.String() == name { + return facet, true + } + } + return definition{}, false +} + +func defaultNamesFor(kind domain.ThreadKind) []Name { + result := make([]Name, 0, len(catalog)) for _, facet := range catalog { - if (kind == IssueKind && facet.defaultForIssue) || (kind == PullRequestKind && facet.defaultForPullRequest) { + if defaultFor(facet, kind) { result = append(result, facet.name) } } return result } +func defaultFor(facet definition, kind domain.ThreadKind) bool { + switch facet.policy { + case defaultForAllThreads: + return true + case defaultForPullRequests: + return kind == domain.PullRequestKind + default: + return false + } +} + +func selectableFor(facet definition, kind domain.ThreadKind) bool { + return defaultFor(facet, kind) || facet.policy == explicitForAllThreads +} + +// DefaultFor returns the default hydration facets for a thread kind. +func DefaultFor(kind domain.ThreadKind) []string { + names := defaultNamesFor(kind) + result := make([]string, len(names)) + for index, name := range names { + result[index] = name.String() + } + return result +} + // SelectableFor returns facets accepted for explicit hydration of a thread // kind. Timeline is intentionally explicit-only because it may be large. -func SelectableFor(kind string) []string { +func SelectableFor(kind domain.ThreadKind) []string { result := DefaultFor(kind) if len(result) == 0 { return nil } for _, facet := range catalog { - if facet.hydratable && facet.explicitOnly { - result = append(result, facet.name) + if facet.policy == explicitForAllThreads { + result = append(result, facet.name.String()) } } return result @@ -78,7 +172,7 @@ func SelectableFor(kind string) []string { func SelectableNames() []string { seen := make(map[string]struct{}, len(catalog)) result := make([]string, 0, len(catalog)) - for _, kind := range []string{IssueKind, PullRequestKind} { + for _, kind := range []domain.ThreadKind{domain.IssueKind, domain.PullRequestKind} { for _, facet := range SelectableFor(kind) { if _, ok := seen[facet]; ok { continue @@ -94,7 +188,7 @@ func SelectableNames() []string { func AllNames() []string { result := make([]string, 0, len(catalog)) for _, facet := range catalog { - result = append(result, facet.name) + result = append(result, facet.name.String()) } return result } diff --git a/internal/facets/facets_test.go b/internal/facets/facets_test.go index 6c622aa5..a5e36981 100644 --- a/internal/facets/facets_test.go +++ b/internal/facets/facets_test.go @@ -3,16 +3,18 @@ package facets import ( "reflect" "testing" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestSelectionPolicy(t *testing.T) { - if got, want := DefaultFor(IssueKind), []string{IssueComments}; !reflect.DeepEqual(got, want) { + if got, want := DefaultFor(domain.IssueKind), []string{IssueComments}; !reflect.DeepEqual(got, want) { t.Fatalf("issue defaults = %v, want %v", got, want) } - if got, want := DefaultFor(PullRequestKind), []string{IssueComments, PRDetails, PRReviews, PRReviewComments}; !reflect.DeepEqual(got, want) { + if got, want := DefaultFor(domain.PullRequestKind), []string{IssueComments, PRDetails, PRReviews, PRReviewComments}; !reflect.DeepEqual(got, want) { t.Fatalf("pull-request defaults = %v, want %v", got, want) } - if got, want := SelectableFor(IssueKind), []string{IssueComments, IssueTimeline}; !reflect.DeepEqual(got, want) { + if got, want := SelectableFor(domain.IssueKind), []string{IssueComments, IssueTimeline}; !reflect.DeepEqual(got, want) { t.Fatalf("issue selectable = %v, want %v", got, want) } if got, want := SelectableNames(), []string{IssueComments, IssueTimeline, PRDetails, PRReviews, PRReviewComments}; !reflect.DeepEqual(got, want) { @@ -20,10 +22,34 @@ func TestSelectionPolicy(t *testing.T) { } } +func TestParsedSelectionOwnsDeduplicationAndApplicability(t *testing.T) { + t.Parallel() + selection, err := ParseSelection([]string{" " + PRDetails + " ", PRDetails, PRReviews}) + if err != nil { + t.Fatal(err) + } + names, err := selection.For(domain.PullRequestKind) + if err != nil { + t.Fatal(err) + } + if len(names) != 2 { + t.Fatalf("parsed selection = %v, want two names", names) + } + if got, want := []string{names[0].String(), names[1].String()}, []string{PRDetails, PRReviews}; !reflect.DeepEqual(got, want) { + t.Fatalf("parsed selection = %v, want %v", got, want) + } + if _, err := selection.For(domain.IssueKind); err == nil { + t.Fatal("pull-request-only facets were accepted for an issue") + } + if _, err := ParseSelection([]string{"unknown"}); err == nil { + t.Fatal("unknown facet was accepted") + } +} + func TestSelectionPolicyReturnsIndependentSlices(t *testing.T) { - first := DefaultFor(PullRequestKind) + first := DefaultFor(domain.PullRequestKind) first[0] = "changed" - second := DefaultFor(PullRequestKind) + second := DefaultFor(domain.PullRequestKind) if second[0] != IssueComments { t.Fatalf("default policy was mutated through returned slice: %v", second) } diff --git a/internal/github/acquisition_models.go b/internal/github/acquisition_models.go index 560cfe6a..390ca3ba 100644 --- a/internal/github/acquisition_models.go +++ b/internal/github/acquisition_models.go @@ -1,6 +1,10 @@ package github -import "time" +import ( + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) // RefResolution records the provider ref supplied by a caller and the commit // that GitHub resolved it to. CommitSHA is the authoritative source revision; @@ -37,11 +41,24 @@ type SourceFileReadOptions struct { TotalBytes int } +// SourceFileReadStatus is the closed set of adapter outcomes for one bounded +// repository-content read. +type SourceFileReadStatus string + +const ( + SourceFileReadComplete SourceFileReadStatus = "complete" + SourceFileReadNotFound SourceFileReadStatus = "not_found" + SourceFileReadTooLarge SourceFileReadStatus = "too_large" + SourceFileReadRetryable SourceFileReadStatus = "retryable" + SourceFileReadUnavailable SourceFileReadStatus = "unavailable" + SourceFileReadFailed SourceFileReadStatus = "failed" +) + // SourceFileReadItem is one ordered adapter-level content outcome. Content is // populated only for complete reads; callers may persist the item unchanged. type SourceFileReadItem struct { Request SourceFileRequest - Status string + Status SourceFileReadStatus File RepositoryFile StartLine int EndLine int @@ -66,7 +83,7 @@ type ThreadSearchOptions struct { Owner string Repo string Query string - Kind ThreadKind + Kind domain.ThreadKind State string Sort string Order string diff --git a/internal/github/auth.go b/internal/github/auth.go index 314d8130..bf7cb2f5 100644 --- a/internal/github/auth.go +++ b/internal/github/auth.go @@ -27,24 +27,6 @@ var ErrRequiredToken = errors.New("configured GitHub token unavailable") // gitcontribute. const KeyringService = "gitcontribute" -// CommandRunner abstracts process execution so that tests can inject behavior. -type CommandRunner interface { - Run(ctx context.Context, name string, args ...string) (string, error) -} - -type execRunner struct{} - -func (execRunner) Run(ctx context.Context, name string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - out, err := cmd.Output() - return string(out), err -} - -// DefaultCommandRunner returns the real command runner. -func DefaultCommandRunner() CommandRunner { - return execRunner{} -} - // StaticTokenSource returns the provided token if it is non-empty. func StaticTokenSource(token string) TokenSource { return staticTokenSource(token) @@ -109,22 +91,21 @@ func (s *keyringTokenSource) Token(ctx context.Context) (string, error) { return token, nil } -// GhCLITokenSource resolves a token by running `gh auth token`. -// Optional args are passed through to `gh` (for example a `--hostname` flag). -func GhCLITokenSource(runner CommandRunner, args ...string) TokenSource { - if runner == nil { - runner = DefaultCommandRunner() - } - return &ghTokenSource{runner: runner, args: args} +// GhCLITokenSource resolves a token by running the one bounded credential +// command owned by this adapter: `gh auth token`. +func GhCLITokenSource() TokenSource { + return &ghTokenSource{run: func(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() + return string(out), err + }} } type ghTokenSource struct { - runner CommandRunner - args []string + run func(context.Context) (string, error) } func (s *ghTokenSource) Token(ctx context.Context) (string, error) { - out, err := s.runner.Run(ctx, "gh", append([]string{"auth", "token"}, s.args...)...) + out, err := s.run(ctx) out = strings.TrimSpace(out) if out == "" { return "", ErrNoToken @@ -183,16 +164,3 @@ func (s requiredTokenSource) Token(ctx context.Context) (string, error) { // DefaultEnvToken is the conventional environment variable name for a // GitHub token. const DefaultEnvToken = "GITHUB_TOKEN" - -// NewTokenSource builds the standard resolution chain: explicit value, -// environment variable, then `gh auth token`. -func NewTokenSource(explicit, envVar string, runner CommandRunner) TokenSource { - if envVar == "" { - envVar = DefaultEnvToken - } - sources := []TokenSource{StaticTokenSource(explicit), EnvTokenSource(envVar)} - if runner != nil { - sources = append(sources, GhCLITokenSource(runner)) - } - return ChainTokenSource(sources...) -} diff --git a/internal/github/client.go b/internal/github/client.go index fc58b88d..e17ad932 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -11,6 +11,7 @@ import ( "time" gh "github.com/google/go-github/v89/github" + "github.com/morluto/gitcontribute/internal/domain" ) const ( @@ -321,7 +322,7 @@ func (c *Client) ListPullRequests(ctx context.Context, owner, name string, opts } pullRequests := make([]Issue, 0, len(result.Items)) for _, item := range result.Items { - if item.Kind == ThreadKindPullRequest { + if item.Kind == domain.PullRequestKind { pullRequests = append(pullRequests, item) } } @@ -610,10 +611,10 @@ func convertIssue(i *gh.Issue) Issue { if i == nil { return Issue{} } - kind := ThreadKindIssue + kind := domain.IssueKind prURL := "" if i.PullRequestLinks != nil { - kind = ThreadKindPullRequest + kind = domain.PullRequestKind prURL = i.PullRequestLinks.GetHTMLURL() } owner, repo := repositoryFromAPIURL(i.GetRepositoryURL()) diff --git a/internal/github/client_acquisition.go b/internal/github/client_acquisition.go index 84e041fb..f70fd3ab 100644 --- a/internal/github/client_acquisition.go +++ b/internal/github/client_acquisition.go @@ -11,6 +11,7 @@ import ( "time" gh "github.com/google/go-github/v89/github" + "github.com/morluto/gitcontribute/internal/domain" ) // RepositoryFileReader is the optional exact-file capability used to ingest a @@ -73,7 +74,7 @@ func sourceLineRange(content string, start, end int) (string, int, int, bool) { return strings.Join(lines[start-1:end], ""), start, end, true } -func sourceReadErrorStatus(err error) (string, time.Duration) { +func sourceReadErrorStatus(err error) (SourceFileReadStatus, time.Duration) { var primary *PrimaryRateLimitError var secondary *SecondaryRateLimitError var transient *TransientError @@ -81,17 +82,17 @@ func sourceReadErrorStatus(err error) (string, time.Duration) { var denied *AccessDeniedError switch { case errors.As(err, &primary): - return "retryable", primary.RetryAfter + return SourceFileReadRetryable, primary.RetryAfter case errors.As(err, &secondary): - return "retryable", secondary.RetryAfter + return SourceFileReadRetryable, secondary.RetryAfter case errors.As(err, &transient): - return "retryable", time.Second + return SourceFileReadRetryable, time.Second case errors.As(err, ¬Found): - return "not_found", 0 + return SourceFileReadNotFound, 0 case errors.As(err, &denied): - return "unavailable", 0 + return SourceFileReadUnavailable, 0 default: - return "failed", 0 + return SourceFileReadFailed, 0 } } @@ -178,7 +179,7 @@ func (c *Client) ReadSourceFiles(ctx context.Context, owner, name, requestedRef } result := SourceFileReadResult{Resolution: resolution, Items: make([]SourceFileReadItem, len(requests)), Rate: rate} for index, request := range requests { - item := SourceFileReadItem{Request: request, Status: "failed", StartLine: request.StartLine, EndLine: request.EndLine} + item := SourceFileReadItem{Request: request, Status: SourceFileReadFailed, StartLine: request.StartLine, EndLine: request.EndLine} if err := ctx.Err(); err != nil { return SourceFileReadResult{}, err } @@ -215,7 +216,7 @@ func (c *Client) ReadSourceFiles(ctx context.Context, owner, name, requestedRef metadata.Content = "" item.File = metadata if len(file.Content) > opts.PerFileBytes { - item.Status, item.Bytes, item.Message = "too_large", len(file.Content), fmt.Sprintf("file exceeds %d-byte per-file limit", opts.PerFileBytes) + item.Status, item.Bytes, item.Message = SourceFileReadTooLarge, len(file.Content), fmt.Sprintf("file exceeds %d-byte per-file limit", opts.PerFileBytes) contentDigest := sha256.Sum256([]byte(file.Content)) item.ContentSHA = hex.EncodeToString(contentDigest[:]) result.Items[index] = item @@ -223,19 +224,19 @@ func (c *Client) ReadSourceFiles(ctx context.Context, owner, name, requestedRef } content, startLine, endLine, ok := sourceLineRange(file.Content, request.StartLine, request.EndLine) if !ok { - item.Status, item.Message = "failed", "requested line range is outside the file" + item.Status, item.Message = SourceFileReadFailed, "requested line range is outside the file" result.Items[index] = item continue } if result.TotalBytes+len(content) > opts.TotalBytes { - item.Status, item.Bytes, item.Message = "too_large", len(content), fmt.Sprintf("batch exceeds %d-byte total limit", opts.TotalBytes) + item.Status, item.Bytes, item.Message = SourceFileReadTooLarge, len(content), fmt.Sprintf("batch exceeds %d-byte total limit", opts.TotalBytes) contentDigest := sha256.Sum256([]byte(content)) item.ContentSHA = hex.EncodeToString(contentDigest[:]) result.Items[index] = item continue } contentDigest := sha256.Sum256([]byte(content)) - item.Status, item.File, item.StartLine, item.EndLine, item.Bytes, item.ContentSHA = "complete", file, startLine, endLine, len(content), hex.EncodeToString(contentDigest[:]) + item.Status, item.File, item.StartLine, item.EndLine, item.Bytes, item.ContentSHA = SourceFileReadComplete, file, startLine, endLine, len(content), hex.EncodeToString(contentDigest[:]) item.File.Content = content result.TotalBytes += len(content) result.Items[index] = item @@ -256,9 +257,9 @@ func (c *Client) SearchThreads(ctx context.Context, opts ThreadSearchOptions) (T queryParts = append(queryParts, text) } switch opts.Kind { - case ThreadKindIssue: + case domain.IssueKind: queryParts = append(queryParts, "is:issue") - case ThreadKindPullRequest: + case domain.PullRequestKind: queryParts = append(queryParts, "is:pr") case "": default: diff --git a/internal/github/client_acquisition_test.go b/internal/github/client_acquisition_test.go index 9b16c4e9..d6c6e0aa 100644 --- a/internal/github/client_acquisition_test.go +++ b/internal/github/client_acquisition_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestSearchThreadsPreservesProviderQueryFiltersPaginationAndRate(t *testing.T) { @@ -41,7 +43,7 @@ func TestSearchThreadsPreservesProviderQueryFiltersPaginationAndRate(t *testing. client := newTestClient(t, srv, StaticTokenSource("")) result, err := client.SearchThreads(context.Background(), ThreadSearchOptions{ - Owner: testOwner, Repo: testRepo, Query: "needle", Kind: ThreadKindPullRequest, State: "open", Sort: "updated", Order: "asc", + Owner: testOwner, Repo: testRepo, Query: "needle", Kind: domain.PullRequestKind, State: "open", Sort: "updated", Order: "asc", PageOptions: PageOptions{Page: 2, PerPage: 3}, }) if err != nil { @@ -50,7 +52,7 @@ func TestSearchThreadsPreservesProviderQueryFiltersPaginationAndRate(t *testing. if result.Total != 12 || !result.Incomplete || !result.Page.HasNext || result.Page.NextPage != 3 || len(result.Items) != 1 { t.Fatalf("result = %+v", result) } - if result.Items[0].Kind != ThreadKindPullRequest || result.Items[0].RepositoryOwner != testOwner || result.Items[0].RepositoryName != testRepo { + if result.Items[0].Kind != domain.PullRequestKind || result.Items[0].RepositoryOwner != testOwner || result.Items[0].RepositoryName != testRepo { t.Fatalf("item = %+v", result.Items[0]) } if result.Rate.Limit != 5000 || result.Rate.Remaining != 4999 { diff --git a/internal/github/client_portfolio_test.go b/internal/github/client_portfolio_test.go index b5f337ff..151b5189 100644 --- a/internal/github/client_portfolio_test.go +++ b/internal/github/client_portfolio_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/morluto/gitcontribute/internal/domain" ) func TestGetAuthenticatedIdentity(t *testing.T) { @@ -161,7 +162,7 @@ func TestSearchAuthoredPullRequestsBuildsQueryAndExtractsRepository(t *testing.T if item.RepositoryOwner != "lab" || item.RepositoryName != "runtime" { t.Errorf("repository = %q/%q, want lab/runtime", item.RepositoryOwner, item.RepositoryName) } - if item.Kind != ThreadKindPullRequest || item.Number != 42 { + if item.Kind != domain.PullRequestKind || item.Number != 42 { t.Errorf("item = kind %q number %d, want pull_request #42", item.Kind, item.Number) } }) diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 74a579c9..1b4215f0 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/morluto/gitcontribute/internal/domain" "github.com/zalando/go-keyring" ) @@ -287,10 +288,10 @@ func TestIssueVsPRClassification(t *testing.T) { if len(res.Items) != 2 { t.Fatalf("got %d issues, want 2", len(res.Items)) } - if res.Items[0].Kind != ThreadKindIssue { + if res.Items[0].Kind != domain.IssueKind { t.Errorf("first issue kind = %q, want issue", res.Items[0].Kind) } - if res.Items[1].Kind != ThreadKindPullRequest { + if res.Items[1].Kind != domain.PullRequestKind { t.Errorf("second issue kind = %q, want pull_request", res.Items[1].Kind) } if res.Items[1].PullRequestURL != "https://github.com/octocat/hello-world/pull/2" { @@ -595,7 +596,7 @@ func TestRateLimiterTransport(t *testing.T) { func TestTokenResolution(t *testing.T) { t.Run("explicit", func(t *testing.T) { - src := NewTokenSource("explicit-token", "GITHUB_TOKEN", nil) + src := ChainTokenSource(StaticTokenSource("explicit-token"), EnvTokenSource("GITHUB_TOKEN")) tok, err := src.Token(context.Background()) if err != nil || tok != "explicit-token" { t.Fatalf("got %q, %v", tok, err) @@ -604,7 +605,7 @@ func TestTokenResolution(t *testing.T) { t.Run("env", func(t *testing.T) { t.Setenv("GITHUB_TOKEN", "env-token") - src := NewTokenSource("", "GITHUB_TOKEN", nil) + src := ChainTokenSource(StaticTokenSource(""), EnvTokenSource("GITHUB_TOKEN")) tok, err := src.Token(context.Background()) if err != nil || tok != "env-token" { t.Fatalf("got %q, %v", tok, err) @@ -612,8 +613,9 @@ func TestTokenResolution(t *testing.T) { }) t.Run("gh", func(t *testing.T) { - runner := fakeRunner{out: "gh-token\n"} - src := NewTokenSource("", "GITHUB_TOKEN", runner) + src := ChainTokenSource(StaticTokenSource(""), EnvTokenSource("GITHUB_TOKEN"), &ghTokenSource{ + run: func(context.Context) (string, error) { return "gh-token\n", nil }, + }) tok, err := src.Token(context.Background()) if err != nil || tok != "gh-token" { t.Fatalf("got %q, %v", tok, err) @@ -621,7 +623,7 @@ func TestTokenResolution(t *testing.T) { }) t.Run("missing", func(t *testing.T) { - src := NewTokenSource("", "NOT_SET_ENV_VAR", nil) + src := ChainTokenSource(StaticTokenSource(""), EnvTokenSource("NOT_SET_ENV_VAR")) tok, err := src.Token(context.Background()) if !errors.Is(err, ErrNoToken) || tok != "" { t.Fatalf("got %q, %v", tok, err) @@ -703,15 +705,6 @@ func TestTokenResolution(t *testing.T) { }) } -type fakeRunner struct { - out string - err error -} - -func (f fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { - return f.out, f.err -} - func TestNoTokenIsAllowed(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { diff --git a/internal/github/errors.go b/internal/github/errors.go index 2730cf2f..7099fc2b 100644 --- a/internal/github/errors.go +++ b/internal/github/errors.go @@ -1,7 +1,6 @@ package github import ( - "errors" "fmt" "time" ) @@ -85,8 +84,3 @@ func (e *TransientError) Error() string { func (e *TransientError) Unwrap() error { return e.Cause } - -// IsNoToken reports whether err is the sentinel no-token value. -func IsNoToken(err error) bool { - return errors.Is(err, ErrNoToken) -} diff --git a/internal/github/models.go b/internal/github/models.go index 83a7a03b..2511350a 100644 --- a/internal/github/models.go +++ b/internal/github/models.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) // Actor is a domain-neutral GitHub account profile. Nullable fields preserve @@ -101,7 +103,7 @@ type ContributionDay struct { } type UserContribution struct { - Kind string + Kind domain.ContributionKind OccurredAt time.Time RepositoryNodeID string RepositoryNameOwner string @@ -114,7 +116,7 @@ type UserContribution struct { type RepositoryContributionTotal struct { RepositoryNodeID string RepositoryNameOwner string - Kind string + Kind domain.ContributionKind Count int } @@ -134,14 +136,6 @@ type UserContributionCollection struct { Rate RateInfo } -// ThreadKind classifies an issue-list entry. -type ThreadKind string - -const ( - ThreadKindIssue ThreadKind = "issue" - ThreadKindPullRequest ThreadKind = "pull_request" -) - // Repository is a domain-neutral view of a GitHub repository. type Repository struct { ID int64 @@ -202,7 +196,7 @@ type Issue struct { ID int64 NodeID string Number int - Kind ThreadKind + Kind domain.ThreadKind Title string Body string State string diff --git a/internal/github/pull_request_index_test.go b/internal/github/pull_request_index_test.go index 2ccde700..842bb294 100644 --- a/internal/github/pull_request_index_test.go +++ b/internal/github/pull_request_index_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strconv" "testing" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestListPullRequestsUsesAllStatePaginationAndFiltersIssueMarkers(t *testing.T) { @@ -41,7 +43,7 @@ func TestListPullRequestsUsesAllStatePaginationAndFiltersIssueMarkers(t *testing if err != nil { t.Fatal(err) } - if len(got.Items) != 1 || got.Items[0].Number != 7 || got.Items[0].Kind != ThreadKindPullRequest { + if len(got.Items) != 1 || got.Items[0].Number != 7 || got.Items[0].Kind != domain.PullRequestKind { t.Fatalf("page one pull requests = %+v", got.Items) } if !got.Page.HasNext || got.Page.NextPage != 2 || len(pages) != 1 || pages[0] != 1 { diff --git a/internal/github/retry_test.go b/internal/github/retry_test.go index d066dbf9..718748c2 100644 --- a/internal/github/retry_test.go +++ b/internal/github/retry_test.go @@ -27,12 +27,6 @@ func (c *fakeClock) Now() time.Time { return c.now } -func (c *fakeClock) Advance(d time.Duration) { - c.mu.Lock() - defer c.mu.Unlock() - c.now = c.now.Add(d) -} - type fakeSleeper struct { mu sync.Mutex calls []time.Duration diff --git a/internal/github/user_graphql.go b/internal/github/user_graphql.go index b3f17f8f..2b75d5e6 100644 --- a/internal/github/user_graphql.go +++ b/internal/github/user_graphql.go @@ -8,6 +8,8 @@ import ( "time" gh "github.com/google/go-github/v89/github" + + "github.com/morluto/gitcontribute/internal/domain" ) const userOrganizationsQuery = `query UserOrganizations($login:String!,$first:Int!,$after:String){user(login:$login){organizations(first:$first,after:$after){totalCount nodes{id login avatarUrl} pageInfo{hasNextPage endCursor}}}}` @@ -301,12 +303,12 @@ func (c *Client) GetUserContributions(ctx context.Context, login string, opts Us total := 0 for _, node := range group.Contributions.Nodes { total += node.CommitCount - out.Items = append(out.Items, UserContribution{Kind: "commit", OccurredAt: node.OccurredAt, RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Restricted: node.Restricted, Count: node.CommitCount}) + out.Items = append(out.Items, UserContribution{Kind: domain.CommitContributionKind, OccurredAt: node.OccurredAt, RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Restricted: node.Restricted, Count: node.CommitCount}) } - out.RepositoryTotals = append(out.RepositoryTotals, RepositoryContributionTotal{RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Kind: "commit", Count: total}) + out.RepositoryTotals = append(out.RepositoryTotals, RepositoryContributionTotal{RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Kind: domain.CommitContributionKind, Count: total}) out.Complete = out.Complete && !group.Contributions.PageInfo.HasNextPage } - appendConnection := func(kind string, connection contributionConnection) { + appendConnection := func(kind domain.ContributionKind, connection contributionConnection) { for _, node := range connection.Nodes { item := UserContribution{Kind: kind, OccurredAt: node.OccurredAt, Restricted: node.Restricted, Count: 1} switch { @@ -336,10 +338,10 @@ func (c *Client) GetUserContributions(ctx context.Context, login string, opts Us out.Complete = false } } - appendConnection("issue", d.Issues) - appendConnection("pull_request", d.PRs) - appendConnection("pull_request_review", d.Reviews) - appendConnection("repository", d.Repositories) + appendConnection(domain.IssueContributionKind, d.Issues) + appendConnection(domain.PullRequestContributionKind, d.PRs) + appendConnection(domain.PullRequestReviewContributionKind, d.Reviews) + appendConnection(domain.RepositoryContributionKind, d.Repositories) return out, nil } diff --git a/internal/health/compute.go b/internal/health/compute.go index 57dff9b0..a331dff5 100644 --- a/internal/health/compute.go +++ b/internal/health/compute.go @@ -40,11 +40,11 @@ func Compute(ctx context.Context, c *corpus.Corpus, repoID int64, opts Options) return nil, fmt.Errorf("decode repository identity: %w", err) } - threads, err := c.ListThreads(ctx, repoID, "", threadListLimit) + threads, err := c.ListThreads(ctx, repoID, corpus.AnyThreadKind(), threadListLimit) if err != nil { return nil, fmt.Errorf("list threads: %w", err) } - threadTotal, err := c.CountThreadsFiltered(ctx, repoID, "", "") + threadTotal, err := c.CountThreadsFiltered(ctx, repoID, corpus.AnyThreadKind(), corpus.AnyThreadState()) if err != nil { return nil, fmt.Errorf("count threads: %w", err) } @@ -87,13 +87,7 @@ func Compute(ctx context.Context, c *corpus.Corpus, repoID int64, opts Options) License: repo.License, Coverage: "repository", }, - Coverage: CoverageSummary{ - ThreadsLimit: threadListLimit, - ThreadsComplete: threadsComplete, - ThreadsTruncated: threadsIncomplete, - ThreadsSampleSize: len(threads), - RepositoryProjection: true, - }, + Coverage: newCoverageSummary(threadListLimit, len(threads), threadsComplete, true), } issueMetrics, prMetrics := countThreads(threads, window, threadsIncomplete) @@ -151,9 +145,9 @@ func countThreads(threads []corpus.Thread, window Window, incomplete bool) (Issu for _, t := range threads { if t.SourceCreatedAt.IsZero() { switch t.Kind { - case corpus.ThreadKindIssue: + case domain.IssueKind: missingIssueCreated = true - case corpus.ThreadKindPullRequest: + case domain.PullRequestKind: missingPRCreated = true } continue @@ -162,14 +156,14 @@ func countThreads(threads []corpus.Thread, window Window, incomplete bool) (Issu continue } switch t.Kind { - case corpus.ThreadKindIssue: + case domain.IssueKind: issueMetrics.SampleSize++ if t.State == "open" { issueMetrics.Open++ } else { issueMetrics.Closed++ } - case corpus.ThreadKindPullRequest: + case domain.PullRequestKind: prMetrics.SampleSize++ if t.State == "open" { prMetrics.Open++ @@ -220,7 +214,7 @@ func computeExternalMetrics(threads []corpus.Thread, start, end time.Time) Exter out := ExternalContributorMetrics{} var unknown, known int for _, t := range threads { - if t.Kind != corpus.ThreadKindPullRequest { + if t.Kind != domain.PullRequestKind { continue } if !t.SourceCreatedAt.IsZero() && !withinWindow(t.SourceCreatedAt, start, end) { @@ -303,7 +297,7 @@ func computeCongestion(threads []corpus.Thread, now time.Time, window Window) Co bucketCounts := make([]int, len(buckets)) var missingCreated int for _, t := range threads { - if t.Kind != corpus.ThreadKindPullRequest || t.State != "open" { + if t.Kind != domain.PullRequestKind || t.State != "open" { continue } out.OpenPRs++ @@ -354,7 +348,7 @@ func computeStaleSignals(ctx context.Context, c *corpus.Corpus, threads []corpus Threshold: threshold.Hours(), } for _, t := range threads { - if t.Kind != corpus.ThreadKindPullRequest || t.State != "open" { + if t.Kind != domain.PullRequestKind || t.State != "open" { continue } out.SampleSize++ @@ -416,13 +410,13 @@ func latestActivity(ctx context.Context, c *corpus.Corpus, t corpus.Thread) (tim func computeResponseTimes(ctx context.Context, c *corpus.Corpus, threads []corpus.Thread, start, end time.Time, window Window) (ResponseTimeDistributions, error) { out := ResponseTimeDistributions{} - issueSamples, issueCoverage, err := responseSamples(ctx, c, threads, start, end, corpus.ThreadKindIssue) + issueSamples, issueCoverage, err := responseSamples(ctx, c, threads, start, end, domain.IssueKind) if err != nil { return out, err } out.Issues = buildResponseMetric(issueSamples, window, issueCoverage, facets.IssueComments) - prSamples, prCoverage, err := responseSamples(ctx, c, threads, start, end, corpus.ThreadKindPullRequest) + prSamples, prCoverage, err := responseSamples(ctx, c, threads, start, end, domain.PullRequestKind) if err != nil { return out, err } @@ -430,7 +424,7 @@ func computeResponseTimes(ctx context.Context, c *corpus.Corpus, threads []corpu return out, nil } -func responseSamples(ctx context.Context, c *corpus.Corpus, threads []corpus.Thread, start, end time.Time, kind string) ([]float64, string, error) { +func responseSamples(ctx context.Context, c *corpus.Corpus, threads []corpus.Thread, start, end time.Time, kind domain.ThreadKind) ([]float64, string, error) { var samples []float64 var withFacets, withoutFacets, noCreated int for _, t := range threads { @@ -470,8 +464,8 @@ func responseSamples(ctx context.Context, c *corpus.Corpus, threads []corpus.Thr return samples, coverage, nil } -func responseFacets(kind string) []string { - if kind == corpus.ThreadKindPullRequest { +func responseFacets(kind domain.ThreadKind) []string { + if kind == domain.PullRequestKind { return []string{facetIssueComments, facetPRReviews, facetPRReviewComments} } return []string{facetIssueComments} diff --git a/internal/health/health_test.go b/internal/health/health_test.go index a6d87da7..e227bb40 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -84,7 +84,7 @@ func TestComputeHealthMetrics(t *testing.T) { applyRepositoryFacet(ctx, t, c, repo.ID, facetThreads, now, true) openIssue := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "open issue", @@ -94,7 +94,7 @@ func TestComputeHealthMetrics(t *testing.T) { }, "NONE") closedIssue := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 2, State: "closed", Title: "closed issue", @@ -105,12 +105,12 @@ func TestComputeHealthMetrics(t *testing.T) { }, "CONTRIBUTOR") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, Number: 3, State: "closed", Title: "outside window", + Kind: domain.IssueKind, Number: 3, State: "closed", Title: "outside window", Author: "old", SourceCreatedAt: now.Add(-60 * 24 * time.Hour), SourceUpdatedAt: now.Add(-50 * 24 * time.Hour), }, "CONTRIBUTOR") openPR := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 10, State: "open", Title: "open pr", @@ -120,7 +120,7 @@ func TestComputeHealthMetrics(t *testing.T) { }, "NONE") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 11, State: "open", Title: "stale pr", @@ -130,7 +130,7 @@ func TestComputeHealthMetrics(t *testing.T) { }, "FIRST_TIMER") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 12, State: "closed", Title: "merged pr", @@ -141,7 +141,7 @@ func TestComputeHealthMetrics(t *testing.T) { }, "OWNER") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 13, State: "closed", Title: "closed pr", @@ -153,7 +153,7 @@ func TestComputeHealthMetrics(t *testing.T) { }, "NONE") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 14, State: "closed", Title: "header-only closed pr", @@ -246,28 +246,28 @@ func TestExternalMergeRateRequiresObservedOutcome(t *testing.T) { { name: "open only is unknown", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "open", AuthorAssociation: "NONE", SourceCreatedAt: now, + Kind: domain.PullRequestKind, State: "open", AuthorAssociation: "NONE", SourceCreatedAt: now, }}, wantCoverage: "partial (no observed closed external PR outcomes)", }, { name: "no external pull requests is missing", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "open", AuthorAssociation: "MEMBER", SourceCreatedAt: now, + Kind: domain.PullRequestKind, State: "open", AuthorAssociation: "MEMBER", SourceCreatedAt: now, }}, wantCoverage: "missing (no external PRs in window)", }, { name: "unknown closed outcome stays unknown", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, + Kind: domain.PullRequestKind, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, }}, wantCoverage: "partial (some closed PRs lack an observed merge state)", }, { name: "known zero is preserved", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, Merge: domain.UnmergedStatus(), + Kind: domain.PullRequestKind, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, Merge: domain.UnmergedStatus(), }}, wantRate: float64Pointer(0), wantCoverage: "complete", @@ -275,7 +275,7 @@ func TestExternalMergeRateRequiresObservedOutcome(t *testing.T) { { name: "known one is preserved", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, Merge: domain.MergedStatus(time.Time{}), + Kind: domain.PullRequestKind, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, Merge: domain.MergedStatus(time.Time{}), }}, wantRate: float64Pointer(1), wantCoverage: "complete", @@ -305,10 +305,10 @@ func TestComputeHonorsRepositoryThreadCoverage(t *testing.T) { t.Fatalf("upsert repository: %v", err) } upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, Number: 1, State: "open", SourceCreatedAt: now.Add(-time.Hour), SourceUpdatedAt: now, + Kind: domain.IssueKind, Number: 1, State: "open", SourceCreatedAt: now.Add(-time.Hour), SourceUpdatedAt: now, }, "NONE") upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, Number: 2, State: "open", SourceCreatedAt: now.Add(-time.Hour), SourceUpdatedAt: now, + Kind: domain.PullRequestKind, Number: 2, State: "open", SourceCreatedAt: now.Add(-time.Hour), SourceUpdatedAt: now, }, "NONE") applyRepositoryFacet(ctx, t, c, repo.ID, facetThreads, now, false) @@ -317,7 +317,7 @@ func TestComputeHonorsRepositoryThreadCoverage(t *testing.T) { t.Fatalf("compute partial health: %v", err) } const wantPartial = "partial (repository thread coverage is incomplete)" - if partial.Issues.Coverage != wantPartial || partial.PullRequests.Coverage != wantPartial || partial.Coverage.ThreadsComplete || !partial.Coverage.ThreadsTruncated { + if partial.Issues.Coverage != wantPartial || partial.PullRequests.Coverage != wantPartial || partial.Coverage.ThreadsComplete() || !partial.Coverage.ThreadsTruncated() { t.Fatalf("incomplete repository coverage not propagated: %+v", partial) } @@ -326,7 +326,7 @@ func TestComputeHonorsRepositoryThreadCoverage(t *testing.T) { if err != nil { t.Fatalf("compute complete health: %v", err) } - if complete.Issues.Coverage != "complete" || complete.PullRequests.Coverage != "complete" || !complete.Coverage.ThreadsComplete || complete.Coverage.ThreadsTruncated { + if complete.Issues.Coverage != "complete" || complete.PullRequests.Coverage != "complete" || !complete.Coverage.ThreadsComplete() || complete.Coverage.ThreadsTruncated() { t.Fatalf("complete repository coverage not propagated: %+v", complete) } if complete.External.MergeRate != nil || complete.External.Coverage != "partial (no observed closed external PR outcomes)" { @@ -341,6 +341,13 @@ func TestComputeHonorsRepositoryThreadCoverage(t *testing.T) { } } +func TestCoverageSummaryRejectsContradictoryThreadFlags(t *testing.T) { + var coverage CoverageSummary + if err := json.Unmarshal([]byte(`{"threads_complete":true,"threads_truncated":true}`), &coverage); err == nil { + t.Fatal("contradictory thread coverage flags were accepted") + } +} + func float64Pointer(value float64) *float64 { return &value } @@ -364,7 +371,7 @@ func TestResponseRequiredFacetCoverage(t *testing.T) { } issue := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "issue", @@ -447,7 +454,7 @@ func TestResponsePullRequestPartialFacetCoverage(t *testing.T) { } pr := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 1, State: "open", Title: "pr", @@ -518,7 +525,7 @@ func TestResponseSelfCommentCaseInsensitive(t *testing.T) { } issue := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindIssue, + Kind: domain.IssueKind, Number: 1, State: "open", Title: "issue", @@ -554,7 +561,7 @@ func TestResponseSelfCommentCaseInsensitive(t *testing.T) { // PR reviews should also ignore same-login self-comments with different case. pr := upsertThread(t, ctx, c, repo.ID, corpus.Thread{ - Kind: corpus.ThreadKindPullRequest, + Kind: domain.PullRequestKind, Number: 2, State: "open", Title: "pr", diff --git a/internal/health/types.go b/internal/health/types.go index 7e9283fa..bb1fe6b9 100644 --- a/internal/health/types.go +++ b/internal/health/types.go @@ -4,14 +4,13 @@ package health import ( + "encoding/json" + "fmt" "time" "github.com/morluto/gitcontribute/internal/domain" ) -// RepoRef is the shared repository identifier. -type RepoRef = domain.RepoRef - // Window describes the time bounds and label for a metric group. type Window struct { Start time.Time `json:"start,omitempty"` @@ -148,8 +147,77 @@ type ResponseTimeMetric struct { // CoverageSummary reports top-level data-availability notes. type CoverageSummary struct { ThreadsLimit int `json:"threads_limit"` - ThreadsComplete bool `json:"threads_complete"` - ThreadsTruncated bool `json:"threads_truncated"` ThreadsSampleSize int `json:"threads_sample_size"` RepositoryProjection bool `json:"repository_projection"` + threads ThreadCoverage +} + +// ThreadCoverage is the mutually exclusive completeness state of the bounded +// repository thread sample. The zero value represents an uncomputed report. +type ThreadCoverage string + +const ( + ThreadCoverageComplete ThreadCoverage = "complete" + ThreadCoverageTruncated ThreadCoverage = "truncated" +) + +// ThreadsComplete reports whether every stored thread was included. +func (s CoverageSummary) ThreadsComplete() bool { return s.threads == ThreadCoverageComplete } + +// ThreadsTruncated reports whether the bounded sample omitted stored threads. +func (s CoverageSummary) ThreadsTruncated() bool { return s.threads == ThreadCoverageTruncated } + +func newCoverageSummary(limit, sampleSize int, complete, repositoryProjection bool) CoverageSummary { + coverage := ThreadCoverageTruncated + if complete { + coverage = ThreadCoverageComplete + } + return CoverageSummary{ + ThreadsLimit: limit, ThreadsSampleSize: sampleSize, + RepositoryProjection: repositoryProjection, threads: coverage, + } +} + +// MarshalJSON preserves the public complementary boolean representation while +// keeping one authoritative state in memory. +func (s CoverageSummary) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + ThreadsLimit int `json:"threads_limit"` + ThreadsComplete bool `json:"threads_complete"` + ThreadsTruncated bool `json:"threads_truncated"` + ThreadsSampleSize int `json:"threads_sample_size"` + RepositoryProjection bool `json:"repository_projection"` + }{ + ThreadsLimit: s.ThreadsLimit, ThreadsComplete: s.ThreadsComplete(), + ThreadsTruncated: s.ThreadsTruncated(), ThreadsSampleSize: s.ThreadsSampleSize, + RepositoryProjection: s.RepositoryProjection, + }) +} + +// UnmarshalJSON parses the legacy public booleans into one coverage state. +func (s *CoverageSummary) UnmarshalJSON(data []byte) error { + var raw struct { + ThreadsLimit int `json:"threads_limit"` + ThreadsComplete bool `json:"threads_complete"` + ThreadsTruncated bool `json:"threads_truncated"` + ThreadsSampleSize int `json:"threads_sample_size"` + RepositoryProjection bool `json:"repository_projection"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.ThreadsComplete && raw.ThreadsTruncated { + return fmt.Errorf("thread coverage cannot be both complete and truncated") + } + coverage := ThreadCoverage("") + if raw.ThreadsComplete { + coverage = ThreadCoverageComplete + } else if raw.ThreadsTruncated { + coverage = ThreadCoverageTruncated + } + *s = CoverageSummary{ + ThreadsLimit: raw.ThreadsLimit, ThreadsSampleSize: raw.ThreadsSampleSize, + RepositoryProjection: raw.RepositoryProjection, threads: coverage, + } + return nil } diff --git a/internal/investigation/models.go b/internal/investigation/models.go index d733bd04..a754624f 100644 --- a/internal/investigation/models.go +++ b/internal/investigation/models.go @@ -3,6 +3,7 @@ package investigation import ( "errors" "fmt" + "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -24,6 +25,15 @@ const ( CategoryOther Category = "other" ) +// ParseCategory converts boundary text into a supported contribution category. +func ParseCategory(value string) (Category, error) { + category := Category(strings.TrimSpace(value)) + if !validCategory(category) { + return "", fmt.Errorf("%w: %q", ErrInvalidCategory, value) + } + return category, nil +} + // HypothesisStatus is the lifecycle of an individual hypothesis. type HypothesisStatus string @@ -35,6 +45,15 @@ const ( HypothesisSuperseded HypothesisStatus = "superseded" ) +// ParseHypothesisStatus converts boundary text into a supported hypothesis status. +func ParseHypothesisStatus(value string) (HypothesisStatus, error) { + status := HypothesisStatus(strings.TrimSpace(value)) + if !validHypothesisStatus(status) { + return "", fmt.Errorf("invalid hypothesis status %q", value) + } + return status, nil +} + // OpportunityStatus is the lifecycle of an opportunity. type OpportunityStatus string @@ -51,6 +70,15 @@ const ( OpportunitySuperseded OpportunityStatus = "superseded" ) +// ParseOpportunityStatus converts boundary text into a supported opportunity status. +func ParseOpportunityStatus(value string) (OpportunityStatus, error) { + status := OpportunityStatus(strings.TrimSpace(value)) + if !validOpportunityStatus(status) { + return "", fmt.Errorf("invalid opportunity status %q", value) + } + return status, nil +} + // CollisionStatus records whether known competing work exists. type CollisionStatus string @@ -62,6 +90,15 @@ const ( CollisionBlocked CollisionStatus = "blocked" ) +// ParseCollisionStatus converts boundary text into a supported collision status. +func ParseCollisionStatus(value string) (CollisionStatus, error) { + status := CollisionStatus(strings.TrimSpace(value)) + if !validCollisionStatus(status) { + return "", fmt.Errorf("invalid collision status %q", value) + } + return status, nil +} + // StatusChange records a deliberate lifecycle transition with rationale. type StatusChange struct { From string @@ -164,7 +201,7 @@ func (h *Hypothesis) ParseStored() error { if h == nil || h.ID == "" || h.InvestigationID == "" { return errors.New("hypothesis identity and investigation are required") } - if !ValidCategory(h.Category) { + if !validCategory(h.Category) { return fmt.Errorf("unsupported hypothesis category %q", h.Category) } switch h.Status { @@ -187,7 +224,7 @@ func (o *Opportunity) ParseStored() error { if o == nil || o.ID == "" || o.InvestigationID == "" || o.HypothesisID == "" { return errors.New("opportunity identity, investigation, and hypothesis are required") } - if !ValidCategory(o.Category) { + if !validCategory(o.Category) { return fmt.Errorf("unsupported opportunity category %q", o.Category) } // Empty is the legacy JSON representation of the initial unknown state. @@ -222,6 +259,16 @@ func validInvestigationStatus(status InvestigationStatus) bool { return status == InvestigationOpen || status == InvestigationClosed } +func validCategory(category Category) bool { + switch category { + case CategoryBug, CategoryPerformance, CategoryArchitecture, CategoryTesting, + CategoryDocumentation, CategoryMaintenance, CategoryCompatibility, CategorySecurity, CategoryOther: + return true + default: + return false + } +} + func validHypothesisStatus(status HypothesisStatus) bool { switch status { case HypothesisProposed, HypothesisPromoted, HypothesisRejected, HypothesisDeferred, HypothesisSuperseded: diff --git a/internal/investigation/repository.go b/internal/investigation/repository.go index abf4cdb6..4e3148d0 100644 --- a/internal/investigation/repository.go +++ b/internal/investigation/repository.go @@ -7,6 +7,21 @@ import ( "github.com/morluto/gitcontribute/internal/evidence" ) +// OpportunityUpdateConstraint selects the evidence predicate that must still +// hold when an optimistic opportunity update is serialized. +type OpportunityUpdateConstraint uint8 + +const ( + OpportunityUpdateUnconditional OpportunityUpdateConstraint = iota + OpportunityUpdateWithoutContradictingEvidence +) + +// BlocksContradictingEvidence reports whether the serialized update must fail +// when contradicting evidence exists. +func (c OpportunityUpdateConstraint) BlocksContradictingEvidence() bool { + return c == OpportunityUpdateWithoutContradictingEvidence +} + // Repository is a narrow persistence boundary for investigations, hypotheses, // and opportunities. Concrete stores live outside this package. type Repository interface { @@ -19,7 +34,7 @@ type Repository interface { GetHypothesis(ctx context.Context, id string) (*Hypothesis, error) ListHypotheses(ctx context.Context, investigationID string) ([]*Hypothesis, error) SaveOpportunity(ctx context.Context, o *Opportunity) error - UpdateOpportunity(ctx context.Context, previous, next *Opportunity, blockContradicting bool) error + UpdateOpportunity(ctx context.Context, previous, next *Opportunity, constraint OpportunityUpdateConstraint) error PromoteHypothesisWithEvidence(ctx context.Context, h *Hypothesis, o *Opportunity, e *evidence.Evidence) error GetOpportunity(ctx context.Context, id string) (*Opportunity, error) ListOpportunities(ctx context.Context, investigationID string) ([]*Opportunity, error) diff --git a/internal/investigation/service.go b/internal/investigation/service.go index 63ebeb16..9a64fcc9 100644 --- a/internal/investigation/service.go +++ b/internal/investigation/service.go @@ -103,7 +103,7 @@ func (s *Service) CreateHypothesis(ctx context.Context, investigationID string, if title == "" { return nil, ErrMissingTitle } - if !ValidCategory(in.Category) { + if !validCategory(in.Category) { return nil, fmt.Errorf("%w: %q", ErrInvalidCategory, in.Category) } if _, err := s.repo.GetInvestigation(ctx, investigationID); err != nil { @@ -140,7 +140,7 @@ func (s *Service) UpdateHypothesis(ctx context.Context, id string, in UpdateHypo if title == "" { return nil, ErrMissingTitle } - if !ValidCategory(in.Category) { + if !validCategory(in.Category) { return nil, fmt.Errorf("%w: %q", ErrInvalidCategory, in.Category) } h, err := s.repo.GetHypothesis(ctx, id) @@ -339,7 +339,11 @@ func (s *Service) SetOpportunityStatus(ctx context.Context, id string, to Opport if err := o.Transition(to, rationale); err != nil { return nil, err } - if err := s.repo.UpdateOpportunity(ctx, &previous, o, advancing); err != nil { + constraint := OpportunityUpdateUnconditional + if advancing { + constraint = OpportunityUpdateWithoutContradictingEvidence + } + if err := s.repo.UpdateOpportunity(ctx, &previous, o, constraint); err != nil { return nil, err } return o, nil @@ -416,31 +420,12 @@ func (s *Service) UpdateCollisionStatus(ctx context.Context, id string, status C At: now, }) o.UpdatedAt = now - if err := s.repo.UpdateOpportunity(ctx, &stored, o, false); err != nil { + if err := s.repo.UpdateOpportunity(ctx, &stored, o, OpportunityUpdateUnconditional); err != nil { return nil, err } return o, nil } -// CheckDuplicates returns source references for known related work in the same repository. -func (s *Service) CheckDuplicates(ctx context.Context, investigationID string) ([]domain.SourceRef, error) { - inv, err := s.repo.GetInvestigation(ctx, investigationID) - if err != nil { - return nil, err - } - return s.repo.FindRelated(ctx, inv.Repo, "") -} - -// ValidCategory reports whether c is a supported contribution category. -func ValidCategory(c Category) bool { - switch c { - case CategoryBug, CategoryPerformance, CategoryArchitecture, CategoryTesting, - CategoryDocumentation, CategoryMaintenance, CategoryCompatibility, CategorySecurity, CategoryOther: - return true - } - return false -} - func isAdvancingStatus(status OpportunityStatus) bool { switch status { case OpportunityValidated, OpportunityMaintainerAligned, OpportunityImplemented, diff --git a/internal/investigation/service_test.go b/internal/investigation/service_test.go index 216cc6fe..6a1849c1 100644 --- a/internal/investigation/service_test.go +++ b/internal/investigation/service_test.go @@ -93,7 +93,7 @@ func (r *fakeRepo) SaveOpportunity(_ context.Context, o *Opportunity) error { return nil } -func (r *fakeRepo) UpdateOpportunity(_ context.Context, _, next *Opportunity, _ bool) error { +func (r *fakeRepo) UpdateOpportunity(_ context.Context, _, next *Opportunity, _ OpportunityUpdateConstraint) error { r.opportunities[next.ID] = next return nil } diff --git a/internal/lens/lens.go b/internal/lens/lens.go index e491db9e..ca5a3145 100644 --- a/internal/lens/lens.go +++ b/internal/lens/lens.go @@ -52,29 +52,23 @@ func (d *Definition) UnmarshalJSON(data []byte) error { // MarshalJSON emits updated_within as a Go duration string so JSON lens // definitions round-trip with the same format used for input. func (f Filter) MarshalJSON() ([]byte, error) { - m := map[string]any{} - if len(f.Kinds) > 0 { - m["kinds"] = f.Kinds - } - if len(f.States) > 0 { - m["states"] = f.States - } - if len(f.Languages) > 0 { - m["languages"] = f.Languages - } - if f.ExcludeArchived { - m["exclude_archived"] = true - } - if f.Unassigned { - m["unassigned"] = true - } + updatedWithin := "" if f.UpdatedWithin > 0 { - m["updated_within"] = f.UpdatedWithin.String() - } - if f.MinStars > 0 { - m["min_stars"] = f.MinStars - } - return json.Marshal(m) + updatedWithin = f.UpdatedWithin.String() + } + return json.Marshal(struct { + Kinds []string `json:"kinds,omitempty"` + States []string `json:"states,omitempty"` + Languages []string `json:"languages,omitempty"` + ExcludeArchived bool `json:"exclude_archived,omitempty"` + Unassigned bool `json:"unassigned,omitempty"` + UpdatedWithin string `json:"updated_within,omitempty"` + MinStars int `json:"min_stars,omitempty"` + }{ + Kinds: f.Kinds, States: f.States, Languages: f.Languages, + ExcludeArchived: f.ExcludeArchived, Unassigned: f.Unassigned, + UpdatedWithin: updatedWithin, MinStars: f.MinStars, + }) } // UnmarshalJSON supports duration strings for updated_within. diff --git a/internal/manifest/models.go b/internal/manifest/models.go index b2fb4407..9fa22c4d 100644 --- a/internal/manifest/models.go +++ b/internal/manifest/models.go @@ -11,9 +11,28 @@ import ( "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/investigation" "github.com/morluto/gitcontribute/internal/workspace" ) +// Status is the manifest-wide evidence completeness state. +type Status string + +const ( + StatusComplete Status = "complete" + StatusIncomplete Status = "incomplete" +) + +// CompletenessStatus is the state of one requested evidence facet. +type CompletenessStatus string + +const ( + CompletenessComplete CompletenessStatus = "complete" + CompletenessIncomplete CompletenessStatus = "incomplete" + CompletenessUnknown CompletenessStatus = "unknown" + CompletenessNotRequested CompletenessStatus = "not_requested" +) + // ErrNotFound means no persisted manifest matched the requested identity. var ( ErrNotFound = errors.New("contribution manifest not found") @@ -59,7 +78,7 @@ type Predicate struct { Readiness ReadinessRecord `json:"readiness"` PullRequest *PullRequestRecord `json:"pull_request,omitempty"` Drafts []DraftRecord `json:"drafts"` - Status string `json:"status"` + Status Status `json:"status"` Completeness []CompletenessFacet `json:"completeness"` Gaps []Gap `json:"gaps"` } @@ -73,21 +92,21 @@ type RepositoryIdentity struct { // OpportunityRecord captures the scoped contribution outcome. type OpportunityRecord struct { - ID string `json:"id"` - InvestigationID string `json:"investigation_id"` - HypothesisID string `json:"hypothesis_id,omitempty"` - ProblemStatement string `json:"problem_statement"` - Scope string `json:"scope"` - Impact string `json:"impact"` - Status string `json:"status"` - SourceRefs []domain.SourceRef `json:"source_refs"` + ID string `json:"id"` + InvestigationID string `json:"investigation_id"` + HypothesisID string `json:"hypothesis_id,omitempty"` + ProblemStatement string `json:"problem_statement"` + Scope string `json:"scope"` + Impact string `json:"impact"` + Status investigation.OpportunityStatus `json:"status"` + SourceRefs []domain.SourceRef `json:"source_refs"` } // ValidationRecord binds a stored run to its command and candidate identity. type ValidationRecord struct { DefinitionID string `json:"definition_id"` RunID string `json:"run_id"` - Kind string `json:"kind"` + Kind evidence.RunKind `json:"kind"` Command []string `json:"command"` CommandSHA256 string `json:"command_sha256"` ExecutionContractSHA256 string `json:"execution_contract_sha256"` @@ -95,17 +114,17 @@ type ValidationRecord struct { Timeout string `json:"timeout"` MaxOutputBytes int64 `json:"max_output_bytes"` Observation *evidence.ObservationContract `json:"observation,omitempty"` - Classification string `json:"classification"` - ObservationStatus string `json:"observation_status"` + Classification evidence.RunClassification `json:"classification"` + ObservationStatus evidence.ObservationStatus `json:"observation_status"` Observations []evidence.ObservationResult `json:"observations"` StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` WorkspaceSnapshotBefore string `json:"workspace_snapshot_before,omitempty"` WorkspaceSnapshotAfter string `json:"workspace_snapshot_after,omitempty"` - WorkspaceBindingStatus string `json:"workspace_binding_status"` + WorkspaceBindingStatus evidence.WorkspaceBindingStatus `json:"workspace_binding_status"` WorkspaceCompatibility string `json:"workspace_compatibility"` CompatibilityReason string `json:"compatibility_reason"` - ExecutionOrigin string `json:"execution_origin,omitempty"` + ExecutionOrigin evidence.ExecutionOrigin `json:"execution_origin,omitempty"` External *evidence.ExternalReceiptProvenance `json:"external,omitempty"` JUnitReport *JUnitReportRecord `json:"junit_report,omitempty"` Selected bool `json:"selected_for_completeness"` @@ -124,13 +143,13 @@ type JUnitReportRecord struct { // EvidenceRecord captures a stored evidence item and evaluated freshness. type EvidenceRecord struct { ID string `json:"id"` - Type string `json:"type"` - Relation string `json:"relation"` + Type evidence.EvidenceType `json:"type"` + Relation evidence.Relation `json:"relation"` Description string `json:"description"` ValidationRunID string `json:"validation_run_id,omitempty"` SourceRefs []domain.SourceRef `json:"source_refs"` SourceProvenance []evidence.SourceRevision `json:"source_provenance"` - Freshness string `json:"freshness"` + Freshness evidence.FreshnessStatus `json:"freshness"` FreshnessReason string `json:"freshness_reason"` Measurements map[string]any `json:"measurements,omitempty"` External *evidence.ExternalEvidenceProvenance `json:"external,omitempty"` @@ -162,35 +181,35 @@ type FacetStatus struct { // PullRequestRecord captures explicitly selected, locally stored PR health. type PullRequestRecord struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number"` - State string `json:"state"` - HeadSHA string `json:"head_sha,omitempty"` - BaseSHA string `json:"base_sha,omitempty"` - ChecksStatus string `json:"checks_status,omitempty"` - ReviewDecision string `json:"review_decision,omitempty"` - UnresolvedReviewThreads *int `json:"unresolved_review_threads,omitempty"` - MergeStateStatus string `json:"merge_state_status,omitempty"` - MergeQueueState string `json:"merge_queue_state,omitempty"` - Attention string `json:"attention"` - SourceUpdatedAt string `json:"source_updated_at"` - Facets []FacetStatus `json:"facets"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + State domain.ThreadState `json:"state"` + HeadSHA string `json:"head_sha,omitempty"` + BaseSHA string `json:"base_sha,omitempty"` + ChecksStatus string `json:"checks_status,omitempty"` + ReviewDecision string `json:"review_decision,omitempty"` + UnresolvedReviewThreads *int `json:"unresolved_review_threads,omitempty"` + MergeStateStatus string `json:"merge_state_status,omitempty"` + MergeQueueState string `json:"merge_queue_state,omitempty"` + Attention string `json:"attention"` + SourceUpdatedAt string `json:"source_updated_at"` + Facets []FacetStatus `json:"facets"` } // DraftRecord identifies a locally prepared contribution draft. type DraftRecord struct { - Kind string `json:"kind"` - Title string `json:"title"` - RenderedAt time.Time `json:"rendered_at"` - ManifestID string `json:"manifest_id,omitempty"` + Kind domain.ThreadKind `json:"kind"` + Title string `json:"title"` + RenderedAt time.Time `json:"rendered_at"` + ManifestID string `json:"manifest_id,omitempty"` } // CompletenessFacet reports whether one evidence area is usable. type CompletenessFacet struct { - Facet string `json:"facet"` - Status string `json:"status"` - Reason string `json:"reason"` + Facet string `json:"facet"` + Status CompletenessStatus `json:"status"` + Reason string `json:"reason"` } // Gap records evidence that is missing, stale, unknown, or incompatible. @@ -203,6 +222,7 @@ type Gap struct { // Finalize computes the deterministic content identity and in-toto subject. func Finalize(predicate Predicate) (Statement, error) { predicate.SchemaVersion = SchemaVersion + predicate.Status = statusForGaps(predicate.Gaps) contentDigest, err := predicateIdentityDigest(predicate) if err != nil { return Statement{}, err @@ -239,6 +259,64 @@ func (s Statement) Validate() error { if s.Predicate.Repository.Owner == "" || s.Predicate.Repository.Repo == "" || s.Predicate.Opportunity.ID == "" { return errors.New("manifest repository and opportunity are required") } + if s.Predicate.Status != statusForGaps(s.Predicate.Gaps) { + return errors.New("manifest status contradicts its explicit gaps") + } + if s.Predicate.Opportunity.Status != "" { + if _, err := investigation.ParseOpportunityStatus(string(s.Predicate.Opportunity.Status)); err != nil { + return fmt.Errorf("manifest opportunity status: %w", err) + } + } + for i, record := range s.Predicate.Validations { + run := evidence.ValidationRun{ + ID: record.RunID, DefinitionID: record.DefinitionID, Kind: record.Kind, + Classification: record.Classification, ObservationStatus: record.ObservationStatus, + WorkspaceBindingStatus: record.WorkspaceBindingStatus, ExecutionOrigin: record.ExecutionOrigin, + } + if err := run.ParseStored(); err != nil { + return fmt.Errorf("manifest validation %d: %w", i, err) + } + if record.JUnitReport != nil { + report := evidence.JUnitReport{ + SchemaVersion: record.JUnitReport.SchemaVersion, + Name: record.JUnitReport.Name, + Counts: record.JUnitReport.Counts, + TestCases: append([]evidence.JUnitTestCase(nil), record.JUnitReport.TestCases...), + Incomplete: record.JUnitReport.Incomplete, + ParseError: record.JUnitReport.ParseError, + RawSHA256: record.JUnitReport.RawSHA256, + } + if err := report.ValidateSummary(); err != nil { + return fmt.Errorf("manifest validation %d JUnit report: %w", i, err) + } + } + } + for i, record := range s.Predicate.Evidence { + item := evidence.Evidence{ID: record.ID, Type: record.Type, Relation: record.Relation} + if err := item.ParseStored(); err != nil { + return fmt.Errorf("manifest evidence %d: %w", i, err) + } + if _, err := evidence.ParseFreshnessStatus(string(record.Freshness)); err != nil { + return fmt.Errorf("manifest evidence %d: %w", i, err) + } + } + if s.Predicate.PullRequest != nil && s.Predicate.PullRequest.State != "" { + if _, err := domain.ParseThreadState(string(s.Predicate.PullRequest.State)); err != nil { + return fmt.Errorf("manifest pull request state: %w", err) + } + } + for i, draft := range s.Predicate.Drafts { + if _, err := domain.ParseThreadKind(string(draft.Kind)); err != nil { + return fmt.Errorf("manifest draft %d: %w", i, err) + } + } + for i, facet := range s.Predicate.Completeness { + switch facet.Status { + case CompletenessComplete, CompletenessIncomplete, CompletenessUnknown, CompletenessNotRequested: + default: + return fmt.Errorf("manifest completeness facet %d has unsupported status %q", i, facet.Status) + } + } wantContent, err := predicateIdentityDigest(s.Predicate) if err != nil { return err @@ -256,6 +334,13 @@ func (s Statement) Validate() error { return nil } +func statusForGaps(gaps []Gap) Status { + if len(gaps) == 0 { + return StatusComplete + } + return StatusIncomplete +} + func predicateIdentityDigest(predicate Predicate) (string, error) { identity := predicate identity.ManifestID, identity.ContentSHA256, identity.GeneratedAt = "", "", time.Time{} diff --git a/internal/manifest/models_test.go b/internal/manifest/models_test.go index ef8b3c7e..ec56f2be 100644 --- a/internal/manifest/models_test.go +++ b/internal/manifest/models_test.go @@ -4,6 +4,9 @@ import ( "testing" "time" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/investigation" "github.com/morluto/gitcontribute/internal/workspace" ) @@ -63,3 +66,97 @@ func TestValidateRejectsTamperedPredicate(t *testing.T) { t.Fatal("tampered predicate passed validation") } } + +func TestFinalizeDerivesStatusFromGaps(t *testing.T) { + predicate := Predicate{ + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv"}, + Status: StatusIncomplete, + } + complete, err := Finalize(predicate) + if err != nil { + t.Fatal(err) + } + if complete.Predicate.Status != StatusComplete { + t.Fatalf("status without gaps = %q", complete.Predicate.Status) + } + + predicate.Status = StatusComplete + predicate.Gaps = []Gap{{Code: "missing", Facet: "tests", Reason: "not run"}} + incomplete, err := Finalize(predicate) + if err != nil { + t.Fatal(err) + } + if incomplete.Predicate.Status != StatusIncomplete { + t.Fatalf("status with gaps = %q", incomplete.Predicate.Status) + } +} + +func TestFinalizeRejectsUnknownDomainDiscriminators(t *testing.T) { + base := func() Predicate { + return Predicate{ + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv"}, + } + } + tests := map[string]func(*Predicate){ + "opportunity status": func(predicate *Predicate) { + predicate.Opportunity.Status = investigation.OpportunityStatus("mystery") + }, + "validation kind": func(predicate *Predicate) { + predicate.Validations = []ValidationRecord{{ + DefinitionID: "definition", RunID: "run", Kind: evidence.RunKind("mystery"), + Classification: evidence.RunClassificationPassing, + }} + }, + "JUnit status": func(predicate *Predicate) { + predicate.Validations = []ValidationRecord{{ + DefinitionID: "definition", RunID: "run", Kind: evidence.RunKindCandidate, + Classification: evidence.RunClassificationPassing, + JUnitReport: &JUnitReportRecord{ + SchemaVersion: evidence.JUnitReportSchemaV1, + Counts: evidence.JUnitCounts{Total: 1, Unknown: 1}, + TestCases: []evidence.JUnitTestCase{{Status: evidence.JUnitTestStatus("mystery")}}, + }, + }} + }, + "evidence freshness": func(predicate *Predicate) { + predicate.Evidence = []EvidenceRecord{{ + ID: "evidence", Type: evidence.EvidenceTypeManualObservation, + Relation: evidence.RelationSupporting, Freshness: evidence.FreshnessStatus("mystery"), + }} + }, + "pull request state": func(predicate *Predicate) { + predicate.PullRequest = &PullRequestRecord{State: domain.ThreadState("mystery")} + }, + "draft kind": func(predicate *Predicate) { + predicate.Drafts = []DraftRecord{{Kind: domain.ThreadKind("mystery")}} + }, + "completeness": func(predicate *Predicate) { + predicate.Completeness = []CompletenessFacet{{Status: CompletenessStatus("mystery")}} + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + predicate := base() + mutate(&predicate) + if _, err := Finalize(predicate); err == nil { + t.Fatal("unknown manifest discriminator was accepted") + } + }) + } +} + +func TestValidateRejectsStatusGapContradiction(t *testing.T) { + statement, err := Finalize(Predicate{ + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv"}, + }) + if err != nil { + t.Fatal(err) + } + statement.Predicate.Status = StatusIncomplete + if err := statement.Validate(); err == nil { + t.Fatal("manifest status contradiction was accepted") + } +} diff --git a/internal/mcpcontract/actor_contracts.go b/internal/mcpcontract/actor_contracts.go index 102285c8..31da0836 100644 --- a/internal/mcpcontract/actor_contracts.go +++ b/internal/mcpcontract/actor_contracts.go @@ -147,11 +147,11 @@ type GetActorsInput struct { // ActorBatchItem is intentionally small: actor reads report item state without // embedding the catalog-wide workflow recovery union in every result schema. type ActorBatchItem[T any] struct { - Key string `json:"key"` - Status string `json:"item_status" jsonschema:"complete, retryable, unavailable, or failed"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` - Value *T `json:"value,omitempty"` + Key string `json:"key"` + Status BatchItemStatus `json:"item_status" jsonschema:"complete, retryable, unavailable, or failed"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Value *T `json:"value,omitempty"` } type GetActorsOutput struct { diff --git a/internal/mcpcontract/coverage_workflow.go b/internal/mcpcontract/coverage_workflow.go index 60ff44d7..a84e95d7 100644 --- a/internal/mcpcontract/coverage_workflow.go +++ b/internal/mcpcontract/coverage_workflow.go @@ -1,5 +1,7 @@ package mcpcontract +import "encoding/json" + type EnsureCoverageInput struct { Target CoverageTarget `json:"target" jsonschema:"Repository or exact-thread coverage target to seed and verify"` Facets []string `json:"facets,omitempty" jsonschema:"Selected facet names required by the caller"` @@ -31,16 +33,16 @@ type EnsureCoverageJobResult struct { } type CorpusSnapshotArtifact struct { - SnapshotToken string `json:"snapshot_token"` - ContractVersion string `json:"contract_version"` - ObservationWatermark int64 `json:"observation_watermark"` - Scope any `json:"scope"` - SourceManifestSHA256 string `json:"source_manifest_sha256"` - DerivedVersions any `json:"derived_versions"` - Completeness any `json:"completeness"` - Provenance any `json:"provenance"` - ArtifactKind string `json:"artifact_kind"` - ArtifactDigest string `json:"artifact_digest"` - Payload any `json:"payload"` - CreatedAt string `json:"created_at"` + SnapshotToken string `json:"snapshot_token"` + ContractVersion string `json:"contract_version"` + ObservationWatermark int64 `json:"observation_watermark"` + Scope json.RawMessage `json:"scope"` + SourceManifestSHA256 string `json:"source_manifest_sha256"` + DerivedVersions json.RawMessage `json:"derived_versions"` + Completeness json.RawMessage `json:"completeness"` + Provenance json.RawMessage `json:"provenance"` + ArtifactKind string `json:"artifact_kind"` + ArtifactDigest string `json:"artifact_digest"` + Payload json.RawMessage `json:"payload"` + CreatedAt string `json:"created_at"` } diff --git a/internal/mcpcontract/fix_pattern_contracts.go b/internal/mcpcontract/fix_pattern_contracts.go index 8338d219..8c2ae7d3 100644 --- a/internal/mcpcontract/fix_pattern_contracts.go +++ b/internal/mcpcontract/fix_pattern_contracts.go @@ -1,5 +1,7 @@ package mcpcontract +import "fmt" + const ( DefaultFixPatternCandidateLimit = 100 DefaultFixPatternHydrationLimit = 25 @@ -10,20 +12,53 @@ const ( // GitHub; superseded requires an explicit replacement relationship. type FixPatternOutcome string +const ( + FixPatternMerged FixPatternOutcome = "merged" + FixPatternClosedUnmerged FixPatternOutcome = "closed_unmerged" + FixPatternSuperseded FixPatternOutcome = "superseded" + FixPatternOpen FixPatternOutcome = "open" + FixPatternUnknown FixPatternOutcome = "unknown" +) + // FixPatternRelationship describes evidence connecting an issue and pull // request. Similarity is intentionally distinct from an explicit link. type FixPatternRelationship string +const ( + FixPatternCloses FixPatternRelationship = "closes" + FixPatternReferences FixPatternRelationship = "references" + FixPatternExplicitReplacement FixPatternRelationship = "explicit_replacement" + 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 ( + FixPatternRegressionTest FixPatternProofStyle = "regression_test" + FixPatternReproduction FixPatternProofStyle = "reproduction" + FixPatternBenchmark FixPatternProofStyle = "benchmark" + FixPatternBeforeAfter FixPatternProofStyle = "before_after" + 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 { @@ -134,3 +169,26 @@ type FixPatternReport struct { 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 +} diff --git a/internal/mcpcontract/fix_pattern_contracts_test.go b/internal/mcpcontract/fix_pattern_contracts_test.go new file mode 100644 index 00000000..6531cff9 --- /dev/null +++ b/internal/mcpcontract/fix_pattern_contracts_test.go @@ -0,0 +1,53 @@ +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 5722ad42..0fe52e38 100644 --- a/internal/mcpcontract/github_acquisition_contracts.go +++ b/internal/mcpcontract/github_acquisition_contracts.go @@ -146,6 +146,15 @@ type SourceFileOutput struct { // provider retry or an unexpected decoding failure. type SourceFileStatus string +const ( + SourceFileComplete SourceFileStatus = "complete" + SourceFileNotFound SourceFileStatus = "not_found" + SourceFileTooLarge SourceFileStatus = "too_large" + SourceFileRetryable SourceFileStatus = "retryable" + SourceFileUnavailable SourceFileStatus = "unavailable" + SourceFileFailed SourceFileStatus = "failed" +) + type SourceFileBatchItem struct { Key string `json:"key"` Status SourceFileStatus `json:"item_status"` diff --git a/internal/mcpcontract/operation_contracts.go b/internal/mcpcontract/operation_contracts.go index e1e65868..205571f5 100644 --- a/internal/mcpcontract/operation_contracts.go +++ b/internal/mcpcontract/operation_contracts.go @@ -352,8 +352,20 @@ type PullRequestCheckTransition struct { CheckCount int `json:"check_count"` } +// PullRequestCheckWaitStatus identifies one terminal or transient watch state. +type PullRequestCheckWaitStatus string + +const ( + PullRequestCheckWaiting PullRequestCheckWaitStatus = "waiting" + PullRequestCheckIncomplete PullRequestCheckWaitStatus = "incomplete" + PullRequestCheckSucceeded PullRequestCheckWaitStatus = "succeeded" + PullRequestCheckFailed PullRequestCheckWaitStatus = "failed" + PullRequestCheckTimedOut PullRequestCheckWaitStatus = "timed_out" + PullRequestCheckSuperseded PullRequestCheckWaitStatus = "superseded" +) + type WaitPullRequestChecksOutput struct { - Status string `json:"status"` + Status PullRequestCheckWaitStatus `json:"status"` Owner string `json:"owner"` Repo string `json:"repo"` Number int `json:"number"` diff --git a/internal/mcpcontract/read_provenance_test.go b/internal/mcpcontract/read_provenance_test.go new file mode 100644 index 00000000..2d2fe1c8 --- /dev/null +++ b/internal/mcpcontract/read_provenance_test.go @@ -0,0 +1,29 @@ +package mcpcontract + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCorpusReadProvenanceDerivesAndParsesCoverage(t *testing.T) { + t.Parallel() + provenance := NewCorpusReadProvenance("ephemeral:abc", false, 7, strings.Repeat("a", 64), true, true) + if provenance.Complete() || !provenance.Truncated() || !provenance.UnknownCoverage() { + t.Fatalf("provenance coverage = complete:%t truncated:%t unknown:%t", provenance.Complete(), provenance.Truncated(), provenance.UnknownCoverage()) + } + payload, err := json.Marshal(provenance) + if err != nil { + t.Fatal(err) + } + var decoded CorpusReadProvenance + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Complete() || !decoded.Truncated() || !decoded.UnknownCoverage() { + t.Fatalf("decoded provenance = %+v", decoded) + } + if err := json.Unmarshal([]byte(`{"snapshot_token":"ephemeral:abc","durable":false,"observation_watermark":7,"query_digest_sha256":"abc","complete":true,"truncated":true,"unknown_coverage":false}`), &decoded); err == nil { + t.Fatal("contradictory provenance coverage was accepted") + } +} diff --git a/internal/mcpcontract/resource_contracts.go b/internal/mcpcontract/resource_contracts.go index fdfc112a..56523c39 100644 --- a/internal/mcpcontract/resource_contracts.go +++ b/internal/mcpcontract/resource_contracts.go @@ -1,7 +1,10 @@ package mcpcontract import ( + "bytes" "context" + "encoding/json" + "errors" "github.com/morluto/gitcontribute/internal/lens" "github.com/morluto/gitcontribute/internal/similarity" @@ -24,6 +27,303 @@ type WorkspaceResource struct { CreatedAt string `json:"created_at"` } +// ResourceCoverage records the effective completeness of one stored resource. +type ResourceCoverage struct { + Complete bool `json:"complete"` + SourceUpdatedAt string `json:"source_updated_at"` +} + +// ThreadFacetObservationResource preserves one immutable facet observation +// while leaving its facet-specific payload as validated JSON. +type ThreadFacetObservationResource struct { + SourceUpdatedAt string `json:"source_updated_at"` + ObservationSequence int64 `json:"observation_sequence"` + Payload json.RawMessage `json:"payload"` +} + +// ThreadFacetResource is the canonical offline payload for one stored facet. +type ThreadFacetResource struct { + SchemaVersion string `json:"schema_version"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Number int `json:"number"` + Facet string `json:"facet"` + Observations []ThreadFacetObservationResource `json:"observations"` + Coverage *ResourceCoverage `json:"coverage,omitempty"` +} + +// ActorFacetResource is one parsed stored actor facet. The facet-specific value +// remains JSON, but invalid durable payloads cannot be represented as a +// successful resource. +type ActorFacetResource struct { + actorID string + facet string + complete bool + observedAt string + sourceUpdatedAt string + authorizationScope string + value json.RawMessage +} + +func NewActorFacetResource(actorID, facet string, complete bool, observedAt, sourceUpdatedAt, authorizationScope string, value json.RawMessage) (ActorFacetResource, error) { + if actorID == "" || facet == "" { + return ActorFacetResource{}, errors.New("actor ID and facet are required") + } + if !json.Valid(value) { + return ActorFacetResource{}, errors.New("actor facet value must be valid JSON") + } + return ActorFacetResource{ + actorID: actorID, facet: facet, complete: complete, + observedAt: observedAt, sourceUpdatedAt: sourceUpdatedAt, + authorizationScope: authorizationScope, + value: append(json.RawMessage(nil), value...), + }, nil +} + +func (r ActorFacetResource) MarshalJSON() ([]byte, error) { + if r.actorID == "" || r.facet == "" || !json.Valid(r.value) { + return nil, errors.New("actor facet resource is not parsed") + } + return json.Marshal(struct { + SchemaVersion string `json:"schema_version"` + ActorID string `json:"actor_id"` + Facet string `json:"facet"` + Complete bool `json:"complete"` + ObservedAt string `json:"observed_at"` + SourceUpdatedAt string `json:"source_updated_at"` + AuthorizationScope string `json:"authorization_scope"` + Value json.RawMessage `json:"value"` + }{ + SchemaVersion: "gitcontribute.actor-facet.v1", ActorID: r.actorID, + Facet: r.facet, Complete: r.complete, ObservedAt: r.observedAt, + SourceUpdatedAt: r.sourceUpdatedAt, AuthorizationScope: r.authorizationScope, + Value: r.value, + }) +} + +// PullRequestFeedbackPullRequestResource identifies the parent pull request +// and preserves an unknown merge state as JSON null. +type PullRequestFeedbackPullRequestResource struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Author string `json:"author"` + State string `json:"state"` + Merged *bool `json:"merged"` +} + +// PullRequestFeedbackItemResource is the exact normalized feedback record +// named by a search match. +type PullRequestFeedbackItemResource struct { + SchemaVersion string `json:"schema_version"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Channel string `json:"channel"` + FeedbackID string `json:"feedback_id"` + FeedbackNodeID string `json:"feedback_node_id"` + ThreadID string `json:"thread_id"` + InReplyToID string `json:"in_reply_to_id"` + FeedbackAuthor string `json:"feedback_author"` + ReviewState string `json:"review_state"` + Body string `json:"body"` + Path string `json:"path"` + Line *int `json:"line"` + StartLine *int `json:"start_line"` + Side string `json:"side"` + StartSide string `json:"start_side"` + CommitOID string `json:"commit_oid"` + Outdated bool `json:"outdated"` + Resolved *bool `json:"resolved"` + ResolutionState string `json:"resolution_state"` + ResolvedBy string `json:"resolved_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + HeadSHA string `json:"head_sha"` + SourceObservationID int64 `json:"source_observation_id"` + PullRequest PullRequestFeedbackPullRequestResource `json:"pull_request"` + EffectiveCoverage *ResourceCoverage `json:"effective_coverage,omitempty"` +} + +// StoredFacetResource is one validated JSON object from durable facet storage +// plus effective corpus coverage. The payload remains facet-specific, while +// the reserved coverage key has one authoritative representation. +type StoredFacetResource struct { + payload json.RawMessage + coverage *ResourceCoverage +} + +func NewStoredFacetResource(payload json.RawMessage, coverage *ResourceCoverage) (StoredFacetResource, error) { + var compact bytes.Buffer + if err := json.Compact(&compact, payload); err != nil { + return StoredFacetResource{}, err + } + encoded := json.RawMessage(compact.Bytes()) + if len(encoded) < 2 || encoded[0] != '{' || encoded[len(encoded)-1] != '}' { + return StoredFacetResource{}, errors.New("stored facet payload must be a JSON object") + } + var reserved struct { + EffectiveCoverage json.RawMessage `json:"effective_coverage"` + } + if err := json.Unmarshal(encoded, &reserved); err != nil { + return StoredFacetResource{}, errors.New("stored facet payload must be a JSON object") + } + if reserved.EffectiveCoverage != nil { + return StoredFacetResource{}, errors.New("stored facet payload owns reserved effective_coverage key") + } + return StoredFacetResource{payload: encoded, coverage: coverage}, nil +} + +func (r StoredFacetResource) MarshalJSON() ([]byte, error) { + if len(r.payload) < 2 || r.payload[0] != '{' || r.payload[len(r.payload)-1] != '}' { + return nil, errors.New("stored facet resource is not parsed") + } + if r.coverage == nil { + return append([]byte(nil), r.payload...), nil + } + coverage, err := json.Marshal(r.coverage) + if err != nil { + return nil, err + } + return mergeJSONObject(r.payload, jsonObjectField{name: "effective_coverage", value: coverage}) +} + +type jsonObjectField struct { + name string + value json.RawMessage +} + +func mergeJSONObject(payload json.RawMessage, fields ...jsonObjectField) ([]byte, error) { + if len(payload) < 2 || payload[0] != '{' || payload[len(payload)-1] != '}' { + return nil, errors.New("resource payload is not a parsed JSON object") + } + out := append([]byte(nil), payload[:len(payload)-1]...) + hasFields := len(payload) > 2 + for _, field := range fields { + if hasFields { + out = append(out, ',') + } + name, err := json.Marshal(field.name) + if err != nil { + return nil, err + } + out = append(out, name...) + out = append(out, ':') + out = append(out, field.value...) + hasFields = true + } + out = append(out, '}') + return out, nil +} + +// PullRequestFeedbackChannelsResource names the four closed feedback facets. +type PullRequestFeedbackChannelsResource struct { + IssueComments *StoredFacetResource `json:"issue_comments,omitempty"` + SubmittedReviews *StoredFacetResource `json:"submitted_reviews,omitempty"` + InlineComments *StoredFacetResource `json:"inline_comments,omitempty"` + ReviewThreads *StoredFacetResource `json:"review_threads,omitempty"` +} + +// PullRequestFeedbackResource is the canonical raw-facet view for one pull +// request. Missing channels are omitted rather than represented as empty data. +type PullRequestFeedbackResource struct { + SchemaVersion string `json:"schema_version"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Channels PullRequestFeedbackChannelsResource `json:"channels"` +} + +// CIFailureResource augments the stored CI snapshot without decoding and +// re-encoding its provider-shaped body, preserving missing and nullable keys. +type CIFailureResource struct { + payload json.RawMessage + schemaVersion string + owner string + repo string + number int + coverage *ResourceCoverage +} + +func NewCIFailureResource(payload json.RawMessage, owner, repo string, number int, coverage *ResourceCoverage) (CIFailureResource, error) { + var compact bytes.Buffer + if err := json.Compact(&compact, payload); err != nil { + return CIFailureResource{}, err + } + encoded := json.RawMessage(compact.Bytes()) + if len(encoded) < 2 || encoded[0] != '{' || encoded[len(encoded)-1] != '}' { + return CIFailureResource{}, errors.New("stored CI payload must be a JSON object") + } + var reserved struct { + SchemaVersion json.RawMessage `json:"schema_version"` + Owner json.RawMessage `json:"owner"` + Repo json.RawMessage `json:"repo"` + Number json.RawMessage `json:"number"` + EffectiveCoverage json.RawMessage `json:"effective_coverage"` + } + if err := json.Unmarshal(encoded, &reserved); err != nil { + return CIFailureResource{}, errors.New("stored CI payload must be a JSON object") + } + if reserved.SchemaVersion != nil || reserved.Owner != nil || reserved.Repo != nil || reserved.Number != nil || reserved.EffectiveCoverage != nil { + return CIFailureResource{}, errors.New("stored CI payload owns reserved resource envelope keys") + } + return CIFailureResource{payload: encoded, schemaVersion: "gitcontribute.ci-failure-report.v1", owner: owner, repo: repo, number: number, coverage: coverage}, nil +} + +func (r CIFailureResource) MarshalJSON() ([]byte, error) { + if r.schemaVersion == "" { + return nil, errors.New("CI failure resource is not parsed") + } + schemaVersion, _ := json.Marshal(r.schemaVersion) + owner, _ := json.Marshal(r.owner) + repo, _ := json.Marshal(r.repo) + number, _ := json.Marshal(r.number) + fields := []jsonObjectField{ + {name: "schema_version", value: schemaVersion}, {name: "owner", value: owner}, + {name: "repo", value: repo}, {name: "number", value: number}, + } + if r.coverage != nil { + coverage, err := json.Marshal(r.coverage) + if err != nil { + return nil, err + } + fields = append(fields, jsonObjectField{name: "effective_coverage", value: coverage}) + } + return mergeJSONObject(r.payload, fields...) +} + +// CIJobLogResource is one parsed, exact stored workflow-job log. +type CIJobLogResource struct { + jobID int64 + body string + truncated bool +} + +func NewCIJobLogResource(jobID int64, body string, truncated bool) (CIJobLogResource, error) { + if jobID <= 0 { + return CIJobLogResource{}, errors.New("CI job log ID must be positive") + } + return CIJobLogResource{jobID: jobID, body: body, truncated: truncated}, nil +} + +func (r CIJobLogResource) MarshalJSON() ([]byte, error) { + if r.jobID <= 0 { + return nil, errors.New("CI job log resource is not parsed") + } + return json.Marshal(struct { + SchemaVersion string `json:"schema_version"` + JobID int64 `json:"job_id"` + Body string `json:"body"` + Truncated bool `json:"truncated"` + }{ + SchemaVersion: "gitcontribute.ci-job-log.v1", + JobID: r.jobID, + Body: r.body, + Truncated: r.truncated, + }) +} + // Reader is the local, read-only application boundary exposed through MCP. // Implementations must not perform network access. type Reader interface { @@ -80,30 +380,27 @@ type ManifestInput struct { // SearchInput describes an offline thread search page. type SearchInput struct { - Query string `json:"query" jsonschema:"Full-text query"` + Query string `json:"query" jsonschema:"Thread full-text query"` Owner string `json:"owner,omitempty" jsonschema:"Optional repository owner"` Repo string `json:"repo,omitempty" jsonschema:"Optional repository name"` - Kind string `json:"kind,omitempty" jsonschema:"Optional thread kind"` - State string `json:"state,omitempty"` - StateReason string `json:"state_reason,omitempty"` - Merged *bool `json:"merged,omitempty"` - Author string `json:"author,omitempty"` - Association string `json:"author_association,omitempty"` - Assignee string `json:"assignee,omitempty"` - Labels []string `json:"labels,omitempty"` - UpdatedAfter string `json:"updated_after,omitempty"` - UpdatedBefore string `json:"updated_before,omitempty"` + Kind string `json:"kind,omitempty" jsonschema:"Optional thread kind: issue or pull_request"` + State string `json:"state,omitempty" jsonschema:"Optional open or closed state"` + StateReason string `json:"state_reason,omitempty" jsonschema:"Optional GitHub completed or not_planned state reason"` + Merged *bool `json:"merged,omitempty" jsonschema:"Optional pull request merged state"` + Author string `json:"author,omitempty" jsonschema:"Optional author login"` + Association string `json:"author_association,omitempty" jsonschema:"Optional GitHub author association"` + Assignee string `json:"assignee,omitempty" jsonschema:"Optional assignee login"` + Labels []string `json:"labels,omitempty" jsonschema:"Labels that must all be present"` + UpdatedAfter string `json:"updated_after,omitempty" jsonschema:"Optional RFC 3339 lower bound"` + UpdatedBefore string `json:"updated_before,omitempty" jsonschema:"Optional RFC 3339 upper bound"` 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"` Sort string `json:"sort,omitempty" jsonschema:"Order: relevance or updated"` MatchMode string `json:"match_mode,omitempty" jsonschema:"Term matching: all requires every term; any requires at least one term"` - View string `json:"view,omitempty" jsonschema:"compact omits full bodies and keeps bounded excerpts; full includes stored bodies"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` + View string `json:"view,omitempty" jsonschema:"compact omits full bodies and returns bounded excerpts; full includes stored bodies"` + SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` } -// RepositoryOutput is the stable MCP representation of a repository. -type RepositoryOutput = TypedRepositoryOutput - // ThreadOutput is the stable MCP representation of an issue or pull request. type ThreadOutput struct { Owner string `json:"owner"` @@ -204,6 +501,40 @@ type SourceRef struct { // non-durable; callers that need cross-call reuse must request a persisted // snapshot rather than treating a mutable projection as historical evidence. type CorpusReadProvenance struct { + SnapshotToken string `json:"snapshot_token"` + Durable bool `json:"durable"` + ObservationWatermark int64 `json:"observation_watermark"` + QueryDigestSHA256 string `json:"query_digest_sha256"` + Limitations []string `json:"limitations,omitempty"` + ExternalContext []SourceRef `json:"external_context,omitempty"` + Recovery *RecoveryPlan `json:"recovery,omitempty"` + coverage corpusReadCoverage +} + +type corpusReadCoverage struct { + known bool + truncated bool + unknown bool +} + +// NewCorpusReadProvenance constructs one coverage-consistent read identity. +func NewCorpusReadProvenance(snapshotToken string, durable bool, observationWatermark int64, queryDigest string, truncated, unknownCoverage bool) CorpusReadProvenance { + return CorpusReadProvenance{ + SnapshotToken: snapshotToken, Durable: durable, + ObservationWatermark: observationWatermark, QueryDigestSHA256: queryDigest, + coverage: corpusReadCoverage{known: true, truncated: truncated, unknown: unknownCoverage}, + } +} + +func (p CorpusReadProvenance) Complete() bool { + return p.coverage.known && !p.coverage.truncated && !p.coverage.unknown +} + +func (p CorpusReadProvenance) Truncated() bool { return p.coverage.truncated } + +func (p CorpusReadProvenance) UnknownCoverage() bool { return p.coverage.unknown } + +type corpusReadProvenanceJSON struct { SnapshotToken string `json:"snapshot_token"` Durable bool `json:"durable"` ObservationWatermark int64 `json:"observation_watermark"` @@ -216,6 +547,32 @@ type CorpusReadProvenance struct { Recovery *RecoveryPlan `json:"recovery,omitempty"` } +func (p CorpusReadProvenance) MarshalJSON() ([]byte, error) { + return json.Marshal(corpusReadProvenanceJSON{ + SnapshotToken: p.SnapshotToken, Durable: p.Durable, + ObservationWatermark: p.ObservationWatermark, QueryDigestSHA256: p.QueryDigestSHA256, + Complete: p.Complete(), Truncated: p.Truncated(), UnknownCoverage: p.UnknownCoverage(), + Limitations: p.Limitations, ExternalContext: p.ExternalContext, Recovery: p.Recovery, + }) +} + +func (p *CorpusReadProvenance) UnmarshalJSON(data []byte) error { + var raw corpusReadProvenanceJSON + if err := decodeStrictJSON(data, &raw); err != nil { + return err + } + if raw.Complete && (raw.Truncated || raw.UnknownCoverage) { + return errors.New("complete corpus read provenance cannot be truncated or have unknown coverage") + } + parsed := NewCorpusReadProvenance(raw.SnapshotToken, raw.Durable, raw.ObservationWatermark, raw.QueryDigestSHA256, raw.Truncated, raw.UnknownCoverage) + if !raw.Complete && !raw.Truncated && !raw.UnknownCoverage { + parsed.coverage.known = false + } + parsed.Limitations, parsed.ExternalContext, parsed.Recovery = raw.Limitations, raw.ExternalContext, raw.Recovery + *p = parsed + return nil +} + // SearchCodeInput describes an offline code search page. type SearchCodeInput struct { Query string `json:"query" jsonschema:"Code search query"` diff --git a/internal/mcpcontract/resource_contracts_test.go b/internal/mcpcontract/resource_contracts_test.go new file mode 100644 index 00000000..e6430fe0 --- /dev/null +++ b/internal/mcpcontract/resource_contracts_test.go @@ -0,0 +1,117 @@ +package mcpcontract + +import ( + "encoding/json" + "testing" +) + +func TestStoredFacetResourceOwnsEffectiveCoverage(t *testing.T) { + t.Parallel() + resource, err := NewStoredFacetResource( + json.RawMessage(`{"head_sha":"abc","items":[]}`), + &ResourceCoverage{Complete: true, SourceUpdatedAt: "2026-08-11T00:00:00Z"}, + ) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + if string(payload) != `{"head_sha":"abc","items":[],"effective_coverage":{"complete":true,"source_updated_at":"2026-08-11T00:00:00Z"}}` { + t.Fatalf("resource = %s", payload) + } + if _, err := NewStoredFacetResource(json.RawMessage(`[]`), nil); err == nil { + t.Fatal("non-object facet payload was accepted") + } + if _, err := NewStoredFacetResource(json.RawMessage(`null`), nil); err == nil { + t.Fatal("null facet payload was accepted") + } + if _, err := NewStoredFacetResource(json.RawMessage(`{"effective_coverage":null}`), nil); err == nil { + t.Fatal("payload-owned effective coverage was accepted") + } + if _, err := json.Marshal(StoredFacetResource{}); err == nil { + t.Fatal("unparsed facet resource was encoded") + } +} + +func TestActorFacetResourceOwnsParsedValue(t *testing.T) { + t.Parallel() + resource, err := NewActorFacetResource( + "github:U_1", "organizations", false, + "2026-08-11T01:00:00Z", "2026-08-11T00:00:00Z", "viewer", + json.RawMessage(`{"organizations":[{"login":"acme"}]}`), + ) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + want := `{"schema_version":"gitcontribute.actor-facet.v1","actor_id":"github:U_1","facet":"organizations","complete":false,"observed_at":"2026-08-11T01:00:00Z","source_updated_at":"2026-08-11T00:00:00Z","authorization_scope":"viewer","value":{"organizations":[{"login":"acme"}]}}` + if string(payload) != want { + t.Fatalf("resource = %s, want %s", payload, want) + } + if _, err := NewActorFacetResource("github:U_1", "organizations", true, "", "", "", json.RawMessage(`not-json`)); err == nil { + t.Fatal("invalid actor facet JSON was accepted") + } + if _, err := json.Marshal(ActorFacetResource{}); err == nil { + t.Fatal("unparsed actor facet resource was encoded") + } +} + +func TestCIFailureResourcePreservesSparseStoredPayload(t *testing.T) { + t.Parallel() + resource, err := NewCIFailureResource( + json.RawMessage(` { "head_sha": "abc", "workflow_runs": null } `), + "acme", "rocket", 7, + &ResourceCoverage{Complete: false, SourceUpdatedAt: "2026-08-11T00:00:00Z"}, + ) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + want := `{"head_sha":"abc","workflow_runs":null,"schema_version":"gitcontribute.ci-failure-report.v1","owner":"acme","repo":"rocket","number":7,"effective_coverage":{"complete":false,"source_updated_at":"2026-08-11T00:00:00Z"}}` + if string(payload) != want { + t.Fatalf("resource = %s, want %s", payload, want) + } + + for _, invalid := range []json.RawMessage{ + json.RawMessage(`null`), + json.RawMessage(`[]`), + json.RawMessage(`{"owner":"stored"}`), + json.RawMessage(`{"effective_coverage":null}`), + } { + if _, err := NewCIFailureResource(invalid, "acme", "rocket", 7, nil); err == nil { + t.Fatalf("invalid CI payload %s was accepted", invalid) + } + } + if _, err := json.Marshal(CIFailureResource{}); err == nil { + t.Fatal("unparsed CI failure resource was encoded") + } +} + +func TestCIJobLogResourceRequiresParsedIdentity(t *testing.T) { + t.Parallel() + resource, err := NewCIJobLogResource(31, "failure", true) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + if string(payload) != `{"schema_version":"gitcontribute.ci-job-log.v1","job_id":31,"body":"failure","truncated":true}` { + t.Fatalf("resource = %s", payload) + } + if _, err := NewCIJobLogResource(0, "failure", false); err == nil { + t.Fatal("non-positive CI job ID was accepted") + } + if _, err := json.Marshal(CIJobLogResource{}); err == nil { + t.Fatal("unparsed CI job log resource was encoded") + } +} diff --git a/internal/mcpcontract/scalable_contracts.go b/internal/mcpcontract/scalable_contracts.go index 3ae5fd6d..ffee30f1 100644 --- a/internal/mcpcontract/scalable_contracts.go +++ b/internal/mcpcontract/scalable_contracts.go @@ -115,8 +115,8 @@ type RepositoryMetadataOutput struct { Recovery *RecoveryPlan `json:"recovery,omitempty"` } -// TypedRepositoryOutput contains repository facts with explicit metadata coverage. -type TypedRepositoryOutput struct { +// RepositoryOutput contains repository facts with explicit metadata coverage. +type RepositoryOutput struct { Ref string `json:"ref"` Owner string `json:"owner"` Repo string `json:"repo"` @@ -140,9 +140,9 @@ type TypedRepositoryOutput struct { // GetRepositoriesOutput preserves repository input order and represents // unobserved metadata with nullable facts instead of false zero values. type GetRepositoriesOutput struct { - Status string `json:"batch_status"` - Items []BatchItem[TypedRepositoryOutput] `json:"items"` - SnapshotToken string `json:"snapshot_token"` + Status string `json:"batch_status"` + Items []BatchItem[RepositoryOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` } // GetThreadsInput selects exact threads and the desired response view. diff --git a/internal/mcpcontract/schema_values.go b/internal/mcpcontract/schema_values.go index 77f07259..432148d9 100644 --- a/internal/mcpcontract/schema_values.go +++ b/internal/mcpcontract/schema_values.go @@ -1,5 +1,11 @@ package mcpcontract +import ( + "encoding/json" + "fmt" + "strings" +) + // Probability is a numeric confidence value in the inclusive range [0, 1]. type Probability float64 @@ -20,11 +26,72 @@ type NonNegativeInt int // BatchItemStatus describes the outcome of one item in a bounded batch. type BatchItemStatus string +const ( + BatchItemComplete BatchItemStatus = "complete" + BatchItemPartial BatchItemStatus = "partial" + BatchItemRetryable BatchItemStatus = "retryable" + BatchItemUnavailable BatchItemStatus = "unavailable" + BatchItemFailed BatchItemStatus = "failed" +) + +// ParseBatchItemStatus canonicalizes one item outcome from a durable or wire +// representation before it enters application logic. +func ParseBatchItemStatus(value string) (BatchItemStatus, error) { + switch BatchItemStatus(strings.ToLower(strings.TrimSpace(value))) { + case BatchItemComplete: + return BatchItemComplete, nil + case BatchItemPartial: + return BatchItemPartial, nil + case BatchItemRetryable: + return BatchItemRetryable, nil + case BatchItemUnavailable: + return BatchItemUnavailable, nil + case BatchItemFailed: + return BatchItemFailed, nil + default: + return "", fmt.Errorf("unsupported batch item status %q", value) + } +} + +func (s *BatchItemStatus) UnmarshalJSON(data []byte) error { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + parsed, err := ParseBatchItemStatus(value) + if err != nil { + return err + } + *s = parsed + return nil +} + // JobStatus describes the durable execution lifecycle exposed through MCP. type JobStatus string +const ( + JobStatusQueued JobStatus = "queued" + JobStatusRunning JobStatus = "running" + JobStatusSucceeded JobStatus = "succeeded" + JobStatusFailed JobStatus = "failed" + JobStatusCancelled JobStatus = "cancelled" +) + // JobExecutionState separates pollable execution from terminal completion. type JobExecutionState string +const ( + JobExecutionQueued JobExecutionState = "queued" + JobExecutionRunning JobExecutionState = "running" + JobExecutionTerminal JobExecutionState = "terminal" +) + // JobOutcome describes the result of a terminal durable job. type JobOutcome string + +const ( + JobOutcomeSucceeded JobOutcome = "succeeded" + JobOutcomePartial JobOutcome = "partial" + JobOutcomeFailed JobOutcome = "failed" + JobOutcomeCancelled JobOutcome = "cancelled" +) diff --git a/internal/mcpcontract/schema_values_test.go b/internal/mcpcontract/schema_values_test.go new file mode 100644 index 00000000..cd75f94b --- /dev/null +++ b/internal/mcpcontract/schema_values_test.go @@ -0,0 +1,32 @@ +package mcpcontract + +import ( + "encoding/json" + "testing" +) + +func TestBatchItemStatusParsesDurableJSON(t *testing.T) { + t.Parallel() + for _, test := range []struct { + encoded string + want BatchItemStatus + }{ + {`" complete "`, BatchItemComplete}, + {`" PARTIAL "`, BatchItemPartial}, + {`" Retryable "`, BatchItemRetryable}, + {`" unavailable "`, BatchItemUnavailable}, + {`" failed "`, BatchItemFailed}, + } { + var status BatchItemStatus + if err := json.Unmarshal([]byte(test.encoded), &status); err != nil { + t.Fatal(err) + } + if status != test.want { + t.Fatalf("status = %q, want %q", status, test.want) + } + } + var status BatchItemStatus + if err := json.Unmarshal([]byte(`"impossible"`), &status); err == nil { + t.Fatal("invalid batch item status was accepted") + } +} diff --git a/internal/mcpserver/capabilities_test.go b/internal/mcpserver/capabilities_test.go index 8c07c500..9826bd5c 100644 --- a/internal/mcpserver/capabilities_test.go +++ b/internal/mcpserver/capabilities_test.go @@ -20,9 +20,9 @@ func (*fakeOptionalCapabilities) EnsureCoverage(context.Context, mcpcontract.Ens return mcpcontract.JobReference{ID: "job-coverage", Kind: "ensure_coverage", Status: "queued"}, nil } func (*fakeOptionalCapabilities) GetRepositories(_ context.Context, in mcpcontract.GetRepositoriesInput) (mcpcontract.GetRepositoriesOutput, error) { - items := make([]mcpcontract.BatchItem[mcpcontract.TypedRepositoryOutput], len(in.Repositories)) + items := make([]mcpcontract.BatchItem[mcpcontract.RepositoryOutput], len(in.Repositories)) for i, repository := range in.Repositories { - value := mcpcontract.TypedRepositoryOutput{ + value := mcpcontract.RepositoryOutput{ Ref: "repository:" + repository.Owner + "/" + repository.Repo, Owner: repository.Owner, Repo: repository.Repo, @@ -33,7 +33,7 @@ func (*fakeOptionalCapabilities) GetRepositories(_ context.Context, in mcpcontra value.DossierStatus = "available" value.DossierAsOf = "2026-07-25T00:00:00Z" } - items[i] = mcpcontract.BatchItem[mcpcontract.TypedRepositoryOutput]{ + items[i] = mcpcontract.BatchItem[mcpcontract.RepositoryOutput]{ Key: repository.Owner + "/" + repository.Repo, Status: "complete", Value: &value, } } diff --git a/internal/mcpserver/catalog.go b/internal/mcpserver/catalog.go index fee7381d..810cca15 100644 --- a/internal/mcpserver/catalog.go +++ b/internal/mcpserver/catalog.go @@ -24,7 +24,7 @@ func addCatalogTool[In, Out any](server *Server, tool catalogTool[In, Out]) { if tool.supportedBy != nil && !tool.supportedBy(server.reader) { return } - if server.readOnly && (tool.annotations == nil || !tool.annotations.ReadOnlyHint) { + if server.access == readOnlyServerAccess && (tool.annotations == nil || !tool.annotations.ReadOnlyHint) { return } if tool.input.err != nil { diff --git a/internal/mcpserver/commit_planning.go b/internal/mcpserver/commit_planning.go index 68be4ee2..743f406e 100644 --- a/internal/mcpserver/commit_planning.go +++ b/internal/mcpserver/commit_planning.go @@ -56,9 +56,11 @@ func (s *Server) registerCommitPlanning() { } func (s *Server) inspectCommitChanges(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.InspectCommitChangesInput) (*mcp.CallToolResult, mcpcontract.CommitInventoryOutput, error) { - if strings.TrimSpace(in.WorkspaceID) == "" { - return nil, mcpcontract.CommitInventoryOutput{}, mcpcontract.InvalidArgument("workspace_id", "is required", nil) + workspaceID, err := normalizeID("workspace_id", in.WorkspaceID) + if err != nil { + return nil, mcpcontract.CommitInventoryOutput{}, err } + in.WorkspaceID = workspaceID reader, ok := s.reader.(CommitPlannerReader) if !ok { return nil, mcpcontract.CommitInventoryOutput{}, errors.New("semantic commit planning is not available") @@ -68,7 +70,13 @@ func (s *Server) inspectCommitChanges(ctx context.Context, _ *mcp.CallToolReques } func (s *Server) planSemanticCommits(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.PlanSemanticCommitsInput) (*mcp.CallToolResult, mcpcontract.SemanticCommitPlanOutput, error) { - if strings.TrimSpace(in.WorkspaceID) == "" || strings.TrimSpace(in.ExpectedInventorySHA256) == "" { + workspaceID, err := normalizeID("workspace_id", in.WorkspaceID) + if err != nil { + return nil, mcpcontract.SemanticCommitPlanOutput{}, err + } + in.WorkspaceID = workspaceID + in.ExpectedInventorySHA256 = strings.TrimSpace(in.ExpectedInventorySHA256) + if in.ExpectedInventorySHA256 == "" { return nil, mcpcontract.SemanticCommitPlanOutput{}, mcpcontract.InvalidArgument("expected_inventory_sha256", "workspace_id and expected_inventory_sha256 are required", nil) } reader, ok := s.reader.(CommitPlannerReader) diff --git a/internal/mcpserver/concerns.go b/internal/mcpserver/concerns.go index 0d30e664..518c7c4c 100644 --- a/internal/mcpserver/concerns.go +++ b/internal/mcpserver/concerns.go @@ -120,10 +120,14 @@ func (s *Server) listConcerns(ctx context.Context, _ *mcp.CallToolRequest, in mc } func (s *Server) createConcern(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.CreateConcernInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if err := validateRepo(mcpcontract.RepoInput{Owner: in.Owner, Repo: in.Repo}); err != nil { + owner, repo, err := normalizeRepository(in.Owner, in.Repo) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } - if strings.TrimSpace(in.CommitSHA) == "" && strings.TrimSpace(in.WorkspaceID) == "" { + in.Owner, in.Repo = owner, repo + in.CommitSHA = strings.TrimSpace(in.CommitSHA) + in.WorkspaceID = strings.TrimSpace(in.WorkspaceID) + if in.CommitSHA == "" && in.WorkspaceID == "" { return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("commit_sha", "commit_sha or workspace_id is required", map[string]any{"commit_sha": ""}) } operator, ok := s.reader.(ConcernOperator) diff --git a/internal/mcpserver/contribution_v1.go b/internal/mcpserver/contribution_v1.go index 2d301d97..c4a91682 100644 --- a/internal/mcpserver/contribution_v1.go +++ b/internal/mcpserver/contribution_v1.go @@ -22,16 +22,21 @@ import ( // ManifestOutput returns the stable identity and full in-toto-shaped statement. func (s *Server) prepareContribution(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.PrepareContributionInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if _, err := normalizeID("opportunity_id", in.OpportunityID); err != nil { + opportunityID, err := normalizeID("opportunity_id", in.OpportunityID) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } - // The SDK owns the issue-versus-pull-request shape. Trimming remains here - // because JSON Schema minLength does not reject whitespace-only values. - if in.Kind == "pull_request" && strings.TrimSpace(in.WorkspaceID) == "" { - return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("workspace_id", "is required for pull_request drafts", map[string]any{"workspace_id": ""}) - } - if in.Kind == "pull_request" && strings.TrimSpace(in.Approach) == "" { - return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("approach", "is required for pull_request drafts", map[string]any{"approach": "Describe the implementation approach."}) + in.OpportunityID = opportunityID + if in.Kind == "pull_request" { + workspaceID, err := normalizeID("workspace_id", in.WorkspaceID) + if err != nil { + return nil, mcpcontract.DurableArtifactReference{}, err + } + in.WorkspaceID = workspaceID + in.Approach = strings.TrimSpace(in.Approach) + if in.Approach == "" { + return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("approach", "is required for pull_request drafts", map[string]any{"approach": "Describe the implementation approach."}) + } } operator, ok := s.reader.(Operator) if !ok { @@ -47,11 +52,17 @@ func (s *Server) prepareContribution(ctx context.Context, _ *mcp.CallToolRequest } func (s *Server) exportManifest(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ExportManifestInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if _, err := normalizeID("opportunity_id", in.OpportunityID); err != nil { + opportunityID, err := normalizeID("opportunity_id", in.OpportunityID) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } - if in.PullRequest != nil && (strings.TrimSpace(in.PullRequest.Owner) == "" || strings.TrimSpace(in.PullRequest.Repo) == "" || in.PullRequest.Number <= 0) { - return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("pull_request", "owner, repo, and a positive number are required", map[string]any{"owner": "acme", "repo": "rocket", "number": 42}) + in.OpportunityID = opportunityID + if in.PullRequest != nil { + owner, repo, err := normalizeRepository(in.PullRequest.Owner, in.PullRequest.Repo) + if err != nil || in.PullRequest.Number <= 0 { + return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("pull_request", "owner, repo, and a positive number are required", map[string]any{"owner": "acme", "repo": "rocket", "number": 42}) + } + in.PullRequest = &mcpcontract.ManifestPullRequestInput{Owner: owner, Repo: repo, Number: in.PullRequest.Number} } operator, ok := s.reader.(Operator) if !ok { diff --git a/internal/mcpserver/draft_verification.go b/internal/mcpserver/draft_verification.go index 6b648777..24d5c43d 100644 --- a/internal/mcpserver/draft_verification.go +++ b/internal/mcpserver/draft_verification.go @@ -9,9 +9,11 @@ import ( ) func (s *Server) verifyPublishedDraft(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.VerifyPublishedDraftInput) (*mcp.CallToolResult, mcpcontract.PublishedDraftVerificationOutput, error) { - if _, err := normalizeID("draft_id", in.DraftID); err != nil { + draftID, err := normalizeID("draft_id", in.DraftID) + if err != nil { return nil, mcpcontract.PublishedDraftVerificationOutput{}, err } + in.DraftID = draftID if in.Revision < 1 || in.Number < 1 { return nil, mcpcontract.PublishedDraftVerificationOutput{}, mcpcontract.InvalidArgument("revision", "revision and number must be positive", nil) } diff --git a/internal/mcpserver/github_acquisition.go b/internal/mcpserver/github_acquisition.go index e046619d..4c401aba 100644 --- a/internal/mcpserver/github_acquisition.go +++ b/internal/mcpserver/github_acquisition.go @@ -44,9 +44,11 @@ func (s *Server) registerGitHubAcquisitionTools() { } func (s *Server) searchGitHubThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchGitHubThreadsInput) (*mcp.CallToolResult, mcpcontract.SearchGitHubThreadsOutput, error) { - if err := validateLiveRepository(in.Repository); err != nil { + repository, err := normalizeLiveRepository(in.Repository) + if err != nil { return nil, mcpcontract.SearchGitHubThreadsOutput{}, err } + in.Repository = repository in.Query = strings.TrimSpace(in.Query) if in.Query == "" { return nil, mcpcontract.SearchGitHubThreadsOutput{}, mcpcontract.InvalidArgument("query", "is required", map[string]any{"query": "regression"}) @@ -66,10 +68,13 @@ func (s *Server) searchGitHubThreads(ctx context.Context, _ *mcp.CallToolRequest } func (s *Server) readSourceFiles(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ReadSourceFilesInput) (*mcp.CallToolResult, mcpcontract.ReadSourceFilesOutput, error) { - if err := validateLiveRepository(in.Repository); err != nil { + repository, err := normalizeLiveRepository(in.Repository) + if err != nil { return nil, mcpcontract.ReadSourceFilesOutput{}, err } - if strings.TrimSpace(in.Ref) == "" { + in.Repository = repository + in.Ref = strings.TrimSpace(in.Ref) + if in.Ref == "" { return nil, mcpcontract.ReadSourceFilesOutput{}, mcpcontract.InvalidArgument("ref", "is required", map[string]any{"ref": "main"}) } if len(in.Files) < 1 || len(in.Files) > 20 { @@ -89,9 +94,11 @@ func (s *Server) readSourceFiles(ctx context.Context, _ *mcp.CallToolRequest, in return linkedResource(out.ResourceURI, "source-bundle", "GitHub source bundle", "Immutable bounded source text persisted in the local corpus."), out, nil } -func validateLiveRepository(repository mcpcontract.RepositoryRef) error { - if strings.TrimSpace(repository.Owner) == "" || strings.TrimSpace(repository.Repo) == "" { - return mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) +func normalizeLiveRepository(repository mcpcontract.RepositoryRef) (mcpcontract.RepositoryRef, error) { + owner, repo, err := normalizeRepository(repository.Owner, repository.Repo) + if err != nil { + return mcpcontract.RepositoryRef{}, mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) } - return nil + repository.Owner, repository.Repo = owner, repo + return repository, nil } diff --git a/internal/mcpserver/portfolio.go b/internal/mcpserver/portfolio.go index d3757626..4b9bc5af 100644 --- a/internal/mcpserver/portfolio.go +++ b/internal/mcpserver/portfolio.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -28,21 +29,26 @@ func (s *Server) listPullRequestPortfolio(ctx context.Context, _ *mcp.CallToolRe } func (s *Server) findPortfolioOverlaps(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.FindPortfolioOverlapsInput) (*mcp.CallToolResult, mcpcontract.FindPortfolioOverlapsOutput, error) { - for _, candidate := range in.Candidates { + for i := range in.Candidates { + candidate := &in.Candidates[i] + candidate.Kind = strings.TrimSpace(candidate.Kind) + candidate.Ref = strings.TrimSpace(candidate.Ref) if candidate.Kind != "opportunity" && candidate.Kind != "workspace" && candidate.Kind != "pull_request" { return nil, mcpcontract.FindPortfolioOverlapsOutput{}, mcpcontract.InvalidArgument("candidates", "candidate kind must be opportunity, workspace, or pull_request", map[string]any{"candidates": []map[string]string{{"kind": "opportunity", "ref": ""}}}) } - if strings.TrimSpace(candidate.Ref) == "" { + if candidate.Ref == "" { return nil, mcpcontract.FindPortfolioOverlapsOutput{}, mcpcontract.InvalidArgument("candidates", "candidate ref is required", nil) } } - for _, pullRequest := range in.PullRequests { - if err := validateThreadRef(pullRequest, true); err != nil { + for i, pullRequest := range in.PullRequests { + normalized, err := normalizeThreadRef(pullRequest, optionalThreadKind) + if err != nil { return nil, mcpcontract.FindPortfolioOverlapsOutput{}, err } - if pullRequest.Kind != "" && pullRequest.Kind != "pull_request" { + if normalized.Kind != "" && normalized.Kind != string(domain.PullRequestKind) { return nil, mcpcontract.FindPortfolioOverlapsOutput{}, mcpcontract.InvalidArgument("pull_requests", "kind must be pull_request when provided", map[string]any{"kind": "pull_request"}) } + in.PullRequests[i] = normalized } reader, ok := s.reader.(PortfolioReader) if !ok { @@ -52,20 +58,31 @@ func (s *Server) findPortfolioOverlaps(ctx context.Context, _ *mcp.CallToolReque return nil, out, err } -func validateThreadRef(ref mcpcontract.ThreadRef, kindOptional bool) error { - if strings.TrimSpace(ref.Owner) == "" || strings.TrimSpace(ref.Repo) == "" { - return mcpcontract.InvalidArgument("threads", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket", "number": 1}) +type threadKindRequirement uint8 + +const ( + optionalThreadKind threadKindRequirement = iota + requiredThreadKind +) + +func normalizeThreadRef(ref mcpcontract.ThreadRef, requirement threadKindRequirement) (mcpcontract.ThreadRef, error) { + repository, err := domain.NewRepoRef(ref.Owner, ref.Repo) + if err != nil { + return mcpcontract.ThreadRef{}, mcpcontract.InvalidArgument("threads", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket", "number": 1}) } if ref.Number < 1 { - return mcpcontract.InvalidArgument("threads", "number must be positive", map[string]any{"owner": ref.Owner, "repo": ref.Repo, "number": 1}) + return mcpcontract.ThreadRef{}, mcpcontract.InvalidArgument("threads", "number must be positive", map[string]any{"owner": repository.Owner(), "repo": repository.Repo(), "number": 1}) } - if ref.Kind == "" && kindOptional { - return nil + ref.Owner, ref.Repo, ref.Kind = repository.Owner(), repository.Repo(), strings.TrimSpace(ref.Kind) + if ref.Kind == "" && requirement == optionalThreadKind { + return ref, nil } - if ref.Kind != "issue" && ref.Kind != "pull_request" { - return mcpcontract.InvalidArgument("threads", "kind must be issue or pull_request", map[string]any{"kind": "pull_request"}) + kind, err := domain.ParseThreadKind(ref.Kind) + if err != nil { + return mcpcontract.ThreadRef{}, mcpcontract.InvalidArgument("threads", "kind must be issue or pull_request", map[string]any{"kind": "pull_request"}) } - return nil + ref.Kind = string(kind) + return ref, nil } func (s *Server) linkPullRequest(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.LinkPullRequestInput) (*mcp.CallToolResult, mcpcontract.LinkPullRequestOutput, error) { @@ -78,14 +95,20 @@ func (s *Server) linkPullRequest(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *Server) preflightContribution(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ContributionPreflightInput) (*mcp.CallToolResult, mcpcontract.ContributionPreflightOutput, error) { - if err := validateThreadRef(mcpcontract.ThreadRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo, Number: 1}, true); err != nil { + owner, repo, err := normalizeRepository(in.Repository.Owner, in.Repository.Repo) + if err != nil { return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) } - if in.Fork != nil && (strings.TrimSpace(in.Fork.Owner) == "" || strings.TrimSpace(in.Fork.Repo) == "") { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("fork", "owner and repo are required when fork is provided", map[string]any{"owner": "alice", "repo": "rocket"}) - } - if in.Fork != nil && strings.EqualFold(strings.TrimSpace(in.Fork.Owner), strings.TrimSpace(in.Repository.Owner)) && strings.EqualFold(strings.TrimSpace(in.Fork.Repo), strings.TrimSpace(in.Repository.Repo)) { - return nil, mcpcontract.ContributionPreflightOutput{}, mcpcontract.InvalidArgument("fork", "fork must differ from the upstream repository", nil) + 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) diff --git a/internal/mcpserver/pr_checks.go b/internal/mcpserver/pr_checks.go index 35fba3f9..263f2148 100644 --- a/internal/mcpserver/pr_checks.go +++ b/internal/mcpserver/pr_checks.go @@ -10,12 +10,12 @@ import ( ) func (s *Server) waitPullRequestChecks(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.WaitPullRequestChecksInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - in.Owner = strings.TrimSpace(in.Owner) - in.Repo = strings.TrimSpace(in.Repo) + owner, repo, repositoryErr := normalizeRepository(in.Owner, in.Repo) in.ExpectedHeadSHA = strings.TrimSpace(in.ExpectedHeadSHA) - if in.Owner == "" || in.Repo == "" || in.Number < 1 || in.ExpectedHeadSHA == "" { + if repositoryErr != nil || in.Number < 1 || in.ExpectedHeadSHA == "" { return nil, mcpcontract.JobReference{}, mcpcontract.InvalidArgument("expected_head_sha", "owner, repo, number, and expected_head_sha are required", map[string]any{"owner": "octo", "repo": "project", "number": 1, "expected_head_sha": "abcdef1234567"}) } + in.Owner, in.Repo = owner, repo operator, ok := s.reader.(PullRequestCheckWaiter) if !ok { return nil, mcpcontract.JobReference{}, errors.New("pull-request check waiting is unavailable") diff --git a/internal/mcpserver/resources.go b/internal/mcpserver/resources.go index 3ae9dd36..1d177cf0 100644 --- a/internal/mcpserver/resources.go +++ b/internal/mcpserver/resources.go @@ -29,18 +29,19 @@ type workspaceResourceReader interface { } type pullRequestWorkflowResourceReader interface { - PullRequestFeedbackResource(context.Context, string, string, int) (map[string]any, error) - PullRequestFeedbackItemResource(context.Context, string, string, int, string, string) (map[string]any, error) - CIFailureResource(context.Context, string, string, int) (map[string]any, error) - CIJobLogResource(context.Context, string, string, int, int64) (map[string]any, error) + PullRequestFeedbackResource(context.Context, string, string, int) (mcpcontract.PullRequestFeedbackResource, error) + PullRequestFeedbackItemResource(context.Context, string, string, int, string, string) (mcpcontract.PullRequestFeedbackItemResource, error) + CIFailureResource(context.Context, string, string, int) (mcpcontract.CIFailureResource, error) + CIJobLogResource(context.Context, string, string, int, int64) (mcpcontract.CIJobLogResource, error) } type threadFacetResourceReader interface { - ThreadFacetResource(context.Context, string, string, string, int, string) (map[string]any, error) + ThreadFacetResource(context.Context, string, string, string, int, string) (mcpcontract.ThreadFacetResource, error) } type actorResourceReader interface { - ActorResource(context.Context, string, string) (any, error) + ActorProfileResource(context.Context, string) (mcpcontract.ActorOutput, error) + ActorFacetResource(context.Context, string, string) (mcpcontract.ActorFacetResource, error) } func (s *Server) readResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { @@ -173,14 +174,14 @@ func (s *Server) readActorResource(ctx context.Context, req resourceRequest) (an return nil, mcp.ResourceNotFoundError(req.uri) } actorID := req.parts[0] - facet := "" - if len(req.parts) == 3 { - facet = req.parts[2] - if strings.TrimSpace(facet) == "" { - return nil, mcp.ResourceNotFoundError(req.uri) - } + if len(req.parts) == 1 || req.parts[2] == "profile" { + return reader.ActorProfileResource(ctx, actorID) + } + facet := req.parts[2] + if strings.TrimSpace(facet) == "" { + return nil, mcp.ResourceNotFoundError(req.uri) } - return reader.ActorResource(ctx, actorID, facet) + return reader.ActorFacetResource(ctx, actorID, facet) } func (s *Server) readCodeIndexResource(ctx context.Context, req resourceRequest) (mcpcontract.CodeIndexArtifact, error) { @@ -216,19 +217,19 @@ func (s *Server) readSourceBundleResource(ctx context.Context, req resourceReque return reader.ReadSourceBundleArtifact(ctx, req.parts[1]) } -func (s *Server) readThreadFacetResource(ctx context.Context, req resourceRequest) (map[string]any, error) { +func (s *Server) readThreadFacetResource(ctx context.Context, req resourceRequest) (mcpcontract.ThreadFacetResource, error) { reader, ok := s.reader.(threadFacetResourceReader) if !ok || len(req.parts) != 6 || req.parts[4] != "facet" { - return nil, mcp.ResourceNotFoundError(req.uri) + return mcpcontract.ThreadFacetResource{}, mcp.ResourceNotFoundError(req.uri) } number, valid := positivePathNumber(req.parts[3]) if !valid || strings.TrimSpace(req.parts[2]) == "" || strings.TrimSpace(req.parts[5]) == "" { - return nil, mcp.ResourceNotFoundError(req.uri) + return mcpcontract.ThreadFacetResource{}, mcp.ResourceNotFoundError(req.uri) } return reader.ThreadFacetResource(ctx, req.parts[0], req.parts[1], req.parts[2], number, req.parts[5]) } -func (s *Server) readPullRequestFeedbackResource(ctx context.Context, req resourceRequest) (map[string]any, error) { +func (s *Server) readPullRequestFeedbackResource(ctx context.Context, req resourceRequest) (any, error) { reader, ok := s.reader.(pullRequestWorkflowResourceReader) if !ok || len(req.parts) < 3 || len(req.parts) > 5 || strings.TrimSpace(req.parts[0]) == "" || strings.TrimSpace(req.parts[1]) == "" { return nil, mcp.ResourceNotFoundError(req.uri) @@ -248,24 +249,24 @@ func (s *Server) readPullRequestFeedbackResource(ctx context.Context, req resour return reader.PullRequestFeedbackItemResource(ctx, req.parts[0], req.parts[1], number, channel, feedbackID) } -func (s *Server) readCIFailureResource(ctx context.Context, req resourceRequest) (map[string]any, error) { +func (s *Server) readCIFailureResource(ctx context.Context, req resourceRequest) (mcpcontract.CIFailureResource, error) { reader, ok := s.reader.(pullRequestWorkflowResourceReader) number, valid := pullRequestResourceNumber(req.parts) if !ok || !valid { - return nil, mcp.ResourceNotFoundError(req.uri) + return mcpcontract.CIFailureResource{}, mcp.ResourceNotFoundError(req.uri) } return reader.CIFailureResource(ctx, req.parts[0], req.parts[1], number) } -func (s *Server) readCIJobLogResource(ctx context.Context, req resourceRequest) (map[string]any, error) { +func (s *Server) readCIJobLogResource(ctx context.Context, req resourceRequest) (mcpcontract.CIJobLogResource, error) { reader, ok := s.reader.(pullRequestWorkflowResourceReader) if !ok || len(req.parts) != 4 { - return nil, mcp.ResourceNotFoundError(req.uri) + return mcpcontract.CIJobLogResource{}, mcp.ResourceNotFoundError(req.uri) } number, valid := positivePathNumber(req.parts[2]) jobID, jobErr := strconv.ParseInt(req.parts[3], 10, 64) if !valid || jobErr != nil || jobID <= 0 { - return nil, mcp.ResourceNotFoundError(req.uri) + return mcpcontract.CIJobLogResource{}, mcp.ResourceNotFoundError(req.uri) } return reader.CIJobLogResource(ctx, req.parts[0], req.parts[1], number, jobID) } diff --git a/internal/mcpserver/scalable.go b/internal/mcpserver/scalable.go index 7253fb66..e8eb143e 100644 --- a/internal/mcpserver/scalable.go +++ b/internal/mcpserver/scalable.go @@ -7,6 +7,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/deepwiki" "github.com/morluto/gitcontribute/internal/facets" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/repositorycontext" @@ -35,7 +36,7 @@ const serverInstructions = "GitContribute exposes source-backed GitHub facts. " // RepositoryMetadataOutput describes the coverage of repository metadata. -// TypedRepositoryOutput contains repository facts with explicit metadata coverage. +// RepositoryOutput contains repository facts with explicit metadata coverage. // GetRepositoriesOutput preserves repository input order and represents // unobserved metadata with nullable facts instead of false zero values. @@ -367,7 +368,7 @@ func (s *Server) registerScalable() { addCatalogTool(s, catalogTool[mcpcontract.CheckMergeConflictsInput, mcpcontract.CheckMergeConflictsOutput]{name: mcpcontract.ToolCheckMergeConflicts, title: "Check local Git merge conflicts in one batch", description: "Compare up to 50 fetched OID pairs without fetching or changing repository state.", annotations: processReadAnnotations(), supportedBy: supports[MergeConflictReader], input: inputSchema[mcpcontract.CheckMergeConflictsInput](func(sc *schemaBuilder) { setArrayBounds(sc, "comparisons", 1, 50) }), output: outputSchema[mcpcontract.CheckMergeConflictsOutput]("Ordered local merge-conflict checks."), handler: s.checkMergeConflicts}) addCatalogTool(s, catalogTool[mcpcontract.DeepWikiInput, mcpcontract.DeepWikiOutput]{name: mcpcontract.ToolQueryDeepWiki, title: "Query derived repository knowledge from DeepWiki", description: "Query DeepWiki for public repository architecture, contribution rules, testing, and subsystem context. Actions map to its public structure, contents, and question reads. Do not use this for live stars, thread state, checks, reviews, or mergeability.", annotations: externalReadAnnotations(), supportedBy: supports[ResearchReader], input: inputSchema[mcpcontract.DeepWikiInput](func(sc *schemaBuilder) { setEnum(sc, "action", "structure", "contents", "question") - setArrayBounds(sc, "repositories", 1, 10) + setArrayBounds(sc, "repositories", 1, deepwiki.MaxRepositories) setRange(sc, "max_output_bytes", mcpcontract.DeepWikiMinOutputBytes, mcpcontract.DeepWikiMaxOutputBytes) setDefault(sc, "max_output_bytes", mcpcontract.DeepWikiDefaultOutputBytes) configureDeepWikiModes(sc) @@ -383,7 +384,7 @@ func (s *Server) getCatalogContract(_ context.Context, _ *mcp.CallToolRequest, _ _, syncAdvertised := s.catalogTools[mcpcontract.ToolSyncPullRequestFeedback] _, searchAdvertised := s.catalogTools[mcpcontract.ToolSearchPullRequestFeedback] mode := "all" - if s.readOnly { + if s.access == readOnlyServerAccess { mode = "read_only" } return nil, mcpcontract.CatalogContract{ @@ -417,10 +418,12 @@ func (s *Server) getThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpc if in.View == "" { in.View = "compact" } - for _, thread := range in.Threads { - if err := validateThreadRef(thread, true); err != nil { + for i, thread := range in.Threads { + normalized, err := normalizeThreadRef(thread, optionalThreadKind) + if err != nil { return nil, mcpcontract.GetThreadsOutput{}, err } + in.Threads[i] = normalized } r, err := s.scalableReader() if err != nil { @@ -430,10 +433,12 @@ func (s *Server) getThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpc return nil, out, err } func (s *Server) getThreadFacets(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.GetThreadFacetsInput) (*mcp.CallToolResult, mcpcontract.GetThreadFacetsOutput, error) { - for _, thread := range in.Threads { - if err := validateThreadRef(thread, false); err != nil { + for i, thread := range in.Threads { + normalized, err := normalizeThreadRef(thread, requiredThreadKind) + if err != nil { return nil, mcpcontract.GetThreadFacetsOutput{}, err } + in.Threads[i] = normalized } r, ok := s.reader.(ThreadFacetReader) if !ok { @@ -579,7 +584,7 @@ func (s *Server) searchGitHubRepositories(ctx context.Context, _ *mcp.CallToolRe return nil, mcpcontract.SearchGitHubRepositoriesOutput{}, errors.New("live GitHub repository search is not available") } out, err := op.SearchGitHubRepositories(ctx, in) - if s.readOnly { + if s.access == readOnlyServerAccess { out.RecoveryPlans = nil } return nil, out, err @@ -599,10 +604,12 @@ func validateRepositorySearchInput(in mcpcontract.SearchGitHubRepositoriesInput) return nil } func (s *Server) syncThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncThreadsInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - for _, thread := range in.Threads { - if err := validateThreadRef(thread, false); err != nil { + for i, thread := range in.Threads { + normalized, err := normalizeThreadRef(thread, requiredThreadKind) + if err != nil { return nil, mcpcontract.JobReference{}, err } + in.Threads[i] = normalized } if in.Selection == "repositories" && in.LimitPerRepository == 0 { in.LimitPerRepository = 100 @@ -648,9 +655,11 @@ func (s *Server) previewRepositoryFixPatterns(ctx context.Context, _ *mcp.CallTo } func (s *Server) syncPortfolio(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncPortfolioInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { if in.Repository != nil { - if err := validateLiveRepository(*in.Repository); err != nil { + repository, err := normalizeLiveRepository(*in.Repository) + if err != nil { return nil, mcpcontract.JobReference{}, err } + in.Repository = &repository if in.Selection == "explicit" { return nil, mcpcontract.JobReference{}, mcpcontract.InvalidArgument("repository", "is only valid for authored selection", nil) } @@ -687,9 +696,11 @@ func (s *Server) syncPullRequestFeedback(ctx context.Context, _ *mcp.CallToolReq } func (s *Server) indexPullRequestFeedback(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.IndexPullRequestFeedbackInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - if in.Repository.Owner == "" || in.Repository.Repo == "" { + owner, repo, err := normalizeRepository(in.Repository.Owner, in.Repository.Repo) + if err != nil { return nil, mcpcontract.JobReference{}, mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"repository": map[string]string{"owner": "acme", "repo": "rocket"}}) } + in.Repository = mcpcontract.RepositoryRef{Owner: owner, Repo: repo} op, ok := s.reader.(PullRequestFeedbackIndexer) if !ok { return nil, mcpcontract.JobReference{}, errors.New("repository pull-request feedback indexing is not available") @@ -699,9 +710,11 @@ func (s *Server) indexPullRequestFeedback(ctx context.Context, _ *mcp.CallToolRe } func (s *Server) searchPullRequestFeedback(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchPullRequestFeedbackInput) (*mcp.CallToolResult, mcpcontract.SearchPullRequestFeedbackOutput, error) { - if in.Repository.Owner == "" || in.Repository.Repo == "" { + owner, repo, err := normalizeRepository(in.Repository.Owner, in.Repository.Repo) + if err != nil { return nil, mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.InvalidArgument("repository", "owner and repo are required", map[string]any{"repository": map[string]string{"owner": "acme", "repo": "rocket"}}) } + in.Repository = mcpcontract.RepositoryRef{Owner: owner, Repo: repo} reader, ok := s.reader.(PullRequestFeedbackSearcher) if !ok { return nil, mcpcontract.SearchPullRequestFeedbackOutput{}, errors.New("offline pull-request feedback search is not available") @@ -743,13 +756,6 @@ func (s *Server) checkMergeConflicts(ctx context.Context, _ *mcp.CallToolRequest return nil, out, err } func (s *Server) deepWiki(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.DeepWikiInput) (*mcp.CallToolResult, mcpcontract.DeepWikiOutput, error) { - in.Action = strings.TrimSpace(in.Action) - if in.MaxOutputBytes == 0 { - in.MaxOutputBytes = mcpcontract.DeepWikiDefaultOutputBytes - } - if in.MaxOutputBytes < mcpcontract.DeepWikiMinOutputBytes || in.MaxOutputBytes > mcpcontract.DeepWikiMaxOutputBytes { - return nil, mcpcontract.DeepWikiOutput{}, mcpcontract.InvalidArgument("max_output_bytes", "must be between 1024 and 1048576", map[string]any{"max_output_bytes": mcpcontract.DeepWikiDefaultOutputBytes}) - } op, ok := s.reader.(ResearchReader) if !ok { return nil, mcpcontract.DeepWikiOutput{}, errors.New("DeepWiki is not available") diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index a3e7e3d3..7c1424c6 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -19,6 +19,23 @@ type schemaBuilder struct { err *error } +// corpusReadProvenanceSchemaShape is the public JSON shape emitted by +// CorpusReadProvenance. The contract keeps its coverage state private so +// callers cannot construct contradictory booleans; this adapter shape tells +// the reflection-based MCP schema generator about the derived wire fields. +type corpusReadProvenanceSchemaShape struct { + SnapshotToken string `json:"snapshot_token"` + Durable bool `json:"durable"` + ObservationWatermark int64 `json:"observation_watermark"` + QueryDigestSHA256 string `json:"query_digest_sha256"` + Complete bool `json:"complete"` + Truncated bool `json:"truncated"` + UnknownCoverage bool `json:"unknown_coverage"` + Limitations []string `json:"limitations,omitempty"` + ExternalContext []mcpcontract.SourceRef `json:"external_context,omitempty"` + Recovery *mcpcontract.RecoveryPlan `json:"recovery,omitempty"` +} + func inferredSchema[T any]() schemaDefinition { toolCallSchema, err := recoveryToolCallSchema() if err != nil { @@ -28,91 +45,95 @@ func inferredSchema[T any]() schemaDefinition { if err != nil { return schemaDefinition{err: fmt.Errorf("infer follow-up action schema: %w", err)} } - schema, err := jsonschema.For[T](&jsonschema.ForOptions{ - TypeSchemas: map[reflect.Type]*jsonschema.Schema{ - reflect.TypeFor[mcpcontract.ToolCall](): toolCallSchema, - reflect.TypeFor[mcpcontract.FollowUpAction](): followUpSchema, - reflect.TypeFor[mcpcontract.Probability](): { - Type: "number", - Description: "Numeric confidence from 0 to 1.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(1.0), - }, - reflect.TypeFor[mcpcontract.SimilarityScore](): { - Type: "number", - Description: "Normalized similarity score from 0 to 1.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(1.0), - }, - reflect.TypeFor[mcpcontract.RadarScore](): { - Type: "integer", - Description: "Deterministic Contribution Radar score from 0 to 100.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(100.0), - }, - reflect.TypeFor[mcpcontract.ProgressPercent](): { - Type: "integer", - Description: "Integer completion percentage from 0 to 100.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(100.0), - }, - reflect.TypeFor[mcpcontract.NonNegativeInt](): { - Type: "integer", - Description: "Non-negative integer count or delay.", - Minimum: jsonschema.Ptr(0.0), - }, - reflect.TypeFor[mcpcontract.BatchItemStatus](): { - Type: "string", - Description: "Per-item batch outcome.", - Enum: []any{"complete", "retryable", "unavailable", "failed"}, - }, - reflect.TypeFor[mcpcontract.SourceFileStatus](): { - Type: "string", - Description: "Bounded source-file outcome.", - Enum: []any{"complete", "not_found", "too_large", "retryable", "unavailable", "failed"}, - }, - reflect.TypeFor[mcpcontract.JobStatus](): { - Type: "string", - Description: "Durable job lifecycle status.", - Enum: []any{"queued", "running", "succeeded", "failed", "cancelled"}, - }, - reflect.TypeFor[mcpcontract.JobExecutionState](): { - Type: "string", - Description: "Whether a durable job is queued, running, or terminal.", - Enum: []any{"queued", "running", "terminal"}, - }, - reflect.TypeFor[mcpcontract.JobOutcome](): { - Type: "string", - Description: "Terminal job outcome; omitted until execution is terminal.", - Enum: []any{"succeeded", "partial", "failed", "cancelled"}, - }, - reflect.TypeFor[mcpcontract.FixPatternOutcome](): { - Type: "string", - Description: "Pull-request outcome; merged state comes from GitHub and superseded requires an explicit replacement relationship.", - Enum: []any{"merged", "closed_unmerged", "superseded", "open", "unknown"}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelationship](): { - Type: "string", - Description: "Evidence connecting a pull request to an issue.", - Enum: []any{"closes", "references", "explicit_replacement", "similarity_only"}, - }, - reflect.TypeFor[mcpcontract.FixPatternReportStatus](): { - Type: "string", - Description: "Whether the bounded report is complete or retains coverage limits or failures.", - Enum: []any{"complete", "partial"}, - }, - reflect.TypeFor[mcpcontract.FixPatternProofStyle](): { - Type: "string", - Description: "Evidence style detected in stored pull-request text.", - Enum: []any{"regression_test", "reproduction", "benchmark", "before_after", "screenshot"}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelatedKind](): { - Type: "string", - Description: "Stored thread kind of a related target.", - Enum: []any{"issue", "pull_request"}, - }, + typeSchemas := map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[mcpcontract.ToolCall](): toolCallSchema, + reflect.TypeFor[mcpcontract.FollowUpAction](): followUpSchema, + reflect.TypeFor[mcpcontract.Probability](): { + Type: "number", + Description: "Numeric confidence from 0 to 1.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(1.0), }, - }) + reflect.TypeFor[mcpcontract.SimilarityScore](): { + Type: "number", + Description: "Normalized similarity score from 0 to 1.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(1.0), + }, + reflect.TypeFor[mcpcontract.RadarScore](): { + Type: "integer", + Description: "Deterministic Contribution Radar score from 0 to 100.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(100.0), + }, + reflect.TypeFor[mcpcontract.ProgressPercent](): { + Type: "integer", + Description: "Integer completion percentage from 0 to 100.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(100.0), + }, + reflect.TypeFor[mcpcontract.NonNegativeInt](): { + Type: "integer", + Description: "Non-negative integer count or delay.", + Minimum: jsonschema.Ptr(0.0), + }, + reflect.TypeFor[mcpcontract.BatchItemStatus](): { + Type: "string", + Description: "Per-item batch outcome.", + Enum: []any{mcpcontract.BatchItemComplete, mcpcontract.BatchItemRetryable, mcpcontract.BatchItemUnavailable, mcpcontract.BatchItemFailed}, + }, + reflect.TypeFor[mcpcontract.SourceFileStatus](): { + Type: "string", + Description: "Bounded source-file outcome.", + Enum: []any{mcpcontract.SourceFileComplete, mcpcontract.SourceFileNotFound, mcpcontract.SourceFileTooLarge, mcpcontract.SourceFileRetryable, mcpcontract.SourceFileUnavailable, mcpcontract.SourceFileFailed}, + }, + reflect.TypeFor[mcpcontract.JobStatus](): { + Type: "string", + Description: "Durable job lifecycle status.", + Enum: []any{mcpcontract.JobStatusQueued, mcpcontract.JobStatusRunning, mcpcontract.JobStatusSucceeded, mcpcontract.JobStatusFailed, mcpcontract.JobStatusCancelled}, + }, + reflect.TypeFor[mcpcontract.JobExecutionState](): { + Type: "string", + Description: "Whether a durable job is queued, running, or terminal.", + Enum: []any{mcpcontract.JobExecutionQueued, mcpcontract.JobExecutionRunning, mcpcontract.JobExecutionTerminal}, + }, + reflect.TypeFor[mcpcontract.JobOutcome](): { + Type: "string", + 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 { + return schemaDefinition{err: fmt.Errorf("infer corpus read provenance schema: %w", err)} + } + typeSchemas[reflect.TypeFor[mcpcontract.CorpusReadProvenance]()] = provenanceSchema + schema, err := jsonschema.For[T](&jsonschema.ForOptions{TypeSchemas: typeSchemas}) if err != nil { return schemaDefinition{err: fmt.Errorf("infer MCP schema: %w", err)} } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 1b3e56ab..921b16f8 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) @@ -315,28 +316,35 @@ type Server struct { reader mcpcontract.Reader server *mcp.Server registrationErr error - readOnly bool + access serverAccess version string catalogTools map[string][]byte } +type serverAccess uint8 + +const ( + fullServerAccess serverAccess = iota + readOnlyServerAccess +) + // New constructs an MCP server with the unified catalog. func New(reader mcpcontract.Reader, version string) (*Server, error) { - return newServer(reader, version, false) + return newServer(reader, version, fullServerAccess) } // NewReadOnly constructs an MCP server that advertises only read-only tools. func NewReadOnly(reader mcpcontract.Reader, version string) (*Server, error) { - return newServer(reader, version, true) + return newServer(reader, version, readOnlyServerAccess) } -func newServer(reader mcpcontract.Reader, version string, readOnly bool) (*Server, error) { +func newServer(reader mcpcontract.Reader, version string, access serverAccess) (*Server, error) { if version == "" { version = "dev" } s := &Server{ reader: reader, - readOnly: readOnly, + access: access, version: version, catalogTools: make(map[string][]byte), server: mcp.NewServer(&mcp.Implementation{ @@ -562,11 +570,12 @@ func (s *Server) getCoverage(ctx context.Context, _ *mcp.CallToolRequest, in mcp return nil, out, err } -func validateRepo(in mcpcontract.RepoInput) error { - if strings.TrimSpace(in.Owner) == "" || strings.TrimSpace(in.Repo) == "" { - return mcpcontract.InvalidArgument("owner", "owner and repo are required together", map[string]any{"owner": "acme", "repo": "rocket"}) +func normalizeRepository(owner, repo string) (string, string, error) { + ref, err := domain.NewRepoRef(owner, repo) + if err != nil { + return "", "", mcpcontract.InvalidArgument("owner", "owner and repo are required together", map[string]any{"owner": "acme", "repo": "rocket"}) } - return nil + return ref.Owner(), ref.Repo(), nil } func normalizeID(field, value string) (string, error) { diff --git a/internal/mcpserver/server_input_resources_test.go b/internal/mcpserver/server_input_resources_test.go index 178c562e..ee534cef 100644 --- a/internal/mcpserver/server_input_resources_test.go +++ b/internal/mcpserver/server_input_resources_test.go @@ -50,22 +50,22 @@ type feedbackResourceCapture struct { feedbackID string } -func (*feedbackResourceCapture) PullRequestFeedbackResource(context.Context, string, string, int) (map[string]any, error) { - return nil, errors.New("unexpected pull-request feedback resource") +func (*feedbackResourceCapture) PullRequestFeedbackResource(context.Context, string, string, int) (mcpcontract.PullRequestFeedbackResource, error) { + return mcpcontract.PullRequestFeedbackResource{}, errors.New("unexpected pull-request feedback resource") } -func (r *feedbackResourceCapture) PullRequestFeedbackItemResource(_ context.Context, _ string, _ string, _ int, channel, feedbackID string) (map[string]any, error) { +func (r *feedbackResourceCapture) PullRequestFeedbackItemResource(_ context.Context, _ string, _ string, _ int, channel, feedbackID string) (mcpcontract.PullRequestFeedbackItemResource, error) { r.channel = channel r.feedbackID = feedbackID - return map[string]any{"schema_version": "gitcontribute.pull-request-feedback-item.v1"}, nil + return mcpcontract.PullRequestFeedbackItemResource{SchemaVersion: "gitcontribute.pull-request-feedback-item.v1"}, nil } -func (*feedbackResourceCapture) CIFailureResource(context.Context, string, string, int) (map[string]any, error) { - return nil, errors.New("unexpected CI failure resource") +func (*feedbackResourceCapture) CIFailureResource(context.Context, string, string, int) (mcpcontract.CIFailureResource, error) { + return mcpcontract.CIFailureResource{}, errors.New("unexpected CI failure resource") } -func (*feedbackResourceCapture) CIJobLogResource(context.Context, string, string, int, int64) (map[string]any, error) { - return nil, errors.New("unexpected CI job log resource") +func (*feedbackResourceCapture) CIJobLogResource(context.Context, string, string, int, int64) (mcpcontract.CIJobLogResource, error) { + return mcpcontract.CIJobLogResource{}, errors.New("unexpected CI job log resource") } func TestResourcePathPartsPreservesEscapedOpaqueIDs(t *testing.T) { @@ -111,7 +111,7 @@ func TestSearchCodeRejectsWhitespaceOnlyQuery(t *testing.T) { func TestSearchThreadsRejectsWhitespaceOnlyQuery(t *testing.T) { server := &Server{reader: &fakeReader{}} - _, _, err := server.searchThreads(context.Background(), nil, SearchThreadsInput{Query: " \t "}) + _, _, err := server.searchThreads(context.Background(), nil, mcpcontract.SearchInput{Query: " \t "}) if err == nil { t.Fatal("whitespace-only thread search query was accepted") } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 05a4d97c..223377a8 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -19,6 +19,162 @@ type fakeReader struct { calls map[string]int } +type canonicalIDReader struct { + *fakeReader + validation mcpcontract.DefineValidationInput + workspace mcpcontract.CreateWorkspaceInput + verification mcpcontract.VerifyPublishedDraftInput + hypothesis mcpcontract.RecordHypothesisInput + relatedWork mcpcontract.CheckDuplicatesInput + promotion mcpcontract.PromoteOpportunityInput + contribution mcpcontract.PrepareContributionInput + manifest mcpcontract.ExportManifestInput + junit mcpcontract.AttachJUnitReportInput + explanation mcpcontract.ExplainMatchInput + dossier mcpcontract.BuildRepositoryDossierInput + investigation mcpcontract.StartInvestigationInput + concern mcpcontract.CreateConcernInput + commitInspect mcpcontract.InspectCommitChangesInput + commitPlan mcpcontract.PlanSemanticCommitsInput +} + +func (r *canonicalIDReader) DefineValidation(_ context.Context, in mcpcontract.DefineValidationInput) (mcpcontract.ValidationOutput, error) { + r.validation = in + return mcpcontract.ValidationOutput{ID: "val-1", InvestigationID: in.InvestigationID}, nil +} + +func (r *canonicalIDReader) CreateWorkspace(_ context.Context, in mcpcontract.CreateWorkspaceInput) (mcpcontract.JobReference, error) { + r.workspace = in + return mcpcontract.JobReference{ID: "job-workspace", Status: "queued"}, nil +} + +func (r *canonicalIDReader) VerifyPublishedDraft(_ context.Context, in mcpcontract.VerifyPublishedDraftInput) (mcpcontract.PublishedDraftVerificationOutput, error) { + r.verification = in + return mcpcontract.PublishedDraftVerificationOutput{Status: "exact_match", DraftID: in.DraftID, Revision: in.Revision}, nil +} + +func (r *canonicalIDReader) RecordHypothesis(_ context.Context, in mcpcontract.RecordHypothesisInput) (mcpcontract.HypothesisOutput, error) { + r.hypothesis = in + return mcpcontract.HypothesisOutput{ID: "hyp-1", InvestigationID: in.InvestigationID}, nil +} + +func (r *canonicalIDReader) CheckDuplicates(_ context.Context, in mcpcontract.CheckDuplicatesInput) (mcpcontract.CheckOutput, error) { + r.relatedWork = in + return mcpcontract.CheckOutput{Target: in.Target, ID: in.ID}, nil +} + +func (r *canonicalIDReader) PromoteOpportunity(_ context.Context, in mcpcontract.PromoteOpportunityInput) (mcpcontract.OpportunityOutput, error) { + r.promotion = in + return mcpcontract.OpportunityOutput{ID: "opp-1", HypothesisID: in.HypothesisID}, nil +} + +func (r *canonicalIDReader) PrepareContribution(_ context.Context, in mcpcontract.PrepareContributionInput) (mcpcontract.DraftOutput, error) { + r.contribution = in + return mcpcontract.DraftOutput{ID: "draft-1", Revision: 1, OpportunityID: in.OpportunityID}, nil +} + +func (r *canonicalIDReader) ExportManifest(_ context.Context, in mcpcontract.ExportManifestInput) (mcpcontract.ManifestOutput, error) { + r.manifest = in + return mcpcontract.ManifestOutput{ManifestID: "sha256:test"}, nil +} + +func (r *canonicalIDReader) AttachJUnitReport(_ context.Context, in mcpcontract.AttachJUnitReportInput) (mcpcontract.AttachJUnitReportOutput, error) { + r.junit = in + return mcpcontract.AttachJUnitReportOutput{RunID: in.RunID}, nil +} + +func (r *canonicalIDReader) ExplainMatch(_ context.Context, in mcpcontract.ExplainMatchInput) (mcpcontract.ExplainMatchOutput, error) { + r.explanation = in + 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 +} + +func (r *canonicalIDReader) CreateConcern(_ context.Context, in mcpcontract.CreateConcernInput) (mcpcontract.ConcernOutput, error) { + r.concern = in + return mcpcontract.ConcernOutput{ID: "concern-1", Owner: in.Owner, Repo: in.Repo}, nil +} + +func (r *canonicalIDReader) InspectCommitChanges(_ context.Context, in mcpcontract.InspectCommitChangesInput) (mcpcontract.CommitInventoryOutput, error) { + r.commitInspect = in + return mcpcontract.CommitInventoryOutput{}, nil +} + +func (r *canonicalIDReader) PlanSemanticCommits(_ context.Context, in mcpcontract.PlanSemanticCommitsInput) (mcpcontract.SemanticCommitPlanOutput, error) { + r.commitPlan = in + return mcpcontract.SemanticCommitPlanOutput{}, nil +} + +type canonicalRepositoryReader struct { + *fakeReader + threadSearch mcpcontract.SearchGitHubThreadsInput + sourceFiles mcpcontract.ReadSourceFilesInput + portfolio mcpcontract.SyncPortfolioInput + overlaps mcpcontract.FindPortfolioOverlapsInput + checkWait mcpcontract.WaitPullRequestChecksInput + feedbackIndex mcpcontract.IndexPullRequestFeedbackInput + feedbackSearch mcpcontract.SearchPullRequestFeedbackInput +} + +func (r *canonicalRepositoryReader) SearchGitHubThreads(_ context.Context, in mcpcontract.SearchGitHubThreadsInput) (mcpcontract.SearchGitHubThreadsOutput, error) { + r.threadSearch = in + return mcpcontract.SearchGitHubThreadsOutput{}, nil +} + +func (r *canonicalRepositoryReader) ReadSourceFiles(_ context.Context, in mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) { + r.sourceFiles = in + return mcpcontract.ReadSourceFilesOutput{}, nil +} + +func (*canonicalRepositoryReader) SyncRepositoryContext(context.Context, mcpcontract.SyncRepositoryContextInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{ID: "job-context", Status: "queued"}, nil +} + +func (*canonicalRepositoryReader) SyncThreads(context.Context, mcpcontract.SyncThreadsInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{ID: "job-threads", Status: "queued"}, nil +} + +func (*canonicalRepositoryReader) HydrateThreads(context.Context, mcpcontract.HydrateThreadsInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{ID: "job-hydrate", Status: "queued"}, nil +} + +func (r *canonicalRepositoryReader) SyncPortfolio(_ context.Context, in mcpcontract.SyncPortfolioInput) (mcpcontract.JobReference, error) { + r.portfolio = in + return mcpcontract.JobReference{ID: "job-portfolio", Status: "queued"}, nil +} + +func (r *canonicalRepositoryReader) FindPortfolioOverlaps(_ context.Context, in mcpcontract.FindPortfolioOverlapsInput) (mcpcontract.FindPortfolioOverlapsOutput, error) { + r.overlaps = in + return mcpcontract.FindPortfolioOverlapsOutput{}, nil +} + +func (*canonicalRepositoryReader) ListPullRequestPortfolio(context.Context, mcpcontract.ListPullRequestPortfolioInput) (mcpcontract.ListPullRequestPortfolioOutput, error) { + return mcpcontract.ListPullRequestPortfolioOutput{}, nil +} + +func (r *canonicalRepositoryReader) WaitPullRequestChecks(_ context.Context, in mcpcontract.WaitPullRequestChecksInput) (mcpcontract.JobReference, error) { + r.checkWait = in + return mcpcontract.JobReference{ID: "job-checks", Status: "queued"}, nil +} + +func (r *canonicalRepositoryReader) IndexPullRequestFeedback(_ context.Context, in mcpcontract.IndexPullRequestFeedbackInput) (mcpcontract.JobReference, error) { + r.feedbackIndex = in + return mcpcontract.JobReference{ID: "job-feedback", Status: "queued"}, nil +} + +func (r *canonicalRepositoryReader) SearchPullRequestFeedback(_ context.Context, in mcpcontract.SearchPullRequestFeedbackInput) (mcpcontract.SearchPullRequestFeedbackOutput, error) { + r.feedbackSearch = in + return mcpcontract.SearchPullRequestFeedbackOutput{}, nil +} + var _ PublishedDraftVerifier = (*fakeReader)(nil) var _ ValidationReceiptOperator = (*fakeReader)(nil) @@ -41,20 +197,20 @@ func (f *fakeReader) recordCall(name string) { var _ WorkspaceCreator = (*fakeReader)(nil) var _ WorkspaceAdopter = (*fakeReader)(nil) -func (*fakeReader) PullRequestFeedbackResource(context.Context, string, string, int) (map[string]any, error) { - return map[string]any{"schema_version": "gitcontribute.pull-request-feedback.v1"}, nil +func (*fakeReader) PullRequestFeedbackResource(context.Context, string, string, int) (mcpcontract.PullRequestFeedbackResource, error) { + return mcpcontract.PullRequestFeedbackResource{SchemaVersion: "gitcontribute.pull-request-feedback.v1"}, nil } -func (*fakeReader) PullRequestFeedbackItemResource(context.Context, string, string, int, string, string) (map[string]any, error) { - return map[string]any{"schema_version": "gitcontribute.pull-request-feedback-item.v1"}, nil +func (*fakeReader) PullRequestFeedbackItemResource(context.Context, string, string, int, string, string) (mcpcontract.PullRequestFeedbackItemResource, error) { + return mcpcontract.PullRequestFeedbackItemResource{SchemaVersion: "gitcontribute.pull-request-feedback-item.v1"}, nil } -func (*fakeReader) CIFailureResource(context.Context, string, string, int) (map[string]any, error) { - return map[string]any{"schema_version": "gitcontribute.ci-failure-report.v1"}, nil +func (*fakeReader) CIFailureResource(context.Context, string, string, int) (mcpcontract.CIFailureResource, error) { + return mcpcontract.NewCIFailureResource(json.RawMessage(`{}`), "acme", "project", 7, nil) } -func (*fakeReader) CIJobLogResource(context.Context, string, string, int, int64) (map[string]any, error) { - return map[string]any{"schema_version": "gitcontribute.ci-job-log.v1", "body": "failure"}, nil +func (*fakeReader) CIJobLogResource(context.Context, string, string, int, int64) (mcpcontract.CIJobLogResource, error) { + return mcpcontract.NewCIJobLogResource(31, "failure", false) } func (*fakeReader) IndexPullRequestFeedback(context.Context, mcpcontract.IndexPullRequestFeedbackInput) (mcpcontract.JobReference, error) { @@ -69,8 +225,8 @@ func (*fakeReader) GetThreadFacets(_ context.Context, _ mcpcontract.GetThreadFac return mcpcontract.GetThreadFacetsOutput{Status: "complete"}, nil } -func (*fakeReader) ThreadFacetResource(context.Context, string, string, string, int, string) (map[string]any, error) { - return map[string]any{"schema_version": "gitcontribute.thread-facet.v1"}, nil +func (*fakeReader) ThreadFacetResource(context.Context, string, string, string, int, string) (mcpcontract.ThreadFacetResource, error) { + return mcpcontract.ThreadFacetResource{SchemaVersion: "gitcontribute.thread-facet.v1"}, nil } func TestPullRequestWorkflowResourcesAreReadable(t *testing.T) { @@ -93,8 +249,18 @@ func TestPullRequestWorkflowResourcesAreReadable(t *testing.T) { if err != nil { t.Fatalf("%s: %v", test.uri, err) } - if got := value.(map[string]any)["schema_version"]; got != test.version { - t.Fatalf("%s schema_version=%v", test.uri, got) + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var document struct { + SchemaVersion string `json:"schema_version"` + } + if err := json.Unmarshal(payload, &document); err != nil { + t.Fatal(err) + } + if document.SchemaVersion != test.version { + t.Fatalf("%s schema_version=%v", test.uri, document.SchemaVersion) } } } @@ -677,6 +843,146 @@ func TestToolCancellationReachesReader(t *testing.T) { } } +func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { + reader := &canonicalIDReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.defineValidation(ctx, nil, mcpcontract.DefineValidationInput{InvestigationID: " inv-1 ", Kind: "test", Command: "go test ./..."}); err != nil { + t.Fatal(err) + } + if _, _, err := server.createWorkspace(ctx, nil, mcpcontract.CreateWorkspaceInput{InvestigationID: " inv-1 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.verifyPublishedDraft(ctx, nil, mcpcontract.VerifyPublishedDraftInput{DraftID: " draft-1 ", Revision: 1, Kind: "issue", Number: 7}); err != nil { + t.Fatal(err) + } + 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 { + t.Fatal(err) + } + if _, _, err := server.promoteOpportunity(ctx, nil, mcpcontract.PromoteOpportunityInput{HypothesisID: " hyp-1 ", ProblemStatement: " problem ", Scope: " scope ", Impact: " impact ", ExpectedEffort: " small "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-1 ", Kind: "issue"}); err != nil { + t.Fatal(err) + } + if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-2 ", Kind: "pull_request", WorkspaceID: " ws-1 ", Approach: " approach "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.exportManifest(ctx, nil, mcpcontract.ExportManifestInput{OpportunityID: " opp-1 ", PullRequest: &mcpcontract.ManifestPullRequestInput{Owner: " acme ", Repo: " rocket ", Number: 7}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.attachJUnitReport(ctx, nil, mcpcontract.AttachJUnitReportInput{RunID: " run-1 ", ReportXML: ""}); err != nil { + t.Fatal(err) + } + 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) + } + if _, _, err := server.createConcern(ctx, nil, mcpcontract.CreateConcernInput{Owner: " acme ", Repo: " rocket ", CommitSHA: " abc123 ", Title: "title", ProblemStatement: "problem"}); err != nil { + t.Fatal(err) + } + + if reader.validation.InvestigationID != "inv-1" || reader.workspace.InvestigationID != "inv-1" || reader.hypothesis.InvestigationID != "inv-1" { + t.Fatalf("investigation IDs were not canonical: validation=%q workspace=%q hypothesis=%q", reader.validation.InvestigationID, reader.workspace.InvestigationID, reader.hypothesis.InvestigationID) + } + if reader.verification.DraftID != "draft-1" || reader.relatedWork.ID != "hyp-1" || reader.relatedWork.Target != "hypothesis" || reader.promotion.HypothesisID != "hyp-1" { + t.Fatalf("workflow IDs were not canonical: verification=%q related=%q/%q promotion=%q", reader.verification.DraftID, reader.relatedWork.Target, reader.relatedWork.ID, reader.promotion.HypothesisID) + } + if reader.promotion.ProblemStatement != "problem" || reader.promotion.Scope != "scope" || reader.promotion.Impact != "impact" || reader.promotion.ExpectedEffort != "small" { + t.Fatalf("opportunity fields were not canonical: %+v", reader.promotion) + } + if reader.contribution.OpportunityID != "opp-2" || reader.contribution.WorkspaceID != "ws-1" || reader.contribution.Approach != "approach" || reader.manifest.OpportunityID != "opp-1" { + t.Fatalf("opportunity IDs were not canonical: contribution=%q manifest=%q", reader.contribution.OpportunityID, reader.manifest.OpportunityID) + } + if reader.manifest.PullRequest == nil || reader.manifest.PullRequest.Owner != "acme" || reader.manifest.PullRequest.Repo != "rocket" || reader.junit.RunID != "run-1" { + t.Fatalf("manifest and validation identities were not canonical: manifest=%+v junit=%+v", reader.manifest.PullRequest, reader.junit) + } + 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.concern.Owner != "acme" || reader.concern.Repo != "rocket" || reader.concern.CommitSHA != "abc123" { + t.Fatalf("concern identity was not canonical: %+v", reader.concern) + } +} + +func TestCommitPlanningBoundariesPassCanonicalInventoryIdentity(t *testing.T) { + reader := &canonicalIDReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.inspectCommitChanges(ctx, nil, mcpcontract.InspectCommitChangesInput{WorkspaceID: " ws-1 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.planSemanticCommits(ctx, nil, mcpcontract.PlanSemanticCommitsInput{WorkspaceID: " ws-1 ", ExpectedInventorySHA256: " inventory-sha "}); err != nil { + t.Fatal(err) + } + if reader.commitInspect.WorkspaceID != "ws-1" || reader.commitPlan.WorkspaceID != "ws-1" || reader.commitPlan.ExpectedInventorySHA256 != "inventory-sha" { + t.Fatalf("commit inventory identity was not canonical: inspect=%+v plan=%+v", reader.commitInspect, reader.commitPlan) + } +} + +func TestLiveRepositoryBoundariesPassCanonicalReferencesToOperators(t *testing.T) { + reader := &canonicalRepositoryReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.searchGitHubThreads(ctx, nil, mcpcontract.SearchGitHubThreadsInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Query: " regression "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.readSourceFiles(ctx, nil, mcpcontract.ReadSourceFilesInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Ref: " main ", Files: []mcpcontract.SourceFileRequest{{Path: "README.md"}}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.syncPortfolio(ctx, nil, mcpcontract.SyncPortfolioInput{Selection: "authored", Repository: &mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.findPortfolioOverlaps(ctx, nil, mcpcontract.FindPortfolioOverlapsInput{ + Candidates: []mcpcontract.PortfolioSubjectInput{{Kind: " opportunity ", Ref: " opp-1 "}}, + PullRequests: []mcpcontract.ThreadRef{{Owner: " acme ", Repo: " rocket ", Number: 7}}, + }); err != nil { + t.Fatal(err) + } + if _, _, err := server.waitPullRequestChecks(ctx, nil, mcpcontract.WaitPullRequestChecksInput{Owner: " acme ", Repo: " rocket ", Number: 7, ExpectedHeadSHA: " abc123 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.indexPullRequestFeedback(ctx, nil, mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.searchPullRequestFeedback(ctx, nil, mcpcontract.SearchPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + + if reader.threadSearch.Repository.Owner != "acme" || reader.threadSearch.Repository.Repo != "rocket" || reader.threadSearch.Query != "regression" { + t.Fatalf("thread-search boundary = %+v", reader.threadSearch) + } + if reader.sourceFiles.Repository.Owner != "acme" || reader.sourceFiles.Repository.Repo != "rocket" || reader.sourceFiles.Ref != "main" { + t.Fatalf("source-file boundary = %+v", reader.sourceFiles) + } + if reader.portfolio.Repository == nil || reader.portfolio.Repository.Owner != "acme" || reader.portfolio.Repository.Repo != "rocket" { + t.Fatalf("portfolio boundary = %+v", reader.portfolio) + } + if len(reader.overlaps.Candidates) != 1 || reader.overlaps.Candidates[0].Kind != "opportunity" || reader.overlaps.Candidates[0].Ref != "opp-1" || reader.overlaps.PullRequests[0].Owner != "acme" || reader.overlaps.PullRequests[0].Repo != "rocket" { + t.Fatalf("portfolio overlap boundary = %+v", reader.overlaps) + } + if reader.checkWait.Owner != "acme" || reader.checkWait.Repo != "rocket" || reader.checkWait.ExpectedHeadSHA != "abc123" { + t.Fatalf("pull-request check boundary = %+v", reader.checkWait) + } + if reader.feedbackIndex.Repository.Owner != "acme" || reader.feedbackIndex.Repository.Repo != "rocket" || reader.feedbackSearch.Repository.Owner != "acme" || reader.feedbackSearch.Repository.Repo != "rocket" { + t.Fatalf("pull-request feedback boundaries: index=%+v search=%+v", reader.feedbackIndex, reader.feedbackSearch) + } +} + func TestV1ParityToolsAndResources(t *testing.T) { client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) defer closeSessions() diff --git a/internal/mcpserver/v1.go b/internal/mcpserver/v1.go index 2c4e92db..b6977902 100644 --- a/internal/mcpserver/v1.go +++ b/internal/mcpserver/v1.go @@ -15,29 +15,6 @@ import ( // SearchRepositoriesOutput contains one page of repository matches. -// SearchThreadsInput describes an offline issue and pull-request search page. -type SearchThreadsInput struct { - Query string `json:"query" jsonschema:"Thread full-text query"` - Owner string `json:"owner,omitempty" jsonschema:"Optional repository owner"` - Repo string `json:"repo,omitempty" jsonschema:"Optional repository name"` - Kind string `json:"kind,omitempty" jsonschema:"Optional thread kind: issue or pull_request"` - State string `json:"state,omitempty" jsonschema:"Optional open or closed state"` - StateReason string `json:"state_reason,omitempty" jsonschema:"Optional GitHub completed or not_planned state reason"` - Merged *bool `json:"merged,omitempty" jsonschema:"Optional pull request merged state"` - Author string `json:"author,omitempty" jsonschema:"Optional author login"` - Association string `json:"author_association,omitempty" jsonschema:"Optional GitHub author association"` - Assignee string `json:"assignee,omitempty" jsonschema:"Optional assignee login"` - Labels []string `json:"labels,omitempty" jsonschema:"Labels that must all be present"` - UpdatedAfter string `json:"updated_after,omitempty" jsonschema:"Optional RFC 3339 lower bound"` - UpdatedBefore string `json:"updated_before,omitempty" jsonschema:"Optional RFC 3339 upper bound"` - 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"` - Sort string `json:"sort,omitempty" jsonschema:"Order: relevance or updated"` - MatchMode string `json:"match_mode,omitempty" jsonschema:"Term matching: all requires every term; any requires at least one term"` - View string `json:"view,omitempty" jsonschema:"compact omits full bodies and returns bounded excerpts; full includes stored bodies"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` -} - // ExplainMatchInput identifies an exact stored result and its original query. // ExplainMatchOutput reports the stored facts that contributed to a match score. @@ -82,10 +59,10 @@ func (s *Server) registerV1() { setEnum(schema, "sort", "relevance", "updated") }), output: outputSchema[mcpcontract.SearchRepositoriesOutput]("One page of stored repository matches."), handler: s.searchRepositories, }) - addCatalogTool(s, catalogTool[SearchThreadsInput, mcpcontract.SearchOutput]{ + addCatalogTool(s, catalogTool[mcpcontract.SearchInput, mcpcontract.SearchOutput]{ name: mcpcontract.ToolSearchThreads, title: "Search stored issues and pull requests", description: "Search stored issue and pull-request titles, labels, bodies, and hydrated text when the question is about the thread itself. This is not a comment-level feedback search: for reviewer-author, inline-anchor, review-state, or resolved/unresolved audits, use corpus.search_pull_request_feedback after github.index_pull_request_feedback. All terms are required by default; use match_mode=any for broader recall. Compact output is bounded, and local coverage is never proof of absence without the returned typed recovery action. Read finalists with corpus.get_threads and hydrate only missing facets. Offline.", - annotations: readOnly, input: inputSchema[SearchThreadsInput](func(schema *schemaBuilder) { + annotations: readOnly, input: inputSchema[mcpcontract.SearchInput](func(schema *schemaBuilder) { setEnum(schema, "kind", "issue", "pull_request") setEnum(schema, "state", "open", "closed") setEnum(schema, "state_reason", "completed", "not_planned") @@ -265,7 +242,7 @@ func (s *Server) searchRepositories(ctx context.Context, _ *mcp.CallToolRequest, return nil, out, err } -func (s *Server) searchThreads(ctx context.Context, _ *mcp.CallToolRequest, in SearchThreadsInput) (*mcp.CallToolResult, mcpcontract.SearchOutput, error) { +func (s *Server) searchThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchInput) (*mcp.CallToolResult, mcpcontract.SearchOutput, error) { in.Query = strings.TrimSpace(in.Query) if in.Query == "" { return nil, mcpcontract.SearchOutput{}, mcpcontract.InvalidArgument("query", "is required", map[string]any{"query": "music"}) @@ -303,15 +280,19 @@ func (s *Server) searchThreads(ctx context.Context, _ *mcp.CallToolRequest, in S if in.View != "compact" && in.View != "full" { return nil, mcpcontract.SearchOutput{}, mcpcontract.InvalidArgument("view", "must be compact or full", map[string]any{"view": "compact"}) } - searchIn := mcpcontract.SearchInput(in) - out, err := s.reader.Search(ctx, searchIn) + out, err := s.reader.Search(ctx, in) return nil, out, err } func (s *Server) explainMatch(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ExplainMatchInput) (*mcp.CallToolResult, mcpcontract.ExplainMatchOutput, error) { - if err := validateRepo(mcpcontract.RepoInput{Owner: in.Owner, Repo: in.Repo}); err != nil { + owner, repo, err := normalizeRepository(in.Owner, in.Repo) + if err != nil { return nil, mcpcontract.ExplainMatchOutput{}, err } + in.Owner, in.Repo = owner, repo + in.Kind = strings.TrimSpace(in.Kind) + in.Path = strings.TrimSpace(in.Path) + in.Commit = strings.TrimSpace(in.Commit) if in.Limit == 0 { in.Limit = 20 } @@ -335,9 +316,11 @@ func (s *Server) explainMatch(ctx context.Context, _ *mcp.CallToolRequest, in mc } func (s *Server) buildRepositoryDossier(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.BuildRepositoryDossierInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - if err := validateRepo(mcpcontract.RepoInput(in)); err != nil { + 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") @@ -347,10 +330,15 @@ func (s *Server) buildRepositoryDossier(ctx context.Context, _ *mcp.CallToolRequ } func (s *Server) startInvestigation(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.StartInvestigationInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if err := validateRepo(mcpcontract.RepoInput{Owner: in.Owner, Repo: in.Repo}); err != nil { + owner, repo, err := normalizeRepository(in.Owner, in.Repo) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } - if in.Number <= 0 && strings.TrimSpace(in.CommitSHA) == "" { + in.Owner, in.Repo = owner, repo + in.CommitSHA = strings.TrimSpace(in.CommitSHA) + in.Lens = strings.TrimSpace(in.Lens) + in.Kind = strings.TrimSpace(in.Kind) + if in.Number <= 0 && in.CommitSHA == "" { return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("commit_sha", "provide commit_sha or a positive stored thread number", map[string]any{"commit_sha": ""}) } operator, ok := s.reader.(Operator) @@ -367,9 +355,11 @@ func (s *Server) startInvestigation(ctx context.Context, _ *mcp.CallToolRequest, } func (s *Server) recordHypothesis(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.RecordHypothesisInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if _, err := normalizeID("investigation_id", in.InvestigationID); err != nil { + investigationID, err := normalizeID("investigation_id", in.InvestigationID) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } + in.InvestigationID = investigationID in.Title = strings.TrimSpace(in.Title) in.Description = strings.TrimSpace(in.Description) in.Category = strings.TrimSpace(in.Category) @@ -436,9 +426,11 @@ func (s *Server) findRelatedWork(ctx context.Context, _ *mcp.CallToolRequest, in } func validateCheckInput(in *mcpcontract.CheckDuplicatesInput) error { - if _, err := normalizeID("id", in.ID); err != nil { + id, err := normalizeID("id", in.ID) + if err != nil { return err } + in.ID = id 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"}) @@ -447,10 +439,16 @@ func validateCheckInput(in *mcpcontract.CheckDuplicatesInput) error { } func (s *Server) promoteOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.PromoteOpportunityInput) (*mcp.CallToolResult, mcpcontract.DurableArtifactReference, error) { - if _, err := normalizeID("hypothesis_id", in.HypothesisID); err != nil { + hypothesisID, err := normalizeID("hypothesis_id", in.HypothesisID) + if err != nil { return nil, mcpcontract.DurableArtifactReference{}, err } - if strings.TrimSpace(in.ProblemStatement) == "" || strings.TrimSpace(in.Scope) == "" || strings.TrimSpace(in.Impact) == "" || strings.TrimSpace(in.ExpectedEffort) == "" { + in.HypothesisID = hypothesisID + in.ProblemStatement = strings.TrimSpace(in.ProblemStatement) + in.Scope = strings.TrimSpace(in.Scope) + in.Impact = strings.TrimSpace(in.Impact) + in.ExpectedEffort = strings.TrimSpace(in.ExpectedEffort) + if in.ProblemStatement == "" || in.Scope == "" || in.Impact == "" || in.ExpectedEffort == "" { return nil, mcpcontract.DurableArtifactReference{}, mcpcontract.InvalidArgument("problem_statement", "problem_statement, scope, impact, and expected_effort are required", map[string]any{"problem_statement": "Concrete problem", "scope": "Bounded scope", "impact": "Observed impact", "expected_effort": "small"}) } if in.Confidence < 0 || in.Confidence > 1 { diff --git a/internal/mcpserver/validation_v1.go b/internal/mcpserver/validation_v1.go index 1f5cf703..c7f8c1f6 100644 --- a/internal/mcpserver/validation_v1.go +++ b/internal/mcpserver/validation_v1.go @@ -50,9 +50,11 @@ func (s *Server) runValidation(ctx context.Context, _ *mcp.CallToolRequest, in m } func (s *Server) defineValidation(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.DefineValidationInput) (*mcp.CallToolResult, mcpcontract.ValidationOutput, error) { - if _, err := normalizeID("investigation_id", in.InvestigationID); err != nil { + investigationID, err := normalizeID("investigation_id", in.InvestigationID) + if err != nil { return nil, mcpcontract.ValidationOutput{}, err } + in.InvestigationID = investigationID in.Kind = strings.TrimSpace(in.Kind) in.Command = strings.TrimSpace(in.Command) in.WorkspaceID = strings.TrimSpace(in.WorkspaceID) @@ -107,8 +109,13 @@ func (s *Server) importExternalEvidenceManifest(ctx context.Context, _ *mcp.Call } func (s *Server) attachJUnitReport(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.AttachJUnitReportInput) (*mcp.CallToolResult, mcpcontract.AttachJUnitReportOutput, error) { - if strings.TrimSpace(in.RunID) == "" || len(in.ReportXML) == 0 || len(in.ReportXML) > 2<<20 { - return nil, mcpcontract.AttachJUnitReportOutput{}, mcpcontract.InvalidArgument("report_xml", "run_id and one JUnit report no larger than 2 MiB are required", nil) + runID, err := normalizeID("run_id", in.RunID) + if err != nil { + return nil, mcpcontract.AttachJUnitReportOutput{}, err + } + in.RunID = runID + if len(in.ReportXML) == 0 || len(in.ReportXML) > 2<<20 { + return nil, mcpcontract.AttachJUnitReportOutput{}, mcpcontract.InvalidArgument("report_xml", "one JUnit report no larger than 2 MiB is required", nil) } operator, ok := s.reader.(JUnitReportImporter) if !ok { diff --git a/internal/mcpserver/workspace_v1.go b/internal/mcpserver/workspace_v1.go index 632f243f..3a1ec6df 100644 --- a/internal/mcpserver/workspace_v1.go +++ b/internal/mcpserver/workspace_v1.go @@ -34,9 +34,11 @@ func (s *Server) adoptWorkspace(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *Server) createWorkspace(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.CreateWorkspaceInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { - if _, err := normalizeID("investigation_id", in.InvestigationID); err != nil { + investigationID, err := normalizeID("investigation_id", in.InvestigationID) + if err != nil { return nil, mcpcontract.JobReference{}, err } + in.InvestigationID = investigationID in.Remote = strings.TrimSpace(in.Remote) in.BaseRef = strings.TrimSpace(in.BaseRef) in.CandidateRef = strings.TrimSpace(in.CandidateRef) diff --git a/internal/precedent/models.go b/internal/precedent/models.go index 07fb9b58..4d24a5bc 100644 --- a/internal/precedent/models.go +++ b/internal/precedent/models.go @@ -20,9 +20,9 @@ type SourceRef struct { // Thread is the stored thread data needed by precedent scoring and output. type Thread struct { ID int64 - Kind string + Kind domain.ThreadKind Number int - State string + State domain.ThreadState StateReason string Title string Body string diff --git a/internal/radar/eligibility_test.go b/internal/radar/eligibility_test.go index 859c840a..44a95b92 100644 --- a/internal/radar/eligibility_test.go +++ b/internal/radar/eligibility_test.go @@ -204,9 +204,9 @@ func completeEligibilityRepo(now time.Time) RepositorySnapshot { Path: "CONTRIBUTING.md", Content: "Contributions are welcome.", URL: "https://github.com/owner/repo/blob/main/CONTRIBUTING.md", }}, Coverage: []Coverage{ - {Facet: "metadata", Scope: "repository", Present: true, Complete: true, AsOf: now}, - {Facet: "threads", Scope: "repository", Present: true, Complete: true, AsOf: now}, - {Facet: "contribution_guidance", Scope: "repository", Present: true, Complete: true, AsOf: now}, + {Facet: "metadata", Scope: "repository", Complete: true, AsOf: now}, + {Facet: "threads", Scope: "repository", Complete: true, AsOf: now}, + {Facet: "contribution_guidance", Scope: "repository", Complete: true, AsOf: now}, }, } } @@ -215,7 +215,7 @@ func completeEligibilityIssue(now time.Time, number int, labels []string, commen return IssueSnapshot{ Number: number, State: "open", Title: "Focused bug", Body: "Steps to reproduce. Expected behavior differs from actual behavior.", Labels: labels, SourceUpdated: now, URL: "https://github.com/owner/repo/issues/1", Discussion: SummarizeDiscussion(comments, now), - Coverage: []Coverage{{Facet: "issue_comments", Scope: "thread", Present: true, Complete: true, AsOf: now}}, + Coverage: []Coverage{{Facet: "issue_comments", Scope: "thread", Complete: true, AsOf: now}}, } } diff --git a/internal/radar/radar.go b/internal/radar/radar.go index caf22ada..6bfd36ed 100644 --- a/internal/radar/radar.go +++ b/internal/radar/radar.go @@ -3,6 +3,7 @@ package radar import ( + "encoding/json" "errors" "fmt" "sort" @@ -60,11 +61,42 @@ type Unknown struct { type Coverage struct { Facet string `json:"facet"` Scope string `json:"scope"` - Present bool `json:"present"` Complete bool `json:"complete"` AsOf time.Time `json:"as_of,omitempty"` } +// MarshalJSON preserves the public presence marker. A Coverage value itself +// is proof that the facet is present; missing facets are absent from the slice. +func (c Coverage) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Facet string `json:"facet"` + Scope string `json:"scope"` + Present bool `json:"present"` + Complete bool `json:"complete"` + AsOf time.Time `json:"as_of,omitempty"` + }{Facet: c.Facet, Scope: c.Scope, Present: true, Complete: c.Complete, AsOf: c.AsOf}) +} + +// UnmarshalJSON parses the public presence marker into the structural slice +// representation. +func (c *Coverage) UnmarshalJSON(data []byte) error { + var raw struct { + Facet string `json:"facet"` + Scope string `json:"scope"` + Present bool `json:"present"` + Complete bool `json:"complete"` + AsOf time.Time `json:"as_of,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if !raw.Present { + return errors.New("absent radar coverage must be omitted rather than encoded as an item") + } + *c = Coverage{Facet: raw.Facet, Scope: raw.Scope, Complete: raw.Complete, AsOf: raw.AsOf} + return nil +} + // LinkedPullRequest is an open PR that explicitly references an issue. type LinkedPullRequest struct { Number int `json:"number"` @@ -704,7 +736,7 @@ func cleanSorted(values []string) []string { func coverageState(coverage []Coverage, facet string) (bool, bool) { for _, item := range coverage { if item.Facet == facet { - return item.Present, item.Present && item.Complete + return true, item.Complete } } return false, false diff --git a/internal/radar/radar_test.go b/internal/radar/radar_test.go index e19a820d..34849a28 100644 --- a/internal/radar/radar_test.go +++ b/internal/radar/radar_test.go @@ -18,8 +18,8 @@ func TestRankOrdersEligibilityAndExplainsScore(t *testing.T) { SourceUpdated: now.Add(-time.Hour), GuidanceStatus: "available", Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true, AsOf: now.Add(-time.Hour)}, - {Facet: "threads", Present: true, Complete: true, AsOf: now.Add(-time.Hour)}, + {Facet: "metadata", Complete: true, AsOf: now.Add(-time.Hour)}, + {Facet: "threads", Complete: true, AsOf: now.Add(-time.Hour)}, }, } report, err := Rank(repo, []IssueSnapshot{ @@ -41,7 +41,7 @@ func TestRankOrdersEligibilityAndExplainsScore(t *testing.T) { Author: "maintainer", AuthorAssociation: "MEMBER", Body: "Thanks for the detailed report.", URL: "https://github.com/owner/repo/issues/1#issuecomment-1", CreatedAt: now.Add(-time.Hour), }}, now), - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true, AsOf: now.Add(-time.Hour)}}, + Coverage: []Coverage{{Facet: "issue_comments", Complete: true, AsOf: now.Add(-time.Hour)}}, URL: "https://github.com/owner/repo/issues/1", }, }, Options{Limit: 10, Now: now, TotalOpenIssues: 3}) @@ -83,12 +83,12 @@ func TestMissingCoverageIsUnknownNotPenalty(t *testing.T) { t.Fatal(err) } completeIssue := base - completeIssue.Coverage = []Coverage{{Facet: "issue_comments", Present: true, Complete: true}} + completeIssue.Coverage = []Coverage{{Facet: "issue_comments", Complete: true}} completeRepo := repo completeRepo.GuidanceStatus = "available" completeRepo.Coverage = []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, - {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, + {Facet: "threads", Complete: true}, } complete, err := Rank(completeRepo, []IssueSnapshot{completeIssue}, Options{Now: now}) if err != nil { @@ -152,12 +152,12 @@ func TestOpenDependencyRequiresCoordinationWithoutBecomingBlocker(t *testing.T) now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, {Facet: "threads", Complete: true}, }}, []IssueSnapshot{{ Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, URL: "https://github.com/owner/repo/issues/7", - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true}}, + Coverage: []Coverage{{Facet: "issue_comments", Complete: true}}, RelatedWork: []RelatedWork{{ Ref: "pull_request:owner/repo#9", Kind: "pull_request", Number: 9, State: "open", Relation: "depends_on", Direction: "outbound", URL: "https://github.com/owner/repo/pull/9", Evidence: []RelatedWorkEvidence{}, @@ -178,10 +178,10 @@ func TestCappedRelatedWorkPreventsReadyToCodeClaim(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, {Facet: "threads", Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true}}, RelatedWorkCapped: true}}, + Coverage: []Coverage{{Facet: "issue_comments", Complete: true}}, RelatedWorkCapped: true}}, Options{Now: now}, ) if err != nil { @@ -197,10 +197,10 @@ func TestUnknownRelatedPullRequestStatePreventsReadyToCodeClaim(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, {Facet: "threads", Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true}}, RelatedWork: []RelatedWork{{ + Coverage: []Coverage{{Facet: "issue_comments", Complete: true}}, RelatedWork: []RelatedWork{{ Ref: "pull_request:owner/repo#9", Kind: "pull_request", Number: 9, Relation: "explicit_reference", Direction: "outbound", Evidence: []RelatedWorkEvidence{}, }}}}, Options{Now: now}, @@ -221,10 +221,10 @@ func TestClosedRelatedPullRequestIsBackgroundOnly(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, {Facet: "threads", Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true}}, RelatedWork: []RelatedWork{{ + Coverage: []Coverage{{Facet: "issue_comments", Complete: true}}, RelatedWork: []RelatedWork{{ Ref: "pull_request:owner/repo#9", Kind: "pull_request", Number: 9, State: "closed", Relation: "claims_to_close", Direction: "inbound", Evidence: []RelatedWorkEvidence{}, }}}}, @@ -339,13 +339,13 @@ func TestCappedCollisionEvidenceCannotClaimEligibility(t *testing.T) { RepositorySnapshot{ Repo: domain.MustRepoRef("owner", "repo"), Coverage: []Coverage{ - {Facet: "metadata", Present: true, Complete: true}, - {Facet: "threads", Present: true, Complete: true}, + {Facet: "metadata", Complete: true}, + {Facet: "threads", Complete: true}, }, }, []IssueSnapshot{{ Number: 1, State: "open", Title: "Issue", Body: "Description", SourceUpdated: now, - Coverage: []Coverage{{Facet: "issue_comments", Present: true, Complete: true}}, + Coverage: []Coverage{{Facet: "issue_comments", Complete: true}}, }}, Options{Now: now, LinkedPullRequestScanCapped: true}, ) diff --git a/internal/radar/related_work.go b/internal/radar/related_work.go index 6a998450..fef45c4c 100644 --- a/internal/radar/related_work.go +++ b/internal/radar/related_work.go @@ -3,6 +3,18 @@ package radar import ( "strings" "time" + + "github.com/morluto/gitcontribute/internal/relatedwork" +) + +// RelatedWorkDirection describes whether a relationship points toward, away +// from, or stays within the candidate being evaluated. +type RelatedWorkDirection string + +const ( + RelatedWorkInbound RelatedWorkDirection = "inbound" + RelatedWorkOutbound RelatedWorkDirection = "outbound" + RelatedWorkLocal RelatedWorkDirection = "local" ) // RelatedWorkEvidence identifies one stored source supporting a relationship. @@ -21,8 +33,8 @@ type RelatedWork struct { Number int `json:"number,omitempty"` Title string `json:"title,omitempty"` State string `json:"state,omitempty"` - Relation string `json:"relation"` - Direction string `json:"direction,omitempty"` + Relation relatedwork.Relation `json:"relation"` + Direction RelatedWorkDirection `json:"direction,omitempty"` URL string `json:"url,omitempty"` Evidence []RelatedWorkEvidence `json:"evidence"` SourceUpdatedAt time.Time `json:"source_updated_at,omitempty"` @@ -46,7 +58,7 @@ func relatedWorkCollisions(values []RelatedWork) (closing, inbound, dependencies if !strings.EqualFold(work.State, "open") { continue } - if work.Relation == "depends_on" && work.Direction == "outbound" { + if work.Relation == relatedwork.RelationDependsOn && work.Direction == RelatedWorkOutbound { dependencies = append(dependencies, work) continue } @@ -54,11 +66,11 @@ func relatedWorkCollisions(values []RelatedWork) (closing, inbound, dependencies continue } switch { - case work.Direction == "inbound" && work.Relation == "claims_to_close": + case work.Direction == RelatedWorkInbound && work.Relation == relatedwork.RelationClaimsToClose: closing = append(closing, work) - case work.Direction == "inbound": + case work.Direction == RelatedWorkInbound: inbound = append(inbound, work) - case work.Direction == "outbound": + case work.Direction == RelatedWorkOutbound: outboundPRs = append(outboundPRs, work) } } diff --git a/internal/relatedwork/references.go b/internal/relatedwork/references.go index 2c9dceef..8a1e6417 100644 --- a/internal/relatedwork/references.go +++ b/internal/relatedwork/references.go @@ -11,10 +11,13 @@ import ( "github.com/morluto/gitcontribute/internal/similarity" ) +// Relation is a source-backed relationship between two work items. +type Relation string + const ( // RelationExplicitReference is a source-text reference with no stronger // closing, dependency, or blocking phrase. - RelationExplicitReference = "explicit_reference" + RelationExplicitReference Relation = "explicit_reference" // RelationMentions is an inbound pull-request reference without closing semantics. RelationMentions = "mentions" // RelationClaimsToClose is an explicit closing-keyword or GitHub closing-issue relationship. @@ -50,7 +53,7 @@ type Reference struct { Repo domain.RepoRef Kind domain.ThreadKind Number int - Relation string + Relation Relation Evidence string } @@ -61,14 +64,14 @@ func Extract(text string, defaultRepo domain.RepoRef) []Reference { text = unquotedMarkdown(text) refs := similarity.ExtractRefs(text, defaultRepo) type relationEvidence struct { - relation string + relation Relation evidence string } relations := make(map[similarity.ThreadRef]relationEvidence, len(refs)) for _, ref := range refs { relations[ref] = relationEvidence{relation: RelationExplicitReference} } - applyRelation := func(pattern *regexp.Regexp, relation string) { + applyRelation := func(pattern *regexp.Regexp, relation Relation) { for _, match := range pattern.FindAllStringSubmatch(text, -1) { if len(match) < 2 { continue @@ -107,7 +110,7 @@ func Extract(text string, defaultRepo domain.RepoRef) []Reference { } // Priority orders relationship specificity for deterministic de-duplication. -func Priority(relation string) int { +func Priority(relation Relation) int { switch relation { case RelationClaimsToClose: return 8 diff --git a/internal/relatedwork/references_test.go b/internal/relatedwork/references_test.go index 13c3799a..298c13f6 100644 --- a/internal/relatedwork/references_test.go +++ b/internal/relatedwork/references_test.go @@ -12,7 +12,7 @@ func TestExtractClassifiesRelationshipsAndExcludesQuotedCode(t *testing.T) { if len(refs) != 3 { t.Fatalf("references = %+v", refs) } - want := map[int]string{1: RelationClaimsToClose, 2: RelationDependsOn, 3: RelationBlocks} + want := map[int]Relation{1: RelationClaimsToClose, 2: RelationDependsOn, 3: RelationBlocks} for _, ref := range refs { if ref.Repo != repo || want[ref.Number] != ref.Relation { t.Fatalf("reference = %+v, want relation %q", ref, want[ref.Number]) @@ -62,7 +62,7 @@ func TestExtractClassifiesReplacementDirectionAndEvidence(t *testing.T) { if len(refs) != 2 { t.Fatalf("references = %+v", refs) } - want := map[int]string{3: RelationReplaces, 8: RelationSupersededBy} + want := map[int]Relation{3: RelationReplaces, 8: RelationSupersededBy} for _, ref := range refs { if ref.Relation != want[ref.Number] || ref.Evidence == "" { t.Fatalf("reference = %+v, want relation %q with evidence", ref, want[ref.Number]) diff --git a/internal/research/builder.go b/internal/research/builder.go index 710c21a5..7df46d86 100644 --- a/internal/research/builder.go +++ b/internal/research/builder.go @@ -408,39 +408,42 @@ func buildCoverage(in assemblyInput, discussionGap string) CoverageSection { repoFacets := map[string]domain.FacetCoverage{} for _, facet := range in.repoCoverage.Facets { repoFacets[facet.Facet()] = facet - facts = append(facts, CoverageFact{ - Scope: "repository", Facet: facet.Facet(), Present: true, Complete: facet.Complete(), - AsOf: facet.AsOf(), Count: facet.Count(), - }) + facts = append(facts, observedCoverageFact("repository", facet.Facet(), facet.Complete(), false, facet.AsOf(), facet.Count())) if !facet.Complete() { gaps = append(gaps, "repository:"+facet.Facet()) } } for _, required := range []string{"metadata", "threads"} { if _, ok := repoFacets[required]; !ok { - facts = append(facts, CoverageFact{Scope: "repository", Facet: required}) + facts = append(facts, missingCoverageFact("repository", required)) gaps = append(gaps, "repository:"+required) } } for _, facet := range in.thread.Coverage { - facts = append(facts, CoverageFact{ - Scope: "thread", Facet: facet.Facet(), Present: facet.Present(), Complete: facet.Complete(), - Truncated: facet.Truncated(), AsOf: facet.AsOf(), Count: facet.Count(), - }) + if facet.Present() { + facts = append(facts, observedCoverageFact("thread", facet.Facet(), facet.Complete(), facet.Truncated(), facet.AsOf(), facet.Count())) + } else { + facts = append(facts, missingCoverageFact("thread", facet.Facet())) + } if !facet.Complete() { gaps = append(gaps, "thread:"+facet.Facet()) } } codePresent := in.code.Present() - facts = append(facts, CoverageFact{ - Scope: "repository", Facet: "code_index", Present: codePresent, Complete: codePresent, - AsOf: in.code.Source().AsOf, Count: len(in.code.Hits()), - }) + if codePresent { + facts = append(facts, observedCoverageFact("repository", "code_index", true, false, in.code.Source().AsOf, len(in.code.Hits()))) + } else { + facts = append(facts, missingCoverageFact("repository", "code_index")) + } if !codePresent { gaps = append(gaps, "repository:code_index") } guidancePresent := strings.TrimSpace(in.guidance) != "" && len(in.guidanceSources) > 0 - facts = append(facts, CoverageFact{Scope: "repository", Facet: "contribution_guidance", Present: guidancePresent, Complete: guidancePresent}) + if guidancePresent { + facts = append(facts, observedCoverageFact("repository", "contribution_guidance", true, false, time.Time{}, 0)) + } else { + facts = append(facts, missingCoverageFact("repository", "contribution_guidance")) + } if !guidancePresent { gaps = append(gaps, "repository:contribution_guidance") } @@ -584,7 +587,7 @@ func codeTerms(title string, labels []string) []string { } func discussionCoverageGap(kind domain.ThreadKind, coverage []FacetCoverage) string { - required := facets.DefaultFor(string(kind)) + required := facets.DefaultFor(kind) byFacet := map[string]FacetCoverage{} for _, item := range coverage { byFacet[item.Facet()] = item diff --git a/internal/research/builder_test.go b/internal/research/builder_test.go index 4059cb28..8e072031 100644 --- a/internal/research/builder_test.go +++ b/internal/research/builder_test.go @@ -24,6 +24,14 @@ type fakeResearchReader struct { health HealthEvidence } +func mustDomainFacetCoverage(facet string, complete bool, asOf time.Time, count int) domain.FacetCoverage { + coverage, err := domain.NewFacetCoverage(facet, complete, asOf, count) + if err != nil { + panic(err) + } + return coverage +} + func mustObservedHealthEvidence(metrics HealthMetrics, sources []SourceRef) HealthEvidence { evidence, err := ObservedHealthEvidence(metrics, sources, "") if err != nil { @@ -80,8 +88,8 @@ func TestBuilderMakesCoverageAndUnknownsExplicit(t *testing.T) { Source: "github:rest", URL: "https://api.github.com/repos/owner/repo", ObservedAt: now.Add(-3 * time.Hour), AsOf: now.Add(-4 * time.Hour), }}, repoCoverage: domain.Coverage{Facets: []domain.FacetCoverage{ - domain.MustFacetCoverage("metadata", true, now.Add(-4*time.Hour), 0), - domain.MustFacetCoverage("threads", true, now.Add(-3*time.Hour), 0), + mustDomainFacetCoverage("metadata", true, now.Add(-4*time.Hour), 0), + mustDomainFacetCoverage("threads", true, now.Add(-3*time.Hour), 0), }}, thread: ThreadEvidence{ Thread: ThreadSnapshot{ diff --git a/internal/research/markdown.go b/internal/research/markdown.go index d196409b..70d94263 100644 --- a/internal/research/markdown.go +++ b/internal/research/markdown.go @@ -199,7 +199,7 @@ func writeCoverage(b *strings.Builder, section CoverageSection) { fmt.Fprintln(b, "_No coverage facts stored._") } else { for _, facet := range section.Facets { - fmt.Fprintf(b, "- **%s:%s:** present=%t, complete=%t, truncated=%t, count=%d", inline(facet.Scope), inline(facet.Facet), facet.Present, facet.Complete, facet.Truncated, facet.Count) + fmt.Fprintf(b, "- **%s:%s:** present=%t, complete=%t, truncated=%t, count=%d", inline(facet.Scope), inline(facet.Facet), facet.Present(), facet.Complete(), facet.Truncated(), facet.Count) if !facet.AsOf.IsZero() { fmt.Fprintf(b, ", as_of=%s", formatMarkdownTime(facet.AsOf)) } diff --git a/internal/research/model.go b/internal/research/model.go index b66b2d29..c870f2dd 100644 --- a/internal/research/model.go +++ b/internal/research/model.go @@ -4,6 +4,7 @@ package research import ( + "encoding/json" "errors" "fmt" "strconv" @@ -32,6 +33,21 @@ const ( StatusUnknown SectionStatus = "unknown" ) +// ParseSectionStatus converts stored or boundary text into one supported +// section-coverage state. +func ParseSectionStatus(value string) (SectionStatus, error) { + switch SectionStatus(strings.TrimSpace(value)) { + case StatusAvailable: + return StatusAvailable, nil + case StatusPartial: + return StatusPartial, nil + case StatusUnknown: + return StatusUnknown, nil + default: + return "", fmt.Errorf("unsupported research section status %q", value) + } +} + // ThreadRef is a validated issue or pull-request reference. Kind may be empty // when the input used OWNER/REPO#NUMBER and the corpus must resolve it. type ThreadRef struct { @@ -271,6 +287,43 @@ type HealthSection struct { // CoverageFact records one repository, thread, or local-index coverage fact. type CoverageFact struct { + Scope string `json:"scope"` + Facet string `json:"facet"` + AsOf time.Time `json:"as_of,omitempty"` + Count int `json:"count"` + state coverageFactState +} + +type coverageFactState uint8 + +const ( + coverageFactMissing coverageFactState = iota + coverageFactPresent + coverageFactComplete + coverageFactTruncated +) + +func missingCoverageFact(scope, facet string) CoverageFact { + return CoverageFact{Scope: scope, Facet: facet, state: coverageFactMissing} +} + +func observedCoverageFact(scope, facet string, complete, truncated bool, asOf time.Time, count int) CoverageFact { + state := coverageFactPresent + if complete { + state = coverageFactComplete + } else if truncated { + state = coverageFactTruncated + } + return CoverageFact{Scope: scope, Facet: facet, AsOf: asOf, Count: count, state: state} +} + +func (f CoverageFact) Present() bool { return f.state != coverageFactMissing } + +func (f CoverageFact) Complete() bool { return f.state == coverageFactComplete } + +func (f CoverageFact) Truncated() bool { return f.state == coverageFactTruncated } + +type coverageFactJSON struct { Scope string `json:"scope"` Facet string `json:"facet"` Present bool `json:"present"` @@ -280,6 +333,35 @@ type CoverageFact struct { Count int `json:"count"` } +func (f CoverageFact) MarshalJSON() ([]byte, error) { + return json.Marshal(coverageFactJSON{ + Scope: f.Scope, Facet: f.Facet, Present: f.Present(), Complete: f.Complete(), + Truncated: f.Truncated(), AsOf: f.AsOf, Count: f.Count, + }) +} + +func (f *CoverageFact) UnmarshalJSON(data []byte) error { + var raw coverageFactJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Complete && (!raw.Present || raw.Truncated) { + return errors.New("complete coverage must be present and cannot be truncated") + } + if raw.Truncated && !raw.Present { + return errors.New("truncated coverage must be present") + } + if !raw.Present && (!raw.AsOf.IsZero() || raw.Count != 0) { + return errors.New("missing coverage cannot carry an observation time or count") + } + if raw.Present { + *f = observedCoverageFact(raw.Scope, raw.Facet, raw.Complete, raw.Truncated, raw.AsOf, raw.Count) + } else { + *f = missingCoverageFact(raw.Scope, raw.Facet) + } + return nil +} + // CoverageSection makes missing and partial inputs inspectable. type CoverageSection struct { SectionMeta @@ -329,6 +411,38 @@ type Brief struct { // Brief generation is read-only, so the identity is transaction-bound rather // than a durable snapshot resource. type ReadProvenance struct { + SnapshotToken string `json:"snapshot_token"` + Durable bool `json:"durable"` + ObservationWatermark int64 `json:"observation_watermark"` + QueryDigestSHA256 string `json:"query_digest_sha256"` + Limitations []string `json:"limitations,omitempty"` + ExternalContext []SourceRef `json:"external_context,omitempty"` + coverage readProvenanceCoverage +} + +type readProvenanceCoverage struct { + known bool + truncated bool + unknown bool +} + +func NewReadProvenance(snapshotToken string, durable bool, observationWatermark int64, queryDigest string, truncated, unknownCoverage bool) ReadProvenance { + return ReadProvenance{ + SnapshotToken: snapshotToken, Durable: durable, + ObservationWatermark: observationWatermark, QueryDigestSHA256: queryDigest, + coverage: readProvenanceCoverage{known: true, truncated: truncated, unknown: unknownCoverage}, + } +} + +func (p ReadProvenance) Complete() bool { + return p.coverage.known && !p.coverage.truncated && !p.coverage.unknown +} + +func (p ReadProvenance) Truncated() bool { return p.coverage.truncated } + +func (p ReadProvenance) UnknownCoverage() bool { return p.coverage.unknown } + +type readProvenanceJSON struct { SnapshotToken string `json:"snapshot_token"` Durable bool `json:"durable"` ObservationWatermark int64 `json:"observation_watermark"` @@ -340,6 +454,32 @@ type ReadProvenance struct { ExternalContext []SourceRef `json:"external_context,omitempty"` } +func (p ReadProvenance) MarshalJSON() ([]byte, error) { + return json.Marshal(readProvenanceJSON{ + SnapshotToken: p.SnapshotToken, Durable: p.Durable, + ObservationWatermark: p.ObservationWatermark, QueryDigestSHA256: p.QueryDigestSHA256, + Complete: p.Complete(), Truncated: p.Truncated(), UnknownCoverage: p.UnknownCoverage(), + Limitations: p.Limitations, ExternalContext: p.ExternalContext, + }) +} + +func (p *ReadProvenance) UnmarshalJSON(data []byte) error { + var raw readProvenanceJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Complete && (raw.Truncated || raw.UnknownCoverage) { + return errors.New("complete research provenance cannot be truncated or have unknown coverage") + } + parsed := NewReadProvenance(raw.SnapshotToken, raw.Durable, raw.ObservationWatermark, raw.QueryDigestSHA256, raw.Truncated, raw.UnknownCoverage) + if !raw.Complete && !raw.Truncated && !raw.UnknownCoverage { + parsed.coverage.known = false + } + parsed.Limitations, parsed.ExternalContext = raw.Limitations, raw.ExternalContext + *p = parsed + return nil +} + // ValidateProvenance verifies the core contract for all fixed sections. func (b *Brief) ValidateProvenance() error { if b == nil { @@ -363,11 +503,25 @@ func (b *Brief) ValidateProvenance() error { {"next_commands", b.Sections.Next.SectionMeta}, } for _, section := range sections { - if section.meta.Status == "" { - return fmt.Errorf("section %s has no status", section.name) + status, err := ParseSectionStatus(string(section.meta.Status)) + if err != nil { + return fmt.Errorf("section %s: %w", section.name, err) } - if len(section.meta.Sources) == 0 && section.meta.UnknownReason == "" { - return fmt.Errorf("section %s has neither source nor unknown reason", section.name) + hasSources := len(section.meta.Sources) > 0 + hasUnknownReason := strings.TrimSpace(section.meta.UnknownReason) != "" + switch status { + case StatusAvailable: + if !hasSources || hasUnknownReason { + return fmt.Errorf("section %s available status requires sources and no unknown reason", section.name) + } + case StatusPartial: + if !hasSources || !hasUnknownReason { + return fmt.Errorf("section %s partial status requires sources and an unknown reason", section.name) + } + case StatusUnknown: + if hasSources || !hasUnknownReason { + return fmt.Errorf("section %s unknown status requires no sources and an unknown reason", section.name) + } } } return nil diff --git a/internal/research/model_test.go b/internal/research/model_test.go new file mode 100644 index 00000000..6c69e54c --- /dev/null +++ b/internal/research/model_test.go @@ -0,0 +1,54 @@ +package research + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCoverageFactParsesOneStructuralState(t *testing.T) { + t.Parallel() + fact := observedCoverageFact("thread", "comments", false, true, time.Unix(1, 0).UTC(), 10) + payload, err := json.Marshal(fact) + if err != nil { + t.Fatal(err) + } + var decoded CoverageFact + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if !decoded.Present() || decoded.Complete() || !decoded.Truncated() { + t.Fatalf("decoded coverage = %+v", decoded) + } + for _, payload := range []string{ + `{"scope":"thread","facet":"comments","present":false,"complete":true,"truncated":false,"count":0}`, + `{"scope":"thread","facet":"comments","present":true,"complete":true,"truncated":true,"count":0}`, + `{"scope":"thread","facet":"comments","present":false,"complete":false,"truncated":false,"count":1}`, + } { + if err := json.Unmarshal([]byte(payload), &decoded); err == nil { + t.Fatalf("contradictory coverage %s was accepted", payload) + } + } +} + +func TestParseSectionStatusRejectsUnknownValue(t *testing.T) { + t.Parallel() + if status, err := ParseSectionStatus(" partial "); err != nil || status != StatusPartial { + t.Fatalf("status = %q, %v", status, err) + } + if _, err := ParseSectionStatus("invented"); err == nil { + t.Fatal("unknown section status was accepted") + } +} + +func TestReadProvenanceRejectsContradictoryCoverage(t *testing.T) { + t.Parallel() + provenance := NewReadProvenance("ephemeral:abc", false, 1, "digest", false, true) + if provenance.Complete() || provenance.Truncated() || !provenance.UnknownCoverage() { + t.Fatalf("provenance coverage = complete:%t truncated:%t unknown:%t", provenance.Complete(), provenance.Truncated(), provenance.UnknownCoverage()) + } + var decoded ReadProvenance + if err := json.Unmarshal([]byte(`{"snapshot_token":"ephemeral:abc","durable":false,"observation_watermark":1,"query_digest_sha256":"digest","complete":true,"truncated":false,"unknown_coverage":true}`), &decoded); err == nil { + t.Fatal("contradictory research provenance was accepted") + } +} diff --git a/internal/setup/client_adapters.go b/internal/setup/client_adapters.go index 658dc918..40b406cb 100644 --- a/internal/setup/client_adapters.go +++ b/internal/setup/client_adapters.go @@ -1,12 +1,14 @@ package setup import ( + "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" + "slices" "github.com/pelletier/go-toml/v2" ) @@ -19,9 +21,8 @@ type clientAdapter struct { path func(string) string detect func(string) bool check func([]byte) (bool, error) - configure func(string, Operation, Launcher, bool) (string, error) - snapshotData func([]byte, *registrationSnapshot) error - restore func(registrationSnapshot, Launcher) error + configure func(string, Operation, Launcher, bool) (ChangeStatus, error) + snapshotData func([]byte) (registrationState, error) read func([]byte) (Launcher, error) } @@ -33,7 +34,6 @@ var clientAdapters = []*clientAdapter{ check: checkCodexRegistration, configure: editCodex, snapshotData: snapshotCodexRegistration, - restore: restoreCodexRegistration, read: readCodexCommand, }, { @@ -43,7 +43,6 @@ var clientAdapters = []*clientAdapter{ check: checkJSONRegistration, configure: editJSONRegistration, snapshotData: snapshotJSONRegistration, - restore: restoreJSONRegistration, read: readJSONCommand, }, { @@ -53,13 +52,17 @@ var clientAdapters = []*clientAdapter{ check: checkJSONRegistration, configure: editJSONRegistration, snapshotData: snapshotJSONRegistration, - restore: restoreJSONRegistration, read: readJSONCommand, }, } -// AllClients lists supported adapters in deterministic application order. -var AllClients = adapterClients() +var allClients = adapterClients() + +// SupportedClients returns supported adapters in deterministic application +// order without exposing the package-owned catalog for mutation. +func SupportedClients() []Client { + return append([]Client(nil), allClients...) +} func adapterClients() []Client { clients := make([]Client, 0, len(clientAdapters)) @@ -122,48 +125,50 @@ func checkCodexRegistration(data []byte) (bool, error) { } func checkJSONRegistration(data []byte) (bool, error) { - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { + root, err := parseJSONObject(data, "client config") + if err != nil { return false, err } rawServers, present := root["mcpServers"] if !present { return false, nil } - servers, ok := rawServers.(map[string]any) - if !ok { + servers, err := parseJSONObject(rawServers, "mcpServers") + if err != nil { return false, errors.New("mcpServers must be an object in claude config") } rawServer, present := servers[serverName] if !present { return false, nil } - if _, ok := rawServer.(map[string]any); !ok { + if _, err := parseJSONObject(rawServer, "gitcontribute server"); err != nil { return false, errors.New("gitcontribute server must be an object in claude config") } return true, nil } -func snapshotCodexRegistration(data []byte, snapshot *registrationSnapshot) error { +func snapshotCodexRegistration(data []byte) (registrationState, error) { start, end, present := findCodexBlock(string(data)) if !present { - return errors.New("codex registration disappeared before activation") + return nil, errors.New("codex registration disappeared before activation") } - snapshot.codexBlock = string(data[start:end]) - return nil + return codexRegistrationState{block: string(data[start:end])}, nil } -func snapshotJSONRegistration(data []byte, snapshot *registrationSnapshot) error { - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { - return fmt.Errorf("parse Claude registration snapshot: %w", err) +func snapshotJSONRegistration(data []byte) (registrationState, error) { + root, err := parseJSONObject(data, "client registration snapshot") + if err != nil { + return nil, fmt.Errorf("parse client registration snapshot: %w", err) } - servers, ok := root["mcpServers"].(map[string]any) - if !ok { - return errors.New("claude mcpServers disappeared before activation") + servers, err := parseJSONObject(root["mcpServers"], "mcpServers") + if err != nil { + return nil, errors.New("client mcpServers disappeared before activation") + } + entry, present := servers[serverName] + if !present { + return nil, errors.New("GitContribute server disappeared before activation") } - snapshot.jsonEntry = servers[serverName] - return nil + return jsonRegistrationState{entry: append(json.RawMessage(nil), entry...)}, nil } func registrationFileMode(path string) (os.FileMode, error) { @@ -182,12 +187,10 @@ func ReadCommand(client Client, home string) (Launcher, error) { if err != nil { return Launcher{}, err } - return ReadCommandFile(client, adapter.path(home)) + return readCommandFile(client, adapter.path(home)) } -// ReadCommandFile is the path-oriented form used by setup-owned tests and -// recovery tooling. The config parser remains owned by the selected adapter. -func ReadCommandFile(client Client, path string) (Launcher, error) { +func readCommandFile(client Client, path string) (Launcher, error) { adapter, err := clientAdapterFor(client) if err != nil { return Launcher{}, err @@ -217,33 +220,60 @@ func readCodexCommand(data []byte) (Launcher, error) { } func readJSONCommand(data []byte) (Launcher, error) { - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { + root, err := parseJSONObject(data, "client config") + if err != nil { return Launcher{}, err } - servers, ok := root["mcpServers"].(map[string]any) - if !ok { + servers, err := parseJSONObject(root["mcpServers"], "mcpServers") + if err != nil { return Launcher{}, errors.New("mcpServers is missing from claude config") } - server, ok := servers[serverName].(map[string]any) - if !ok { + server, err := parseJSONObject(servers[serverName], "gitcontribute server") + if err != nil { return Launcher{}, errors.New("gitcontribute server not found in claude config") } - command, ok := server["command"].(string) - if !ok || command == "" { + return launcherFromJSONObject(server) +} + +func launcherFromJSONObject(server jsonObject) (Launcher, error) { + var command string + if err := json.Unmarshal(server["command"], &command); err != nil || command == "" { return Launcher{}, errors.New("gitcontribute command is missing from claude config") } - argsIn, ok := server["args"].([]any) - if !ok { + var argsIn []json.RawMessage + if err := json.Unmarshal(server["args"], &argsIn); err != nil { return Launcher{}, errors.New("gitcontribute args are missing from claude config") } args := make([]string, 0, len(argsIn)) for i, raw := range argsIn { - arg, ok := raw.(string) - if !ok { + var arg string + if err := json.Unmarshal(raw, &arg); err != nil { return Launcher{}, fmt.Errorf("gitcontribute args[%d] must be a string", i) } args = append(args, arg) } return Launcher{Command: command, Args: args}, nil } + +type jsonObject map[string]json.RawMessage + +func parseJSONObject(data []byte, name string) (jsonObject, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) < 2 || trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' { + return nil, fmt.Errorf("%s must be a JSON object", name) + } + var object jsonObject + if err := json.Unmarshal(trimmed, &object); err != nil { + return nil, err + } + return object, nil +} + +func exactLauncherEntry(data json.RawMessage, launcher Launcher) bool { + entry, err := parseJSONObject(data, "GitContribute server") + if err != nil || len(entry) != 2 { + return false + } + parsed, err := launcherFromJSONObject(entry) + return err == nil && parsed.Command == launcher.Command && slices.Equal(parsed.Args, launcher.Args) +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index cc9ce297..672287d7 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -80,6 +80,22 @@ const ( RegistrationStale RegistrationStatus = "stale" ) +// ChangeStatus is the closed result vocabulary shared by client registration +// and the managed Codex discovery skill. +type ChangeStatus string + +const ( + ChangeFailed ChangeStatus = "failed" + ChangeNotConfigured ChangeStatus = "not configured" + ChangeWouldRemove ChangeStatus = "would remove" + ChangeRemoved ChangeStatus = "removed" + ChangeAlreadyConfigured ChangeStatus = "already configured" + ChangeWouldUpdate ChangeStatus = "would update" + ChangeUpdated ChangeStatus = "updated" + ChangeWouldConfigure ChangeStatus = "would configure" + ChangeConfigured ChangeStatus = "configured" +) + // RegistrationInspection is a read-only, client-neutral view of one MCP entry. type RegistrationInspection struct { Client Client `json:"client"` @@ -93,7 +109,6 @@ type RegistrationInspection struct { type Options struct { Operation Operation Clients []Client - All bool DryRun bool Home string Executable string @@ -101,17 +116,17 @@ type Options struct { // Result describes the registration effect for one coding client. type Result struct { - Client Client `json:"client"` - Path string `json:"path"` - Status string `json:"status"` - Error string `json:"error,omitempty"` + Client Client `json:"client"` + Path string `json:"path"` + Status ChangeStatus `json:"status"` + Error string `json:"error,omitempty"` } // CodexSkillResult reports the managed discovery-skill effect. type CodexSkillResult struct { - Path string `json:"path,omitempty"` - Status string `json:"status"` - Error string `json:"error,omitempty"` + Path string `json:"path,omitempty"` + Status ChangeStatus `json:"status"` + Error string `json:"error,omitempty"` } type Report struct { @@ -229,7 +244,7 @@ func Run(opts Options) (_ Report, returnErr error) { return Report{}, fmt.Errorf("resolve home directory: %w", err) } } - clients, err := selectedClients(opts) + clients, err := selectedClients(opts.Clients) if err != nil { return Report{}, err } @@ -255,14 +270,6 @@ func Run(opts Options) (_ Report, returnErr error) { return report, nil } -// ActivateExisting updates a set of existing GitContribute registrations as -// one rollback-safe operation. It never creates a new client registration or -// changes the optional Codex discovery skill. If activation or verification is -// interrupted, every selected client configuration is restored. -func ActivateExisting(ctx context.Context, opts Options) (Report, error) { - return ActivateExistingAndVerify(ctx, opts, nil) -} - // ActivateExistingAndVerify keeps the registration snapshots until verify // succeeds, allowing callers to include executable and schema checks in the // same rollback boundary. @@ -279,7 +286,7 @@ func ActivateExistingAndVerify(ctx context.Context, opts Options, verify func() return Report{}, err } defer func() { returnErr = errors.Join(returnErr, lease.Unlock()) }() - return activateExisting(ctx, opts, func(ctx context.Context, _ int) error { return ctx.Err() }, verify) + return activateExisting(ctx, opts, verify) } // RepairExisting rewrites existing registrations to the canonical MCP @@ -299,7 +306,7 @@ func RepairExisting(ctx context.Context, home string, clients []Client) (_ Repor } defer func() { returnErr = errors.Join(returnErr, lease.Unlock()) }() - selected, err := selectedClients(Options{Clients: clients}) + selected, err := selectedClients(clients) if err != nil { return Report{}, err } @@ -323,7 +330,6 @@ func RepairExisting(ctx context.Context, home string, clients []Client) (_ Repor home, selected, launchers, - func(ctx context.Context, _ int) error { return ctx.Err() }, nil, ) } @@ -341,23 +347,26 @@ func acquireSetupLease(home string) (*flock.Flock, error) { } type registrationSnapshot struct { - adapter *clientAdapter - client Client - path string - mode os.FileMode - codexBlock string - jsonEntry any - activated Launcher - changed bool + path string + mode os.FileMode + state registrationState + activated *Launcher +} + +type registrationState interface { + restore(path string, activated Launcher) error } -func activateExisting(ctx context.Context, opts Options, checkpoint func(context.Context, int) error, verify func() error) (Report, error) { +type codexRegistrationState struct{ block string } +type jsonRegistrationState struct{ entry json.RawMessage } + +func activateExisting(ctx context.Context, opts Options, verify func() error) (Report, error) { if err := ctx.Err(); err != nil { return Report{}, err } opts.Operation = Configure opts.DryRun = false - clients, err := selectedClients(opts) + clients, err := selectedClients(opts.Clients) if err != nil { return Report{}, err } @@ -369,7 +378,7 @@ func activateExisting(ctx context.Context, opts Options, checkpoint func(context for _, client := range clients { launchers[client] = launcher } - return activateExistingLaunchers(ctx, opts.Home, clients, launchers, checkpoint, verify) + return activateExistingLaunchers(ctx, opts.Home, clients, launchers, verify) } func activateExistingLaunchers( @@ -377,7 +386,6 @@ func activateExistingLaunchers( home string, clients []Client, launchers map[Client]Launcher, - checkpoint func(context.Context, int) error, verify func() error, ) (Report, error) { snapshots, err := snapshotRegistrations(clients, home) @@ -397,7 +405,7 @@ func activateExistingLaunchers( return report, cause } - if err := activateRegistrations(ctx, home, clients, launchers, checkpoint, &report, snapshots); err != nil { + if err := activateRegistrations(ctx, home, clients, launchers, &report, snapshots); err != nil { return rollback(err) } if err := verifyRegistrations(home, clients, launchers, verify); err != nil { @@ -428,11 +436,11 @@ func snapshotRegistrations(clients []Client, home string) ([]registrationSnapsho if err != nil { return nil, err } - snapshot := registrationSnapshot{adapter: adapter, client: client, path: path, mode: mode} - if err := adapter.snapshotData(data, &snapshot); err != nil { + state, err := adapter.snapshotData(data) + if err != nil { return nil, err } - snapshots = append(snapshots, snapshot) + snapshots = append(snapshots, registrationSnapshot{path: path, mode: mode, state: state}) } return snapshots, nil } @@ -441,7 +449,7 @@ func restoreRegistrationSnapshots(snapshots []registrationSnapshot) error { var restoreErrs []error for i := len(snapshots) - 1; i >= 0; i-- { snapshot := snapshots[i] - if !snapshot.changed { + if snapshot.activated == nil { continue } currentInfo, err := os.Stat(snapshot.path) @@ -453,7 +461,7 @@ func restoreRegistrationSnapshots(snapshots []registrationSnapshot) error { restoreErrs = append(restoreErrs, fmt.Errorf("preserve concurrently changed registration %s", snapshot.path)) continue } - restoreErr := snapshot.adapter.restore(snapshot, snapshot.activated) + restoreErr := snapshot.state.restore(snapshot.path, *snapshot.activated) if restoreErr != nil { restoreErrs = append(restoreErrs, fmt.Errorf("restore %s: %w", snapshot.path, restoreErr)) continue @@ -465,8 +473,8 @@ func restoreRegistrationSnapshots(snapshots []registrationSnapshot) error { return errors.Join(restoreErrs...) } -func restoreCodexRegistration(snapshot registrationSnapshot, activated Launcher) error { - data, err := readFileWithinParent(snapshot.path) +func (state codexRegistrationState) restore(path string, activated Launcher) error { + data, err := readFileWithinParent(path) if err != nil { return err } @@ -475,28 +483,31 @@ func restoreCodexRegistration(snapshot registrationSnapshot, activated Launcher) if !present || strings.TrimSpace(text[start:end]) != strings.TrimSpace(codexTOMLBlock(activated)) { return errors.New("preserve concurrently changed GitContribute entry") } - return writeAtomic(snapshot.path, []byte(text[:start]+snapshot.codexBlock+text[end:])) + return writeAtomic(path, []byte(text[:start]+state.block+text[end:])) } -func restoreJSONRegistration(snapshot registrationSnapshot, activated Launcher) error { - data, err := readFileWithinParent(snapshot.path) +func (state jsonRegistrationState) restore(path string, activated Launcher) error { + data, err := readFileWithinParent(path) if err != nil { return err } - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { + root, err := parseJSONObject(data, "client config") + if err != nil { return err } - servers, ok := root["mcpServers"].(map[string]any) - if !ok || !equalJSON(servers[serverName], map[string]any{"command": activated.Command, "args": activated.Args}) { + servers, err := parseJSONObject(root["mcpServers"], "mcpServers") + if err != nil || !exactLauncherEntry(servers[serverName], activated) { return errors.New("preserve concurrently changed GitContribute entry") } - servers[serverName] = snapshot.jsonEntry - root["mcpServers"] = servers - return writeJSON(snapshot.path, root) + servers[serverName] = append(json.RawMessage(nil), state.entry...) + root["mcpServers"], err = json.Marshal(servers) + if err != nil { + return err + } + return writeJSON(path, root) } -func activateRegistrations(ctx context.Context, home string, clients []Client, launchers map[Client]Launcher, checkpoint func(context.Context, int) error, report *Report, snapshots []registrationSnapshot) error { +func activateRegistrations(ctx context.Context, home string, clients []Client, launchers map[Client]Launcher, report *Report, snapshots []registrationSnapshot) error { for i, client := range clients { if err := ctx.Err(); err != nil { return err @@ -510,9 +521,9 @@ func activateRegistrations(ctx context.Context, home string, clients []Client, l if result.Error != "" { return fmt.Errorf("activate %s registration: %s", client, result.Error) } - snapshots[i].activated = launcher - snapshots[i].changed = true - if err := checkpoint(ctx, i); err != nil { + activated := launcher + snapshots[i].activated = &activated + if err := ctx.Err(); err != nil { return err } } @@ -526,7 +537,7 @@ func verifyRegistrations(home string, clients []Client, launchers map[Client]Lau return fmt.Errorf("verify %s registration: launcher is missing", client) } result := configureClient(Configure, client, home, launcher, true) - if result.Error != "" || result.Status != "already configured" { + if result.Error != "" || result.Status != ChangeAlreadyConfigured { return fmt.Errorf("verify %s registration: status %q: %s", client, result.Status, result.Error) } } @@ -559,11 +570,7 @@ func containsClient(clients []Client, want Client) bool { return false } -func selectedClients(opts Options) ([]Client, error) { - wanted := opts.Clients - if opts.All { - wanted = AllClients - } +func selectedClients(wanted []Client) ([]Client, error) { seen := map[Client]bool{} for _, client := range wanted { if _, err := clientAdapterFor(client); err != nil { @@ -572,7 +579,7 @@ func selectedClients(opts Options) ([]Client, error) { seen[client] = true } var out []Client - for _, client := range AllClients { + for _, client := range allClients { if seen[client] { out = append(out, client) } @@ -582,3 +589,13 @@ func selectedClients(opts Options) ([]Client, error) { } return out, nil } + +// ParseClients resolves loose CLI or protocol names into one deterministic, +// deduplicated client set. +func ParseClients(values []string) ([]Client, error) { + clients := make([]Client, 0, len(values)) + for _, value := range values { + clients = append(clients, Client(strings.ToLower(strings.TrimSpace(value)))) + } + return selectedClients(clients) +} diff --git a/internal/setup/setup_edit.go b/internal/setup/setup_edit.go index 6106d802..03dc68d2 100644 --- a/internal/setup/setup_edit.go +++ b/internal/setup/setup_edit.go @@ -64,13 +64,13 @@ func ResolveNPMVersion(version string) (string, error) { func configureClient(operation Operation, client Client, home string, launcher Launcher, dryRun bool) Result { adapter, err := clientAdapterFor(client) if err != nil { - return Result{Client: client, Status: "failed", Error: err.Error()} + return Result{Client: client, Status: ChangeFailed, Error: err.Error()} } path := adapter.path(home) status, err := adapter.configure(path, operation, launcher, dryRun) result := Result{Client: client, Path: path, Status: status} if err != nil { - result.Status = "failed" + result.Status = ChangeFailed result.Error = err.Error() } return result @@ -92,48 +92,48 @@ func configureCodexSkill(home string, operation Operation, dryRun bool) CodexSki path := CodexSkillPath(home) state, err := inspectCodexSkill(path) if err != nil { - return CodexSkillResult{Path: path, Status: "failed", Error: err.Error()} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: err.Error()} } if operation == Remove { if state == codexSkillAbsent || state == codexSkillUnmanaged { - return CodexSkillResult{Path: path, Status: "not configured"} + return CodexSkillResult{Path: path, Status: ChangeNotConfigured} } if dryRun { - return CodexSkillResult{Path: path, Status: "would remove"} + return CodexSkillResult{Path: path, Status: ChangeWouldRemove} } if err := os.Remove(path); err != nil { - return CodexSkillResult{Path: path, Status: "failed", Error: err.Error()} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: err.Error()} } if err := os.Remove(filepath.Dir(path)); err != nil && !errors.Is(err, os.ErrNotExist) { entries, readErr := os.ReadDir(filepath.Dir(path)) if readErr != nil || len(entries) == 0 { - return CodexSkillResult{Path: path, Status: "failed", Error: err.Error()} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: err.Error()} } } - return CodexSkillResult{Path: path, Status: "removed"} + return CodexSkillResult{Path: path, Status: ChangeRemoved} } if state == codexSkillCurrent { - return CodexSkillResult{Path: path, Status: "already configured"} + return CodexSkillResult{Path: path, Status: ChangeAlreadyConfigured} } if state == codexSkillUnmanaged { - return CodexSkillResult{Path: path, Status: "failed", Error: "discovery skill path exists but is not managed by GitContribute"} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: "discovery skill path exists but is not managed by GitContribute"} } if state == codexSkillManagedStale { if dryRun { - return CodexSkillResult{Path: path, Status: "would update"} + return CodexSkillResult{Path: path, Status: ChangeWouldUpdate} } if err := writeAtomic(path, codexSkillContent); err != nil { - return CodexSkillResult{Path: path, Status: "failed", Error: err.Error()} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: err.Error()} } - return CodexSkillResult{Path: path, Status: "updated"} + return CodexSkillResult{Path: path, Status: ChangeUpdated} } if dryRun { - return CodexSkillResult{Path: path, Status: "would configure"} + return CodexSkillResult{Path: path, Status: ChangeWouldConfigure} } if err := writeAtomic(path, codexSkillContent); err != nil { - return CodexSkillResult{Path: path, Status: "failed", Error: err.Error()} + return CodexSkillResult{Path: path, Status: ChangeFailed, Error: err.Error()} } - return CodexSkillResult{Path: path, Status: "configured"} + return CodexSkillResult{Path: path, Status: ChangeConfigured} } func inspectCodexSkill(path string) (codexSkillState, error) { @@ -154,59 +154,68 @@ func inspectCodexSkill(path string) (codexSkillState, error) { return codexSkillUnmanaged, nil } -func editJSONRegistration(path string, operation Operation, launcher Launcher, dryRun bool) (string, error) { - root := map[string]any{} +func editJSONRegistration(path string, operation Operation, launcher Launcher, dryRun bool) (ChangeStatus, error) { + root := jsonObject{} original, err := os.ReadFile(path) if err == nil && len(bytes.TrimSpace(original)) > 0 { - if err := json.Unmarshal(original, &root); err != nil { + root, err = parseJSONObject(original, "client config") + if err != nil { return "", fmt.Errorf("parse %s: %w", path, err) } } else if err != nil && !errors.Is(err, os.ErrNotExist) { return "", err } - servers, validServers := root["mcpServers"].(map[string]any) - if _, exists := root["mcpServers"]; exists && !validServers { - return "", fmt.Errorf("%s: mcpServers must be an object", path) - } - if servers == nil { - servers = map[string]any{} + servers := jsonObject{} + if rawServers, exists := root["mcpServers"]; exists { + servers, err = parseJSONObject(rawServers, "mcpServers") + if err != nil { + return "", fmt.Errorf("%s: mcpServers must be an object", path) + } } _, present := servers[serverName] if operation == Remove { if !present { - return "not configured", nil + return ChangeNotConfigured, nil } delete(servers, serverName) - root["mcpServers"] = servers + root["mcpServers"], err = json.Marshal(servers) + if err != nil { + return "", err + } if dryRun { - return "would remove", nil + return ChangeWouldRemove, nil } - return "removed", writeJSON(path, root) + return ChangeRemoved, writeJSON(path, root) + } + if present && exactLauncherEntry(servers[serverName], launcher) { + return ChangeAlreadyConfigured, nil } - want := map[string]any{"command": launcher.Command, "args": launcher.Args} - if present && equalJSON(servers[serverName], want) { - return "already configured", nil + servers[serverName], err = json.Marshal(launcher) + if err != nil { + return "", err + } + root["mcpServers"], err = json.Marshal(servers) + if err != nil { + return "", err } - servers[serverName] = want - root["mcpServers"] = servers if dryRun { if present { - return "would update", nil + return ChangeWouldUpdate, nil } - return "would configure", nil + return ChangeWouldConfigure, nil } if err := writeJSON(path, root); err != nil { return "", err } if present { - return "updated", nil + return ChangeUpdated, nil } - return "configured", nil + return ChangeConfigured, nil } var npmVersion = regexp.MustCompile(`^(latest|[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$`) -func editCodex(path string, operation Operation, launcher Launcher, dryRun bool) (string, error) { +func editCodex(path string, operation Operation, launcher Launcher, dryRun bool) (ChangeStatus, error) { original, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return "", err @@ -221,20 +230,20 @@ func editCodex(path string, operation Operation, launcher Launcher, dryRun bool) start, end, present := findCodexBlock(text) if operation == Remove { if !present { - return "not configured", nil + return ChangeNotConfigured, nil } if dryRun { - return "would remove", nil + return ChangeWouldRemove, nil } updated := strings.TrimSpace(text[:start] + text[end:]) if updated != "" { updated += "\n" } - return "removed", writeAtomic(path, []byte(updated)) + return ChangeRemoved, writeAtomic(path, []byte(updated)) } block := codexTOMLBlock(launcher) if present && strings.TrimSpace(text[start:end]) == strings.TrimSpace(block) { - return "already configured", nil + return ChangeAlreadyConfigured, nil } updated := text if present { @@ -250,17 +259,17 @@ func editCodex(path string, operation Operation, launcher Launcher, dryRun bool) } if dryRun { if present { - return "would update", nil + return ChangeWouldUpdate, nil } - return "would configure", nil + return ChangeWouldConfigure, nil } if err := writeAtomic(path, []byte(updated)); err != nil { return "", err } if present { - return "updated", nil + return ChangeUpdated, nil } - return "configured", nil + return ChangeConfigured, nil } func findCodexBlock(text string) (int, int, bool) { @@ -365,10 +374,4 @@ func writeAtomic(path string, data []byte) error { return nil } -func equalJSON(a, b any) bool { - aa, _ := json.Marshal(a) - bb, _ := json.Marshal(b) - return bytes.Equal(aa, bb) -} - func exists(path string) bool { _, err := os.Stat(path); return err == nil } diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index fd0e7014..b3b98a01 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -13,11 +13,22 @@ import ( "testing" ) +func TestReadClaudeCommandRejectsNonStringArguments(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + data := `{"mcpServers":{"gitcontribute":{"command":"node","args":["mcp",123]}}}` + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readCommandFile(Claude, path); err == nil || !strings.Contains(err.Error(), "args[1]") { + t.Fatalf("readClaudeCommand error = %v, want indexed non-string argument error", err) + } +} + func TestActivateExistingRestoresAllRegistrationsWhenInterrupted(t *testing.T) { home := t.TempDir() oldExecutable := filepath.Join(home, "bin", "1.2.3", "gitcontribute") newExecutable := filepath.Join(home, "bin", "1.2.4", "gitcontribute") - opts := Options{Operation: Configure, All: true, Home: home, Executable: oldExecutable} + opts := Options{Operation: Configure, Clients: SupportedClients(), Home: home, Executable: oldExecutable} if _, err := Run(opts); err != nil { t.Fatal(err) } @@ -34,12 +45,7 @@ func TestActivateExistingRestoresAllRegistrationsWhenInterrupted(t *testing.T) { interrupted := errors.New("activation interrupted") opts.Executable = newExecutable - _, err = activateExisting(context.Background(), opts, func(_ context.Context, index int) error { - if index == 0 { - return interrupted - } - return nil - }, nil) + _, err = activateExisting(context.Background(), opts, func() error { return interrupted }) if !errors.Is(err, interrupted) { t.Fatalf("activate error = %v, want interruption", err) } @@ -99,12 +105,12 @@ func TestActivateExistingPreservesConcurrentEditDuringRollback(t *testing.T) { concurrentEdit := []byte("model = \"concurrent\"\n") interrupted := errors.New("activation interrupted") opts.Executable = newExecutable - _, err := activateExisting(context.Background(), opts, func(_ context.Context, _ int) error { + _, err := activateExisting(context.Background(), opts, func() error { if writeErr := os.WriteFile(codexPath, concurrentEdit, 0o600); writeErr != nil { t.Fatal(writeErr) } return interrupted - }, nil) + }) var rollbackFailure *ActivationRollbackError if !errors.As(err, &rollbackFailure) { t.Fatalf("activate error = %v, want ActivationRollbackError", err) @@ -129,7 +135,7 @@ func TestRunConfiguresAndRemovesClientsIdempotently(t *testing.T) { if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte("{\"theme\":\"dark\"}\n"), 0600); err != nil { t.Fatal(err) } - opts := Options{Operation: Configure, All: true, Home: home, Executable: filepath.Join(home, "bin", "gitcontribute")} + opts := Options{Operation: Configure, Clients: SupportedClients(), Home: home, Executable: filepath.Join(home, "bin", "gitcontribute")} report, err := Run(opts) if err != nil { t.Fatal(err) @@ -178,6 +184,46 @@ func TestRunConfiguresAndRemovesClientsIdempotently(t *testing.T) { } } +func TestEditJSONRegistrationPreservesUnrelatedIntegerPrecision(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + const largeInteger = "9007199254740993" + original := []byte(`{"opaque_id":` + largeInteger + `,"mcpServers":{"other":{"opaque_id":` + largeInteger + `}}}`) + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + launcher := Launcher{Command: "/managed/gitcontribute", Args: canonicalMCPArgs()} + if status, err := editJSONRegistration(path, Configure, launcher, false); err != nil || status != ChangeConfigured { + t.Fatalf("configure status = %q, error = %v", status, err) + } + assertPreserved := func() { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + root, err := parseJSONObject(data, "test config") + if err != nil { + t.Fatal(err) + } + servers, err := parseJSONObject(root["mcpServers"], "mcpServers") + if err != nil { + t.Fatal(err) + } + other, err := parseJSONObject(servers["other"], "other server") + if err != nil { + t.Fatal(err) + } + if string(root["opaque_id"]) != largeInteger || string(other["opaque_id"]) != largeInteger { + t.Fatalf("unrelated integers changed: %s", data) + } + } + assertPreserved() + if status, err := editJSONRegistration(path, Remove, launcher, false); err != nil || status != ChangeRemoved { + t.Fatalf("remove status = %q, error = %v", status, err) + } + assertPreserved() +} + func TestDryRunDoesNotWrite(t *testing.T) { home := t.TempDir() report, err := Run(Options{Operation: Configure, Clients: []Client{Codex}, Home: home, Executable: "/bin/gitcontribute", DryRun: true}) diff --git a/internal/similarity/duplicate.go b/internal/similarity/duplicate.go index 9f276c52..16951b64 100644 --- a/internal/similarity/duplicate.go +++ b/internal/similarity/duplicate.go @@ -89,8 +89,8 @@ func (r DuplicateRule) Prepare(thread ThreadText) PreparedDuplicate { labels := normalizedLabels(thread.Labels) return PreparedDuplicate{ ref: thread.Ref, - title: Tokens(thread.Title, true), - body: TokensLimited(thread.Body, true, r.maxBodyTokens), + title: Tokens(thread.Title), + body: TokensLimited(thread.Body, r.maxBodyTokens), labels: labels, author: thread.Author, references: ExtractRefs(thread.Title+"\n"+thread.Body, thread.Ref.Repo), diff --git a/internal/similarity/precedent.go b/internal/similarity/precedent.go index 11c66a37..111cdc7d 100644 --- a/internal/similarity/precedent.go +++ b/internal/similarity/precedent.go @@ -11,9 +11,6 @@ type PrecedentRule struct{} // DefaultPrecedentRule returns the supported precedent-v1 scoring policy. func DefaultPrecedentRule() PrecedentRule { return PrecedentRule{} } -// Version identifies the exact precedent scoring semantics. -func (PrecedentRule) Version() RuleVersion { return PrecedentV1 } - // PreparedLexical is an immutable precedent-v1 token set. type PreparedLexical struct { tokens map[string]struct{} diff --git a/internal/similarity/text.go b/internal/similarity/text.go index 712dd32c..5b0a2256 100644 --- a/internal/similarity/text.go +++ b/internal/similarity/text.go @@ -44,12 +44,12 @@ func NormalizeText(value string) string { } // Tokens returns sorted unique tokens using the duplicate-v1 normalization policy. -func Tokens(text string, filterStopWords bool) []string { - return TokensLimited(text, filterStopWords, 0) +func Tokens(text string) []string { + return TokensLimited(text, 0) } // TokensLimited returns sorted unique tokens after processing at most maxWords input words. -func TokensLimited(text string, filterStopWords bool, maxWords int) []string { +func TokensLimited(text string, maxWords int) []string { fields := strings.Fields(NormalizeText(text)) if maxWords > 0 && len(fields) > maxWords { fields = fields[:maxWords] @@ -59,10 +59,8 @@ func TokensLimited(text string, filterStopWords bool, maxWords int) []string { if len(word) <= 1 { continue } - if filterStopWords { - if _, excluded := stopWords[word]; excluded { - continue - } + if _, excluded := stopWords[word]; excluded { + continue } seen[word] = struct{}{} } diff --git a/internal/similarity/text_test.go b/internal/similarity/text_test.go index a1115fd0..e1e1cd2b 100644 --- a/internal/similarity/text_test.go +++ b/internal/similarity/text_test.go @@ -9,12 +9,12 @@ import ( ) func TestTokensNormalizeAndFilterStopWords(t *testing.T) { - got := similarity.Tokens("Hello, World! 123", true) + got := similarity.Tokens("Hello, World! 123") want := []string{"123", "hello", "world"} if diff := cmp.Diff(want, got); diff != "" { t.Fatalf("tokens mismatch (-want +got):\n%s", diff) } - for _, token := range similarity.Tokens("the quick brown fox", true) { + for _, token := range similarity.Tokens("the quick brown fox") { if token == "the" { t.Fatal("stop word found in tokens") } diff --git a/internal/tracking/models.go b/internal/tracking/models.go index 49b0d4a5..9eb2284b 100644 --- a/internal/tracking/models.go +++ b/internal/tracking/models.go @@ -1,8 +1,11 @@ package tracking import ( + "fmt" + "strings" "time" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" ) @@ -11,6 +14,33 @@ const ( CurrentBundleSchemaVersion = 2 ) +// ParseOutcome converts durable or boundary text into a supported tracking +// decision. +func ParseOutcome(value string) (Outcome, error) { + switch Outcome(strings.TrimSpace(value)) { + case OutcomeViewed: + return OutcomeViewed, nil + case OutcomeIgnored: + return OutcomeIgnored, nil + case OutcomeSaved: + return OutcomeSaved, nil + case OutcomeInvestigated: + return OutcomeInvestigated, nil + case OutcomeImplemented: + return OutcomeImplemented, nil + case OutcomeSubmitted: + return OutcomeSubmitted, nil + case OutcomeMerged: + return OutcomeMerged, nil + case OutcomeRejected: + return OutcomeRejected, nil + case OutcomeAbandoned: + return OutcomeAbandoned, nil + default: + return "", fmt.Errorf("unsupported tracking outcome %q", value) + } +} + // Outcome records a local triage or lifecycle decision. type Outcome string @@ -26,6 +56,27 @@ const ( OutcomeAbandoned Outcome = "abandoned" ) +// ParseTargetKind converts durable or boundary text into a supported tracking +// target. +func ParseTargetKind(value string) (TargetKind, error) { + switch TargetKind(strings.TrimSpace(value)) { + case TargetRepository: + return TargetRepository, nil + case TargetIssue: + return TargetIssue, nil + case TargetPullRequest: + return TargetPullRequest, nil + case TargetThread: + return TargetThread, nil + case TargetOpportunity: + return TargetOpportunity, nil + case TargetInvestigation: + return TargetInvestigation, nil + default: + return "", fmt.Errorf("unsupported triage target kind %q", value) + } +} + // TargetKind names the kinds of local corpus references that can be tracked. type TargetKind string @@ -38,6 +89,43 @@ const ( TargetInvestigation TargetKind = "investigation" ) +// ContributionKindFilter is an optional parsed issue-or-pull-request filter. +// Its zero value includes both contribution kinds. +type ContributionKindFilter struct { + kind domain.ThreadKind +} + +// ParseContributionKind accepts the boundary aliases for a contribution and +// returns the repository-owned thread-kind value used by persisted records. +func ParseContributionKind(value string) (domain.ThreadKind, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "issue": + return domain.IssueKind, nil + case "pr", "pull_request", "pullrequest": + return domain.PullRequestKind, nil + default: + return "", fmt.Errorf("unsupported contribution kind %q", value) + } +} + +// ParseContributionKindFilter parses an optional contribution-kind filter. +func ParseContributionKindFilter(value string) (ContributionKindFilter, error) { + if strings.TrimSpace(value) == "" { + return ContributionKindFilter{}, nil + } + kind, err := ParseContributionKind(value) + if err != nil { + return ContributionKindFilter{}, err + } + return ContributionKindFilter{kind: kind}, nil +} + +// IsAny reports whether both contribution kinds are selected. +func (f ContributionKindFilter) IsAny() bool { return f.kind == "" } + +// String returns the canonical persisted spelling, or empty for both kinds. +func (f ContributionKindFilter) String() string { return string(f.kind) } + // TriageEvent records a single local triage decision for a typed target. type TriageEvent struct { ID string @@ -60,7 +148,7 @@ type TriageEvent struct { type Contribution struct { ID string OpportunityID string - Kind string + Kind domain.ThreadKind Title string Body string Reference string @@ -94,7 +182,7 @@ type TriageEventFilter struct { // ContributionFilter bounds the contributions returned by a query. type ContributionFilter struct { OpportunityID string - Kind string + Kind ContributionKindFilter Limit int } diff --git a/internal/tracking/service.go b/internal/tracking/service.go index 50deb49d..e27cd24a 100644 --- a/internal/tracking/service.go +++ b/internal/tracking/service.go @@ -43,11 +43,6 @@ func NewService(repo Repository) *Service { return &Service{repo: repo, clock: time.Now} } -// SetClock overrides the time source. It is intended for tests. -func (s *Service) SetClock(clock func() time.Time) { - s.clock = clock -} - // RecordTriageEvent stores a local triage decision after validating it. func (s *Service) RecordTriageEvent(ctx context.Context, e *TriageEvent) (*TriageEvent, error) { if e == nil { @@ -252,10 +247,11 @@ func validateContribution(c *Contribution) error { if c.OpportunityID == "" { return errors.New("contribution opportunity id is required") } - c.Kind = strings.TrimSpace(c.Kind) - if c.Kind != "issue" && c.Kind != "pull_request" { - return fmt.Errorf("unsupported contribution kind %q", c.Kind) + kind, err := ParseContributionKind(string(c.Kind)) + if err != nil { + return err } + c.Kind = kind c.Title = strings.TrimSpace(c.Title) if c.Title == "" { return errors.New("contribution title is required") @@ -268,9 +264,11 @@ func validateContributionOutcome(o *ContributionOutcome) error { if o.ContributionID == "" { return errors.New("contribution id is required") } - if !isContributionOutcome(o.Outcome) { + parsed, err := ParseOutcome(string(o.Outcome)) + if err != nil || !isContributionOutcome(parsed) { return fmt.Errorf("invalid contribution outcome %q", o.Outcome) } + o.Outcome = parsed return nil } @@ -286,34 +284,18 @@ func validateTriageEvent(e *TriageEvent) error { if e.TargetRef == "" { return errors.New("triage target reference is required") } - if !isValidTargetKind(e.TargetKind) { - return fmt.Errorf("unsupported triage target kind %q", e.TargetKind) + targetKind, err := ParseTargetKind(string(e.TargetKind)) + if err != nil { + return err } - if !isValidOutcome(e.Outcome) { - return fmt.Errorf("unsupported triage outcome %q", e.Outcome) + outcome, err := ParseOutcome(string(e.Outcome)) + if err != nil { + return err } + e.TargetKind, e.Outcome = targetKind, outcome return nil } -func isValidTargetKind(k TargetKind) bool { - switch k { - case TargetRepository, TargetIssue, TargetPullRequest, TargetThread, - TargetOpportunity, TargetInvestigation: - return true - } - return false -} - -func isValidOutcome(o Outcome) bool { - switch o { - case OutcomeViewed, OutcomeIgnored, OutcomeSaved, OutcomeInvestigated, - OutcomeImplemented, OutcomeSubmitted, OutcomeMerged, OutcomeRejected, - OutcomeAbandoned: - return true - } - return false -} - func isContributionOutcome(o Outcome) bool { switch o { case OutcomeSubmitted, OutcomeMerged, OutcomeRejected, OutcomeAbandoned: diff --git a/internal/tui/actions_test.go b/internal/tui/actions_test.go index 4acd3f7c..b2482e04 100644 --- a/internal/tui/actions_test.go +++ b/internal/tui/actions_test.go @@ -15,7 +15,7 @@ func TestLocalWriteActionRequiresConfirmation(t *testing.T) { actions := &fakeActionProvider{ actions: []tuicontract.Action{{ ID: "start", Label: "Start investigation", Description: "Create local records.", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }}, result: tuicontract.ActionResult{Message: "Started investigation", Reload: true}, } @@ -36,7 +36,7 @@ func TestLocalWriteActionRequiresConfirmation(t *testing.T) { model, cmd = m.Update(keyPress(tea.KeyEnter)) m = model.(Model) - if cmd != nil || !m.actionConfirm || actions.executeCount != 0 { + if cmd != nil || m.actionState != actionConfirming || actions.executeCount != 0 { t.Fatal("local write must wait for confirmation") } if !strings.Contains(m.View().Content, "No network access or GitHub mutation") { @@ -75,7 +75,7 @@ func TestOfflineReadActionRunsWithoutConfirmation(t *testing.T) { m = model.(Model) model, cmd = m.Update(keyPress(tea.KeyEnter)) m = model.(Model) - if cmd == nil || m.actionConfirm { + if cmd == nil || m.actionState == actionConfirming { t.Fatal("offline read should execute without confirmation") } model, reload := m.Update(cmd()) @@ -138,7 +138,7 @@ func TestActionResultCanOpenCreatedWorkflowItemAfterReload(t *testing.T) { data := sampleData() data.Candidates[0].Actions = []tuicontract.Action{{ ID: "start", Label: "Start investigation", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }} provider := &fakeActionProvider{ actions: data.Candidates[0].Actions, @@ -162,7 +162,7 @@ func TestActionResultCanOpenCreatedWorkflowItemAfterReload(t *testing.T) { m = model.(Model) model, reload := m.Update(cmd()) m = model.(Model) - if reload == nil || !m.resultOpen { + if reload == nil || m.overlay != overlayResult { t.Fatal("successful local write must show its result while reloading") } model, _ = m.Update(reload()) @@ -181,7 +181,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { m := loadModel(t, &fakeReader{data: sampleData()}) model, cmd := m.Update(keyPress('a')) m = model.(Model) - if cmd != nil || m.actionOpen { + if cmd != nil || m.overlay == overlayActions { t.Fatal("action key must be inert without an application provider") } }) @@ -193,8 +193,8 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { m = model.(Model) model, _ = m.Update(cmd()) m = model.(Model) - if m.actionOpen || m.actionMsg != "No actions available for this item" { - t.Fatalf("empty action outcome = open:%v message:%q", m.actionOpen, m.actionMsg) + if m.overlay == overlayActions || m.actionMsg != "No actions available for this item" { + t.Fatalf("empty action outcome = overlay:%v message:%q", m.overlay, m.actionMsg) } }) @@ -225,7 +225,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { t.Run("cancel confirmation", func(t *testing.T) { provider := &fakeActionProvider{actions: []tuicontract.Action{{ - ID: "write", Label: "Write", Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + ID: "write", Label: "Write", Capability: tuicontract.CapabilityLocalWrite, }}} m := loadModel(t, &fakeReader{data: sampleData()}) m.actionProvider = provider @@ -237,12 +237,12 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { m = model.(Model) model, _ = m.Update(keyPress('n')) m = model.(Model) - if m.actionConfirm || !m.actionOpen || provider.executeCount != 0 { + if m.actionState == actionConfirming || m.overlay != overlayActions || provider.executeCount != 0 { t.Fatal("cancel must return to the palette without executing") } model, _ = m.Update(keyPress(tea.KeyEsc)) m = model.(Model) - if m.actionOpen { + if m.overlay == overlayActions { t.Fatal("escape must close the action palette") } }) @@ -255,7 +255,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { m = model.(Model) model, _ = m.Update(cmd()) m = model.(Model) - if !m.actionOpen || !strings.Contains(m.View().Content, "ACTION FAILED") { + if m.overlay != overlayActions || !strings.Contains(m.View().Content, "ACTION FAILED") { t.Fatalf("expected visible discovery failure, got:\n%s", m.View().Content) } provider.actionsErr = nil @@ -264,7 +264,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { }} model, cmd = m.Update(keyPress(tea.KeyEnter)) m = model.(Model) - if cmd == nil || !m.actionLoading { + if cmd == nil || m.actionState != actionsLoading { t.Fatal("Enter must retry failed action discovery") } model, _ = m.Update(cmd()) @@ -291,7 +291,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { model, _ = m.Update(cmd()) m = model.(Model) for _, want := range []string{"ACTION FAILED", "Check duplicates", "context canceled", "Recovery", "Enter"} { - if !m.actionOpen || !strings.Contains(m.View().Content, want) { + if m.overlay != overlayActions || !strings.Contains(m.View().Content, want) { t.Fatalf("expected visible execution failure containing %q, got:\n%s", want, m.View().Content) } } @@ -300,7 +300,7 @@ func TestActionPaletteNavigationCancellationAndErrors(t *testing.T) { m = model.(Model) model, _ = m.Update(cmd()) m = model.(Model) - if !m.resultOpen || !strings.Contains(m.View().Content, "Duplicate check complete") { + if m.overlay != overlayResult || !strings.Contains(m.View().Content, "Duplicate check complete") { t.Fatalf("expected visible execution failure, got:\n%s", m.View().Content) } }) diff --git a/internal/tui/run.go b/internal/tui/run.go index df086b66..6d6c419b 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -99,15 +99,6 @@ func filterData(data tuicontract.Data, repo string) tuicontract.Data { return data } -// Run starts the TUI program with the provided local reader and I/O. -// -// It is suitable for later CLI wiring: the caller supplies a context, a -// Reader, and input/output streams. The TUI loads local data on start and -// never performs network I/O on its own. -func Run(ctx context.Context, reader tuicontract.Reader, input io.Reader, output io.Writer) (Model, error) { - return run(ctx, reader, nil, nil, input, output) -} - func run( ctx context.Context, reader tuicontract.Reader, diff --git a/internal/tui/snapshot_test.go b/internal/tui/snapshot_test.go index 2d7ec1c7..004c0ef1 100644 --- a/internal/tui/snapshot_test.go +++ b/internal/tui/snapshot_test.go @@ -156,7 +156,7 @@ func snapshotData() tuicontract.Data { Related: []tuicontract.Fact{{Summary: "PR #45287 · non-closing"}}, }, Actions: []tuicontract.Action{ - {ID: "start_investigation", Label: "Start investigation", Description: "Create a local investigation and initial hypothesis.", Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true}, + {ID: "start_investigation", Label: "Start investigation", Description: "Create a local investigation and initial hypothesis.", Capability: tuicontract.CapabilityLocalWrite}, {ID: "check_duplicates", Label: "Check duplicates", Description: "Review stored issue and pull-request matches.", Capability: tuicontract.CapabilityOfflineRead}, }, } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d0489815..c429f670 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -34,7 +34,7 @@ type viewSpec struct { key string } -var viewSpecs = []viewSpec{ +var viewSpecs = [...]viewSpec{ {viewDiscover, "CONTRIBUTIONS", "Discover", "candidates"}, {viewResearch, "CONTRIBUTIONS", "Research", "hypotheses"}, {viewActive, "CONTRIBUTIONS", "Active", "investigations"}, @@ -47,14 +47,6 @@ var viewSpecs = []viewSpec{ {viewRelatedWork, "SOURCES", "Related work", "clusters"}, } -var viewOrder = func() []view { - out := make([]view, 0, len(viewSpecs)) - for _, spec := range viewSpecs { - out = append(out, spec.view) - } - return out -}() - type paneFocus int const ( @@ -63,14 +55,47 @@ const ( focusDetail ) +type corpusLoadState uint8 + +const ( + corpusLoadIdle corpusLoadState = iota + corpusLoading + corpusLoaded + corpusLoadFailed +) + +type overlay uint8 + +const ( + overlayNone overlay = iota + overlaySearch + overlayHelp + overlayBrief + overlayActions + overlayResult +) + +type briefState uint8 + +const ( + briefReady briefState = iota + briefLoading + briefFailed +) + +type actionState uint8 + +const ( + actionsReady actionState = iota + actionsLoading + actionConfirming + actionExecuting + actionFailed +) + // Option customizes a Model. type Option func(*Model) -// WithSize sets the initial terminal size. -func WithSize(w, h int) Option { - return func(m *Model) { m.width, m.height = w, h } -} - // WithActionProvider registers typed contextual application operations. // Loading, navigation, filtering, and detail inspection remain local. func WithActionProvider(provider tuicontract.ActionProvider) Option { @@ -92,8 +117,7 @@ type Model struct { view view focus paneFocus - loading bool - loaded bool + loadState corpusLoadState err error items map[view][]tuicontract.Item windows map[view]tuicontract.Window @@ -103,29 +127,23 @@ type Model struct { detailTop int search textinput.Model - searching bool - help bool - briefOpen bool + overlay overlay briefTop int briefProvider tuicontract.BriefProvider - briefLoading bool + briefState briefState briefErr error briefItem tuicontract.Item brief tuicontract.ResearchBrief - actionMsg string - actionProvider tuicontract.ActionProvider - actionOpen bool - actionLoading bool - actionExecuting bool - actionConfirm bool - actionErr error - actionItem tuicontract.Item - actions []tuicontract.Action - actionCursor int - resultOpen bool - resultTop int - actionResult tuicontract.ActionResult + actionMsg string + actionProvider tuicontract.ActionProvider + actionState actionState + actionErr error + actionItem tuicontract.Item + actions []tuicontract.Action + actionCursor int + resultTop int + actionResult tuicontract.ActionResult } // New creates a Model for the given reader and lifecycle context. @@ -235,9 +253,10 @@ func (m *Model) switchView(next view) { m.cursor = 0 m.listStart = 0 m.detailTop = 0 - m.briefOpen = false + if m.overlay == overlayBrief || m.overlay == overlayResult { + m.overlay = overlayNone + } m.briefTop = 0 - m.resultOpen = false m.resultTop = 0 m.actionMsg = "" m.applyFilter() @@ -263,18 +282,18 @@ func (m *Model) focusActionTarget(target *tuicontract.ActionTarget) { } func (m *Model) nextView() { - for i, current := range viewOrder { - if current == m.view { - m.switchView(viewOrder[(i+1)%len(viewOrder)]) + for i, spec := range viewSpecs { + if spec.view == m.view { + m.switchView(viewSpecs[(i+1)%len(viewSpecs)].view) return } } } func (m *Model) prevView() { - for i, current := range viewOrder { - if current == m.view { - m.switchView(viewOrder[(i-1+len(viewOrder))%len(viewOrder)]) + for i, spec := range viewSpecs { + if spec.view == m.view { + m.switchView(viewSpecs[(i-1+len(viewSpecs))%len(viewSpecs)].view) return } } @@ -309,16 +328,17 @@ func (m Model) openActions() (Model, tea.Cmd) { if !ok || m.actionProvider == nil { return m, nil } - m.actionOpen = true - m.actionLoading = true - m.actionExecuting = false - m.actionConfirm = false + m.overlay = overlayActions + m.actionState = actionsLoading m.actionErr = nil m.actionItem = item m.actions = nil m.actionCursor = 0 return m, func() tea.Msg { actions, err := m.actionProvider.Actions(m.ctx, item) + if err == nil { + actions, err = tuicontract.ParseActions(actions) + } return actionsLoadedMsg{actions: actions, err: err} } } @@ -327,13 +347,16 @@ func (m Model) retryActionDiscovery() (Model, tea.Cmd) { if m.actionProvider == nil || m.actionItem.Kind == "" { return m, nil } - m.actionLoading = true + m.actionState = actionsLoading m.actionErr = nil m.actions = nil m.actionCursor = 0 item := m.actionItem return m, func() tea.Msg { actions, err := m.actionProvider.Actions(m.ctx, item) + if err == nil { + actions, err = tuicontract.ParseActions(actions) + } return actionsLoadedMsg{actions: actions, err: err} } } @@ -350,8 +373,7 @@ func (m Model) executeSelectedAction() (Model, tea.Cmd) { if !ok || m.actionProvider == nil { return m, nil } - m.actionExecuting = true - m.actionConfirm = false + m.actionState = actionExecuting m.actionErr = nil request := tuicontract.ActionRequest{ActionID: action.ID, Item: m.actionItem} return m, func() tea.Msg { @@ -361,15 +383,16 @@ func (m Model) executeSelectedAction() (Model, tea.Cmd) { } func (m Model) openBrief(item tuicontract.Item) (Model, tea.Cmd) { - m.briefOpen = true + m.overlay = overlayBrief m.briefTop = 0 m.briefErr = nil + m.briefState = briefReady m.briefItem = item m.brief = tuicontract.ResearchBrief{} if m.briefProvider == nil { return m, nil } - m.briefLoading = true + m.briefState = briefLoading return m, func() tea.Msg { brief, err := m.briefProvider.ResearchBrief(m.ctx, item) return briefLoadedMsg{itemRef: item.Ref, brief: brief, err: err} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index d735557d..cdeb70c8 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -154,7 +154,7 @@ func TestInitLoadsData(t *testing.T) { fake := &fakeReader{data: sampleData()} m := loadModel(t, fake) - if !m.loaded { + if m.loadState != corpusLoaded { t.Fatal("expected model to be loaded") } if fake.loadCount != 1 { @@ -322,7 +322,7 @@ func TestKeyboardHelp(t *testing.T) { model, _ = m.Update(keyPress('?')) m = model.(Model) - if !m.help { + if m.overlay != overlayHelp { t.Fatal("expected help to be visible") } @@ -352,14 +352,14 @@ func TestSearchExitAndDetailClose(t *testing.T) { // open search model, _ := m.Update(keyPress('/')) m = model.(Model) - if !m.searching { + if m.overlay != overlaySearch { t.Fatal("expected search to be active") } // close search model, _ = m.Update(keyPress(tea.KeyEsc)) m = model.(Model) - if m.searching { + if m.overlay == overlaySearch { t.Fatal("expected search to be inactive") } @@ -424,7 +424,7 @@ func TestCandidateEnterOpensResearchBriefAtEveryResponsiveSize(t *testing.T) { m = model.(Model) model, _ = m.Update(keyPress(tea.KeyEnter)) m = model.(Model) - if !m.briefOpen || !strings.Contains(m.View().Content, "RESEARCH BRIEF") { + if m.overlay != overlayBrief || !strings.Contains(m.View().Content, "RESEARCH BRIEF") { t.Fatalf("candidate Enter did not open the brief at %dx%d:\n%s", size.width, size.height, m.View().Content) } }) @@ -525,7 +525,7 @@ func TestDetailShowsSelectedItemsPrimaryContextualAction(t *testing.T) { data := sampleData() data.Candidates[0].Actions = []tuicontract.Action{{ ID: "start_investigation", Label: "Start investigation", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }} data.Hypotheses[0].Actions = []tuicontract.Action{{ ID: "check_duplicates", Label: "Check duplicates", @@ -548,7 +548,7 @@ func TestDetailKeepsPrimaryActionVisibleWhenEvidenceOverflows(t *testing.T) { data := sampleData() data.Candidates[0].Actions = []tuicontract.Action{{ ID: "start_investigation", Label: "Start investigation", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }} data.Candidates[0].Assessment = &tuicontract.Assessment{} for i := 0; i < 20; i++ { @@ -629,7 +629,7 @@ func TestWorkbenchNeverExceedsTerminalViewport(t *testing.T) { m.actionProvider = &fakeActionProvider{actions: []tuicontract.Action{{ ID: "start", Label: "Start investigation", Description: "Create local records.", - Capability: tuicontract.CapabilityLocalWrite, RequiresConfirmation: true, + Capability: tuicontract.CapabilityLocalWrite, }}} model, cmd := m.Update(keyPress('a')) m = model.(Model) diff --git a/internal/tui/update.go b/internal/tui/update.go index 04e2d283..a574e87b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -34,45 +34,47 @@ type briefLoadedMsg struct { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case loadMsg: - m.loading = true + m.loadState = corpusLoading m.err = nil return m, m.loadCmd() case loadedMsg: - m.loading = false if msg.err != nil { m.err = msg.err - m.loaded = false + m.loadState = corpusLoadFailed m.items = make(map[view][]tuicontract.Item) m.windows = make(map[view]tuicontract.Window) m.filtered = nil return m, nil } - m.loaded = true + m.loadState = corpusLoaded m.loadData(msg.data) m.applyFilter() return m, nil case actionsLoadedMsg: - m.actionLoading = false m.actionErr = msg.err m.actions = msg.actions - if msg.err == nil && len(msg.actions) == 0 { - m.actionOpen = false + switch { + case msg.err != nil: + m.actionState = actionFailed + case len(msg.actions) == 0: + m.overlay = overlayNone m.actionMsg = "No actions available for this item" + default: + m.actionState = actionsReady } return m, nil case actionCompletedMsg: - m.actionExecuting = false if msg.err != nil { m.actionErr = msg.err + m.actionState = actionFailed return m, nil } - m.actionOpen = false m.actionMsg = msg.result.Message m.actionResult = msg.result - m.resultOpen = true + m.overlay = overlayResult m.resultTop = 0 if msg.result.Reload { return m, m.loadCmd() @@ -83,9 +85,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.itemRef != m.briefItem.Ref { return m, nil } - m.briefLoading = false m.briefErr = msg.err m.brief = msg.brief + if msg.err != nil { + m.briefState = briefFailed + } else { + m.briefState = briefReady + } return m, nil case tea.WindowSizeMsg: @@ -153,12 +159,13 @@ func splitByStatus(items []tuicontract.Item, status string) (matching, other []t } func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.resultOpen { + switch m.overlay { + case overlayResult: switch msg.String() { case "esc", "q": - m.resultOpen = false + m.overlay = overlayNone case "enter": - m.resultOpen = false + m.overlay = overlayNone m.focusActionTarget(m.actionResult.Target) case "up", "k": m.resultTop = max(0, m.resultTop-1) @@ -172,14 +179,13 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.resultTop = 0 } return m, nil - } - if m.briefOpen { + case overlayBrief: switch msg.String() { case "esc", "q": - m.briefOpen = false + m.overlay = overlayNone case "enter": - if m.briefErr != nil { + if m.briefState == briefFailed { return m.openBrief(m.briefItem) } case "up", "k": @@ -194,16 +200,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.briefTop = 0 } return m, nil - } - if m.actionOpen { + case overlayActions: return m.handleActionKey(msg) - } - if m.search.Focused() { + case overlaySearch: switch msg.String() { case "esc", "enter": - m.searching = false + m.overlay = overlayNone m.search.Blur() return m, nil case "up": @@ -217,12 +221,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.search, cmd = m.search.Update(msg) m.applyFilter() return m, cmd - } - if m.help { + case overlayHelp: switch msg.String() { case "?", "esc", "enter": - m.help = false + m.overlay = overlayNone } return m, nil } @@ -250,7 +253,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.nextView() } case "/": - m.searching = true + m.overlay = overlaySearch m.focus = focusList return m, m.search.Focus() case "up", "k": @@ -279,16 +282,16 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.focus = focusList } case "?": - m.help = true + m.overlay = overlayHelp case "a": return m.openActions() default: if len(msg.String()) == 1 { key := msg.String()[0] if key >= '1' && key <= '9' { - m.switchView(viewOrder[int(key-'1')]) - } else if key == '0' && len(viewOrder) >= 10 { - m.switchView(viewOrder[9]) + m.switchView(viewSpecs[int(key-'1')].view) + } else if key == '0' && len(viewSpecs) >= 10 { + m.switchView(viewSpecs[9].view) } } } @@ -296,16 +299,17 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m Model) handleActionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.actionLoading || m.actionExecuting { - if msg.String() == "esc" && m.actionLoading { - m.actionOpen = false + switch m.actionState { + case actionsLoading, actionExecuting: + if msg.String() == "esc" && m.actionState == actionsLoading { + m.overlay = overlayNone } return m, nil - } - if m.actionErr != nil { + + case actionFailed: switch msg.String() { case "esc", "q": - m.actionOpen = false + m.overlay = overlayNone case "enter": if len(m.actions) == 0 { return m.retryActionDiscovery() @@ -313,19 +317,19 @@ func (m Model) handleActionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.executeSelectedAction() } return m, nil - } - if m.actionConfirm { + + case actionConfirming: switch msg.String() { case "y", "enter": return m.executeSelectedAction() case "n", "esc": - m.actionConfirm = false + m.actionState = actionsReady } return m, nil } switch msg.String() { case "esc", "q": - m.actionOpen = false + m.overlay = overlayNone case "up", "k": m.actionCursor = max(0, m.actionCursor-1) case "down", "j": @@ -335,8 +339,8 @@ func (m Model) handleActionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !ok { return m, nil } - if action.RequiresConfirmation { - m.actionConfirm = true + if action.RequiresConfirmation() { + m.actionState = actionConfirming return m, nil } return m.executeSelectedAction() diff --git a/internal/tui/view.go b/internal/tui/view.go index 86f04d43..e2cc0ea2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -32,21 +32,24 @@ func (m Model) View() tea.View { bodyHeight := max(8, height-2) var body string - switch { - case m.resultOpen: + switch m.overlay { + case overlayResult: body = m.renderActionResult(width, bodyHeight) - case m.briefOpen: + case overlayBrief: body = m.renderResearchBrief(width, bodyHeight) - case m.actionOpen: + case overlayActions: body = m.renderActions(width, bodyHeight) - case m.help: + case overlayHelp: body = m.renderHelp(width, bodyHeight) - case m.loading: - body = panel("Loading local contribution corpus…", width, bodyHeight, true) - case m.err != nil: - body = panel(errorStyle.Render("Error · could not open local corpus\n\n"+m.err.Error()), width, bodyHeight, true) default: - body = m.renderWorkbench(width, bodyHeight) + switch m.loadState { + case corpusLoading: + body = panel("Loading local contribution corpus…", width, bodyHeight, true) + case corpusLoadFailed: + body = panel(errorStyle.Render("Error · could not open local corpus\n\n"+m.err.Error()), width, bodyHeight, true) + default: + body = m.renderWorkbench(width, bodyHeight) + } } content := strings.Join([]string{ @@ -144,7 +147,7 @@ func (m Model) renderList(width, height int) string { if m.width < wideLayoutMinimum { lines[0] = dimStyle.Render("[ / ] stage ") + titleLine } - if m.searching || strings.TrimSpace(m.search.Value()) != "" { + if m.overlay == overlaySearch || strings.TrimSpace(m.search.Value()) != "" { lines = append(lines, m.search.View()) } else { lines = append(lines, "") @@ -292,35 +295,40 @@ func (m Model) renderDetail(width, height int) string { func (m Model) renderFooter(width int) string { var text string - switch { - case m.resultOpen: + switch m.overlay { + case overlayResult: if m.actionResult.Target != nil { text = "enter view result esc return ↑↓ scroll" } else { text = "enter return esc return ↑↓ scroll" } - case m.briefOpen: - if m.briefErr != nil { + case overlayBrief: + if m.briefState == briefFailed { text = "enter retry esc return" } else { text = "↑↓ scroll pgup/pgdown page home top esc return" } - case m.actionOpen && m.actionConfirm: - text = "y / enter confirm n / esc cancel local corpus only · no GitHub mutation" - case m.actionOpen && m.actionErr != nil: - text = "enter retry esc return" - case m.actionOpen: - text = "↑↓ move enter run esc close" - case m.help: + case overlayActions: + switch m.actionState { + case actionConfirming: + text = "y / enter confirm n / esc cancel local corpus only · no GitHub mutation" + case actionFailed: + text = "enter retry esc return" + default: + text = "↑↓ move enter run esc close" + } + case overlayHelp: text = "esc close help" - case m.searching: + case overlaySearch: text = "type to filter ↑↓ move enter apply esc close" - case m.focus == focusDetail: - text = "tab focus ↑↓ scroll esc back [ ] stage / filter ? help q quit" default: - text = "tab focus ↑↓ move enter inspect [ ] stage / filter ? help q quit" + if m.focus == focusDetail { + text = "tab focus ↑↓ scroll esc back [ ] stage / filter ? help q quit" + } else { + text = "tab focus ↑↓ move enter inspect [ ] stage / filter ? help q quit" + } } - if m.actionProvider != nil && !m.help && !m.searching && !m.actionOpen { + if m.actionProvider != nil && m.overlay == overlayNone { text = strings.Replace(text, " / filter", " a actions / filter", 1) } return truncate(dimStyle.Render(text), width) diff --git a/internal/tui/view_overlays.go b/internal/tui/view_overlays.go index 727cce65..273a382f 100644 --- a/internal/tui/view_overlays.go +++ b/internal/tui/view_overlays.go @@ -68,10 +68,10 @@ func (m Model) renderResearchBrief(width, height int) string { if item.Ref == "" { return panel(errorStyle.Render("Research brief unavailable\n\nThe selected candidate is no longer visible."), width, height, true) } - if m.briefLoading { + if m.briefState == briefLoading { return panel("Loading stored research brief…\n\n"+dimStyle.Render("Offline · no corpus writes"), width, height, true) } - if m.briefErr != nil { + if m.briefState == briefFailed { lines := []string{ errorStyle.Render("Research brief unavailable"), "", @@ -199,12 +199,12 @@ func (m Model) renderActions(width, height int) string { dimStyle.Render(truncate(displayRef(m.actionItem.Ref)+" · "+m.actionItem.Title, contentWidth)), "", } - switch { - case m.actionLoading: + switch m.actionState { + case actionsLoading: lines = append(lines, "Loading available actions…") - case m.actionExecuting: + case actionExecuting: lines = append(lines, "Running action…") - case m.actionErr != nil: + case actionFailed: action, selected := m.selectedAction() label := action.Label recovery := "Press Enter to retry this action." @@ -222,7 +222,7 @@ func (m Model) renderActions(width, height int) string { recovery, "Press Esc to return to the workbench.", ) - case m.actionConfirm: + case actionConfirming: action, _ := m.selectedAction() lines = append(lines, warningStyle.Render("Confirm local write"), diff --git a/internal/tuicontract/contracts.go b/internal/tuicontract/contracts.go index 67e9f92a..155fb69e 100644 --- a/internal/tuicontract/contracts.go +++ b/internal/tuicontract/contracts.go @@ -1,7 +1,13 @@ // Package tuicontract defines the product-owned offline workbench boundary. package tuicontract -import "context" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) // Reader is a narrow local data source. Implementations must not perform // network I/O. @@ -17,13 +23,102 @@ const ( CapabilityLocalWrite Capability = "local_write" ) +// ParseCapability converts an action boundary value into one supported +// side-effect class. +func ParseCapability(value string) (Capability, error) { + switch Capability(strings.TrimSpace(value)) { + case CapabilityOfflineRead: + return CapabilityOfflineRead, nil + case CapabilityLocalWrite: + return CapabilityLocalWrite, nil + default: + return "", fmt.Errorf("unsupported action capability %q", value) + } +} + // Action is one contextual, currently executable application operation. type Action struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Capability Capability `json:"capability"` +} + +// RequiresConfirmation derives the interaction guard from the action's +// side-effect capability. +func (a Action) RequiresConfirmation() bool { return a.Capability == CapabilityLocalWrite } + +// ParseAction canonicalizes one provider action before the TUI exposes or +// executes it. +func ParseAction(action Action) (Action, error) { + action.ID = strings.TrimSpace(action.ID) + action.Label = strings.TrimSpace(action.Label) + if action.ID == "" { + return Action{}, errors.New("action id is required") + } + if action.Label == "" { + return Action{}, errors.New("action label is required") + } + capability, err := ParseCapability(string(action.Capability)) + if err != nil { + return Action{}, err + } + action.Capability = capability + return action, nil +} + +// ParseActions validates one complete provider menu and rejects ambiguous +// duplicate action identities. +func ParseActions(actions []Action) ([]Action, error) { + parsed := make([]Action, len(actions)) + seen := make(map[string]struct{}, len(actions)) + for index, action := range actions { + var err error + parsed[index], err = ParseAction(action) + if err != nil { + return nil, fmt.Errorf("action %d: %w", index, err) + } + if _, duplicate := seen[parsed[index].ID]; duplicate { + return nil, fmt.Errorf("action %d duplicates id %q", index, parsed[index].ID) + } + seen[parsed[index].ID] = struct{}{} + } + return parsed, nil +} + +type actionJSON struct { ID string `json:"id"` Label string `json:"label"` Description string `json:"description,omitempty"` Capability Capability `json:"capability"` - RequiresConfirmation bool `json:"requires_confirmation"` + RequiresConfirmation *bool `json:"requires_confirmation,omitempty"` +} + +// MarshalJSON preserves the public confirmation field as a derived view. +func (a Action) MarshalJSON() ([]byte, error) { + confirmation := a.RequiresConfirmation() + return json.Marshal(actionJSON{ + ID: a.ID, Label: a.Label, Description: a.Description, + Capability: a.Capability, RequiresConfirmation: &confirmation, + }) +} + +// UnmarshalJSON rejects capability and confirmation combinations that cannot +// be represented by Action in memory. +func (a *Action) UnmarshalJSON(data []byte) error { + var raw actionJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + parsed, err := ParseAction(Action{ID: raw.ID, Label: raw.Label, Description: raw.Description, Capability: raw.Capability}) + if err != nil { + return err + } + if raw.RequiresConfirmation != nil && *raw.RequiresConfirmation != parsed.RequiresConfirmation() { + return errors.New("action confirmation must match its capability") + } + *a = parsed + return nil } // ActionRequest binds an action to the item from which it was offered. diff --git a/internal/tuicontract/contracts_test.go b/internal/tuicontract/contracts_test.go new file mode 100644 index 00000000..2edadef4 --- /dev/null +++ b/internal/tuicontract/contracts_test.go @@ -0,0 +1,45 @@ +package tuicontract + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestActionDerivesConfirmationFromCapability(t *testing.T) { + t.Parallel() + write := Action{ID: "write", Label: "Write", Capability: CapabilityLocalWrite} + if !write.RequiresConfirmation() { + t.Fatal("local write action did not require confirmation") + } + payload, err := json.Marshal(write) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(payload), `"requires_confirmation":true`) { + t.Fatalf("action JSON = %s", payload) + } + var decoded Action + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if decoded != write { + t.Fatalf("decoded action = %+v, want %+v", decoded, write) + } + if err := json.Unmarshal([]byte(`{"id":"write","label":"Write","capability":"local_write","requires_confirmation":false}`), &decoded); err == nil { + t.Fatal("contradictory action confirmation was accepted") + } +} + +func TestParseActionsRejectsInvalidOrAmbiguousProviderMenus(t *testing.T) { + t.Parallel() + if _, err := ParseActions([]Action{{ID: "read", Label: "Read", Capability: Capability("network")}}); err == nil { + t.Fatal("unknown action capability was accepted") + } + if _, err := ParseActions([]Action{ + {ID: "read", Label: "First", Capability: CapabilityOfflineRead}, + {ID: " read ", Label: "Second", Capability: CapabilityOfflineRead}, + }); err == nil { + t.Fatal("duplicate canonical action ids were accepted") + } +} diff --git a/internal/workspace/adopt.go b/internal/workspace/adopt.go index a9ff652a..0cd13c48 100644 --- a/internal/workspace/adopt.go +++ b/internal/workspace/adopt.go @@ -78,12 +78,16 @@ func (m *Manager) Adopt(ctx context.Context, opts AdoptOptions) (*Workspace, err if err != nil { return nil, err } - return &Workspace{ + workspace := &Workspace{ Name: opts.Name, Path: canonical, Remote: remote, BaseSHA: baseSHA, CandidateSHA: candidateSHA, MergeBase: strings.TrimSpace(mergeBase), - Dirty: status.Dirty, HasUntracked: hasUntracked, Ownership: OwnershipExternal, - GitDir: gitDir, GitCommonDir: commonDir, CreatedAt: time.Now().UTC(), - }, nil + Ownership: OwnershipExternal, + GitDir: gitDir, GitCommonDir: commonDir, CreatedAt: time.Now().UTC(), + } + if err := workspace.SetChanges(status.Dirty, hasUntracked); err != nil { + return nil, err + } + return workspace, nil } type worktreeRecord struct { diff --git a/internal/workspace/adopt_test.go b/internal/workspace/adopt_test.go index 648f7c13..0e6e51fe 100644 --- a/internal/workspace/adopt_test.go +++ b/internal/workspace/adopt_test.go @@ -29,7 +29,7 @@ func TestManagerAdoptExternalWorktreeWithoutMutation(t *testing.T) { if err != nil { t.Fatal(err) } - if ws.Ownership != OwnershipExternal || ws.BaseSHA != baseSHA || ws.CandidateSHA != candidateSHA || !ws.Dirty || !ws.HasUntracked { + if ws.Ownership != OwnershipExternal || ws.BaseSHA != baseSHA || ws.CandidateSHA != candidateSHA || !ws.Dirty() || !ws.HasUntracked() { t.Fatalf("unexpected adopted workspace: %+v", ws) } if err := mgr.ValidateWorkspace(ctx, ws); err != nil { diff --git a/internal/workspace/snapshot.go b/internal/workspace/snapshot.go index d9dd16d7..65eb915f 100644 --- a/internal/workspace/snapshot.go +++ b/internal/workspace/snapshot.go @@ -32,12 +32,45 @@ type ContentDigest struct { } // UntrackedResource identifies one untracked path and its content when bounded. +type UntrackedResourceKind string + +const ( + UntrackedFile UntrackedResourceKind = "file" + UntrackedSymlink UntrackedResourceKind = "symlink" + UntrackedUnsupported UntrackedResourceKind = "unsupported" +) + +func parseUntrackedResourceKind(value UntrackedResourceKind) (UntrackedResourceKind, error) { + switch UntrackedResourceKind(strings.ToLower(strings.TrimSpace(string(value)))) { + case UntrackedFile: + return UntrackedFile, nil + case UntrackedSymlink: + return UntrackedSymlink, nil + case UntrackedUnsupported: + return UntrackedUnsupported, nil + default: + return "", fmt.Errorf("unsupported untracked resource kind %q", value) + } +} + +func parseUntrackedResources(values []UntrackedResource) ([]UntrackedResource, error) { + parsed := append([]UntrackedResource(nil), values...) + for i := range parsed { + kind, err := parseUntrackedResourceKind(parsed[i].Kind) + if err != nil { + return nil, err + } + parsed[i].Kind = kind + } + return parsed, nil +} + type UntrackedResource struct { - Path string `json:"path"` - Kind string `json:"kind"` - Mode uint32 `json:"mode"` - SHA256 string `json:"sha256,omitempty"` - Bytes int64 `json:"bytes,omitempty"` + Path string `json:"path"` + Kind UntrackedResourceKind `json:"kind"` + Mode uint32 `json:"mode"` + SHA256 string `json:"sha256,omitempty"` + Bytes int64 `json:"bytes,omitempty"` } // SubmoduleIdentity binds the index and checked-out identities of a submodule. @@ -63,8 +96,32 @@ type SnapshotGap struct { // Snapshot is a deterministic composite identity for a managed worktree. type Snapshot struct { + Version string `json:"version"` + Ownership Ownership `json:"ownership"` + BaseSHA string `json:"base_sha,omitempty"` + HeadSHA string `json:"head_sha"` + MergeBase string `json:"merge_base,omitempty"` + Staged ContentDigest `json:"staged"` + Unstaged ContentDigest `json:"unstaged"` + Untracked []UntrackedResource `json:"untracked"` + Submodules []SubmoduleIdentity `json:"submodules"` + ChangedFiles []string `json:"changed_files"` + Commits []CommitSummary `json:"commits"` + CommitTotal int `json:"commit_total"` + Gaps []SnapshotGap `json:"gaps"` + SHA256 string `json:"sha256"` +} + +// Complete reports whether every candidate-content source was bound into the +// snapshot. Gaps are the authoritative representation. +func (s Snapshot) Complete() bool { return len(s.Gaps) == 0 } + +// CommitsTruncated reports whether the bounded commit summaries omit entries. +func (s Snapshot) CommitsTruncated() bool { return s.CommitTotal > len(s.Commits) } + +type snapshotJSON struct { Version string `json:"version"` - Ownership string `json:"ownership"` + Ownership Ownership `json:"ownership"` BaseSHA string `json:"base_sha,omitempty"` HeadSHA string `json:"head_sha"` MergeBase string `json:"merge_base,omitempty"` @@ -81,13 +138,61 @@ type Snapshot struct { SHA256 string `json:"sha256"` } +// MarshalJSON preserves the public snapshot contract while deriving redundant +// completeness flags from their authoritative data. +func (s Snapshot) MarshalJSON() ([]byte, error) { + ownership, err := parseOwnership(s.Ownership) + if err != nil { + return nil, err + } + untracked, err := parseUntrackedResources(s.Untracked) + if err != nil { + return nil, err + } + return json.Marshal(snapshotJSON{ + Version: s.Version, Ownership: ownership, BaseSHA: s.BaseSHA, HeadSHA: s.HeadSHA, MergeBase: s.MergeBase, + Staged: s.Staged, Unstaged: s.Unstaged, Untracked: untracked, Submodules: s.Submodules, + ChangedFiles: s.ChangedFiles, Commits: s.Commits, CommitTotal: s.CommitTotal, + CommitsTruncated: s.CommitsTruncated(), Complete: s.Complete(), Gaps: s.Gaps, SHA256: s.SHA256, + }) +} + +// UnmarshalJSON rejects snapshot flags that contradict their authoritative +// commit totals or explicit gaps. +func (s *Snapshot) UnmarshalJSON(data []byte) error { + var raw snapshotJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.CommitsTruncated != (raw.CommitTotal > len(raw.Commits)) { + return errors.New("workspace snapshot commit truncation contradicts its commit population") + } + if raw.Complete != (len(raw.Gaps) == 0) { + return errors.New("workspace snapshot completeness contradicts its explicit gaps") + } + ownership, err := parseOwnership(raw.Ownership) + if err != nil { + return err + } + untracked, err := parseUntrackedResources(raw.Untracked) + if err != nil { + return err + } + *s = Snapshot{ + Version: raw.Version, Ownership: ownership, BaseSHA: raw.BaseSHA, HeadSHA: raw.HeadSHA, MergeBase: raw.MergeBase, + Staged: raw.Staged, Unstaged: raw.Unstaged, Untracked: untracked, Submodules: raw.Submodules, + ChangedFiles: raw.ChangedFiles, Commits: raw.Commits, CommitTotal: raw.CommitTotal, Gaps: raw.Gaps, SHA256: raw.SHA256, + } + return nil +} + // SnapshotByPath derives a bounded, no-hook identity for a managed worktree. func (m *Manager) SnapshotByPath(ctx context.Context, path, baseSHA, mergeBase string) (Snapshot, error) { managed, err := m.managedPath(path) if err != nil { return Snapshot{}, err } - snapshot := Snapshot{Version: WorkspaceSnapshotVersion, Ownership: "managed", BaseSHA: strings.TrimSpace(baseSHA), MergeBase: strings.TrimSpace(mergeBase), Complete: true} + snapshot := Snapshot{Version: WorkspaceSnapshotVersion, Ownership: OwnershipManaged, BaseSHA: strings.TrimSpace(baseSHA), MergeBase: strings.TrimSpace(mergeBase)} if snapshot.HeadSHA, err = trimmedGit(m.git(ctx, managed, "rev-parse", "HEAD")); err != nil { return Snapshot{}, fmt.Errorf("resolve workspace HEAD: %w", err) } @@ -119,7 +224,6 @@ func (m *Manager) SnapshotByPath(ctx context.Context, path, baseSHA, mergeBase s } return snapshot.Gaps[i].Path < snapshot.Gaps[j].Path }) - snapshot.Complete = len(snapshot.Gaps) == 0 snapshot.SHA256, err = snapshotDigest(snapshot) if err != nil { return Snapshot{}, err @@ -164,7 +268,7 @@ func (m *Manager) addUntrackedSnapshot(ctx context.Context, managed string, snap entry := UntrackedResource{Path: gitPath, Mode: uint32(info.Mode().Perm()), Bytes: info.Size()} switch { case info.Mode().IsRegular(): - entry.Kind = "file" + entry.Kind = UntrackedFile if info.Size() > maxSnapshotFileBytes || total+info.Size() > maxSnapshotTotalBytes { snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_content_omitted", Path: gitPath, Reason: "content exceeds the snapshot byte bound"}) break @@ -176,7 +280,7 @@ func (m *Manager) addUntrackedSnapshot(ctx context.Context, managed string, snap } entry.SHA256, entry.Bytes, total = digest, bytesRead, total+bytesRead case info.Mode()&os.ModeSymlink != 0: - entry.Kind = "symlink" + entry.Kind = UntrackedSymlink target, err := root.Readlink(localPath) if err != nil { snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_symlink_unavailable", Path: gitPath, Reason: err.Error()}) @@ -185,7 +289,7 @@ func (m *Manager) addUntrackedSnapshot(ctx context.Context, managed string, snap entry.SHA256 = digestBytes([]byte(target)) entry.Bytes = int64(len(target)) default: - entry.Kind = "unsupported" + entry.Kind = UntrackedUnsupported snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_type_unsupported", Path: gitPath, Reason: info.Mode().String()}) } snapshot.Untracked = append(snapshot.Untracked, entry) @@ -264,8 +368,7 @@ func (m *Manager) addCommitSnapshot(ctx context.Context, managed string, snapsho for i := 0; i < len(parts); i += 2 { snapshot.Commits = append(snapshot.Commits, CommitSummary{SHA: parts[i], Subject: parts[i+1]}) } - snapshot.CommitsTruncated = snapshot.CommitTotal > len(snapshot.Commits) - if snapshot.CommitsTruncated { + if snapshot.CommitsTruncated() { snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "commits_truncated", Reason: fmt.Sprintf("%d commits exceed the %d-commit metadata bound", snapshot.CommitTotal, maxSnapshotCommits)}) } return nil diff --git a/internal/workspace/snapshot_test.go b/internal/workspace/snapshot_test.go new file mode 100644 index 00000000..f1926dae --- /dev/null +++ b/internal/workspace/snapshot_test.go @@ -0,0 +1,67 @@ +package workspace + +import ( + "encoding/json" + "testing" +) + +func TestSnapshotUnmarshalRejectsContradictoryDerivedFlags(t *testing.T) { + for name, payload := range map[string]string{ + "commit truncation": `{"commit_total":1,"commits":[],"commits_truncated":false,"complete":true,"gaps":[]}`, + "completeness": `{"commit_total":0,"commits":[],"commits_truncated":false,"complete":true,"gaps":[{"code":"unbound","reason":"missing"}]}`, + } { + t.Run(name, func(t *testing.T) { + var snapshot Snapshot + if err := json.Unmarshal([]byte(payload), &snapshot); err == nil { + t.Fatal("contradictory snapshot flags were accepted") + } + }) + } +} + +func TestSnapshotUnmarshalRejectsUnknownOwnedKinds(t *testing.T) { + for name, payload := range map[string]string{ + "ownership": `{"ownership":"borrowed","commit_total":0,"commits":[],"commits_truncated":false,"complete":true,"gaps":[]}`, + "resource": `{"ownership":"managed","untracked":[{"path":"note.txt","kind":"directory"}],"commit_total":0,"commits":[],"commits_truncated":false,"complete":true,"gaps":[]}`, + } { + t.Run(name, func(t *testing.T) { + var snapshot Snapshot + if err := json.Unmarshal([]byte(payload), &snapshot); err == nil { + t.Fatal("unknown workspace discriminator was accepted") + } + }) + } +} + +func TestWorkspaceUnmarshalRejectsCleanStateWithUntrackedFiles(t *testing.T) { + var workspace Workspace + if err := json.Unmarshal([]byte(`{"Dirty":false,"HasUntracked":true}`), &workspace); err == nil { + t.Fatal("contradictory workspace change flags were accepted") + } +} + +func TestWorkspaceUnmarshalParsesOwnership(t *testing.T) { + for name, payload := range map[string]string{ + "legacy managed": `{"Dirty":false,"HasUntracked":false}`, + "external": `{"Dirty":false,"HasUntracked":false,"Ownership":"external"}`, + } { + t.Run(name, func(t *testing.T) { + var workspace Workspace + if err := json.Unmarshal([]byte(payload), &workspace); err != nil { + t.Fatal(err) + } + want := OwnershipManaged + if name == "external" { + want = OwnershipExternal + } + if workspace.Ownership != want { + t.Fatalf("ownership = %q, want %q", workspace.Ownership, want) + } + }) + } + + var workspace Workspace + if err := json.Unmarshal([]byte(`{"Dirty":false,"HasUntracked":false,"Ownership":"borrowed"}`), &workspace); err == nil { + t.Fatal("unknown workspace ownership was accepted") + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index ea2979c5..aa642ff3 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -2,6 +2,7 @@ package workspace import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -87,8 +88,79 @@ const ( OwnershipExternal Ownership = "external" ) +func parseOwnership(value Ownership) (Ownership, error) { + switch Ownership(strings.ToLower(strings.TrimSpace(string(value)))) { + case "", OwnershipManaged: + // Empty ownership predates explicit external-worktree adoption and is + // therefore the legacy representation of a managed workspace. + return OwnershipManaged, nil + case OwnershipExternal: + return OwnershipExternal, nil + default: + return "", fmt.Errorf("unsupported workspace ownership %q", value) + } +} + // Workspace is a product-owned record for a Git worktree. type Workspace struct { + Name string + InvestigationID string + RepoOwner string + RepoName string + Path string + Remote string + BaseSHA string + CandidateSHA string + MergeBase string + Ownership Ownership + GitDir string + GitCommonDir string + CreatedAt time.Time + + mirror string + changes workspaceChangeState +} + +// workspaceChangeState is the only valid relationship between dirty and +// untracked workspace observations. +type workspaceChangeState uint8 + +const ( + workspaceClean workspaceChangeState = iota + workspaceDirty + workspaceDirtyWithUntracked +) + +func parseWorkspaceChangeState(dirty, hasUntracked bool) (workspaceChangeState, error) { + switch { + case !dirty && hasUntracked: + return workspaceClean, errors.New("a workspace with untracked files cannot be clean") + case hasUntracked: + return workspaceDirtyWithUntracked, nil + case dirty: + return workspaceDirty, nil + default: + return workspaceClean, nil + } +} + +// SetChanges replaces the observed change state after parsing it. +func (w *Workspace) SetChanges(dirty, hasUntracked bool) error { + state, err := parseWorkspaceChangeState(dirty, hasUntracked) + if err != nil { + return err + } + w.changes = state + return nil +} + +// Dirty reports whether tracked or untracked changes were observed. +func (w Workspace) Dirty() bool { return w.changes != workspaceClean } + +// HasUntracked reports whether untracked, non-ignored files were observed. +func (w Workspace) HasUntracked() bool { return w.changes == workspaceDirtyWithUntracked } + +type workspaceJSON struct { Name string InvestigationID string RepoOwner string @@ -104,8 +176,44 @@ type Workspace struct { GitDir string GitCommonDir string CreatedAt time.Time +} - mirror string +// MarshalJSON preserves the durable workspace payload while deriving its +// compatibility booleans from one change state. +func (w Workspace) MarshalJSON() ([]byte, error) { + ownership, err := parseOwnership(w.Ownership) + if err != nil { + return nil, err + } + return json.Marshal(workspaceJSON{ + Name: w.Name, InvestigationID: w.InvestigationID, RepoOwner: w.RepoOwner, RepoName: w.RepoName, + Path: w.Path, Remote: w.Remote, BaseSHA: w.BaseSHA, CandidateSHA: w.CandidateSHA, MergeBase: w.MergeBase, + Dirty: w.Dirty(), HasUntracked: w.HasUntracked(), Ownership: ownership, + GitDir: w.GitDir, GitCommonDir: w.GitCommonDir, CreatedAt: w.CreatedAt, + }) +} + +// UnmarshalJSON parses the durable compatibility booleans once. +func (w *Workspace) UnmarshalJSON(data []byte) error { + var raw workspaceJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + changes, err := parseWorkspaceChangeState(raw.Dirty, raw.HasUntracked) + if err != nil { + return err + } + ownership, err := parseOwnership(raw.Ownership) + if err != nil { + return err + } + *w = Workspace{ + Name: raw.Name, InvestigationID: raw.InvestigationID, RepoOwner: raw.RepoOwner, RepoName: raw.RepoName, + Path: raw.Path, Remote: raw.Remote, BaseSHA: raw.BaseSHA, CandidateSHA: raw.CandidateSHA, MergeBase: raw.MergeBase, + Ownership: ownership, GitDir: raw.GitDir, GitCommonDir: raw.GitCommonDir, CreatedAt: raw.CreatedAt, + changes: changes, + } + return nil } // AdoptOptions identifies an existing worktree without granting ownership of @@ -391,11 +499,13 @@ func (m *Manager) Create(ctx context.Context, mirrorName, baseRef, candidateRef, BaseSHA: baseSHA, CandidateSHA: candidateSHA, MergeBase: mergeBase, - Dirty: st.Dirty, Ownership: OwnershipManaged, CreatedAt: time.Now().UTC(), mirror: mi.name, } + if err := ws.SetChanges(st.Dirty, false); err != nil { + return nil, err + } m.mu.Lock() m.workspaces[name] = ws @@ -456,14 +566,24 @@ func (m *Manager) Status(ctx context.Context, name string) (Status, error) { if err != nil { return Status{}, err } + hasUntracked, err := m.hasUntracked(ctx, ws.Path) + if err != nil { + return Status{}, err + } m.mu.Lock() - ws.Dirty = st.Dirty + err = ws.SetChanges(st.Dirty, hasUntracked) m.mu.Unlock() + if err != nil { + return Status{}, err + } return st, nil } func (m *Manager) status(ctx context.Context, path string) (Status, error) { - out, err := m.git(ctx, path, "status", "--porcelain") + // Do not inherit status.showUntrackedFiles from repository configuration: + // SetChanges treats an untracked-only worktree as dirty, and hasUntracked + // independently observes those files below. + out, err := m.git(ctx, path, "status", "--porcelain", "--untracked-files=normal") if errors.Is(err, buflimit.ErrOutputLimit) { return Status{Dirty: true}, nil } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 0cad09d4..2a6fe686 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -156,7 +156,7 @@ func TestWorkspaceSnapshotBindsStagedUnstagedAndUntrackedContent(t *testing.T) { if err != nil { t.Fatal(err) } - if !initial.Complete || initial.SHA256 == "" || initial.HeadSHA != ws.CandidateSHA || initial.CommitTotal != 1 { + if !initial.Complete() || initial.SHA256 == "" || initial.HeadSHA != ws.CandidateSHA || initial.CommitTotal != 1 { t.Fatalf("initial snapshot = %+v", initial) } @@ -199,7 +199,7 @@ func TestWorkspaceSnapshotBindsStagedUnstagedAndUntrackedContent(t *testing.T) { if err != nil { t.Fatal(err) } - if withIgnored.Complete || !snapshotHasGap(withIgnored.Gaps, "ignored_content_unbound") { + if withIgnored.Complete() || !snapshotHasGap(withIgnored.Gaps, "ignored_content_unbound") { t.Fatalf("ignored content was not exposed as incomplete: %+v", withIgnored.Gaps) } } @@ -387,6 +387,30 @@ func TestManager_DirtyState(t *testing.T) { } } +func TestManager_StatusIgnoresRepositoryUntrackedDisplayPreference(t *testing.T) { + t.Parallel() + ctx := context.Background() + remote, _, _ := setupRemote(t) + mgr := newManager(t) + if err := mgr.Clone(ctx, remote, "origin"); err != nil { + t.Fatal(err) + } + ws, err := mgr.Create(ctx, "origin", "master", "feature", "ws1") + if err != nil { + t.Fatal(err) + } + runGit(t, ws.Path, "config", "status.showUntrackedFiles", "no") + writeFile(t, filepath.Join(ws.Path, "untracked.txt"), "untracked") + + status, err := mgr.Status(ctx, "ws1") + if err != nil { + t.Fatal(err) + } + if !status.Dirty || !ws.HasUntracked() { + t.Fatalf("status = %+v, has untracked = %t; want dirty untracked workspace", status, ws.HasUntracked()) + } +} + func TestManager_DuplicateCreate(t *testing.T) { t.Parallel() ctx := context.Background() From dbd353df8072c32db6eccccf8939afc447e7a864 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:36:43 +0800 Subject: [PATCH 2/3] fix(mcp): advertise partial batch outcomes --- internal/mcpcontract/actor_contracts.go | 2 +- internal/mcpserver/catalog_test.go | 2 +- internal/mcpserver/schemas.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcpcontract/actor_contracts.go b/internal/mcpcontract/actor_contracts.go index 31da0836..e2ec8232 100644 --- a/internal/mcpcontract/actor_contracts.go +++ b/internal/mcpcontract/actor_contracts.go @@ -148,7 +148,7 @@ type GetActorsInput struct { // embedding the catalog-wide workflow recovery union in every result schema. type ActorBatchItem[T any] struct { Key string `json:"key"` - Status BatchItemStatus `json:"item_status" jsonschema:"complete, retryable, unavailable, or failed"` + Status BatchItemStatus `json:"item_status" jsonschema:"complete, partial, retryable, unavailable, or failed"` Reason string `json:"reason,omitempty"` Message string `json:"message,omitempty"` Value *T `json:"value,omitempty"` diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 55422a5e..e2ac5080 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -188,7 +188,7 @@ func TestToolSchemasExposeMachineReadableContracts(t *testing.T) { assertSchemaValue(t, tools[mcpcontract.ToolCreateWorkspace].InputSchema, []string{"required"}, []any{"investigation_id"}) assertSchemaValue(t, tools[mcpcontract.ToolAdoptWorkspace].InputSchema, []string{"required"}, []any{"investigation_id", "path", "base_ref"}) assertSchemaValue(t, tools[mcpcontract.ToolFindPrecedents].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "matches", "items", "properties", "score", "maximum"}, float64(1)) - assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "item_status", "enum"}, []any{"complete", "retryable", "unavailable", "failed"}) + assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "item_status", "enum"}, []any{"complete", "partial", "retryable", "unavailable", "failed"}) assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "execution_state", "enum"}, []any{"queued", "running", "terminal"}) assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "outcome", "enum"}, []any{"succeeded", "partial", "failed", "cancelled"}) assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "progress_percent", "maximum"}, float64(100)) diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index 7c1424c6..309cf793 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -80,7 +80,7 @@ func inferredSchema[T any]() schemaDefinition { reflect.TypeFor[mcpcontract.BatchItemStatus](): { Type: "string", Description: "Per-item batch outcome.", - Enum: []any{mcpcontract.BatchItemComplete, mcpcontract.BatchItemRetryable, mcpcontract.BatchItemUnavailable, mcpcontract.BatchItemFailed}, + Enum: []any{mcpcontract.BatchItemComplete, mcpcontract.BatchItemPartial, mcpcontract.BatchItemRetryable, mcpcontract.BatchItemUnavailable, mcpcontract.BatchItemFailed}, }, reflect.TypeFor[mcpcontract.SourceFileStatus](): { Type: "string", From 42bb08d1aa8f5263c25b05a6e43fff88b0f027fc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:58:34 +0800 Subject: [PATCH 3/3] refactor: split oversized domain owners --- internal/app/mcp_repository_search_test.go | 152 ++++++ internal/app/mcp_scalable_operations.go | 457 ----------------- internal/app/mcp_scalable_results.go | 467 ++++++++++++++++++ internal/app/mcp_scalable_test.go | 140 ------ internal/app/neighbors.go | 290 ----------- internal/app/neighbors_related_work.go | 303 ++++++++++++ internal/corpus/corpus_test.go | 61 --- internal/corpus/portfolio_overlap_queries.go | 332 +++++++++++++ internal/corpus/portfolio_relationships.go | 319 ------------ internal/corpus/run_projection_test.go | 70 +++ internal/evidence/models.go | 195 -------- internal/evidence/models_parse.go | 201 ++++++++ .../discovery_resource_contracts.go | 163 ++++++ internal/mcpcontract/resource_contracts.go | 160 ------ internal/mcpserver/server_test.go | 349 ------------- internal/mcpserver/server_workflows_test.go | 359 ++++++++++++++ internal/workspace/inspection.go | 156 ++++++ internal/workspace/workspace.go | 148 ------ 18 files changed, 2203 insertions(+), 2119 deletions(-) create mode 100644 internal/app/mcp_repository_search_test.go create mode 100644 internal/app/mcp_scalable_results.go create mode 100644 internal/app/neighbors_related_work.go create mode 100644 internal/corpus/portfolio_overlap_queries.go create mode 100644 internal/corpus/run_projection_test.go create mode 100644 internal/evidence/models_parse.go create mode 100644 internal/mcpcontract/discovery_resource_contracts.go create mode 100644 internal/mcpserver/server_workflows_test.go create mode 100644 internal/workspace/inspection.go diff --git a/internal/app/mcp_repository_search_test.go b/internal/app/mcp_repository_search_test.go new file mode 100644 index 00000000..af58e05a --- /dev/null +++ b/internal/app/mcp_repository_search_test.go @@ -0,0 +1,152 @@ +package app + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type fakeRepositorySearchReader struct { + github.Reader + result github.RepositorySearchResult + options github.RepositorySearchOptions +} + +func (f *fakeRepositorySearchReader) SearchRepositories(_ context.Context, options github.RepositorySearchOptions) (github.RepositorySearchResult, error) { + f.options = options + return f.result, nil +} + +func TestSearchGitHubRepositoriesPersistsObservedMetadata(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newSearchTestService(t) + now := time.Unix(1000, 0).UTC() + remote := github.Repository{Owner: "acme", Name: "rocket", Description: "fast inference", Stars: 9001, Language: "Go", UpdatedAt: now} + reader := &fakeRepositorySearchReader{result: github.RepositorySearchResult{Total: 321, Items: []github.Repository{remote}, Page: github.PageInfo{Page: 2, NextPage: 3, HasNext: true}}} + svc.SetGitHubReader(reader) + + out, err := (&MCPReader{svc}).SearchGitHubRepositories(ctx, mcpcontract.SearchGitHubRepositoriesInput{Text: "fast inference", MatchFields: []string{"name", "description"}, Topics: []string{"llm-inference"}, Language: "Go", StarsMin: ptr(200), PushedAfter: "2026-06-15", Archived: ptr(false), Fork: ptr(false), Sort: "stars", Order: "desc", Limit: 12, Page: 2, ResponseFormat: "concise"}) + if err != nil { + t.Fatal(err) + } + if reader.options.PerPage != 12 || reader.options.Page != 2 || reader.options.Sort != "stars" || reader.options.Query != `"fast inference" in:name,description topic:llm-inference language:Go stars:>=200 pushed:>=2026-06-15 archived:false fork:false` { + t.Fatalf("compiled options = %+v", reader.options) + } + if out.NextPage != 3 || out.ResponseFormat != "concise" || len(out.Items) != 1 || out.Items[0].Value == nil || out.Items[0].Value.Ref != "repository:acme/rocket" || *out.Items[0].Value.Stars != 9001 { + t.Fatalf("live search result = %+v, options = %+v", out, reader.options) + } + if out.Items[0].Value.Watchers != nil || len(out.RecoveryPlans) != 1 || len(out.RecoveryPlans[0].Then) != 1 || out.RecoveryPlans[0].Then[0].Type() != "sync_threads" { + t.Fatalf("concise search context = %+v", out) + } + if out.Items[0].Value.DossierStatus != "missing" { + t.Fatalf("new search result dossier availability = %+v", out.Items[0].Value) + } + stored, err := (&MCPReader{svc}).GetRepositories(ctx, mcpcontract.GetRepositoriesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}}) + if err != nil { + t.Fatal(err) + } + if stored.Items[0].Value == nil || stored.Items[0].Value.Metadata.Status != "complete" || *stored.Items[0].Value.Stars != 9001 { + t.Fatalf("search metadata was not persisted: %+v", stored) + } + if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil { + t.Fatal(err) + } + out, err = (&MCPReader{svc}).SearchGitHubRepositories(ctx, mcpcontract.SearchGitHubRepositoriesInput{Text: "fast inference", Limit: 12, Page: 2}) + if err != nil { + t.Fatal(err) + } + if out.Items[0].Value == nil || out.Items[0].Value.DossierStatus != "available" || out.Items[0].Value.DossierAsOf == "" { + t.Fatalf("live search did not report local dossier availability: %+v", out) + } +} + +func TestCompileRepositorySearchRejectsAmbiguousAndInvalidInputs(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in mcpcontract.SearchGitHubRepositoriesInput + }{ + {name: "empty", in: mcpcontract.SearchGitHubRepositoriesInput{}}, + {name: "raw and structured", in: mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "cuda", Language: "Go"}}, + {name: "unknown match field", in: mcpcontract.SearchGitHubRepositoriesInput{Text: "cuda", MatchFields: []string{"topics"}}}, + {name: "reversed stars", in: mcpcontract.SearchGitHubRepositoriesInput{Text: "cuda", StarsMin: ptr(20), StarsMax: ptr(10)}}, + {name: "invalid date", in: mcpcontract.SearchGitHubRepositoriesInput{PushedAfter: "yesterday"}}, + {name: "reversed dates", in: mcpcontract.SearchGitHubRepositoriesInput{CreatedAfter: "2026-07-01", CreatedBefore: "2026-06-01"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, _, _, err := compileRepositorySearch(tc.in); err == nil { + t.Fatal("invalid search was accepted") + } + }) + } +} + +func TestCompileRepositorySearchPreservesExplicitZeroStarBound(t *testing.T) { + t.Parallel() + zero := 0 + query, _, _, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{StarsMax: &zero}) + if err != nil { + t.Fatal(err) + } + if query != "stars:<=0" { + t.Fatalf("query = %q, want stars:<=0", query) + } +} + +func TestRepositorySearchValidationExamplesAreUsable(t *testing.T) { + t.Parallel() + _, _, _, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{}) + var toolErr *mcpcontract.ToolError + if !errors.As(err, &toolErr) { + t.Fatalf("error = %v, want ToolError", err) + } + if toolErr.Example["text"] != "GitHub contribution research" || !reflect.DeepEqual(toolErr.Example["match_fields"], []string{"name", "description"}) { + t.Fatalf("empty-search example = %#v", toolErr.Example) + } + + _, _, _, err = compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "language:go", Language: "Go"}) + if !errors.As(err, &toolErr) || toolErr.Example["raw_query"] != "is:public language:go stars:>=100" { + t.Fatalf("ambiguous-search example = %#v, error=%v", toolErr.Example, err) + } +} + +func TestCompileRepositorySearchWarnsAboutRawReadmeQueries(t *testing.T) { + t.Parallel() + query, interpretation, warnings, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "attention in:readme"}) + if err != nil { + t.Fatal(err) + } + if query != "attention in:readme" || !strings.Contains(interpretation, "advanced raw query") || len(warnings) != 1 || warnings[0].Code != "broad_readme_match" { + t.Fatalf("raw query context = %q %q %+v", query, interpretation, warnings) + } +} + +func TestCompileRepositorySearchWarnsAboutStructuredReadmeMatching(t *testing.T) { + t.Parallel() + query, _, warnings, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{Text: "attention", MatchFields: []string{"name", "readme"}}) + if err != nil { + t.Fatal(err) + } + if query != "attention in:name,readme" || len(warnings) != 1 || warnings[0].Code != "broad_readme_match" { + t.Fatalf("structured README warning = %q %+v", query, warnings) + } +} + +func TestRepositorySearchDetailedFormatPreservesSecondaryFacts(t *testing.T) { + t.Parallel() + archived := true + remote := github.Repository{Owner: "acme", Name: "rocket", Description: "fast", Stars: 42, Watchers: 9, Forks: 3, OpenIssues: 7, Archived: archived, Topics: []string{"cuda"}} + match := liveRepositorySearchMatch(remote, mcpcontract.RepositoryMetadataOutput{Status: "complete"}, detailedResponse) + if match.Ref != "repository:acme/rocket" || match.Watchers == nil || *match.Watchers != 9 || match.Archived == nil || !*match.Archived || len(match.Topics) != 1 { + t.Fatalf("detailed match = %+v", match) + } +} diff --git a/internal/app/mcp_scalable_operations.go b/internal/app/mcp_scalable_operations.go index 46d84722..a5cd67e2 100644 --- a/internal/app/mcp_scalable_operations.go +++ b/internal/app/mcp_scalable_operations.go @@ -9,7 +9,6 @@ import ( "sync" "time" - "github.com/morluto/gitcontribute/internal/codeindex" "github.com/morluto/gitcontribute/internal/contracts" "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" @@ -18,462 +17,6 @@ import ( "github.com/morluto/gitcontribute/internal/repositorycontext" ) -type batchOperationStatus string - -const ( - batchOperationComplete batchOperationStatus = "complete" - batchOperationPartial batchOperationStatus = "partial" - batchOperationFailed batchOperationStatus = "failed" -) - -type batchOperationSummary[T any] struct { - Status batchOperationStatus `json:"status"` - Items []T `json:"items"` - Completed int `json:"completed"` - Total int `json:"total"` -} - -type threadSyncBatchResult struct { - batchOperationSummary[threadSyncItem] - Requests int `json:"requests"` - RequestBudget int `json:"request_budget"` - PlannedRequests int `json:"planned_requests"` -} - -type threadSyncOutcome interface { - threadSyncOutcome() - status() mcpcontract.BatchItemStatus - requestsUsed() int -} - -type threadSyncRepositorySuccess struct { - updated int - requests int - requestCapped bool - message string - threads []mcpcontract.ThreadRef -} - -func (threadSyncRepositorySuccess) threadSyncOutcome() {} -func (s threadSyncRepositorySuccess) status() mcpcontract.BatchItemStatus { - if s.requestCapped { - return mcpcontract.BatchItemPartial - } - return mcpcontract.BatchItemComplete -} -func (s threadSyncRepositorySuccess) requestsUsed() int { return s.requests } - -type threadSyncExactSuccess struct { - requestCapped bool - message string - threads []mcpcontract.ThreadRef -} - -func (threadSyncExactSuccess) threadSyncOutcome() {} -func (s threadSyncExactSuccess) status() mcpcontract.BatchItemStatus { - if s.requestCapped { - return mcpcontract.BatchItemPartial - } - return mcpcontract.BatchItemComplete -} -func (threadSyncExactSuccess) requestsUsed() int { return 0 } - -type threadSyncFailure struct { - itemStatus mcpcontract.BatchItemStatus - reason string - message string - retryAfterMS *int -} - -func (threadSyncFailure) threadSyncOutcome() {} -func (f threadSyncFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } -func (threadSyncFailure) requestsUsed() int { return 0 } - -type threadSyncExactFailure struct { - threadSyncFailure - threads []mcpcontract.ThreadRef -} - -type threadSyncItem struct { - key string - outcome threadSyncOutcome -} - -func successfulThreadSyncItem(key string, updated, requests int, requestCapped bool, message string, threads []mcpcontract.ThreadRef) threadSyncItem { - return threadSyncItem{key: key, outcome: threadSyncRepositorySuccess{ - updated: updated, requests: requests, requestCapped: requestCapped, message: message, threads: threads, - }} -} - -func unavailableThreadSyncItem(key, reason, message string) threadSyncItem { - return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: mcpcontract.BatchItemUnavailable, reason: reason, message: message}} -} - -func failedThreadSyncItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadSyncItem { - return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: status, reason: reason, message: message, retryAfterMS: &retryAfterMS}} -} - -func (i threadSyncItem) Status() mcpcontract.BatchItemStatus { - if i.outcome == nil { - return "" - } - return i.outcome.status() -} - -func (i threadSyncItem) RequestsUsed() int { - if i.outcome == nil { - return 0 - } - return i.outcome.requestsUsed() -} - -func (i threadSyncItem) forExactThread(key string, fallback mcpcontract.ThreadRef) (threadSyncItem, error) { - switch outcome := i.outcome.(type) { - case threadSyncRepositorySuccess: - return threadSyncItem{key: key, outcome: threadSyncExactSuccess{ - requestCapped: outcome.requestCapped, message: outcome.message, threads: outcome.threads, - }}, nil - case threadSyncFailure: - return threadSyncItem{key: key, outcome: threadSyncExactFailure{ - threadSyncFailure: outcome, threads: []mcpcontract.ThreadRef{fallback}, - }}, nil - default: - return threadSyncItem{}, errors.New("thread sync item cannot be projected to an exact thread") - } -} - -func (i threadSyncItem) MarshalJSON() ([]byte, error) { - switch outcome := i.outcome.(type) { - case threadSyncRepositorySuccess: - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Updated int `json:"updated"` - Requests int `json:"requests"` - RequestCapped bool `json:"request_capped"` - Message string `json:"message"` - Threads []mcpcontract.ThreadRef `json:"threads"` - }{i.key, outcome.status(), outcome.updated, outcome.requests, outcome.requestCapped, outcome.message, outcome.threads}) - case threadSyncExactSuccess: - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - RequestCapped bool `json:"request_capped"` - Message string `json:"message"` - Threads []mcpcontract.ThreadRef `json:"threads"` - }{i.key, outcome.status(), outcome.requestCapped, outcome.message, outcome.threads}) - case threadSyncFailure: - return marshalThreadSyncFailure(i.key, outcome, nil) - case threadSyncExactFailure: - return marshalThreadSyncFailure(i.key, outcome.threadSyncFailure, outcome.threads) - default: - return nil, errors.New("thread sync item has no supported outcome") - } -} - -func marshalThreadSyncFailure(key string, failure threadSyncFailure, threads []mcpcontract.ThreadRef) ([]byte, error) { - if failure.itemStatus == mcpcontract.BatchItemComplete || failure.itemStatus == mcpcontract.BatchItemPartial || failure.itemStatus == "" { - return nil, errors.New("thread sync failure has a non-failure status") - } - if threads != nil { - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS *int `json:"retry_after_ms,omitempty"` - Threads []mcpcontract.ThreadRef `json:"threads"` - }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS, threads}) - } - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS *int `json:"retry_after_ms,omitempty"` - }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS}) -} - -type threadHydrationBatchResult struct { - Status batchOperationStatus `json:"status"` - Items []threadHydrationItem `json:"items"` - Completed int `json:"completed"` - Total int `json:"total"` -} - -type threadHydrationSuccess struct { - kind string - requests int - facets []contracts.HydratedFacet -} - -type threadHydrationFailure struct { - reason string - message string - retryAfterMS int -} - -type threadHydrationItem struct { - key string - status mcpcontract.BatchItemStatus - success *threadHydrationSuccess - failure *threadHydrationFailure -} - -func completeThreadHydrationItem(key, kind string, requests int, facets []contracts.HydratedFacet) threadHydrationItem { - return threadHydrationItem{ - key: key, status: mcpcontract.BatchItemComplete, - success: &threadHydrationSuccess{kind: kind, requests: requests, facets: facets}, - } -} - -func failedThreadHydrationItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadHydrationItem { - return threadHydrationItem{ - key: key, status: status, - failure: &threadHydrationFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}, - } -} - -func (i threadHydrationItem) Status() mcpcontract.BatchItemStatus { return i.status } - -func (i threadHydrationItem) Reason() string { - if i.failure == nil { - return "" - } - return i.failure.reason -} - -func (i threadHydrationItem) Message() string { - if i.failure == nil { - return "" - } - return i.failure.message -} - -func (i threadHydrationItem) MarshalJSON() ([]byte, error) { - if i.success != nil && i.failure == nil { - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Kind string `json:"kind"` - HeaderRefreshed bool `json:"header_refreshed"` - Requests int `json:"requests"` - Facets []contracts.HydratedFacet `json:"facets"` - }{i.key, i.status, i.success.kind, true, i.success.requests, i.success.facets}) - } - if i.failure != nil && i.success == nil { - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS int `json:"retry_after_ms"` - }{i.key, i.status, i.failure.reason, i.failure.message, i.failure.retryAfterMS}) - } - return nil, errors.New("thread hydration item has no single outcome") -} - -type repositoryIndexBatchResult struct { - batchOperationSummary[repositoryIndexItem] - SnapshotToken string `json:"snapshot_token"` -} - -type repositoryIndexOutcome interface { - repositoryIndexOutcome() - status() mcpcontract.BatchItemStatus -} - -type repositoryIndexSuccess struct{ result contracts.AcquisitionResult } - -func (repositoryIndexSuccess) repositoryIndexOutcome() {} -func (repositoryIndexSuccess) status() mcpcontract.BatchItemStatus { - return mcpcontract.BatchItemComplete -} - -type repositoryIndexFailure struct { - reason string - message string - retryAfterMS int -} - -func (repositoryIndexFailure) repositoryIndexOutcome() {} -func (repositoryIndexFailure) status() mcpcontract.BatchItemStatus { - return mcpcontract.BatchItemFailed -} - -type repositoryIndexItem struct { - key string - outcome repositoryIndexOutcome -} - -func successfulRepositoryIndexItem(key string, result contracts.AcquisitionResult) repositoryIndexItem { - return repositoryIndexItem{key: key, outcome: repositoryIndexSuccess{result: result}} -} - -func failedRepositoryIndexItem(key, reason, message string, retryAfterMS int) repositoryIndexItem { - return repositoryIndexItem{key: key, outcome: repositoryIndexFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}} -} - -func (i repositoryIndexItem) Status() mcpcontract.BatchItemStatus { - if i.outcome == nil { - return "" - } - return i.outcome.status() -} - -func (i repositoryIndexItem) SnapshotToken() string { - if outcome, ok := i.outcome.(repositoryIndexSuccess); ok { - return outcome.result.SnapshotToken - } - return "" -} - -func (i repositoryIndexItem) MarshalJSON() ([]byte, error) { - switch outcome := i.outcome.(type) { - case repositoryIndexSuccess: - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - CommitSHA string `json:"commit_sha"` - Files int `json:"files"` - Bytes int `json:"bytes"` - Inserted bool `json:"inserted"` - SnapshotToken string `json:"snapshot_token"` - IndexManifest codeindex.Manifest `json:"index_manifest"` - ArtifactDigest string `json:"artifact_digest"` - ManifestDigest string `json:"manifest_digest"` - }{i.key, outcome.status(), outcome.result.CommitSHA, outcome.result.Files, outcome.result.Bytes, outcome.result.Inserted, - outcome.result.SnapshotToken, outcome.result.IndexManifest, outcome.result.ArtifactDigest, outcome.result.ManifestDigest}) - case repositoryIndexFailure: - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS int `json:"retry_after_ms"` - }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS}) - default: - return nil, errors.New("repository index item has no supported outcome") - } -} - -type repositoryContextBatchResult struct { - batchOperationSummary[repositoryContextItem] - Requests int `json:"requests"` - RequestBudget int `json:"request_budget"` - PlannedRequests int `json:"planned_requests"` -} - -type repositoryContextOutcome interface { - repositoryContextOutcome() - status() mcpcontract.BatchItemStatus -} - -type repositoryContextSuccess struct { - requests int - repository mcpcontract.RepositoryOutput -} - -func (repositoryContextSuccess) repositoryContextOutcome() {} -func (repositoryContextSuccess) status() mcpcontract.BatchItemStatus { - return mcpcontract.BatchItemComplete -} - -type repositoryContextBudgetFailure struct { - reason string - message string -} - -func (repositoryContextBudgetFailure) repositoryContextOutcome() {} -func (repositoryContextBudgetFailure) status() mcpcontract.BatchItemStatus { - return mcpcontract.BatchItemUnavailable -} - -type repositoryContextRequestFailure struct { - itemStatus mcpcontract.BatchItemStatus - reason string - message string - retryAfterMS int - requests int -} - -func (repositoryContextRequestFailure) repositoryContextOutcome() {} -func (f repositoryContextRequestFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } - -type repositoryContextItem struct { - key string - outcome repositoryContextOutcome -} - -func successfulRepositoryContextItem(key string, requests int, repository mcpcontract.RepositoryOutput) repositoryContextItem { - return repositoryContextItem{key: key, outcome: repositoryContextSuccess{requests: requests, repository: repository}} -} - -func unavailableRepositoryContextItem(key, reason, message string) repositoryContextItem { - return repositoryContextItem{key: key, outcome: repositoryContextBudgetFailure{reason: reason, message: message}} -} - -func failedRepositoryContextItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS, requests int) repositoryContextItem { - return repositoryContextItem{key: key, outcome: repositoryContextRequestFailure{ - itemStatus: status, reason: reason, message: message, retryAfterMS: retryAfterMS, requests: requests, - }} -} - -func (i repositoryContextItem) Status() mcpcontract.BatchItemStatus { - if i.outcome == nil { - return "" - } - return i.outcome.status() -} - -func (i repositoryContextItem) MarshalJSON() ([]byte, error) { - switch outcome := i.outcome.(type) { - case repositoryContextSuccess: - type facet struct { - Status mcpcontract.BatchItemStatus `json:"status"` - } - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Requests int `json:"requests"` - Repository mcpcontract.RepositoryOutput `json:"repository"` - Facets struct { - Metadata facet `json:"metadata"` - ContributionGuidance facet `json:"contribution_guidance"` - } `json:"facets"` - }{ - Key: i.key, Status: outcome.status(), Requests: outcome.requests, Repository: outcome.repository, - Facets: struct { - Metadata facet `json:"metadata"` - ContributionGuidance facet `json:"contribution_guidance"` - }{Metadata: facet{Status: mcpcontract.BatchItemComplete}, ContributionGuidance: facet{Status: mcpcontract.BatchItemComplete}}, - }) - case repositoryContextBudgetFailure: - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - }{i.key, outcome.status(), outcome.reason, outcome.message}) - case repositoryContextRequestFailure: - if outcome.itemStatus == mcpcontract.BatchItemComplete || outcome.itemStatus == mcpcontract.BatchItemPartial || outcome.itemStatus == "" { - return nil, errors.New("repository context failure has a non-failure status") - } - return json.Marshal(struct { - Key string `json:"key"` - Status mcpcontract.BatchItemStatus `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - RetryAfterMS int `json:"retry_after_ms"` - Requests int `json:"requests"` - }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS, outcome.requests}) - default: - return nil, errors.New("repository context item has no supported outcome") - } -} - -// SyncRepositoryContext submits a durable metadata and contribution-guidance -// GitHub read. It does not fetch threads, comments, reviews, or code. func (r *MCPReader) SyncRepositoryContext(ctx context.Context, in mcpcontract.SyncRepositoryContextInput) (mcpcontract.JobReference, error) { request, canonical, err := parseRepositoryContextSyncInput(in) if err != nil { diff --git a/internal/app/mcp_scalable_results.go b/internal/app/mcp_scalable_results.go new file mode 100644 index 00000000..d6308a5e --- /dev/null +++ b/internal/app/mcp_scalable_results.go @@ -0,0 +1,467 @@ +package app + +import ( + "encoding/json" + "errors" + + "github.com/morluto/gitcontribute/internal/codeindex" + "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type batchOperationStatus string + +const ( + batchOperationComplete batchOperationStatus = "complete" + batchOperationPartial batchOperationStatus = "partial" + batchOperationFailed batchOperationStatus = "failed" +) + +type batchOperationSummary[T any] struct { + Status batchOperationStatus `json:"status"` + Items []T `json:"items"` + Completed int `json:"completed"` + Total int `json:"total"` +} + +type threadSyncBatchResult struct { + batchOperationSummary[threadSyncItem] + Requests int `json:"requests"` + RequestBudget int `json:"request_budget"` + PlannedRequests int `json:"planned_requests"` +} + +type threadSyncOutcome interface { + threadSyncOutcome() + status() mcpcontract.BatchItemStatus + requestsUsed() int +} + +type threadSyncRepositorySuccess struct { + updated int + requests int + requestCapped bool + message string + threads []mcpcontract.ThreadRef +} + +func (threadSyncRepositorySuccess) threadSyncOutcome() {} +func (s threadSyncRepositorySuccess) status() mcpcontract.BatchItemStatus { + if s.requestCapped { + return mcpcontract.BatchItemPartial + } + return mcpcontract.BatchItemComplete +} +func (s threadSyncRepositorySuccess) requestsUsed() int { return s.requests } + +type threadSyncExactSuccess struct { + requestCapped bool + message string + threads []mcpcontract.ThreadRef +} + +func (threadSyncExactSuccess) threadSyncOutcome() {} +func (s threadSyncExactSuccess) status() mcpcontract.BatchItemStatus { + if s.requestCapped { + return mcpcontract.BatchItemPartial + } + return mcpcontract.BatchItemComplete +} +func (threadSyncExactSuccess) requestsUsed() int { return 0 } + +type threadSyncFailure struct { + itemStatus mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS *int +} + +func (threadSyncFailure) threadSyncOutcome() {} +func (f threadSyncFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } +func (threadSyncFailure) requestsUsed() int { return 0 } + +type threadSyncExactFailure struct { + threadSyncFailure + threads []mcpcontract.ThreadRef +} + +type threadSyncItem struct { + key string + outcome threadSyncOutcome +} + +func successfulThreadSyncItem(key string, updated, requests int, requestCapped bool, message string, threads []mcpcontract.ThreadRef) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncRepositorySuccess{ + updated: updated, requests: requests, requestCapped: requestCapped, message: message, threads: threads, + }} +} + +func unavailableThreadSyncItem(key, reason, message string) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: mcpcontract.BatchItemUnavailable, reason: reason, message: message}} +} + +func failedThreadSyncItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadSyncItem { + return threadSyncItem{key: key, outcome: threadSyncFailure{itemStatus: status, reason: reason, message: message, retryAfterMS: &retryAfterMS}} +} + +func (i threadSyncItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" + } + return i.outcome.status() +} + +func (i threadSyncItem) RequestsUsed() int { + if i.outcome == nil { + return 0 + } + return i.outcome.requestsUsed() +} + +func (i threadSyncItem) forExactThread(key string, fallback mcpcontract.ThreadRef) (threadSyncItem, error) { + switch outcome := i.outcome.(type) { + case threadSyncRepositorySuccess: + return threadSyncItem{key: key, outcome: threadSyncExactSuccess{ + requestCapped: outcome.requestCapped, message: outcome.message, threads: outcome.threads, + }}, nil + case threadSyncFailure: + return threadSyncItem{key: key, outcome: threadSyncExactFailure{ + threadSyncFailure: outcome, threads: []mcpcontract.ThreadRef{fallback}, + }}, nil + default: + return threadSyncItem{}, errors.New("thread sync item cannot be projected to an exact thread") + } +} + +func (i threadSyncItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case threadSyncRepositorySuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Updated int `json:"updated"` + Requests int `json:"requests"` + RequestCapped bool `json:"request_capped"` + Message string `json:"message"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{i.key, outcome.status(), outcome.updated, outcome.requests, outcome.requestCapped, outcome.message, outcome.threads}) + case threadSyncExactSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + RequestCapped bool `json:"request_capped"` + Message string `json:"message"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{i.key, outcome.status(), outcome.requestCapped, outcome.message, outcome.threads}) + case threadSyncFailure: + return marshalThreadSyncFailure(i.key, outcome, nil) + case threadSyncExactFailure: + return marshalThreadSyncFailure(i.key, outcome.threadSyncFailure, outcome.threads) + default: + return nil, errors.New("thread sync item has no supported outcome") + } +} + +func marshalThreadSyncFailure(key string, failure threadSyncFailure, threads []mcpcontract.ThreadRef) ([]byte, error) { + if failure.itemStatus == mcpcontract.BatchItemComplete || failure.itemStatus == mcpcontract.BatchItemPartial || failure.itemStatus == "" { + return nil, errors.New("thread sync failure has a non-failure status") + } + if threads != nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS *int `json:"retry_after_ms,omitempty"` + Threads []mcpcontract.ThreadRef `json:"threads"` + }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS, threads}) + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS *int `json:"retry_after_ms,omitempty"` + }{key, failure.itemStatus, failure.reason, failure.message, failure.retryAfterMS}) +} + +type threadHydrationBatchResult struct { + Status batchOperationStatus `json:"status"` + Items []threadHydrationItem `json:"items"` + Completed int `json:"completed"` + Total int `json:"total"` +} + +type threadHydrationSuccess struct { + kind string + requests int + facets []contracts.HydratedFacet +} + +type threadHydrationFailure struct { + reason string + message string + retryAfterMS int +} + +type threadHydrationItem struct { + key string + status mcpcontract.BatchItemStatus + success *threadHydrationSuccess + failure *threadHydrationFailure +} + +func completeThreadHydrationItem(key, kind string, requests int, facets []contracts.HydratedFacet) threadHydrationItem { + return threadHydrationItem{ + key: key, status: mcpcontract.BatchItemComplete, + success: &threadHydrationSuccess{kind: kind, requests: requests, facets: facets}, + } +} + +func failedThreadHydrationItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS int) threadHydrationItem { + return threadHydrationItem{ + key: key, status: status, + failure: &threadHydrationFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}, + } +} + +func (i threadHydrationItem) Status() mcpcontract.BatchItemStatus { return i.status } + +func (i threadHydrationItem) Reason() string { + if i.failure == nil { + return "" + } + return i.failure.reason +} + +func (i threadHydrationItem) Message() string { + if i.failure == nil { + return "" + } + return i.failure.message +} + +func (i threadHydrationItem) MarshalJSON() ([]byte, error) { + if i.success != nil && i.failure == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Kind string `json:"kind"` + HeaderRefreshed bool `json:"header_refreshed"` + Requests int `json:"requests"` + Facets []contracts.HydratedFacet `json:"facets"` + }{i.key, i.status, i.success.kind, true, i.success.requests, i.success.facets}) + } + if i.failure != nil && i.success == nil { + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, i.status, i.failure.reason, i.failure.message, i.failure.retryAfterMS}) + } + return nil, errors.New("thread hydration item has no single outcome") +} + +type repositoryIndexBatchResult struct { + batchOperationSummary[repositoryIndexItem] + SnapshotToken string `json:"snapshot_token"` +} + +type repositoryIndexOutcome interface { + repositoryIndexOutcome() + status() mcpcontract.BatchItemStatus +} + +type repositoryIndexSuccess struct{ result contracts.AcquisitionResult } + +func (repositoryIndexSuccess) repositoryIndexOutcome() {} +func (repositoryIndexSuccess) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemComplete +} + +type repositoryIndexFailure struct { + reason string + message string + retryAfterMS int +} + +func (repositoryIndexFailure) repositoryIndexOutcome() {} +func (repositoryIndexFailure) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemFailed +} + +type repositoryIndexItem struct { + key string + outcome repositoryIndexOutcome +} + +func successfulRepositoryIndexItem(key string, result contracts.AcquisitionResult) repositoryIndexItem { + return repositoryIndexItem{key: key, outcome: repositoryIndexSuccess{result: result}} +} + +func failedRepositoryIndexItem(key, reason, message string, retryAfterMS int) repositoryIndexItem { + return repositoryIndexItem{key: key, outcome: repositoryIndexFailure{reason: reason, message: message, retryAfterMS: retryAfterMS}} +} + +func (i repositoryIndexItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" + } + return i.outcome.status() +} + +func (i repositoryIndexItem) SnapshotToken() string { + if outcome, ok := i.outcome.(repositoryIndexSuccess); ok { + return outcome.result.SnapshotToken + } + return "" +} + +func (i repositoryIndexItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case repositoryIndexSuccess: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + CommitSHA string `json:"commit_sha"` + Files int `json:"files"` + Bytes int `json:"bytes"` + Inserted bool `json:"inserted"` + SnapshotToken string `json:"snapshot_token"` + IndexManifest codeindex.Manifest `json:"index_manifest"` + ArtifactDigest string `json:"artifact_digest"` + ManifestDigest string `json:"manifest_digest"` + }{i.key, outcome.status(), outcome.result.CommitSHA, outcome.result.Files, outcome.result.Bytes, outcome.result.Inserted, + outcome.result.SnapshotToken, outcome.result.IndexManifest, outcome.result.ArtifactDigest, outcome.result.ManifestDigest}) + case repositoryIndexFailure: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS}) + default: + return nil, errors.New("repository index item has no supported outcome") + } +} + +type repositoryContextBatchResult struct { + batchOperationSummary[repositoryContextItem] + Requests int `json:"requests"` + RequestBudget int `json:"request_budget"` + PlannedRequests int `json:"planned_requests"` +} + +type repositoryContextOutcome interface { + repositoryContextOutcome() + status() mcpcontract.BatchItemStatus +} + +type repositoryContextSuccess struct { + requests int + repository mcpcontract.RepositoryOutput +} + +func (repositoryContextSuccess) repositoryContextOutcome() {} +func (repositoryContextSuccess) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemComplete +} + +type repositoryContextBudgetFailure struct { + reason string + message string +} + +func (repositoryContextBudgetFailure) repositoryContextOutcome() {} +func (repositoryContextBudgetFailure) status() mcpcontract.BatchItemStatus { + return mcpcontract.BatchItemUnavailable +} + +type repositoryContextRequestFailure struct { + itemStatus mcpcontract.BatchItemStatus + reason string + message string + retryAfterMS int + requests int +} + +func (repositoryContextRequestFailure) repositoryContextOutcome() {} +func (f repositoryContextRequestFailure) status() mcpcontract.BatchItemStatus { return f.itemStatus } + +type repositoryContextItem struct { + key string + outcome repositoryContextOutcome +} + +func successfulRepositoryContextItem(key string, requests int, repository mcpcontract.RepositoryOutput) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextSuccess{requests: requests, repository: repository}} +} + +func unavailableRepositoryContextItem(key, reason, message string) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextBudgetFailure{reason: reason, message: message}} +} + +func failedRepositoryContextItem(key string, status mcpcontract.BatchItemStatus, reason, message string, retryAfterMS, requests int) repositoryContextItem { + return repositoryContextItem{key: key, outcome: repositoryContextRequestFailure{ + itemStatus: status, reason: reason, message: message, retryAfterMS: retryAfterMS, requests: requests, + }} +} + +func (i repositoryContextItem) Status() mcpcontract.BatchItemStatus { + if i.outcome == nil { + return "" + } + return i.outcome.status() +} + +func (i repositoryContextItem) MarshalJSON() ([]byte, error) { + switch outcome := i.outcome.(type) { + case repositoryContextSuccess: + type facet struct { + Status mcpcontract.BatchItemStatus `json:"status"` + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Requests int `json:"requests"` + Repository mcpcontract.RepositoryOutput `json:"repository"` + Facets struct { + Metadata facet `json:"metadata"` + ContributionGuidance facet `json:"contribution_guidance"` + } `json:"facets"` + }{ + Key: i.key, Status: outcome.status(), Requests: outcome.requests, Repository: outcome.repository, + Facets: struct { + Metadata facet `json:"metadata"` + ContributionGuidance facet `json:"contribution_guidance"` + }{Metadata: facet{Status: mcpcontract.BatchItemComplete}, ContributionGuidance: facet{Status: mcpcontract.BatchItemComplete}}, + }) + case repositoryContextBudgetFailure: + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + }{i.key, outcome.status(), outcome.reason, outcome.message}) + case repositoryContextRequestFailure: + if outcome.itemStatus == mcpcontract.BatchItemComplete || outcome.itemStatus == mcpcontract.BatchItemPartial || outcome.itemStatus == "" { + return nil, errors.New("repository context failure has a non-failure status") + } + return json.Marshal(struct { + Key string `json:"key"` + Status mcpcontract.BatchItemStatus `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + RetryAfterMS int `json:"retry_after_ms"` + Requests int `json:"requests"` + }{i.key, outcome.status(), outcome.reason, outcome.message, outcome.retryAfterMS, outcome.requests}) + default: + return nil, errors.New("repository context item has no supported outcome") + } +} + +// SyncRepositoryContext submits a durable metadata and contribution-guidance +// GitHub read. It does not fetch threads, comments, reviews, or code. diff --git a/internal/app/mcp_scalable_test.go b/internal/app/mcp_scalable_test.go index 8f641b69..4f5c57b2 100644 --- a/internal/app/mcp_scalable_test.go +++ b/internal/app/mcp_scalable_test.go @@ -3,7 +3,6 @@ package app import ( "context" "encoding/json" - "errors" "reflect" "strings" "testing" @@ -13,7 +12,6 @@ import ( "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" ) @@ -610,144 +608,6 @@ func TestGetJobsDetailedReturnsTypedArtifactsWithoutStoredPayloads(t *testing.T) } } -type fakeRepositorySearchReader struct { - github.Reader - result github.RepositorySearchResult - options github.RepositorySearchOptions -} - -func (f *fakeRepositorySearchReader) SearchRepositories(_ context.Context, options github.RepositorySearchOptions) (github.RepositorySearchResult, error) { - f.options = options - return f.result, nil -} - -func TestSearchGitHubRepositoriesPersistsObservedMetadata(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newSearchTestService(t) - now := time.Unix(1000, 0).UTC() - remote := github.Repository{Owner: "acme", Name: "rocket", Description: "fast inference", Stars: 9001, Language: "Go", UpdatedAt: now} - reader := &fakeRepositorySearchReader{result: github.RepositorySearchResult{Total: 321, Items: []github.Repository{remote}, Page: github.PageInfo{Page: 2, NextPage: 3, HasNext: true}}} - svc.SetGitHubReader(reader) - - out, err := (&MCPReader{svc}).SearchGitHubRepositories(ctx, mcpcontract.SearchGitHubRepositoriesInput{Text: "fast inference", MatchFields: []string{"name", "description"}, Topics: []string{"llm-inference"}, Language: "Go", StarsMin: ptr(200), PushedAfter: "2026-06-15", Archived: ptr(false), Fork: ptr(false), Sort: "stars", Order: "desc", Limit: 12, Page: 2, ResponseFormat: "concise"}) - if err != nil { - t.Fatal(err) - } - if reader.options.PerPage != 12 || reader.options.Page != 2 || reader.options.Sort != "stars" || reader.options.Query != `"fast inference" in:name,description topic:llm-inference language:Go stars:>=200 pushed:>=2026-06-15 archived:false fork:false` { - t.Fatalf("compiled options = %+v", reader.options) - } - if out.NextPage != 3 || out.ResponseFormat != "concise" || len(out.Items) != 1 || out.Items[0].Value == nil || out.Items[0].Value.Ref != "repository:acme/rocket" || *out.Items[0].Value.Stars != 9001 { - t.Fatalf("live search result = %+v, options = %+v", out, reader.options) - } - if out.Items[0].Value.Watchers != nil || len(out.RecoveryPlans) != 1 || len(out.RecoveryPlans[0].Then) != 1 || out.RecoveryPlans[0].Then[0].Type() != "sync_threads" { - t.Fatalf("concise search context = %+v", out) - } - if out.Items[0].Value.DossierStatus != "missing" { - t.Fatalf("new search result dossier availability = %+v", out.Items[0].Value) - } - stored, err := (&MCPReader{svc}).GetRepositories(ctx, mcpcontract.GetRepositoriesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}}) - if err != nil { - t.Fatal(err) - } - if stored.Items[0].Value == nil || stored.Items[0].Value.Metadata.Status != "complete" || *stored.Items[0].Value.Stars != 9001 { - t.Fatalf("search metadata was not persisted: %+v", stored) - } - if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil { - t.Fatal(err) - } - out, err = (&MCPReader{svc}).SearchGitHubRepositories(ctx, mcpcontract.SearchGitHubRepositoriesInput{Text: "fast inference", Limit: 12, Page: 2}) - if err != nil { - t.Fatal(err) - } - if out.Items[0].Value == nil || out.Items[0].Value.DossierStatus != "available" || out.Items[0].Value.DossierAsOf == "" { - t.Fatalf("live search did not report local dossier availability: %+v", out) - } -} - -func TestCompileRepositorySearchRejectsAmbiguousAndInvalidInputs(t *testing.T) { - t.Parallel() - cases := []struct { - name string - in mcpcontract.SearchGitHubRepositoriesInput - }{ - {name: "empty", in: mcpcontract.SearchGitHubRepositoriesInput{}}, - {name: "raw and structured", in: mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "cuda", Language: "Go"}}, - {name: "unknown match field", in: mcpcontract.SearchGitHubRepositoriesInput{Text: "cuda", MatchFields: []string{"topics"}}}, - {name: "reversed stars", in: mcpcontract.SearchGitHubRepositoriesInput{Text: "cuda", StarsMin: ptr(20), StarsMax: ptr(10)}}, - {name: "invalid date", in: mcpcontract.SearchGitHubRepositoriesInput{PushedAfter: "yesterday"}}, - {name: "reversed dates", in: mcpcontract.SearchGitHubRepositoriesInput{CreatedAfter: "2026-07-01", CreatedBefore: "2026-06-01"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, _, _, err := compileRepositorySearch(tc.in); err == nil { - t.Fatal("invalid search was accepted") - } - }) - } -} - -func TestCompileRepositorySearchPreservesExplicitZeroStarBound(t *testing.T) { - t.Parallel() - zero := 0 - query, _, _, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{StarsMax: &zero}) - if err != nil { - t.Fatal(err) - } - if query != "stars:<=0" { - t.Fatalf("query = %q, want stars:<=0", query) - } -} - -func TestRepositorySearchValidationExamplesAreUsable(t *testing.T) { - t.Parallel() - _, _, _, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{}) - var toolErr *mcpcontract.ToolError - if !errors.As(err, &toolErr) { - t.Fatalf("error = %v, want ToolError", err) - } - if toolErr.Example["text"] != "GitHub contribution research" || !reflect.DeepEqual(toolErr.Example["match_fields"], []string{"name", "description"}) { - t.Fatalf("empty-search example = %#v", toolErr.Example) - } - - _, _, _, err = compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "language:go", Language: "Go"}) - if !errors.As(err, &toolErr) || toolErr.Example["raw_query"] != "is:public language:go stars:>=100" { - t.Fatalf("ambiguous-search example = %#v, error=%v", toolErr.Example, err) - } -} - -func TestCompileRepositorySearchWarnsAboutRawReadmeQueries(t *testing.T) { - t.Parallel() - query, interpretation, warnings, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{RawQuery: "attention in:readme"}) - if err != nil { - t.Fatal(err) - } - if query != "attention in:readme" || !strings.Contains(interpretation, "advanced raw query") || len(warnings) != 1 || warnings[0].Code != "broad_readme_match" { - t.Fatalf("raw query context = %q %q %+v", query, interpretation, warnings) - } -} - -func TestCompileRepositorySearchWarnsAboutStructuredReadmeMatching(t *testing.T) { - t.Parallel() - query, _, warnings, err := compileRepositorySearch(mcpcontract.SearchGitHubRepositoriesInput{Text: "attention", MatchFields: []string{"name", "readme"}}) - if err != nil { - t.Fatal(err) - } - if query != "attention in:name,readme" || len(warnings) != 1 || warnings[0].Code != "broad_readme_match" { - t.Fatalf("structured README warning = %q %+v", query, warnings) - } -} - -func TestRepositorySearchDetailedFormatPreservesSecondaryFacts(t *testing.T) { - t.Parallel() - archived := true - remote := github.Repository{Owner: "acme", Name: "rocket", Description: "fast", Stars: 42, Watchers: 9, Forks: 3, OpenIssues: 7, Archived: archived, Topics: []string{"cuda"}} - match := liveRepositorySearchMatch(remote, mcpcontract.RepositoryMetadataOutput{Status: "complete"}, detailedResponse) - if match.Ref != "repository:acme/rocket" || match.Watchers == nil || *match.Watchers != 9 || match.Archived == nil || !*match.Archived || len(match.Topics) != 1 { - t.Fatalf("detailed match = %+v", match) - } -} - func TestFindPrecedentsUsesClosedAndMergedHistory(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/app/neighbors.go b/internal/app/neighbors.go index 6bce86a8..75114b03 100644 --- a/internal/app/neighbors.go +++ b/internal/app/neighbors.go @@ -7,15 +7,11 @@ import ( "fmt" "sort" "strings" - "time" - "github.com/google/uuid" "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" - "github.com/morluto/gitcontribute/internal/investigation" ) const ( @@ -518,289 +514,3 @@ func sortPRCollisions(c []PullRequestCollision) { return c[i].Number < c[j].Number }) } - -type relatedWorkSubjectKind uint8 - -const ( - relatedWorkHypothesis relatedWorkSubjectKind = iota + 1 - relatedWorkOpportunity -) - -func parseRelatedWorkSubjectKind(value string) (relatedWorkSubjectKind, error) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "hypothesis": - return relatedWorkHypothesis, nil - case "opportunity": - return relatedWorkOpportunity, nil - default: - return 0, fmt.Errorf("unknown related-work target %q", value) - } -} - -func (k relatedWorkSubjectKind) String() string { - if k == relatedWorkOpportunity { - return "opportunity" - } - return "hypothesis" -} - -type relatedWorkSubject struct { - kind relatedWorkSubjectKind - id string - investigation *investigation.Investigation - query clustering.Candidate - hypothesisID string - opportunityID string -} - -func (s *Service) loadRelatedWorkSubject(ctx context.Context, kind relatedWorkSubjectKind, id string) (relatedWorkSubject, error) { - invSvc, err := s.readInvestigationSvc(ctx) - if err != nil { - return relatedWorkSubject{}, err - } - id = strings.TrimSpace(id) - var subject relatedWorkSubject - subject.kind = kind - switch kind { - case relatedWorkHypothesis: - hypothesis, err := invSvc.GetHypothesis(ctx, id) - if err != nil { - return relatedWorkSubject{}, mapInvestigationError(err) - } - subject.id = hypothesis.ID - subject.hypothesisID = hypothesis.ID - subject.investigation, err = invSvc.GetInvestigation(ctx, hypothesis.InvestigationID) - if err != nil { - return relatedWorkSubject{}, mapInvestigationError(err) - } - subject.query = candidateFromHypothesis(hypothesis, subject.investigation.Repo) - case relatedWorkOpportunity: - opportunity, err := invSvc.GetOpportunity(ctx, id) - if err != nil { - return relatedWorkSubject{}, mapInvestigationError(err) - } - subject.id = opportunity.ID - subject.hypothesisID = opportunity.HypothesisID - subject.opportunityID = opportunity.ID - subject.investigation, err = invSvc.GetInvestigation(ctx, opportunity.InvestigationID) - if err != nil { - return relatedWorkSubject{}, mapInvestigationError(err) - } - subject.query = candidateFromOpportunity(opportunity, subject.investigation.Repo) - default: - return relatedWorkSubject{}, errors.New("related-work subject was not parsed") - } - return subject, nil -} - -func (s *Service) duplicatesForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.DuplicateCheckResult, error) { - neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, allSimilarThreads, limit) - if err != nil { - return nil, err - } - findings := make([]evidence.Evidence, 0, len(neighbors)) - for _, n := range neighbors { - findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationInconclusive)) - } - hypothesisID := "" - if subject.kind == relatedWorkHypothesis { - hypothesisID = subject.hypothesisID - } - return &contracts.DuplicateCheckResult{ - HypothesisID: hypothesisID, - OpportunityID: subject.opportunityID, - Repo: subject.investigation.Repo, - Query: subject.query.Title, - Findings: findings, - SourceRevision: revision, - Limit: effectiveLimit, - Total: len(findings), - }, nil -} - -// CheckHypothesisDuplicates searches the local corpus for threads similar to -// a hypothesis, returning each finding as evidence. -func (s *Service) CheckHypothesisDuplicates(ctx context.Context, hypothesisID string, limit int) (*contracts.DuplicateCheckResult, error) { - subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) - if err != nil { - return nil, err - } - return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) -} - -// CheckOpportunityDuplicates searches the local corpus for threads similar to -// an opportunity. -func (s *Service) CheckOpportunityDuplicates(ctx context.Context, opportunityID string, limit int) (*contracts.DuplicateCheckResult, error) { - subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) - if err != nil { - return nil, err - } - return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) -} - -// CheckHypothesisCollisions searches the local corpus for open pull requests -// that may collide with a hypothesis. -func (s *Service) CheckHypothesisCollisions(ctx context.Context, hypothesisID string, limit int) (*contracts.CollisionCheckResult, error) { - subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) - if err != nil { - return nil, err - } - return s.collisionsForRelatedWorkSubject(ctx, subject, limit) -} - -// CheckOpportunityCollisions searches the local corpus for open pull requests -// that may collide with an opportunity. -func (s *Service) CheckOpportunityCollisions(ctx context.Context, opportunityID string, limit int) (*contracts.CollisionCheckResult, error) { - subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) - if err != nil { - return nil, err - } - return s.collisionsForRelatedWorkSubject(ctx, subject, limit) -} - -func (s *Service) collisionsForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.CollisionCheckResult, error) { - neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, openPullRequestsOnly, limit) - if err != nil { - return nil, err - } - findings := make([]evidence.Evidence, 0, len(neighbors)) - for _, n := range neighbors { - findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationContradicting)) - } - return &contracts.CollisionCheckResult{ - HypothesisID: subject.hypothesisID, - OpportunityID: subject.opportunityID, - Repo: subject.investigation.Repo, - Query: subject.query.Title, - Findings: findings, - SourceRevision: revision, - Limit: effectiveLimit, - Total: len(findings), - }, nil -} - -type similarThreadScope uint8 - -const ( - allSimilarThreads similarThreadScope = iota + 1 - openPullRequestsOnly -) - -func (s similarThreadScope) filters() (corpus.ThreadKindFilter, corpus.ThreadStateFilter) { - if s == openPullRequestsOnly { - return corpus.PullRequestThreadKind(), corpus.OpenThreadState() - } - return corpus.AnyThreadKind(), corpus.AnyThreadState() -} - -func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, query clustering.Candidate, scope similarThreadScope, limit int) ([]clustering.Neighbor, string, int, error) { - if !repo.IsValid() { - return nil, "", 0, errors.New("repository is required") - } - limit, err := normalizeSimilarityLimit(limit) - if err != nil { - return nil, "", 0, err - } - c, err := s.openReadOnlyCorpus(ctx) - if err != nil { - return nil, "", 0, err - } - repository, err := c.GetRepository(ctx, repo.Owner(), repo.Repo()) - if err != nil { - return nil, "", 0, err - } - if repository == nil { - // No local corpus data for this repository; return an empty result without - // performing network access. - return nil, "", limit, nil - } - kind, state := scope.filters() - threads, err := c.ListThreadsFiltered(ctx, repository.ID, kind, state, similarityCandidateLimit(limit)) - if err != nil { - return nil, "", 0, err - } - candidates := make([]clustering.Candidate, 0, len(threads)) - for _, t := range threads { - candidates = append(candidates, candidateFromThread(repo, t)) - } - all := append([]clustering.Candidate{query}, candidates...) - neighbors, err := clustering.Neighbors(ctx, query, candidates, limit) - if err != nil { - return nil, "", 0, err - } - return neighbors, clustering.SourceRevision(all), limit, nil -} - -func normalizeSimilarityLimit(limit int) (int, error) { - if limit <= 0 { - return defaultNeighborsLimit, nil - } - if limit > maxResultLimit { - return 0, fmt.Errorf("neighbors limit cannot exceed %d", maxResultLimit) - } - return limit, nil -} - -func similarityCandidateLimit(limit int) int { - return min(maxCandidateLimit, max(minCandidateLimit, limit*candidateLimitFactor)) -} - -func candidateFromHypothesis(h *investigation.Hypothesis, repo domain.RepoRef) clustering.Candidate { - body := h.Description - if h.ExpectedBehavior != "" { - body += "\n" + h.ExpectedBehavior - } - if h.ObservedBehavior != "" { - body += "\n" + h.ObservedBehavior - } - if h.PotentialImpact != "" { - body += "\n" + h.PotentialImpact - } - for _, ref := range h.SourceRefs { - if ref.URL != "" { - body += "\n" + ref.URL - } - } - for _, link := range h.Links { - if link.Ref != "" { - body += "\n" + link.Ref - } - } - return clustering.Candidate{Repo: repo, Title: h.Title, Body: body} -} - -func candidateFromOpportunity(o *investigation.Opportunity, repo domain.RepoRef) clustering.Candidate { - body := o.ProblemStatement - if o.Scope != "" { - body += "\n" + o.Scope - } - if o.Impact != "" { - body += "\n" + o.Impact - } - for _, ref := range o.SourceRefs { - if ref.URL != "" { - body += "\n" + ref.URL - } - } - return clustering.Candidate{Repo: repo, Title: o.Title, Body: body} -} - -func evidenceFromNeighbor(n clustering.Neighbor, _ domain.RepoRef, investigationID, hypothesisID, opportunityID string, relation evidence.Relation) evidence.Evidence { - path := "issues" - if n.Ref.Kind == domain.PullRequestKind { - path = "pull" - } - url := fmt.Sprintf("https://github.com/%s/%s/%s/%d", n.Ref.Owner, n.Ref.Repo, path, n.Ref.Number) - now := time.Now().UTC() - return evidence.Evidence{ - ID: uuid.NewString(), - InvestigationID: investigationID, - HypothesisID: hypothesisID, - OpportunityID: opportunityID, - Type: evidence.EvidenceTypeGitHubSource, - Relation: relation, - Description: fmt.Sprintf("possible related %s #%d: %s (score %.2f): %s", n.Ref.Kind, n.Ref.Number, n.Title, n.Score, n.Reason), - SourceRefs: []domain.SourceRef{{Source: "local-corpus", URL: url, ObservedAt: now}}, - CreatedAt: now, - } -} diff --git a/internal/app/neighbors_related_work.go b/internal/app/neighbors_related_work.go new file mode 100644 index 00000000..ae40e7e2 --- /dev/null +++ b/internal/app/neighbors_related_work.go @@ -0,0 +1,303 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "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" + "github.com/morluto/gitcontribute/internal/investigation" +) + +type relatedWorkSubjectKind uint8 + +const ( + relatedWorkHypothesis relatedWorkSubjectKind = iota + 1 + relatedWorkOpportunity +) + +func parseRelatedWorkSubjectKind(value string) (relatedWorkSubjectKind, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "hypothesis": + return relatedWorkHypothesis, nil + case "opportunity": + return relatedWorkOpportunity, nil + default: + return 0, fmt.Errorf("unknown related-work target %q", value) + } +} + +func (k relatedWorkSubjectKind) String() string { + if k == relatedWorkOpportunity { + return "opportunity" + } + return "hypothesis" +} + +type relatedWorkSubject struct { + kind relatedWorkSubjectKind + id string + investigation *investigation.Investigation + query clustering.Candidate + hypothesisID string + opportunityID string +} + +func (s *Service) loadRelatedWorkSubject(ctx context.Context, kind relatedWorkSubjectKind, id string) (relatedWorkSubject, error) { + invSvc, err := s.readInvestigationSvc(ctx) + if err != nil { + return relatedWorkSubject{}, err + } + id = strings.TrimSpace(id) + var subject relatedWorkSubject + subject.kind = kind + switch kind { + case relatedWorkHypothesis: + hypothesis, err := invSvc.GetHypothesis(ctx, id) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.id = hypothesis.ID + subject.hypothesisID = hypothesis.ID + subject.investigation, err = invSvc.GetInvestigation(ctx, hypothesis.InvestigationID) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.query = candidateFromHypothesis(hypothesis, subject.investigation.Repo) + case relatedWorkOpportunity: + opportunity, err := invSvc.GetOpportunity(ctx, id) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.id = opportunity.ID + subject.hypothesisID = opportunity.HypothesisID + subject.opportunityID = opportunity.ID + subject.investigation, err = invSvc.GetInvestigation(ctx, opportunity.InvestigationID) + if err != nil { + return relatedWorkSubject{}, mapInvestigationError(err) + } + subject.query = candidateFromOpportunity(opportunity, subject.investigation.Repo) + default: + return relatedWorkSubject{}, errors.New("related-work subject was not parsed") + } + return subject, nil +} + +func (s *Service) duplicatesForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.DuplicateCheckResult, error) { + neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, allSimilarThreads, limit) + if err != nil { + return nil, err + } + findings := make([]evidence.Evidence, 0, len(neighbors)) + for _, n := range neighbors { + findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationInconclusive)) + } + hypothesisID := "" + if subject.kind == relatedWorkHypothesis { + hypothesisID = subject.hypothesisID + } + return &contracts.DuplicateCheckResult{ + HypothesisID: hypothesisID, + OpportunityID: subject.opportunityID, + Repo: subject.investigation.Repo, + Query: subject.query.Title, + Findings: findings, + SourceRevision: revision, + Limit: effectiveLimit, + Total: len(findings), + }, nil +} + +// CheckHypothesisDuplicates searches the local corpus for threads similar to +// a hypothesis, returning each finding as evidence. +func (s *Service) CheckHypothesisDuplicates(ctx context.Context, hypothesisID string, limit int) (*contracts.DuplicateCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) + if err != nil { + return nil, err + } + return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) +} + +// CheckOpportunityDuplicates searches the local corpus for threads similar to +// an opportunity. +func (s *Service) CheckOpportunityDuplicates(ctx context.Context, opportunityID string, limit int) (*contracts.DuplicateCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) + if err != nil { + return nil, err + } + return s.duplicatesForRelatedWorkSubject(ctx, subject, limit) +} + +// CheckHypothesisCollisions searches the local corpus for open pull requests +// that may collide with a hypothesis. +func (s *Service) CheckHypothesisCollisions(ctx context.Context, hypothesisID string, limit int) (*contracts.CollisionCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkHypothesis, hypothesisID) + if err != nil { + return nil, err + } + return s.collisionsForRelatedWorkSubject(ctx, subject, limit) +} + +// CheckOpportunityCollisions searches the local corpus for open pull requests +// that may collide with an opportunity. +func (s *Service) CheckOpportunityCollisions(ctx context.Context, opportunityID string, limit int) (*contracts.CollisionCheckResult, error) { + subject, err := s.loadRelatedWorkSubject(ctx, relatedWorkOpportunity, opportunityID) + if err != nil { + return nil, err + } + return s.collisionsForRelatedWorkSubject(ctx, subject, limit) +} + +func (s *Service) collisionsForRelatedWorkSubject(ctx context.Context, subject relatedWorkSubject, limit int) (*contracts.CollisionCheckResult, error) { + neighbors, revision, effectiveLimit, err := s.findSimilarThreads(ctx, subject.investigation.Repo, subject.query, openPullRequestsOnly, limit) + if err != nil { + return nil, err + } + findings := make([]evidence.Evidence, 0, len(neighbors)) + for _, n := range neighbors { + findings = append(findings, evidenceFromNeighbor(n, subject.investigation.Repo, subject.investigation.ID, subject.hypothesisID, subject.opportunityID, evidence.RelationContradicting)) + } + return &contracts.CollisionCheckResult{ + HypothesisID: subject.hypothesisID, + OpportunityID: subject.opportunityID, + Repo: subject.investigation.Repo, + Query: subject.query.Title, + Findings: findings, + SourceRevision: revision, + Limit: effectiveLimit, + Total: len(findings), + }, nil +} + +type similarThreadScope uint8 + +const ( + allSimilarThreads similarThreadScope = iota + 1 + openPullRequestsOnly +) + +func (s similarThreadScope) filters() (corpus.ThreadKindFilter, corpus.ThreadStateFilter) { + if s == openPullRequestsOnly { + return corpus.PullRequestThreadKind(), corpus.OpenThreadState() + } + return corpus.AnyThreadKind(), corpus.AnyThreadState() +} + +func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, query clustering.Candidate, scope similarThreadScope, limit int) ([]clustering.Neighbor, string, int, error) { + if !repo.IsValid() { + return nil, "", 0, errors.New("repository is required") + } + limit, err := normalizeSimilarityLimit(limit) + if err != nil { + return nil, "", 0, err + } + c, err := s.openReadOnlyCorpus(ctx) + if err != nil { + return nil, "", 0, err + } + repository, err := c.GetRepository(ctx, repo.Owner(), repo.Repo()) + if err != nil { + return nil, "", 0, err + } + if repository == nil { + // No local corpus data for this repository; return an empty result without + // performing network access. + return nil, "", limit, nil + } + kind, state := scope.filters() + threads, err := c.ListThreadsFiltered(ctx, repository.ID, kind, state, similarityCandidateLimit(limit)) + if err != nil { + return nil, "", 0, err + } + candidates := make([]clustering.Candidate, 0, len(threads)) + for _, t := range threads { + candidates = append(candidates, candidateFromThread(repo, t)) + } + all := append([]clustering.Candidate{query}, candidates...) + neighbors, err := clustering.Neighbors(ctx, query, candidates, limit) + if err != nil { + return nil, "", 0, err + } + return neighbors, clustering.SourceRevision(all), limit, nil +} + +func normalizeSimilarityLimit(limit int) (int, error) { + if limit <= 0 { + return defaultNeighborsLimit, nil + } + if limit > maxResultLimit { + return 0, fmt.Errorf("neighbors limit cannot exceed %d", maxResultLimit) + } + return limit, nil +} + +func similarityCandidateLimit(limit int) int { + return min(maxCandidateLimit, max(minCandidateLimit, limit*candidateLimitFactor)) +} + +func candidateFromHypothesis(h *investigation.Hypothesis, repo domain.RepoRef) clustering.Candidate { + body := h.Description + if h.ExpectedBehavior != "" { + body += "\n" + h.ExpectedBehavior + } + if h.ObservedBehavior != "" { + body += "\n" + h.ObservedBehavior + } + if h.PotentialImpact != "" { + body += "\n" + h.PotentialImpact + } + for _, ref := range h.SourceRefs { + if ref.URL != "" { + body += "\n" + ref.URL + } + } + for _, link := range h.Links { + if link.Ref != "" { + body += "\n" + link.Ref + } + } + return clustering.Candidate{Repo: repo, Title: h.Title, Body: body} +} + +func candidateFromOpportunity(o *investigation.Opportunity, repo domain.RepoRef) clustering.Candidate { + body := o.ProblemStatement + if o.Scope != "" { + body += "\n" + o.Scope + } + if o.Impact != "" { + body += "\n" + o.Impact + } + for _, ref := range o.SourceRefs { + if ref.URL != "" { + body += "\n" + ref.URL + } + } + return clustering.Candidate{Repo: repo, Title: o.Title, Body: body} +} + +func evidenceFromNeighbor(n clustering.Neighbor, _ domain.RepoRef, investigationID, hypothesisID, opportunityID string, relation evidence.Relation) evidence.Evidence { + path := "issues" + if n.Ref.Kind == domain.PullRequestKind { + path = "pull" + } + url := fmt.Sprintf("https://github.com/%s/%s/%s/%d", n.Ref.Owner, n.Ref.Repo, path, n.Ref.Number) + now := time.Now().UTC() + return evidence.Evidence{ + ID: uuid.NewString(), + InvestigationID: investigationID, + HypothesisID: hypothesisID, + OpportunityID: opportunityID, + Type: evidence.EvidenceTypeGitHubSource, + Relation: relation, + Description: fmt.Sprintf("possible related %s #%d: %s (score %.2f): %s", n.Ref.Kind, n.Ref.Number, n.Title, n.Score, n.Reason), + SourceRefs: []domain.SourceRef{{Source: "local-corpus", URL: url, ObservedAt: now}}, + CreatedAt: now, + } +} diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go index b9ba60b8..ae3f93bf 100644 --- a/internal/corpus/corpus_test.go +++ b/internal/corpus/corpus_test.go @@ -776,64 +776,3 @@ func TestCoverageIsIndependentFromProjections(t *testing.T) { } _ = thread } - -func TestRunCompletionAndStats(t *testing.T) { - t.Parallel() - ctx := context.Background() - c, _ := openTestCorpus(t) - - run, err := c.StartRun(ctx, "sync") - if err != nil { - t.Fatalf("start run: %v", err) - } - if err := c.FinishRun(ctx, run.ID, `{"pages":3,"items":42}`); err != nil { - t.Fatalf("finish run: %v", err) - } - - run, err = c.GetRun(ctx, run.ID) - if err != nil { - t.Fatalf("get run: %v", err) - } - if run.State.Status() != RunStatusCompleted { - t.Fatalf("run status = %q, want completed", run.State.Status()) - } - if run.Stats != `{"pages":3,"items":42}` { - t.Fatalf("run stats = %q", run.Stats) - } - if _, ok := run.State.CompletedAt(); !ok { - t.Fatal("run completed_at is nil") - } -} - -func TestProjectionIgnoresStaleThreadObservationsBySourceUpdatedAt(t *testing.T) { - t.Parallel() - ctx := context.Background() - c, _ := openTestCorpus(t) - - repo, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "1", time.Unix(1, 0).UTC(), `{}`) - if err != nil { - t.Fatalf("apply repository: %v", err) - } - - newer := time.Unix(2000, 0).UTC() - older := time.Unix(1000, 0).UTC() - - // Apply observations out of chronological order. - if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "new", "b", "a", newer, `{}`); err != nil { - t.Fatalf("apply newer: %v", err) - } - if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "old", "b", "a", older, `{}`); err != nil { - t.Fatalf("apply older: %v", err) - } - - thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) - if err != nil { - t.Fatalf("get thread: %v", err) - } - if thread.Title != "new" { - t.Fatalf("title = %q, want new", thread.Title) - } - if !thread.SourceUpdatedAt.Equal(newer) { - t.Fatalf("source_updated_at = %v, want %v", thread.SourceUpdatedAt, newer) - } -} diff --git a/internal/corpus/portfolio_overlap_queries.go b/internal/corpus/portfolio_overlap_queries.go new file mode 100644 index 00000000..361e7a7b --- /dev/null +++ b/internal/corpus/portfolio_overlap_queries.go @@ -0,0 +1,332 @@ +package corpus + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + + "github.com/morluto/gitcontribute/internal/domain" +) + +type projectedPortfolioSignals struct { + covered bool + signals []PortfolioSignal + refs []ObservationRef +} + +func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, facet string) (out projectedPortfolioSignals, err error) { + var refs string + err = c.db.QueryRowContext(ctx, ` + SELECT s.source_observation_refs + FROM portfolio_signal_projections p + JOIN portfolio_signal_snapshots s ON s.id=p.snapshot_id + WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? + `, subject.Kind(), subject.Ref(), facet).Scan(&refs) + if errors.Is(err, sql.ErrNoRows) { + return out, nil + } + if err != nil { + return out, err + } + out.covered = true + if err := json.Unmarshal([]byte(refs), &out.refs); err != nil { + return out, fmt.Errorf("decode portfolio signal observation refs: %w", err) + } + rows, err := c.db.QueryContext(ctx, ` + SELECT s.kind, s.value, COALESCE(s.target_kind, ''), COALESCE(s.target_ref, ''), COALESCE(s.score, 0) + FROM portfolio_signal_projections p + JOIN portfolio_signals s ON s.snapshot_id=p.snapshot_id + WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? + ORDER BY s.position + `, subject.Kind(), subject.Ref(), facet) + if err != nil { + return projectedPortfolioSignals{}, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close projected portfolio signal rows: %w", closeErr) + out = projectedPortfolioSignals{} + } + }() + for rows.Next() { + var kind, value, targetKind, targetRef string + var score float64 + if err := rows.Scan(&kind, &value, &targetKind, &targetRef, &score); err != nil { + return out, err + } + signal, err := parsePortfolioSignal(kind, value, targetKind, targetRef, score) + if err != nil { + return out, fmt.Errorf("parse stored portfolio signal: %w", err) + } + out.signals = append(out.signals, signal) + } + if err := rows.Err(); err != nil { + return out, err + } + return out, nil +} + +// ListPullRequestIssueLinks returns a bounded, deterministic offline view of +// authoritative closing-issue relationships for stored pull requests. It +// performs one corpus query and preserves selected-thread ordering. +func (c *Corpus) ListPullRequestIssueLinks(ctx context.Context, repoID int64, state ThreadStateFilter, limit int) (out []PullRequestIssueLinks, capped bool, err error) { + if repoID <= 0 { + return nil, false, errors.New("repository id must be positive") + } + if limit <= 0 || limit > 10_000 { + return nil, false, errors.New("pull request issue-link limit must be between 1 and 10000") + } + stateFilter := "" + args := []any{repoID, domain.PullRequestKind} + if !state.IsAny() { + stateFilter = " AND state = ?" + args = append(args, state.String()) + } + args = append(args, limit+1, PortfolioSubjectPullRequest, PortfolioFacetLinkedIssues) + rows, err := c.db.QueryContext(ctx, ` + WITH selected AS ( + SELECT id, number, source_updated_at + FROM threads + WHERE repository_id = ? AND kind = ?`+stateFilter+` + ORDER BY source_updated_at DESC, number DESC + LIMIT ? + ) + SELECT selected.id, selected.number, projection.snapshot_id, + snapshot.source_updated_at, snapshot.source_observation_refs, + signal.value + FROM selected + LEFT JOIN portfolio_signal_projections projection + ON projection.subject_kind = ? + AND projection.subject_ref = CAST(selected.id AS TEXT) + AND projection.facet = ? + LEFT JOIN portfolio_signal_snapshots snapshot ON snapshot.id = projection.snapshot_id + LEFT JOIN portfolio_signals signal ON signal.snapshot_id = projection.snapshot_id + ORDER BY selected.source_updated_at DESC, selected.number DESC, signal.position + `, args...) + if err != nil { + return nil, false, fmt.Errorf("list pull request issue links: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close pull request issue links: %w", closeErr) + out = nil + } + }() + indexes := map[int64]int{} + for rows.Next() { + var threadID int64 + var number int + var snapshotID, sourceUpdated sql.NullInt64 + var encodedRefs, value sql.NullString + if err := rows.Scan(&threadID, &number, &snapshotID, &sourceUpdated, &encodedRefs, &value); err != nil { + return nil, false, err + } + index, ok := indexes[threadID] + if !ok { + index = len(out) + indexes[threadID] = index + out = append(out, PullRequestIssueLinks{ThreadID: threadID, Number: number, Covered: snapshotID.Valid}) + if snapshotID.Valid { + out[index].SourceUpdatedAt = scanTime(sourceUpdated.Int64) + if err := json.Unmarshal([]byte(encodedRefs.String), &out[index].SourceObservationRefs); err != nil { + return nil, false, fmt.Errorf("decode pull request issue-link observation refs: %w", err) + } + } + } + if value.Valid { + out[index].LinkedIssues = append(out[index].LinkedIssues, value.String) + } + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate pull request issue links: %w", err) + } + if len(out) > limit { + out = out[:limit] + capped = true + } + return out, capped, nil +} + +// FindPortfolioOverlaps compares candidates with exact authored PR corpus IDs. +// It is an offline read and preserves candidate input order. +func (c *Corpus) FindPortfolioOverlaps(ctx context.Context, candidates []PortfolioSubject, pullRequestThreadIDs []int64) ([]PortfolioOverlapResult, error) { + if len(candidates) == 0 || len(candidates) > 50 { + return nil, errors.New("candidates must contain 1 to 50 items") + } + if len(pullRequestThreadIDs) == 0 || len(pullRequestThreadIDs) > 100 { + return nil, errors.New("pull request ids must contain 1 to 100 items") + } + prs := append([]int64(nil), pullRequestThreadIDs...) + sort.Slice(prs, func(i, j int) bool { return prs[i] < prs[j] }) + results := make([]PortfolioOverlapResult, len(candidates)) + for i, candidate := range candidates { + result, err := c.findCandidateOverlaps(ctx, candidate, prs) + if err != nil { + return nil, err + } + results[i] = result + } + return results, nil +} + +func (c *Corpus) findCandidateOverlaps(ctx context.Context, candidate PortfolioSubject, prs []int64) (PortfolioOverlapResult, error) { + if err := validatePortfolioSubject(candidate); err != nil { + return PortfolioOverlapResult{}, err + } + result := PortfolioOverlapResult{Candidate: candidate, status: portfolioOverlapUnknown, coverage: make(map[string]bool)} + candidateFacets, allCovered, err := c.loadCandidateFacets(ctx, candidate, result.coverage) + if err != nil { + return PortfolioOverlapResult{}, err + } + for _, prID := range prs { + covered, err := c.comparePortfolioPullRequest(ctx, candidate, candidateFacets, prID, &result) + if err != nil { + return PortfolioOverlapResult{}, err + } + allCovered = allCovered && covered + } + if len(result.Matches) > 0 { + result.status = portfolioOverlapFound + } else if allCovered { + result.status = portfolioNoOverlap + } + return result, nil +} + +func (c *Corpus) loadCandidateFacets(ctx context.Context, candidate PortfolioSubject, coverage map[string]bool) (map[string]projectedPortfolioSignals, bool, error) { + facets := make(map[string]projectedPortfolioSignals) + allCovered := true + for _, facet := range requiredPortfolioFacets(candidate) { + projected, err := c.projectedSignals(ctx, candidate, facet) + if err != nil { + return nil, false, err + } + facets[facet] = projected + coverage["candidate."+facet] = projected.covered + allCovered = allCovered && projected.covered + } + return facets, allCovered, nil +} + +func (c *Corpus) comparePortfolioPullRequest(ctx context.Context, candidate PortfolioSubject, candidateFacets map[string]projectedPortfolioSignals, prID int64, result *PortfolioOverlapResult) (bool, error) { + pr, err := NewPullRequestPortfolioSubject(prID) + if err != nil { + return false, err + } + evidence, err := c.explicitPortfolioEvidence(ctx, candidate, prID) + if err != nil { + return false, err + } + allCovered := true + for _, facet := range []string{PortfolioFacetChangedFiles, PortfolioFacetLinkedIssues} { + projected, err := c.projectedSignals(ctx, pr, facet) + if err != nil { + return false, err + } + result.coverage["pull_request."+pr.Ref()+"."+facet] = projected.covered + allCovered = allCovered && projected.covered + evidence = append(evidence, overlapEvidence(candidate, pr, candidateFacets[facet], projected)...) + } + evidence = append(evidence, overlapEvidence(candidate, pr, candidateFacets[PortfolioFacetOpportunitySimilarity], projectedPortfolioSignals{})...) + if len(evidence) == 0 { + return allCovered, nil + } + sort.SliceStable(evidence, func(i, j int) bool { + if evidence[i].Kind != evidence[j].Kind { + return evidence[i].Kind < evidence[j].Kind + } + return evidence[i].Value < evidence[j].Value + }) + result.Matches = append(result.Matches, PortfolioOverlapMatch{PullRequestThreadID: prID, Evidence: evidence}) + return allCovered, nil +} + +func requiredPortfolioFacets(subject PortfolioSubject) []string { + if subject.Kind() == PortfolioSubjectPullRequest { + return []string{PortfolioFacetChangedFiles, PortfolioFacetLinkedIssues} + } + return portfolioFacets +} + +func (c *Corpus) explicitPortfolioLink(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) (*PortfolioOverlapEvidence, error) { + column := "" + switch candidate.Kind() { + case PortfolioSubjectOpportunity: + column = "opportunity_id" + case PortfolioSubjectWorkspace: + column = "workspace_id" + default: + return nil, errPortfolioLinkNotApplicable + } + var linkID int64 + err := c.db.QueryRowContext(ctx, `SELECT id FROM portfolio_links WHERE pull_request_thread_id=? AND `+column+`=? ORDER BY id LIMIT 1`, pullRequestThreadID, candidate.Ref()).Scan(&linkID) + if errors.Is(err, sql.ErrNoRows) { + return nil, errPortfolioLinkNotFound + } + if err != nil { + return nil, fmt.Errorf("read explicit portfolio link: %w", err) + } + ref, err := newPortfolioLinkObservationRef(linkID) + if err != nil { + return nil, err + } + return &PortfolioOverlapEvidence{Kind: "explicit_link", Value: candidate.Ref() + "->" + strconv.FormatInt(pullRequestThreadID, 10), SourceObservationRefs: []ObservationRef{ref}}, nil +} + +func (c *Corpus) explicitPortfolioEvidence(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) ([]PortfolioOverlapEvidence, error) { + evidence, err := c.explicitPortfolioLink(ctx, candidate, pullRequestThreadID) + if errors.Is(err, errPortfolioLinkNotApplicable) || errors.Is(err, errPortfolioLinkNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return []PortfolioOverlapEvidence{*evidence}, nil +} + +func overlapEvidence(candidate, pr PortfolioSubject, candidateSignals, prSignals projectedPortfolioSignals) []PortfolioOverlapEvidence { + var out []PortfolioOverlapEvidence + values := make(map[string]struct{}, len(prSignals.signals)) + for _, signal := range prSignals.signals { + values[signal.Kind()+"\x00"+signal.Value()] = struct{}{} + } + for _, signal := range candidateSignals.signals { + switch signal.Kind() { + case PortfolioSignalFilePath, PortfolioSignalLinkedIssue: + if _, ok := values[signal.Kind()+"\x00"+signal.Value()]; ok { + out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: signal.Value(), SourceObservationRefs: mergeObservationRefs(candidateSignals.refs, prSignals.refs)}) + } + case PortfolioSignalOpportunitySimilarity: + target, _ := signal.Target() + if target == pr { + out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: candidate.Ref() + "->" + pr.Ref(), Score: signal.Score(), SourceObservationRefs: candidateSignals.refs}) + } + } + } + return out +} + +func mergeObservationRefs(first, second []ObservationRef) []ObservationRef { + seen := make(map[ObservationRef]struct{}, len(first)+len(second)) + out := make([]ObservationRef, 0, len(first)+len(second)) + for _, refs := range [][]ObservationRef{first, second} { + for _, ref := range refs { + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + out = append(out, ref) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind() != out[j].Kind() { + return out[i].Kind() < out[j].Kind() + } + return out[i].ID() < out[j].ID() + }) + return out +} diff --git a/internal/corpus/portfolio_relationships.go b/internal/corpus/portfolio_relationships.go index b892ae13..a402a075 100644 --- a/internal/corpus/portfolio_relationships.go +++ b/internal/corpus/portfolio_relationships.go @@ -671,322 +671,3 @@ func canonicalPortfolioSignals(signals []PortfolioSignal) []PortfolioSignal { }) return out } - -type projectedPortfolioSignals struct { - covered bool - signals []PortfolioSignal - refs []ObservationRef -} - -func (c *Corpus) projectedSignals(ctx context.Context, subject PortfolioSubject, facet string) (out projectedPortfolioSignals, err error) { - var refs string - err = c.db.QueryRowContext(ctx, ` - SELECT s.source_observation_refs - FROM portfolio_signal_projections p - JOIN portfolio_signal_snapshots s ON s.id=p.snapshot_id - WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? - `, subject.Kind(), subject.Ref(), facet).Scan(&refs) - if errors.Is(err, sql.ErrNoRows) { - return out, nil - } - if err != nil { - return out, err - } - out.covered = true - if err := json.Unmarshal([]byte(refs), &out.refs); err != nil { - return out, fmt.Errorf("decode portfolio signal observation refs: %w", err) - } - rows, err := c.db.QueryContext(ctx, ` - SELECT s.kind, s.value, COALESCE(s.target_kind, ''), COALESCE(s.target_ref, ''), COALESCE(s.score, 0) - FROM portfolio_signal_projections p - JOIN portfolio_signals s ON s.snapshot_id=p.snapshot_id - WHERE p.subject_kind=? AND p.subject_ref=? AND p.facet=? - ORDER BY s.position - `, subject.Kind(), subject.Ref(), facet) - if err != nil { - return projectedPortfolioSignals{}, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("close projected portfolio signal rows: %w", closeErr) - out = projectedPortfolioSignals{} - } - }() - for rows.Next() { - var kind, value, targetKind, targetRef string - var score float64 - if err := rows.Scan(&kind, &value, &targetKind, &targetRef, &score); err != nil { - return out, err - } - signal, err := parsePortfolioSignal(kind, value, targetKind, targetRef, score) - if err != nil { - return out, fmt.Errorf("parse stored portfolio signal: %w", err) - } - out.signals = append(out.signals, signal) - } - if err := rows.Err(); err != nil { - return out, err - } - return out, nil -} - -// ListPullRequestIssueLinks returns a bounded, deterministic offline view of -// authoritative closing-issue relationships for stored pull requests. It -// performs one corpus query and preserves selected-thread ordering. -func (c *Corpus) ListPullRequestIssueLinks(ctx context.Context, repoID int64, state ThreadStateFilter, limit int) (out []PullRequestIssueLinks, capped bool, err error) { - if repoID <= 0 { - return nil, false, errors.New("repository id must be positive") - } - if limit <= 0 || limit > 10_000 { - return nil, false, errors.New("pull request issue-link limit must be between 1 and 10000") - } - stateFilter := "" - args := []any{repoID, domain.PullRequestKind} - if !state.IsAny() { - stateFilter = " AND state = ?" - args = append(args, state.String()) - } - args = append(args, limit+1, PortfolioSubjectPullRequest, PortfolioFacetLinkedIssues) - rows, err := c.db.QueryContext(ctx, ` - WITH selected AS ( - SELECT id, number, source_updated_at - FROM threads - WHERE repository_id = ? AND kind = ?`+stateFilter+` - ORDER BY source_updated_at DESC, number DESC - LIMIT ? - ) - SELECT selected.id, selected.number, projection.snapshot_id, - snapshot.source_updated_at, snapshot.source_observation_refs, - signal.value - FROM selected - LEFT JOIN portfolio_signal_projections projection - ON projection.subject_kind = ? - AND projection.subject_ref = CAST(selected.id AS TEXT) - AND projection.facet = ? - LEFT JOIN portfolio_signal_snapshots snapshot ON snapshot.id = projection.snapshot_id - LEFT JOIN portfolio_signals signal ON signal.snapshot_id = projection.snapshot_id - ORDER BY selected.source_updated_at DESC, selected.number DESC, signal.position - `, args...) - if err != nil { - return nil, false, fmt.Errorf("list pull request issue links: %w", err) - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("close pull request issue links: %w", closeErr) - out = nil - } - }() - indexes := map[int64]int{} - for rows.Next() { - var threadID int64 - var number int - var snapshotID, sourceUpdated sql.NullInt64 - var encodedRefs, value sql.NullString - if err := rows.Scan(&threadID, &number, &snapshotID, &sourceUpdated, &encodedRefs, &value); err != nil { - return nil, false, err - } - index, ok := indexes[threadID] - if !ok { - index = len(out) - indexes[threadID] = index - out = append(out, PullRequestIssueLinks{ThreadID: threadID, Number: number, Covered: snapshotID.Valid}) - if snapshotID.Valid { - out[index].SourceUpdatedAt = scanTime(sourceUpdated.Int64) - if err := json.Unmarshal([]byte(encodedRefs.String), &out[index].SourceObservationRefs); err != nil { - return nil, false, fmt.Errorf("decode pull request issue-link observation refs: %w", err) - } - } - } - if value.Valid { - out[index].LinkedIssues = append(out[index].LinkedIssues, value.String) - } - } - if err := rows.Err(); err != nil { - return nil, false, fmt.Errorf("iterate pull request issue links: %w", err) - } - if len(out) > limit { - out = out[:limit] - capped = true - } - return out, capped, nil -} - -// FindPortfolioOverlaps compares candidates with exact authored PR corpus IDs. -// It is an offline read and preserves candidate input order. -func (c *Corpus) FindPortfolioOverlaps(ctx context.Context, candidates []PortfolioSubject, pullRequestThreadIDs []int64) ([]PortfolioOverlapResult, error) { - if len(candidates) == 0 || len(candidates) > 50 { - return nil, errors.New("candidates must contain 1 to 50 items") - } - if len(pullRequestThreadIDs) == 0 || len(pullRequestThreadIDs) > 100 { - return nil, errors.New("pull request ids must contain 1 to 100 items") - } - prs := append([]int64(nil), pullRequestThreadIDs...) - sort.Slice(prs, func(i, j int) bool { return prs[i] < prs[j] }) - results := make([]PortfolioOverlapResult, len(candidates)) - for i, candidate := range candidates { - result, err := c.findCandidateOverlaps(ctx, candidate, prs) - if err != nil { - return nil, err - } - results[i] = result - } - return results, nil -} - -func (c *Corpus) findCandidateOverlaps(ctx context.Context, candidate PortfolioSubject, prs []int64) (PortfolioOverlapResult, error) { - if err := validatePortfolioSubject(candidate); err != nil { - return PortfolioOverlapResult{}, err - } - result := PortfolioOverlapResult{Candidate: candidate, status: portfolioOverlapUnknown, coverage: make(map[string]bool)} - candidateFacets, allCovered, err := c.loadCandidateFacets(ctx, candidate, result.coverage) - if err != nil { - return PortfolioOverlapResult{}, err - } - for _, prID := range prs { - covered, err := c.comparePortfolioPullRequest(ctx, candidate, candidateFacets, prID, &result) - if err != nil { - return PortfolioOverlapResult{}, err - } - allCovered = allCovered && covered - } - if len(result.Matches) > 0 { - result.status = portfolioOverlapFound - } else if allCovered { - result.status = portfolioNoOverlap - } - return result, nil -} - -func (c *Corpus) loadCandidateFacets(ctx context.Context, candidate PortfolioSubject, coverage map[string]bool) (map[string]projectedPortfolioSignals, bool, error) { - facets := make(map[string]projectedPortfolioSignals) - allCovered := true - for _, facet := range requiredPortfolioFacets(candidate) { - projected, err := c.projectedSignals(ctx, candidate, facet) - if err != nil { - return nil, false, err - } - facets[facet] = projected - coverage["candidate."+facet] = projected.covered - allCovered = allCovered && projected.covered - } - return facets, allCovered, nil -} - -func (c *Corpus) comparePortfolioPullRequest(ctx context.Context, candidate PortfolioSubject, candidateFacets map[string]projectedPortfolioSignals, prID int64, result *PortfolioOverlapResult) (bool, error) { - pr, err := NewPullRequestPortfolioSubject(prID) - if err != nil { - return false, err - } - evidence, err := c.explicitPortfolioEvidence(ctx, candidate, prID) - if err != nil { - return false, err - } - allCovered := true - for _, facet := range []string{PortfolioFacetChangedFiles, PortfolioFacetLinkedIssues} { - projected, err := c.projectedSignals(ctx, pr, facet) - if err != nil { - return false, err - } - result.coverage["pull_request."+pr.Ref()+"."+facet] = projected.covered - allCovered = allCovered && projected.covered - evidence = append(evidence, overlapEvidence(candidate, pr, candidateFacets[facet], projected)...) - } - evidence = append(evidence, overlapEvidence(candidate, pr, candidateFacets[PortfolioFacetOpportunitySimilarity], projectedPortfolioSignals{})...) - if len(evidence) == 0 { - return allCovered, nil - } - sort.SliceStable(evidence, func(i, j int) bool { - if evidence[i].Kind != evidence[j].Kind { - return evidence[i].Kind < evidence[j].Kind - } - return evidence[i].Value < evidence[j].Value - }) - result.Matches = append(result.Matches, PortfolioOverlapMatch{PullRequestThreadID: prID, Evidence: evidence}) - return allCovered, nil -} - -func requiredPortfolioFacets(subject PortfolioSubject) []string { - if subject.Kind() == PortfolioSubjectPullRequest { - return []string{PortfolioFacetChangedFiles, PortfolioFacetLinkedIssues} - } - return portfolioFacets -} - -func (c *Corpus) explicitPortfolioLink(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) (*PortfolioOverlapEvidence, error) { - column := "" - switch candidate.Kind() { - case PortfolioSubjectOpportunity: - column = "opportunity_id" - case PortfolioSubjectWorkspace: - column = "workspace_id" - default: - return nil, errPortfolioLinkNotApplicable - } - var linkID int64 - err := c.db.QueryRowContext(ctx, `SELECT id FROM portfolio_links WHERE pull_request_thread_id=? AND `+column+`=? ORDER BY id LIMIT 1`, pullRequestThreadID, candidate.Ref()).Scan(&linkID) - if errors.Is(err, sql.ErrNoRows) { - return nil, errPortfolioLinkNotFound - } - if err != nil { - return nil, fmt.Errorf("read explicit portfolio link: %w", err) - } - ref, err := newPortfolioLinkObservationRef(linkID) - if err != nil { - return nil, err - } - return &PortfolioOverlapEvidence{Kind: "explicit_link", Value: candidate.Ref() + "->" + strconv.FormatInt(pullRequestThreadID, 10), SourceObservationRefs: []ObservationRef{ref}}, nil -} - -func (c *Corpus) explicitPortfolioEvidence(ctx context.Context, candidate PortfolioSubject, pullRequestThreadID int64) ([]PortfolioOverlapEvidence, error) { - evidence, err := c.explicitPortfolioLink(ctx, candidate, pullRequestThreadID) - if errors.Is(err, errPortfolioLinkNotApplicable) || errors.Is(err, errPortfolioLinkNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return []PortfolioOverlapEvidence{*evidence}, nil -} - -func overlapEvidence(candidate, pr PortfolioSubject, candidateSignals, prSignals projectedPortfolioSignals) []PortfolioOverlapEvidence { - var out []PortfolioOverlapEvidence - values := make(map[string]struct{}, len(prSignals.signals)) - for _, signal := range prSignals.signals { - values[signal.Kind()+"\x00"+signal.Value()] = struct{}{} - } - for _, signal := range candidateSignals.signals { - switch signal.Kind() { - case PortfolioSignalFilePath, PortfolioSignalLinkedIssue: - if _, ok := values[signal.Kind()+"\x00"+signal.Value()]; ok { - out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: signal.Value(), SourceObservationRefs: mergeObservationRefs(candidateSignals.refs, prSignals.refs)}) - } - case PortfolioSignalOpportunitySimilarity: - target, _ := signal.Target() - if target == pr { - out = append(out, PortfolioOverlapEvidence{Kind: signal.Kind(), Value: candidate.Ref() + "->" + pr.Ref(), Score: signal.Score(), SourceObservationRefs: candidateSignals.refs}) - } - } - } - return out -} - -func mergeObservationRefs(first, second []ObservationRef) []ObservationRef { - seen := make(map[ObservationRef]struct{}, len(first)+len(second)) - out := make([]ObservationRef, 0, len(first)+len(second)) - for _, refs := range [][]ObservationRef{first, second} { - for _, ref := range refs { - if _, ok := seen[ref]; ok { - continue - } - seen[ref] = struct{}{} - out = append(out, ref) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Kind() != out[j].Kind() { - return out[i].Kind() < out[j].Kind() - } - return out[i].ID() < out[j].ID() - }) - return out -} diff --git a/internal/corpus/run_projection_test.go b/internal/corpus/run_projection_test.go new file mode 100644 index 00000000..d37bad36 --- /dev/null +++ b/internal/corpus/run_projection_test.go @@ -0,0 +1,70 @@ +package corpus + +import ( + "context" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) + +func TestRunCompletionAndStats(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + + run, err := c.StartRun(ctx, "sync") + if err != nil { + t.Fatalf("start run: %v", err) + } + if err := c.FinishRun(ctx, run.ID, `{"pages":3,"items":42}`); err != nil { + t.Fatalf("finish run: %v", err) + } + + run, err = c.GetRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if run.State.Status() != RunStatusCompleted { + t.Fatalf("run status = %q, want completed", run.State.Status()) + } + if run.Stats != `{"pages":3,"items":42}` { + t.Fatalf("run stats = %q", run.Stats) + } + if _, ok := run.State.CompletedAt(); !ok { + t.Fatal("run completed_at is nil") + } +} + +func TestProjectionIgnoresStaleThreadObservationsBySourceUpdatedAt(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + + repo, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "1", time.Unix(1, 0).UTC(), `{}`) + if err != nil { + t.Fatalf("apply repository: %v", err) + } + + newer := time.Unix(2000, 0).UTC() + older := time.Unix(1000, 0).UTC() + + // Apply observations out of chronological order. + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "new", "b", "a", newer, `{}`); err != nil { + t.Fatalf("apply newer: %v", err) + } + if _, err := c.ApplyThreadObservation(ctx, repo.ID, domain.IssueKind, 1, "open", "old", "b", "a", older, `{}`); err != nil { + t.Fatalf("apply older: %v", err) + } + + thread, err := c.GetThread(ctx, repo.ID, domain.IssueKind, 1) + if err != nil { + t.Fatalf("get thread: %v", err) + } + if thread.Title != "new" { + t.Fatalf("title = %q, want new", thread.Title) + } + if !thread.SourceUpdatedAt.Equal(newer) { + t.Fatalf("source_updated_at = %v, want %v", thread.SourceUpdatedAt, newer) + } +} diff --git a/internal/evidence/models.go b/internal/evidence/models.go index 02d8bafc..1e34b50a 100644 --- a/internal/evidence/models.go +++ b/internal/evidence/models.go @@ -638,198 +638,3 @@ type ComparisonResult struct { Classification ComparisonClassification Explanation string } - -// ParseStored parses validation-definition discriminators after -// durable JSON decoding. -func (d *ValidationDefinition) ParseStored() error { - if d == nil || d.ID == "" { - return errors.New("validation definition ID is required") - } - if d.Protocol != "" && d.Protocol != ValidationProtocolMCPStdio { - return fmt.Errorf("unsupported validation protocol %q", d.Protocol) - } - if d.Observation != nil && d.Observation.intent == "" { - return errors.New("stored observation contract was not parsed") - } - return nil -} - -// ParseStored parses validation-run outcomes after durable JSON -// decoding. -func (r *ValidationRun) ParseStored() error { - if r == nil || r.ID == "" || r.DefinitionID == "" { - return errors.New("validation run identity is required") - } - if !validRunKind(r.Kind) { - return fmt.Errorf("unsupported validation run kind %q", r.Kind) - } - if !validRunClassification(r.Classification) { - return fmt.Errorf("unsupported validation run classification %q", r.Classification) - } - // Empty is the legacy representation of a run with no observation contract. - if r.ObservationStatus == "" { - r.ObservationStatus = ObservationNotEvaluated - } - if !validObservationStatus(r.ObservationStatus) { - return fmt.Errorf("unsupported observation status %q", r.ObservationStatus) - } - if !validWorkspaceBindingStatus(r.WorkspaceBindingStatus) { - return fmt.Errorf("unsupported workspace binding status %q", r.WorkspaceBindingStatus) - } - if !validExecutionOrigin(r.ExecutionOrigin) { - return fmt.Errorf("unsupported execution origin %q", r.ExecutionOrigin) - } - if !validCleanupStatus(r.Cleanup.Status) { - return fmt.Errorf("unsupported cleanup status %q", r.Cleanup.Status) - } - if !validValidationPhase(r.TimeoutPhase) || !validValidationPhase(r.FailurePhase) { - return errors.New("validation run has an unsupported failure or timeout phase") - } - if r.JUnitReport != nil { - if err := r.JUnitReport.ParseStored(); err != nil { - return fmt.Errorf("stored JUnit report: %w", err) - } - } - return nil -} - -// ParseStored parses repeat-run classifications after durable JSON -// decoding. -func (g *ValidationRunGroup) ParseStored() error { - if g == nil || g.ID == "" || g.DefinitionID == "" { - return errors.New("validation run group identity is required") - } - if !validRunGroupClassification(g.Classification) { - return fmt.Errorf("unsupported validation group classification %q", g.Classification) - } - for i := range g.Attempts { - attempt := &g.Attempts[i] - if attempt.ObservationStatus == "" { - attempt.ObservationStatus = ObservationNotEvaluated - } - if !validRunKind(attempt.Kind) || !validRunClassification(attempt.Classification) || !validObservationStatus(attempt.ObservationStatus) || !validCleanupStatus(attempt.Cleanup.Status) || !validValidationPhase(attempt.TimeoutPhase) || !validValidationPhase(attempt.FailurePhase) { - return fmt.Errorf("validation attempt %d has an unsupported discriminator", i) - } - } - for i, aggregate := range g.Aggregates { - if !validRunKind(aggregate.Kind) || !validRunGroupClassification(aggregate.Classification) || !validResourceClassification(aggregate.ResourceClassification) { - return fmt.Errorf("validation aggregate %d has an unsupported discriminator", i) - } - } - if g.Comparison != nil && !validComparisonClassification(g.Comparison.Classification) { - return fmt.Errorf("unsupported validation comparison %q", g.Comparison.Classification) - } - return nil -} - -// ParseStored parses evidence type and relation claims after -// durable JSON decoding. -func (e *Evidence) ParseStored() error { - if e == nil || e.ID == "" { - return errors.New("evidence ID is required") - } - if !isValidEvidenceType(e.Type) { - return fmt.Errorf("unsupported evidence type %q", e.Type) - } - if !isValidRelation(e.Relation) { - return fmt.Errorf("unsupported evidence relation %q", e.Relation) - } - if e.ValidationDefinition != nil { - if err := e.ValidationDefinition.ParseStored(); err != nil { - return fmt.Errorf("embedded validation definition: %w", err) - } - } - if e.ValidationRun != nil { - if err := e.ValidationRun.ParseStored(); err != nil { - return fmt.Errorf("embedded validation run: %w", err) - } - } - if e.External != nil { - if _, err := ParseExternalEvidenceCompleteness(string(e.External.Completeness)); err != nil { - return fmt.Errorf("unsupported external evidence completeness %q", e.External.Completeness) - } - if e.External.Integrity != ExternalEvidenceVerified && e.External.Integrity != ExternalEvidenceUnverified { - return fmt.Errorf("unsupported external evidence integrity %q", e.External.Integrity) - } - } - return nil -} - -func validRunKind(kind RunKind) bool { return kind == RunKindBase || kind == RunKindCandidate } - -func validRunClassification(classification RunClassification) bool { - switch classification { - case RunClassificationPassing, RunClassificationFailing, RunClassificationError, RunClassificationCancelled: - return true - default: - return false - } -} - -func validObservationStatus(status ObservationStatus) bool { - switch status { - case ObservationNotEvaluated, ObservationMatched, ObservationMismatched: - return true - default: - return false - } -} - -func validCleanupStatus(status CleanupStatus) bool { - switch status { - case CleanupUnknown, CleanupClean, CleanupFailed, CleanupUnavailable: - return true - default: - return false - } -} - -func validValidationPhase(phase ValidationPhase) bool { - switch phase { - case ValidationPhaseNone, ValidationPhaseStartup, ValidationPhaseReadiness, ValidationPhaseExecution, ValidationPhaseShutdown: - return true - default: - return false - } -} - -func validWorkspaceBindingStatus(status WorkspaceBindingStatus) bool { - switch status { - case WorkspaceBindingUnknown, WorkspaceBindingUnavailable, WorkspaceBindingIncomplete, - WorkspaceBindingChanged, WorkspaceBindingBound, WorkspaceBindingStale, WorkspaceBindingIncompatible: - return true - default: - return false - } -} - -func validExecutionOrigin(origin ExecutionOrigin) bool { - return origin == ExecutionOriginLocal || origin == ExecutionOriginExternal -} - -func validResourceClassification(classification ResourceClassification) bool { - switch classification { - case ResourceUnknown, ResourceAvailable, ResourceCleanupFailed, ResourceInconclusive: - return true - default: - return false - } -} - -func validRunGroupClassification(classification RunGroupClassification) bool { - switch classification { - case RunGroupStablePass, RunGroupStableFail, RunGroupFlaky, RunGroupInconclusive, RunGroupCancelled: - return true - default: - return false - } -} - -func validComparisonClassification(classification ComparisonClassification) bool { - switch classification { - case ComparisonFixed, ComparisonNotFixed, ComparisonRegression, ComparisonNoDifference, ComparisonInconclusive: - return true - default: - return false - } -} diff --git a/internal/evidence/models_parse.go b/internal/evidence/models_parse.go new file mode 100644 index 00000000..c9b869ea --- /dev/null +++ b/internal/evidence/models_parse.go @@ -0,0 +1,201 @@ +package evidence + +import ( + "errors" + "fmt" +) + +// ParseStored parses validation-definition discriminators after +// durable JSON decoding. +func (d *ValidationDefinition) ParseStored() error { + if d == nil || d.ID == "" { + return errors.New("validation definition ID is required") + } + if d.Protocol != "" && d.Protocol != ValidationProtocolMCPStdio { + return fmt.Errorf("unsupported validation protocol %q", d.Protocol) + } + if d.Observation != nil && d.Observation.intent == "" { + return errors.New("stored observation contract was not parsed") + } + return nil +} + +// ParseStored parses validation-run outcomes after durable JSON +// decoding. +func (r *ValidationRun) ParseStored() error { + if r == nil || r.ID == "" || r.DefinitionID == "" { + return errors.New("validation run identity is required") + } + if !validRunKind(r.Kind) { + return fmt.Errorf("unsupported validation run kind %q", r.Kind) + } + if !validRunClassification(r.Classification) { + return fmt.Errorf("unsupported validation run classification %q", r.Classification) + } + // Empty is the legacy representation of a run with no observation contract. + if r.ObservationStatus == "" { + r.ObservationStatus = ObservationNotEvaluated + } + if !validObservationStatus(r.ObservationStatus) { + return fmt.Errorf("unsupported observation status %q", r.ObservationStatus) + } + if !validWorkspaceBindingStatus(r.WorkspaceBindingStatus) { + return fmt.Errorf("unsupported workspace binding status %q", r.WorkspaceBindingStatus) + } + if !validExecutionOrigin(r.ExecutionOrigin) { + return fmt.Errorf("unsupported execution origin %q", r.ExecutionOrigin) + } + if !validCleanupStatus(r.Cleanup.Status) { + return fmt.Errorf("unsupported cleanup status %q", r.Cleanup.Status) + } + if !validValidationPhase(r.TimeoutPhase) || !validValidationPhase(r.FailurePhase) { + return errors.New("validation run has an unsupported failure or timeout phase") + } + if r.JUnitReport != nil { + if err := r.JUnitReport.ParseStored(); err != nil { + return fmt.Errorf("stored JUnit report: %w", err) + } + } + return nil +} + +// ParseStored parses repeat-run classifications after durable JSON +// decoding. +func (g *ValidationRunGroup) ParseStored() error { + if g == nil || g.ID == "" || g.DefinitionID == "" { + return errors.New("validation run group identity is required") + } + if !validRunGroupClassification(g.Classification) { + return fmt.Errorf("unsupported validation group classification %q", g.Classification) + } + for i := range g.Attempts { + attempt := &g.Attempts[i] + if attempt.ObservationStatus == "" { + attempt.ObservationStatus = ObservationNotEvaluated + } + if !validRunKind(attempt.Kind) || !validRunClassification(attempt.Classification) || !validObservationStatus(attempt.ObservationStatus) || !validCleanupStatus(attempt.Cleanup.Status) || !validValidationPhase(attempt.TimeoutPhase) || !validValidationPhase(attempt.FailurePhase) { + return fmt.Errorf("validation attempt %d has an unsupported discriminator", i) + } + } + for i, aggregate := range g.Aggregates { + if !validRunKind(aggregate.Kind) || !validRunGroupClassification(aggregate.Classification) || !validResourceClassification(aggregate.ResourceClassification) { + return fmt.Errorf("validation aggregate %d has an unsupported discriminator", i) + } + } + if g.Comparison != nil && !validComparisonClassification(g.Comparison.Classification) { + return fmt.Errorf("unsupported validation comparison %q", g.Comparison.Classification) + } + return nil +} + +// ParseStored parses evidence type and relation claims after +// durable JSON decoding. +func (e *Evidence) ParseStored() error { + if e == nil || e.ID == "" { + return errors.New("evidence ID is required") + } + if !isValidEvidenceType(e.Type) { + return fmt.Errorf("unsupported evidence type %q", e.Type) + } + if !isValidRelation(e.Relation) { + return fmt.Errorf("unsupported evidence relation %q", e.Relation) + } + if e.ValidationDefinition != nil { + if err := e.ValidationDefinition.ParseStored(); err != nil { + return fmt.Errorf("embedded validation definition: %w", err) + } + } + if e.ValidationRun != nil { + if err := e.ValidationRun.ParseStored(); err != nil { + return fmt.Errorf("embedded validation run: %w", err) + } + } + if e.External != nil { + if _, err := ParseExternalEvidenceCompleteness(string(e.External.Completeness)); err != nil { + return fmt.Errorf("unsupported external evidence completeness %q", e.External.Completeness) + } + if e.External.Integrity != ExternalEvidenceVerified && e.External.Integrity != ExternalEvidenceUnverified { + return fmt.Errorf("unsupported external evidence integrity %q", e.External.Integrity) + } + } + return nil +} + +func validRunKind(kind RunKind) bool { return kind == RunKindBase || kind == RunKindCandidate } + +func validRunClassification(classification RunClassification) bool { + switch classification { + case RunClassificationPassing, RunClassificationFailing, RunClassificationError, RunClassificationCancelled: + return true + default: + return false + } +} + +func validObservationStatus(status ObservationStatus) bool { + switch status { + case ObservationNotEvaluated, ObservationMatched, ObservationMismatched: + return true + default: + return false + } +} + +func validCleanupStatus(status CleanupStatus) bool { + switch status { + case CleanupUnknown, CleanupClean, CleanupFailed, CleanupUnavailable: + return true + default: + return false + } +} + +func validValidationPhase(phase ValidationPhase) bool { + switch phase { + case ValidationPhaseNone, ValidationPhaseStartup, ValidationPhaseReadiness, ValidationPhaseExecution, ValidationPhaseShutdown: + return true + default: + return false + } +} + +func validWorkspaceBindingStatus(status WorkspaceBindingStatus) bool { + switch status { + case WorkspaceBindingUnknown, WorkspaceBindingUnavailable, WorkspaceBindingIncomplete, + WorkspaceBindingChanged, WorkspaceBindingBound, WorkspaceBindingStale, WorkspaceBindingIncompatible: + return true + default: + return false + } +} + +func validExecutionOrigin(origin ExecutionOrigin) bool { + return origin == ExecutionOriginLocal || origin == ExecutionOriginExternal +} + +func validResourceClassification(classification ResourceClassification) bool { + switch classification { + case ResourceUnknown, ResourceAvailable, ResourceCleanupFailed, ResourceInconclusive: + return true + default: + return false + } +} + +func validRunGroupClassification(classification RunGroupClassification) bool { + switch classification { + case RunGroupStablePass, RunGroupStableFail, RunGroupFlaky, RunGroupInconclusive, RunGroupCancelled: + return true + default: + return false + } +} + +func validComparisonClassification(classification ComparisonClassification) bool { + switch classification { + case ComparisonFixed, ComparisonNotFixed, ComparisonRegression, ComparisonNoDifference, ComparisonInconclusive: + return true + default: + return false + } +} diff --git a/internal/mcpcontract/discovery_resource_contracts.go b/internal/mcpcontract/discovery_resource_contracts.go new file mode 100644 index 00000000..ca7b5279 --- /dev/null +++ b/internal/mcpcontract/discovery_resource_contracts.go @@ -0,0 +1,163 @@ +package mcpcontract + +import ( + "github.com/morluto/gitcontribute/internal/lens" + "github.com/morluto/gitcontribute/internal/similarity" +) + +// ClusterTarget selects one repository or one exact cluster member. +type ClusterTarget struct { + Owner string `json:"owner" jsonschema:"GitHub repository owner"` + Repo string `json:"repo" jsonschema:"GitHub repository name"` + Kind string `json:"kind,omitempty" jsonschema:"Optional member kind: issue or pull_request"` + Number int `json:"number,omitempty" jsonschema:"Optional positive member number"` +} + +// FindClustersInput selects up to 20 repositories or exact cluster members. +type FindClustersInput struct { + Targets []ClusterTarget `json:"targets" jsonschema:"One to 20 repository or exact-member targets"` + Limit int `json:"limit,omitempty" jsonschema:"Maximum clusters per target from 1 to 100"` + SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` +} + +// FindNeighborsInput selects source threads and bounds similar-thread results. +type FindNeighborsInput struct { + Threads []ThreadRef `json:"threads" jsonschema:"One to 20 exact source threads"` + Limit int `json:"limit,omitempty" jsonschema:"Maximum neighbors per source thread from 1 to 100"` + SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` +} + +// NeighborOutput describes one similar stored thread and its score. +type NeighborOutput struct { + Kind string `json:"kind"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Score SimilarityScore `json:"score"` + Reason string `json:"reason"` +} + +// NeighborSetOutput contains deterministic neighbors for one stored thread. +type NeighborSetOutput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Number int `json:"number"` + SourceRevision string `json:"source_revision"` + Neighbors []NeighborOutput `json:"neighbors"` +} + +// FindNeighborsOutput preserves source-thread order and isolates item failures. +type FindNeighborsOutput struct { + Status string `json:"status"` + Items []BatchItem[NeighborSetOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` +} + +// ClusterMemberOutput describes one member of a duplicate cluster. +type ClusterMemberOutput struct { + Kind string `json:"kind"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title,omitempty"` + State string `json:"state,omitempty"` + Score SimilarityScore `json:"score"` + Reason string `json:"reason"` + Included bool `json:"included"` +} + +// ClusterOutput contains a stable duplicate cluster and its canonical member. +type ClusterOutput struct { + StableID string `json:"stable_id"` + State string `json:"state"` + Canonical ClusterMemberOutput `json:"canonical"` + MemberCount int `json:"member_count"` + Members []ClusterMemberOutput `json:"members,omitempty"` +} + +// ClusterSetOutput contains duplicate clusters for one repository target. +type ClusterSetOutput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + RuleVersion similarity.RuleVersion `json:"rule_version,omitempty"` + Total int `json:"total"` + Clusters []ClusterOutput `json:"clusters"` + Truncated bool `json:"truncated" jsonschema:"Whether more clusters matched"` + Recovery *RecoveryPlan `json:"recovery,omitempty"` +} + +// FindClustersOutput preserves target order and isolates item failures. +type FindClustersOutput struct { + Status string `json:"status"` + Items []BatchItem[ClusterSetOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` +} + +type CoverageTargetKind string + +const ( + CoverageTargetRepository CoverageTargetKind = "repository" + CoverageTargetExactThread CoverageTargetKind = "exact_thread" +) + +type ExactCoverageThread struct { + Kind string `json:"kind" jsonschema:"Thread kind: issue or pull_request"` + Number int `json:"number" jsonschema:"Positive issue or pull request number"` +} + +// CoverageTarget is an explicit discriminated target. Thread is required only +// for exact_thread and forbidden for repository. +type CoverageTarget struct { + Type CoverageTargetKind `json:"type" jsonschema:"Target variant: repository or exact_thread"` + Repository RepositoryRef `json:"repository"` + Thread *ExactCoverageThread `json:"thread,omitempty"` +} + +// GetCoverageInput selects bounded repository or thread facet coverage reads. +type GetCoverageInput struct { + Targets []CoverageTarget `json:"targets" jsonschema:"One to 100 repository or exact-thread targets"` + SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` +} + +// FacetCoverageOutput reports completeness and freshness for one facet. +type FacetCoverageOutput struct { + Facet string `json:"facet"` + Complete bool `json:"complete"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` +} + +// CoverageOutput reports all known coverage for one repository or thread. +type CoverageOutput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Kind string `json:"kind,omitempty"` + Number int `json:"number,omitempty"` + AsOf string `json:"as_of"` + Facets []FacetCoverageOutput `json:"facets"` +} + +// GetCoverageOutput preserves target order and isolates missing or invalid +// targets without failing unrelated coverage reads. +type GetCoverageOutput struct { + Status string `json:"status"` + Items []BatchItem[CoverageOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` + Provenance CorpusReadProvenance `json:"provenance"` +} + +// LensInput selects a saved lens by name. +type LensInput struct { + Name string `json:"name" jsonschema:"Lens name"` +} + +// LensOutput contains a saved lens definition and timestamps. +type LensOutput struct { + Name string `json:"name"` + Definition lens.Definition `json:"definition"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/mcpcontract/resource_contracts.go b/internal/mcpcontract/resource_contracts.go index 56523c39..2f7a760d 100644 --- a/internal/mcpcontract/resource_contracts.go +++ b/internal/mcpcontract/resource_contracts.go @@ -5,9 +5,6 @@ import ( "context" "encoding/json" "errors" - - "github.com/morluto/gitcontribute/internal/lens" - "github.com/morluto/gitcontribute/internal/similarity" ) // WorkspaceResource is the canonical host-path-free representation of a @@ -703,160 +700,3 @@ type OpportunityOutput struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } - -// ClusterTarget selects one repository or one exact cluster member. -type ClusterTarget struct { - Owner string `json:"owner" jsonschema:"GitHub repository owner"` - Repo string `json:"repo" jsonschema:"GitHub repository name"` - Kind string `json:"kind,omitempty" jsonschema:"Optional member kind: issue or pull_request"` - Number int `json:"number,omitempty" jsonschema:"Optional positive member number"` -} - -// FindClustersInput selects up to 20 repositories or exact cluster members. -type FindClustersInput struct { - Targets []ClusterTarget `json:"targets" jsonschema:"One to 20 repository or exact-member targets"` - Limit int `json:"limit,omitempty" jsonschema:"Maximum clusters per target from 1 to 100"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` -} - -// FindNeighborsInput selects source threads and bounds similar-thread results. -type FindNeighborsInput struct { - Threads []ThreadRef `json:"threads" jsonschema:"One to 20 exact source threads"` - Limit int `json:"limit,omitempty" jsonschema:"Maximum neighbors per source thread from 1 to 100"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` -} - -// NeighborOutput describes one similar stored thread and its score. -type NeighborOutput struct { - Kind string `json:"kind"` - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - Score SimilarityScore `json:"score"` - Reason string `json:"reason"` -} - -// NeighborSetOutput contains deterministic neighbors for one stored thread. -type NeighborSetOutput struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Kind string `json:"kind"` - Number int `json:"number"` - SourceRevision string `json:"source_revision"` - Neighbors []NeighborOutput `json:"neighbors"` -} - -// FindNeighborsOutput preserves source-thread order and isolates item failures. -type FindNeighborsOutput struct { - Status string `json:"status"` - Items []BatchItem[NeighborSetOutput] `json:"items"` - SnapshotToken string `json:"snapshot_token"` -} - -// ClusterMemberOutput describes one member of a duplicate cluster. -type ClusterMemberOutput struct { - Kind string `json:"kind"` - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number"` - Title string `json:"title,omitempty"` - State string `json:"state,omitempty"` - Score SimilarityScore `json:"score"` - Reason string `json:"reason"` - Included bool `json:"included"` -} - -// ClusterOutput contains a stable duplicate cluster and its canonical member. -type ClusterOutput struct { - StableID string `json:"stable_id"` - State string `json:"state"` - Canonical ClusterMemberOutput `json:"canonical"` - MemberCount int `json:"member_count"` - Members []ClusterMemberOutput `json:"members,omitempty"` -} - -// ClusterSetOutput contains duplicate clusters for one repository target. -type ClusterSetOutput struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - RuleVersion similarity.RuleVersion `json:"rule_version,omitempty"` - Total int `json:"total"` - Clusters []ClusterOutput `json:"clusters"` - Truncated bool `json:"truncated" jsonschema:"Whether more clusters matched"` - Recovery *RecoveryPlan `json:"recovery,omitempty"` -} - -// FindClustersOutput preserves target order and isolates item failures. -type FindClustersOutput struct { - Status string `json:"status"` - Items []BatchItem[ClusterSetOutput] `json:"items"` - SnapshotToken string `json:"snapshot_token"` -} - -type CoverageTargetKind string - -const ( - CoverageTargetRepository CoverageTargetKind = "repository" - CoverageTargetExactThread CoverageTargetKind = "exact_thread" -) - -type ExactCoverageThread struct { - Kind string `json:"kind" jsonschema:"Thread kind: issue or pull_request"` - Number int `json:"number" jsonschema:"Positive issue or pull request number"` -} - -// CoverageTarget is an explicit discriminated target. Thread is required only -// for exact_thread and forbidden for repository. -type CoverageTarget struct { - Type CoverageTargetKind `json:"type" jsonschema:"Target variant: repository or exact_thread"` - Repository RepositoryRef `json:"repository"` - Thread *ExactCoverageThread `json:"thread,omitempty"` -} - -// GetCoverageInput selects bounded repository or thread facet coverage reads. -type GetCoverageInput struct { - Targets []CoverageTarget `json:"targets" jsonschema:"One to 100 repository or exact-thread targets"` - SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token from a previous offline read"` -} - -// FacetCoverageOutput reports completeness and freshness for one facet. -type FacetCoverageOutput struct { - Facet string `json:"facet"` - Complete bool `json:"complete"` - Status string `json:"status"` - UpdatedAt string `json:"updated_at"` -} - -// CoverageOutput reports all known coverage for one repository or thread. -type CoverageOutput struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Kind string `json:"kind,omitempty"` - Number int `json:"number,omitempty"` - AsOf string `json:"as_of"` - Facets []FacetCoverageOutput `json:"facets"` -} - -// GetCoverageOutput preserves target order and isolates missing or invalid -// targets without failing unrelated coverage reads. -type GetCoverageOutput struct { - Status string `json:"status"` - Items []BatchItem[CoverageOutput] `json:"items"` - SnapshotToken string `json:"snapshot_token"` - Provenance CorpusReadProvenance `json:"provenance"` -} - -// LensInput selects a saved lens by name. -type LensInput struct { - Name string `json:"name" jsonschema:"Lens name"` -} - -// LensOutput contains a saved lens definition and timestamps. -type LensOutput struct { - Name string `json:"name"` - Definition lens.Definition `json:"definition"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 223377a8..6e992dec 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -3,7 +3,6 @@ package mcpserver import ( "context" "encoding/json" - "errors" "strings" "testing" @@ -738,351 +737,3 @@ func TestFixPatternResourceTemplateTracksReaderCapability(t *testing.T) { }) } } - -func TestContributionWorkflowPrompts(t *testing.T) { - client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) - defer closeSessions() - - prompts, err := client.ListPrompts(context.Background(), nil) - if err != nil { - t.Fatalf("list prompts: %v", err) - } - names := map[string]bool{} - for _, prompt := range prompts.Prompts { - names[prompt.Name] = true - } - for _, name := range []string{ - "investigate_contribution_candidate", - "review_contribution_readiness", - "prepare_local_contribution_draft", - } { - if !names[name] { - t.Fatalf("missing prompt %q in %+v", name, prompts.Prompts) - } - } - - got, err := client.GetPrompt(context.Background(), &mcp.GetPromptParams{ - Name: "review_contribution_readiness", - Arguments: map[string]string{"opportunity_id": "opp-1"}, - }) - if err != nil { - t.Fatalf("get prompt: %v", err) - } - text, ok := got.Messages[0].Content.(*mcp.TextContent) - if !ok { - t.Fatalf("prompt content = %#v", got.Messages[0].Content) - } - if !strings.Contains(text.Text, "gitcontribute://readiness/opp-1") || - !strings.Contains(text.Text, "untrusted data") || - !strings.Contains(text.Text, "Do not refresh GitHub") { - t.Fatalf("prompt text missing safety/resource guidance:\n%s", text.Text) - } - - investigate, err := client.GetPrompt(context.Background(), &mcp.GetPromptParams{ - Name: "investigate_contribution_candidate", - Arguments: map[string]string{"owner": "acme", "repo": "rocket", "number": "17"}, - }) - if err != nil { - t.Fatalf("get investigate prompt: %v", err) - } - investigateText, ok := investigate.Messages[0].Content.(*mcp.TextContent) - if !ok { - t.Fatalf("investigate prompt content = %#v", investigate.Messages[0].Content) - } - if strings.Contains(investigateText.Text, "/issue/17") || - !strings.Contains(investigateText.Text, "corpus.get_threads") || - !strings.Contains(investigateText.Text, "acme/rocket#17") { - t.Fatalf("investigate prompt hardcodes or fails to resolve thread kind:\n%s", investigateText.Text) - } - - _, err = client.GetPrompt(context.Background(), &mcp.GetPromptParams{Name: "review_contribution_readiness"}) - if err == nil { - t.Fatal("expected missing argument error") - } -} - -func TestLensResource(t *testing.T) { - client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) - defer closeSessions() - - result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{ - URI: "gitcontribute://lens/active-go", - }) - if err != nil { - t.Fatalf("read lens: %v", err) - } - if len(result.Contents) != 1 || result.Contents[0].Text == "" { - t.Fatalf("resource result = %+v", result) - } - - _, err = client.ReadResource(context.Background(), &mcp.ReadResourceParams{ - URI: "gitcontribute://lens/missing", - }) - if err == nil { - t.Fatal("expected resource-not-found error") - } -} - -func TestToolCancellationReachesReader(t *testing.T) { - fake := &fakeReader{searchStarted: make(chan struct{})} - client, closeSessions := connect(t, fake) - defer closeSessions() - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - _, err := client.CallTool(ctx, &mcp.CallToolParams{ - Name: mcpcontract.ToolSearchThreads, Arguments: map[string]any{"query": "block"}, - }) - done <- err - }() - <-fake.searchStarted - cancel() - if err := <-done; !errors.Is(err, context.Canceled) { - t.Fatalf("call error = %v, want context canceled", err) - } -} - -func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { - reader := &canonicalIDReader{fakeReader: &fakeReader{}} - server := &Server{reader: reader} - ctx := context.Background() - - if _, _, err := server.defineValidation(ctx, nil, mcpcontract.DefineValidationInput{InvestigationID: " inv-1 ", Kind: "test", Command: "go test ./..."}); err != nil { - t.Fatal(err) - } - if _, _, err := server.createWorkspace(ctx, nil, mcpcontract.CreateWorkspaceInput{InvestigationID: " inv-1 "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.verifyPublishedDraft(ctx, nil, mcpcontract.VerifyPublishedDraftInput{DraftID: " draft-1 ", Revision: 1, Kind: "issue", Number: 7}); err != nil { - t.Fatal(err) - } - 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 { - t.Fatal(err) - } - if _, _, err := server.promoteOpportunity(ctx, nil, mcpcontract.PromoteOpportunityInput{HypothesisID: " hyp-1 ", ProblemStatement: " problem ", Scope: " scope ", Impact: " impact ", ExpectedEffort: " small "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-1 ", Kind: "issue"}); err != nil { - t.Fatal(err) - } - if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-2 ", Kind: "pull_request", WorkspaceID: " ws-1 ", Approach: " approach "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.exportManifest(ctx, nil, mcpcontract.ExportManifestInput{OpportunityID: " opp-1 ", PullRequest: &mcpcontract.ManifestPullRequestInput{Owner: " acme ", Repo: " rocket ", Number: 7}}); err != nil { - t.Fatal(err) - } - if _, _, err := server.attachJUnitReport(ctx, nil, mcpcontract.AttachJUnitReportInput{RunID: " run-1 ", ReportXML: ""}); err != nil { - t.Fatal(err) - } - 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) - } - if _, _, err := server.createConcern(ctx, nil, mcpcontract.CreateConcernInput{Owner: " acme ", Repo: " rocket ", CommitSHA: " abc123 ", Title: "title", ProblemStatement: "problem"}); err != nil { - t.Fatal(err) - } - - if reader.validation.InvestigationID != "inv-1" || reader.workspace.InvestigationID != "inv-1" || reader.hypothesis.InvestigationID != "inv-1" { - t.Fatalf("investigation IDs were not canonical: validation=%q workspace=%q hypothesis=%q", reader.validation.InvestigationID, reader.workspace.InvestigationID, reader.hypothesis.InvestigationID) - } - if reader.verification.DraftID != "draft-1" || reader.relatedWork.ID != "hyp-1" || reader.relatedWork.Target != "hypothesis" || reader.promotion.HypothesisID != "hyp-1" { - t.Fatalf("workflow IDs were not canonical: verification=%q related=%q/%q promotion=%q", reader.verification.DraftID, reader.relatedWork.Target, reader.relatedWork.ID, reader.promotion.HypothesisID) - } - if reader.promotion.ProblemStatement != "problem" || reader.promotion.Scope != "scope" || reader.promotion.Impact != "impact" || reader.promotion.ExpectedEffort != "small" { - t.Fatalf("opportunity fields were not canonical: %+v", reader.promotion) - } - if reader.contribution.OpportunityID != "opp-2" || reader.contribution.WorkspaceID != "ws-1" || reader.contribution.Approach != "approach" || reader.manifest.OpportunityID != "opp-1" { - t.Fatalf("opportunity IDs were not canonical: contribution=%q manifest=%q", reader.contribution.OpportunityID, reader.manifest.OpportunityID) - } - if reader.manifest.PullRequest == nil || reader.manifest.PullRequest.Owner != "acme" || reader.manifest.PullRequest.Repo != "rocket" || reader.junit.RunID != "run-1" { - t.Fatalf("manifest and validation identities were not canonical: manifest=%+v junit=%+v", reader.manifest.PullRequest, reader.junit) - } - 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.concern.Owner != "acme" || reader.concern.Repo != "rocket" || reader.concern.CommitSHA != "abc123" { - t.Fatalf("concern identity was not canonical: %+v", reader.concern) - } -} - -func TestCommitPlanningBoundariesPassCanonicalInventoryIdentity(t *testing.T) { - reader := &canonicalIDReader{fakeReader: &fakeReader{}} - server := &Server{reader: reader} - ctx := context.Background() - - if _, _, err := server.inspectCommitChanges(ctx, nil, mcpcontract.InspectCommitChangesInput{WorkspaceID: " ws-1 "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.planSemanticCommits(ctx, nil, mcpcontract.PlanSemanticCommitsInput{WorkspaceID: " ws-1 ", ExpectedInventorySHA256: " inventory-sha "}); err != nil { - t.Fatal(err) - } - if reader.commitInspect.WorkspaceID != "ws-1" || reader.commitPlan.WorkspaceID != "ws-1" || reader.commitPlan.ExpectedInventorySHA256 != "inventory-sha" { - t.Fatalf("commit inventory identity was not canonical: inspect=%+v plan=%+v", reader.commitInspect, reader.commitPlan) - } -} - -func TestLiveRepositoryBoundariesPassCanonicalReferencesToOperators(t *testing.T) { - reader := &canonicalRepositoryReader{fakeReader: &fakeReader{}} - server := &Server{reader: reader} - ctx := context.Background() - - if _, _, err := server.searchGitHubThreads(ctx, nil, mcpcontract.SearchGitHubThreadsInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Query: " regression "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.readSourceFiles(ctx, nil, mcpcontract.ReadSourceFilesInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Ref: " main ", Files: []mcpcontract.SourceFileRequest{{Path: "README.md"}}}); err != nil { - t.Fatal(err) - } - if _, _, err := server.syncPortfolio(ctx, nil, mcpcontract.SyncPortfolioInput{Selection: "authored", Repository: &mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { - t.Fatal(err) - } - if _, _, err := server.findPortfolioOverlaps(ctx, nil, mcpcontract.FindPortfolioOverlapsInput{ - Candidates: []mcpcontract.PortfolioSubjectInput{{Kind: " opportunity ", Ref: " opp-1 "}}, - PullRequests: []mcpcontract.ThreadRef{{Owner: " acme ", Repo: " rocket ", Number: 7}}, - }); err != nil { - t.Fatal(err) - } - if _, _, err := server.waitPullRequestChecks(ctx, nil, mcpcontract.WaitPullRequestChecksInput{Owner: " acme ", Repo: " rocket ", Number: 7, ExpectedHeadSHA: " abc123 "}); err != nil { - t.Fatal(err) - } - if _, _, err := server.indexPullRequestFeedback(ctx, nil, mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { - t.Fatal(err) - } - if _, _, err := server.searchPullRequestFeedback(ctx, nil, mcpcontract.SearchPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { - t.Fatal(err) - } - - if reader.threadSearch.Repository.Owner != "acme" || reader.threadSearch.Repository.Repo != "rocket" || reader.threadSearch.Query != "regression" { - t.Fatalf("thread-search boundary = %+v", reader.threadSearch) - } - if reader.sourceFiles.Repository.Owner != "acme" || reader.sourceFiles.Repository.Repo != "rocket" || reader.sourceFiles.Ref != "main" { - t.Fatalf("source-file boundary = %+v", reader.sourceFiles) - } - if reader.portfolio.Repository == nil || reader.portfolio.Repository.Owner != "acme" || reader.portfolio.Repository.Repo != "rocket" { - t.Fatalf("portfolio boundary = %+v", reader.portfolio) - } - if len(reader.overlaps.Candidates) != 1 || reader.overlaps.Candidates[0].Kind != "opportunity" || reader.overlaps.Candidates[0].Ref != "opp-1" || reader.overlaps.PullRequests[0].Owner != "acme" || reader.overlaps.PullRequests[0].Repo != "rocket" { - t.Fatalf("portfolio overlap boundary = %+v", reader.overlaps) - } - if reader.checkWait.Owner != "acme" || reader.checkWait.Repo != "rocket" || reader.checkWait.ExpectedHeadSHA != "abc123" { - t.Fatalf("pull-request check boundary = %+v", reader.checkWait) - } - if reader.feedbackIndex.Repository.Owner != "acme" || reader.feedbackIndex.Repository.Repo != "rocket" || reader.feedbackSearch.Repository.Owner != "acme" || reader.feedbackSearch.Repository.Repo != "rocket" { - t.Fatalf("pull-request feedback boundaries: index=%+v search=%+v", reader.feedbackIndex, reader.feedbackSearch) - } -} - -func TestV1ParityToolsAndResources(t *testing.T) { - client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) - defer closeSessions() - - tools := map[string]*mcp.Tool{} - for tool, err := range client.Tools(context.Background(), nil) { - if err != nil { - t.Fatalf("list tools: %v", err) - } - tools[tool.Name] = tool - } - - for _, name := range []string{ - mcpcontract.ToolSearchRepositories, mcpcontract.ToolSearchThreads, mcpcontract.ToolExplainMatch, mcpcontract.ToolGetJob, - mcpcontract.ToolCreateWorkspace, mcpcontract.ToolAdoptWorkspace, mcpcontract.ToolRunValidation, - mcpcontract.ToolStartInvestigation, mcpcontract.ToolRecordHypothesis, - mcpcontract.ToolPromoteOpportunity, mcpcontract.ToolDefineValidation, - mcpcontract.ToolPrepareContribution, mcpcontract.ToolCancelJob, - } { - if tools[name] == nil { - t.Fatalf("missing v1 tool %q", name) - } - } - - readTests := []struct { - name string - args map[string]any - }{ - {mcpcontract.ToolSearchRepositories, map[string]any{"query": "rocket"}}, - {mcpcontract.ToolSearchThreads, map[string]any{"query": "stall"}}, - {mcpcontract.ToolExplainMatch, map[string]any{"owner": "acme", "repo": "rocket", "kind": "issue", "number": 7}}, - {mcpcontract.ToolGetJob, map[string]any{"ids": []string{"job-1"}}}, - } - for _, tt := range readTests { - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.name, Arguments: tt.args}) - if err != nil || result.IsError { - t.Fatalf("call %s: err=%v result=%+v", tt.name, err, result) - } - if result.StructuredContent == nil { - t.Fatalf("%s returned nil structured content", tt.name) - } - } - - writeTests := []struct { - name string - args map[string]any - }{ - {mcpcontract.ToolCreateWorkspace, map[string]any{"investigation_id": "inv-1"}}, - {mcpcontract.ToolAdoptWorkspace, map[string]any{"investigation_id": "inv-1", "path": "/tmp/worktree", "base_ref": "main", "name": "external"}}, - {mcpcontract.ToolRunValidation, map[string]any{"id": "val-1", "target": "both", "run_count": 3, "execute": true}}, - {mcpcontract.ToolStartInvestigation, map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123"}}, - {mcpcontract.ToolRecordHypothesis, map[string]any{"investigation_id": "inv-1", "title": "leak", "description": "memory leak", "category": "bug"}}, - {mcpcontract.ToolPromoteOpportunity, map[string]any{"hypothesis_id": "hyp-1", "problem_statement": "leak", "scope": "small", "impact": "high", "expected_effort": "1h", "confidence": 0.8}}, - {mcpcontract.ToolDefineValidation, map[string]any{"investigation_id": "inv-1", "kind": "test", "command": "go test ./...", "workspace_id": "ws-1"}}, - {mcpcontract.ToolPrepareContribution, map[string]any{"opportunity_id": "opp-1", "kind": "issue"}}, - {mcpcontract.ToolCancelJob, map[string]any{"ids": []string{"job-1"}}}, - } - for _, tt := range writeTests { - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.name, Arguments: tt.args}) - if err != nil || result.IsError { - t.Fatalf("call %s: err=%v result=%+v", tt.name, err, result) - } - if result.StructuredContent == nil { - t.Fatalf("%s returned nil structured content", tt.name) - } - } - - _, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "github-index://repositories/acme/rocket"}) - if err == nil { - t.Fatal("legacy github-index resource should not be routed") - } - for _, uri := range []string{ - "gitcontribute://repositories/acme/rocket", - "gitcontribute://dossiers/acme/rocket", - "gitcontribute://investigations/inv-1", - "gitcontribute://workflows/contribution/opp-1", - "gitcontribute://lenses/default", - "gitcontribute://job/job-1", - "gitcontribute://jobs/job-1", - } { - if _, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: uri}); err == nil { - t.Errorf("unadvertised alias %q was routed", uri) - } - } - - templates := map[string]bool{} - for template, err := range client.ResourceTemplates(context.Background(), nil) { - if err != nil { - t.Fatal(err) - } - templates[template.URITemplate] = true - } - for _, uriTemplate := range []string{ - "gitcontribute://thread/{owner}/{repo}/{kind}/{number}/facet/{facet}", - "gitcontribute://concern/{id}", - "gitcontribute://draft/{id}/{revision}", - "gitcontribute://manifest/{id}", - } { - if !templates[uriTemplate] { - t.Errorf("missing resource template %q", uriTemplate) - } - } -} diff --git a/internal/mcpserver/server_workflows_test.go b/internal/mcpserver/server_workflows_test.go new file mode 100644 index 00000000..e0cc72c2 --- /dev/null +++ b/internal/mcpserver/server_workflows_test.go @@ -0,0 +1,359 @@ +package mcpserver + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestContributionWorkflowPrompts(t *testing.T) { + client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) + defer closeSessions() + + prompts, err := client.ListPrompts(context.Background(), nil) + if err != nil { + t.Fatalf("list prompts: %v", err) + } + names := map[string]bool{} + for _, prompt := range prompts.Prompts { + names[prompt.Name] = true + } + for _, name := range []string{ + "investigate_contribution_candidate", + "review_contribution_readiness", + "prepare_local_contribution_draft", + } { + if !names[name] { + t.Fatalf("missing prompt %q in %+v", name, prompts.Prompts) + } + } + + got, err := client.GetPrompt(context.Background(), &mcp.GetPromptParams{ + Name: "review_contribution_readiness", + Arguments: map[string]string{"opportunity_id": "opp-1"}, + }) + if err != nil { + t.Fatalf("get prompt: %v", err) + } + text, ok := got.Messages[0].Content.(*mcp.TextContent) + if !ok { + t.Fatalf("prompt content = %#v", got.Messages[0].Content) + } + if !strings.Contains(text.Text, "gitcontribute://readiness/opp-1") || + !strings.Contains(text.Text, "untrusted data") || + !strings.Contains(text.Text, "Do not refresh GitHub") { + t.Fatalf("prompt text missing safety/resource guidance:\n%s", text.Text) + } + + investigate, err := client.GetPrompt(context.Background(), &mcp.GetPromptParams{ + Name: "investigate_contribution_candidate", + Arguments: map[string]string{"owner": "acme", "repo": "rocket", "number": "17"}, + }) + if err != nil { + t.Fatalf("get investigate prompt: %v", err) + } + investigateText, ok := investigate.Messages[0].Content.(*mcp.TextContent) + if !ok { + t.Fatalf("investigate prompt content = %#v", investigate.Messages[0].Content) + } + if strings.Contains(investigateText.Text, "/issue/17") || + !strings.Contains(investigateText.Text, "corpus.get_threads") || + !strings.Contains(investigateText.Text, "acme/rocket#17") { + t.Fatalf("investigate prompt hardcodes or fails to resolve thread kind:\n%s", investigateText.Text) + } + + _, err = client.GetPrompt(context.Background(), &mcp.GetPromptParams{Name: "review_contribution_readiness"}) + if err == nil { + t.Fatal("expected missing argument error") + } +} + +func TestLensResource(t *testing.T) { + client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) + defer closeSessions() + + result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "gitcontribute://lens/active-go", + }) + if err != nil { + t.Fatalf("read lens: %v", err) + } + if len(result.Contents) != 1 || result.Contents[0].Text == "" { + t.Fatalf("resource result = %+v", result) + } + + _, err = client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "gitcontribute://lens/missing", + }) + if err == nil { + t.Fatal("expected resource-not-found error") + } +} + +func TestToolCancellationReachesReader(t *testing.T) { + fake := &fakeReader{searchStarted: make(chan struct{})} + client, closeSessions := connect(t, fake) + defer closeSessions() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := client.CallTool(ctx, &mcp.CallToolParams{ + Name: mcpcontract.ToolSearchThreads, Arguments: map[string]any{"query": "block"}, + }) + done <- err + }() + <-fake.searchStarted + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("call error = %v, want context canceled", err) + } +} + +func TestWriteBoundariesPassCanonicalIDsToOperators(t *testing.T) { + reader := &canonicalIDReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.defineValidation(ctx, nil, mcpcontract.DefineValidationInput{InvestigationID: " inv-1 ", Kind: "test", Command: "go test ./..."}); err != nil { + t.Fatal(err) + } + if _, _, err := server.createWorkspace(ctx, nil, mcpcontract.CreateWorkspaceInput{InvestigationID: " inv-1 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.verifyPublishedDraft(ctx, nil, mcpcontract.VerifyPublishedDraftInput{DraftID: " draft-1 ", Revision: 1, Kind: "issue", Number: 7}); err != nil { + t.Fatal(err) + } + 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 { + t.Fatal(err) + } + if _, _, err := server.promoteOpportunity(ctx, nil, mcpcontract.PromoteOpportunityInput{HypothesisID: " hyp-1 ", ProblemStatement: " problem ", Scope: " scope ", Impact: " impact ", ExpectedEffort: " small "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-1 ", Kind: "issue"}); err != nil { + t.Fatal(err) + } + if _, _, err := server.prepareContribution(ctx, nil, mcpcontract.PrepareContributionInput{OpportunityID: " opp-2 ", Kind: "pull_request", WorkspaceID: " ws-1 ", Approach: " approach "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.exportManifest(ctx, nil, mcpcontract.ExportManifestInput{OpportunityID: " opp-1 ", PullRequest: &mcpcontract.ManifestPullRequestInput{Owner: " acme ", Repo: " rocket ", Number: 7}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.attachJUnitReport(ctx, nil, mcpcontract.AttachJUnitReportInput{RunID: " run-1 ", ReportXML: ""}); err != nil { + t.Fatal(err) + } + 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) + } + if _, _, err := server.createConcern(ctx, nil, mcpcontract.CreateConcernInput{Owner: " acme ", Repo: " rocket ", CommitSHA: " abc123 ", Title: "title", ProblemStatement: "problem"}); err != nil { + t.Fatal(err) + } + + if reader.validation.InvestigationID != "inv-1" || reader.workspace.InvestigationID != "inv-1" || reader.hypothesis.InvestigationID != "inv-1" { + t.Fatalf("investigation IDs were not canonical: validation=%q workspace=%q hypothesis=%q", reader.validation.InvestigationID, reader.workspace.InvestigationID, reader.hypothesis.InvestigationID) + } + if reader.verification.DraftID != "draft-1" || reader.relatedWork.ID != "hyp-1" || reader.relatedWork.Target != "hypothesis" || reader.promotion.HypothesisID != "hyp-1" { + t.Fatalf("workflow IDs were not canonical: verification=%q related=%q/%q promotion=%q", reader.verification.DraftID, reader.relatedWork.Target, reader.relatedWork.ID, reader.promotion.HypothesisID) + } + if reader.promotion.ProblemStatement != "problem" || reader.promotion.Scope != "scope" || reader.promotion.Impact != "impact" || reader.promotion.ExpectedEffort != "small" { + t.Fatalf("opportunity fields were not canonical: %+v", reader.promotion) + } + if reader.contribution.OpportunityID != "opp-2" || reader.contribution.WorkspaceID != "ws-1" || reader.contribution.Approach != "approach" || reader.manifest.OpportunityID != "opp-1" { + t.Fatalf("opportunity IDs were not canonical: contribution=%q manifest=%q", reader.contribution.OpportunityID, reader.manifest.OpportunityID) + } + if reader.manifest.PullRequest == nil || reader.manifest.PullRequest.Owner != "acme" || reader.manifest.PullRequest.Repo != "rocket" || reader.junit.RunID != "run-1" { + t.Fatalf("manifest and validation identities were not canonical: manifest=%+v junit=%+v", reader.manifest.PullRequest, reader.junit) + } + 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.concern.Owner != "acme" || reader.concern.Repo != "rocket" || reader.concern.CommitSHA != "abc123" { + t.Fatalf("concern identity was not canonical: %+v", reader.concern) + } +} + +func TestCommitPlanningBoundariesPassCanonicalInventoryIdentity(t *testing.T) { + reader := &canonicalIDReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.inspectCommitChanges(ctx, nil, mcpcontract.InspectCommitChangesInput{WorkspaceID: " ws-1 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.planSemanticCommits(ctx, nil, mcpcontract.PlanSemanticCommitsInput{WorkspaceID: " ws-1 ", ExpectedInventorySHA256: " inventory-sha "}); err != nil { + t.Fatal(err) + } + if reader.commitInspect.WorkspaceID != "ws-1" || reader.commitPlan.WorkspaceID != "ws-1" || reader.commitPlan.ExpectedInventorySHA256 != "inventory-sha" { + t.Fatalf("commit inventory identity was not canonical: inspect=%+v plan=%+v", reader.commitInspect, reader.commitPlan) + } +} + +func TestLiveRepositoryBoundariesPassCanonicalReferencesToOperators(t *testing.T) { + reader := &canonicalRepositoryReader{fakeReader: &fakeReader{}} + server := &Server{reader: reader} + ctx := context.Background() + + if _, _, err := server.searchGitHubThreads(ctx, nil, mcpcontract.SearchGitHubThreadsInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Query: " regression "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.readSourceFiles(ctx, nil, mcpcontract.ReadSourceFilesInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, Ref: " main ", Files: []mcpcontract.SourceFileRequest{{Path: "README.md"}}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.syncPortfolio(ctx, nil, mcpcontract.SyncPortfolioInput{Selection: "authored", Repository: &mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.findPortfolioOverlaps(ctx, nil, mcpcontract.FindPortfolioOverlapsInput{ + Candidates: []mcpcontract.PortfolioSubjectInput{{Kind: " opportunity ", Ref: " opp-1 "}}, + PullRequests: []mcpcontract.ThreadRef{{Owner: " acme ", Repo: " rocket ", Number: 7}}, + }); err != nil { + t.Fatal(err) + } + if _, _, err := server.waitPullRequestChecks(ctx, nil, mcpcontract.WaitPullRequestChecksInput{Owner: " acme ", Repo: " rocket ", Number: 7, ExpectedHeadSHA: " abc123 "}); err != nil { + t.Fatal(err) + } + if _, _, err := server.indexPullRequestFeedback(ctx, nil, mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + if _, _, err := server.searchPullRequestFeedback(ctx, nil, mcpcontract.SearchPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}}); err != nil { + t.Fatal(err) + } + + if reader.threadSearch.Repository.Owner != "acme" || reader.threadSearch.Repository.Repo != "rocket" || reader.threadSearch.Query != "regression" { + t.Fatalf("thread-search boundary = %+v", reader.threadSearch) + } + if reader.sourceFiles.Repository.Owner != "acme" || reader.sourceFiles.Repository.Repo != "rocket" || reader.sourceFiles.Ref != "main" { + t.Fatalf("source-file boundary = %+v", reader.sourceFiles) + } + if reader.portfolio.Repository == nil || reader.portfolio.Repository.Owner != "acme" || reader.portfolio.Repository.Repo != "rocket" { + t.Fatalf("portfolio boundary = %+v", reader.portfolio) + } + if len(reader.overlaps.Candidates) != 1 || reader.overlaps.Candidates[0].Kind != "opportunity" || reader.overlaps.Candidates[0].Ref != "opp-1" || reader.overlaps.PullRequests[0].Owner != "acme" || reader.overlaps.PullRequests[0].Repo != "rocket" { + t.Fatalf("portfolio overlap boundary = %+v", reader.overlaps) + } + if reader.checkWait.Owner != "acme" || reader.checkWait.Repo != "rocket" || reader.checkWait.ExpectedHeadSHA != "abc123" { + t.Fatalf("pull-request check boundary = %+v", reader.checkWait) + } + if reader.feedbackIndex.Repository.Owner != "acme" || reader.feedbackIndex.Repository.Repo != "rocket" || reader.feedbackSearch.Repository.Owner != "acme" || reader.feedbackSearch.Repository.Repo != "rocket" { + t.Fatalf("pull-request feedback boundaries: index=%+v search=%+v", reader.feedbackIndex, reader.feedbackSearch) + } +} + +func TestV1ParityToolsAndResources(t *testing.T) { + client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) + defer closeSessions() + + tools := map[string]*mcp.Tool{} + for tool, err := range client.Tools(context.Background(), nil) { + if err != nil { + t.Fatalf("list tools: %v", err) + } + tools[tool.Name] = tool + } + + for _, name := range []string{ + mcpcontract.ToolSearchRepositories, mcpcontract.ToolSearchThreads, mcpcontract.ToolExplainMatch, mcpcontract.ToolGetJob, + mcpcontract.ToolCreateWorkspace, mcpcontract.ToolAdoptWorkspace, mcpcontract.ToolRunValidation, + mcpcontract.ToolStartInvestigation, mcpcontract.ToolRecordHypothesis, + mcpcontract.ToolPromoteOpportunity, mcpcontract.ToolDefineValidation, + mcpcontract.ToolPrepareContribution, mcpcontract.ToolCancelJob, + } { + if tools[name] == nil { + t.Fatalf("missing v1 tool %q", name) + } + } + + readTests := []struct { + name string + args map[string]any + }{ + {mcpcontract.ToolSearchRepositories, map[string]any{"query": "rocket"}}, + {mcpcontract.ToolSearchThreads, map[string]any{"query": "stall"}}, + {mcpcontract.ToolExplainMatch, map[string]any{"owner": "acme", "repo": "rocket", "kind": "issue", "number": 7}}, + {mcpcontract.ToolGetJob, map[string]any{"ids": []string{"job-1"}}}, + } + for _, tt := range readTests { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.name, Arguments: tt.args}) + if err != nil || result.IsError { + t.Fatalf("call %s: err=%v result=%+v", tt.name, err, result) + } + if result.StructuredContent == nil { + t.Fatalf("%s returned nil structured content", tt.name) + } + } + + writeTests := []struct { + name string + args map[string]any + }{ + {mcpcontract.ToolCreateWorkspace, map[string]any{"investigation_id": "inv-1"}}, + {mcpcontract.ToolAdoptWorkspace, map[string]any{"investigation_id": "inv-1", "path": "/tmp/worktree", "base_ref": "main", "name": "external"}}, + {mcpcontract.ToolRunValidation, map[string]any{"id": "val-1", "target": "both", "run_count": 3, "execute": true}}, + {mcpcontract.ToolStartInvestigation, map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123"}}, + {mcpcontract.ToolRecordHypothesis, map[string]any{"investigation_id": "inv-1", "title": "leak", "description": "memory leak", "category": "bug"}}, + {mcpcontract.ToolPromoteOpportunity, map[string]any{"hypothesis_id": "hyp-1", "problem_statement": "leak", "scope": "small", "impact": "high", "expected_effort": "1h", "confidence": 0.8}}, + {mcpcontract.ToolDefineValidation, map[string]any{"investigation_id": "inv-1", "kind": "test", "command": "go test ./...", "workspace_id": "ws-1"}}, + {mcpcontract.ToolPrepareContribution, map[string]any{"opportunity_id": "opp-1", "kind": "issue"}}, + {mcpcontract.ToolCancelJob, map[string]any{"ids": []string{"job-1"}}}, + } + for _, tt := range writeTests { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.name, Arguments: tt.args}) + if err != nil || result.IsError { + t.Fatalf("call %s: err=%v result=%+v", tt.name, err, result) + } + if result.StructuredContent == nil { + t.Fatalf("%s returned nil structured content", tt.name) + } + } + + _, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "github-index://repositories/acme/rocket"}) + if err == nil { + t.Fatal("legacy github-index resource should not be routed") + } + for _, uri := range []string{ + "gitcontribute://repositories/acme/rocket", + "gitcontribute://dossiers/acme/rocket", + "gitcontribute://investigations/inv-1", + "gitcontribute://workflows/contribution/opp-1", + "gitcontribute://lenses/default", + "gitcontribute://job/job-1", + "gitcontribute://jobs/job-1", + } { + if _, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: uri}); err == nil { + t.Errorf("unadvertised alias %q was routed", uri) + } + } + + templates := map[string]bool{} + for template, err := range client.ResourceTemplates(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + templates[template.URITemplate] = true + } + for _, uriTemplate := range []string{ + "gitcontribute://thread/{owner}/{repo}/{kind}/{number}/facet/{facet}", + "gitcontribute://concern/{id}", + "gitcontribute://draft/{id}/{revision}", + "gitcontribute://manifest/{id}", + } { + if !templates[uriTemplate] { + t.Errorf("missing resource template %q", uriTemplate) + } + } +} diff --git a/internal/workspace/inspection.go b/internal/workspace/inspection.go new file mode 100644 index 00000000..a82cbfd2 --- /dev/null +++ b/internal/workspace/inspection.go @@ -0,0 +1,156 @@ +package workspace + +import ( + "context" + "fmt" + "path/filepath" + "strings" +) + +// UntrackedFileSnapshot identifies exact untracked content without returning +// file bytes. ObjectID is Git's content hash from hash-object --no-filters. +type UntrackedFileSnapshot struct { + Path string + ObjectID string +} + +// UntrackedFilesByPath returns a bounded, deterministic snapshot of untracked, +// non-ignored files. Git performs path discovery and content hashing. +func (m *Manager) UntrackedFilesByPath(ctx context.Context, path string) ([]UntrackedFileSnapshot, error) { + managed, err := m.managedPath(path) + if err != nil { + return nil, err + } + out, err := m.git(ctx, managed, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return nil, fmt.Errorf("list untracked files: %w", err) + } + parts := strings.Split(out, "\x00") + files := make([]UntrackedFileSnapshot, 0, len(parts)) + for _, file := range parts { + if file == "" { + continue + } + if len(files) == maxUntrackedFiles { + return nil, fmt.Errorf("workspace exceeds %d untracked files", maxUntrackedFiles) + } + objectID, err := m.git(ctx, managed, "hash-object", "--no-filters", "--", file) + if err != nil { + return nil, fmt.Errorf("hash untracked file %q: %w", file, err) + } + files = append(files, UntrackedFileSnapshot{Path: file, ObjectID: strings.TrimSpace(objectID)}) + } + return files, nil +} + +// ChangedFilesByPath returns raw Git paths changed from the supplied base. +// Git owns rename, deletion, and quoted-path handling; NUL delimiters preserve +// paths containing whitespace or other special characters. +func (m *Manager) ChangedFilesByPath(ctx context.Context, path, baseSHA string) ([]string, error) { + managed, err := m.managedPath(path) + if err != nil { + return nil, err + } + return m.changedFilesByPath(ctx, managed, baseSHA) +} + +// ChangedFilesWorkspace revalidates workspace authority before listing files. +func (m *Manager) ChangedFilesWorkspace(ctx context.Context, ws *Workspace) ([]string, error) { + path, err := m.authorizedPath(ctx, ws) + if err != nil { + return nil, err + } + return m.changedFilesByPath(ctx, path, ws.BaseSHA) +} + +func (m *Manager) changedFilesByPath(ctx context.Context, path, baseSHA string) ([]string, error) { + args := []string{"diff", "--name-only", "--find-renames", "-z"} + if baseSHA != "" { + args = append(args, baseSHA) + } + args = append(args, "--") + out, err := m.git(ctx, path, args...) + if err != nil { + return nil, fmt.Errorf("list changed files: %w", err) + } + parts := strings.Split(out, "\x00") + files := make([]string, 0, len(parts)) + for _, path := range parts { + if path != "" { + files = append(files, path) + } + } + return files, nil +} + +// HasUntrackedByPath reports whether a managed workspace contains untracked, +// non-ignored files. Callers preparing a complete diff must handle these +// explicitly because git diff does not include them. +func (m *Manager) HasUntrackedByPath(ctx context.Context, path string) (bool, error) { + managed, err := m.managedPath(path) + if err != nil { + return false, err + } + return m.hasUntracked(ctx, managed) +} + +// HasUntrackedWorkspace revalidates workspace authority before reading files. +func (m *Manager) HasUntrackedWorkspace(ctx context.Context, ws *Workspace) (bool, error) { + path, err := m.authorizedPath(ctx, ws) + if err != nil { + return false, err + } + return m.hasUntracked(ctx, path) +} + +func (m *Manager) hasUntracked(ctx context.Context, path string) (bool, error) { + out, err := m.git(ctx, path, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return false, fmt.Errorf("list untracked files: %w", err) + } + return len(out) > 0, nil +} + +// ValidateWorkspacePath verifies that path exists within the managed worktree +// subtree without invoking Git or changing filesystem state. Mirrors and other +// manager state are deliberately excluded from executable capabilities. +func (m *Manager) ValidateWorkspacePath(path string) error { + resolved, err := m.managedPath(path) + if err != nil { + return err + } + if !containsPath(filepath.Join(m.root, "workspaces"), resolved) { + return ErrNotManaged + } + return nil +} + +func (m *Manager) managedPath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + resolved, err := filepath.EvalSymlinks(filepath.Clean(abs)) + if err != nil { + return "", fmt.Errorf("resolve managed path symlinks: %w", err) + } + if !m.contains(resolved) { + return "", ErrNotManaged + } + return resolved, nil +} + +func (m *Manager) contains(path string) bool { + return containsPath(m.root, path) +} + +func containsPath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + if filepath.IsAbs(rel) { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index aa642ff3..a7665f63 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -739,151 +739,3 @@ func (m *Manager) diffByPath(ctx context.Context, path, baseSHA string) (string, } return out, nil } - -// UntrackedFileSnapshot identifies exact untracked content without returning -// file bytes. ObjectID is Git's content hash from hash-object --no-filters. -type UntrackedFileSnapshot struct { - Path string - ObjectID string -} - -// UntrackedFilesByPath returns a bounded, deterministic snapshot of untracked, -// non-ignored files. Git performs path discovery and content hashing. -func (m *Manager) UntrackedFilesByPath(ctx context.Context, path string) ([]UntrackedFileSnapshot, error) { - managed, err := m.managedPath(path) - if err != nil { - return nil, err - } - out, err := m.git(ctx, managed, "ls-files", "--others", "--exclude-standard", "-z") - if err != nil { - return nil, fmt.Errorf("list untracked files: %w", err) - } - parts := strings.Split(out, "\x00") - files := make([]UntrackedFileSnapshot, 0, len(parts)) - for _, file := range parts { - if file == "" { - continue - } - if len(files) == maxUntrackedFiles { - return nil, fmt.Errorf("workspace exceeds %d untracked files", maxUntrackedFiles) - } - objectID, err := m.git(ctx, managed, "hash-object", "--no-filters", "--", file) - if err != nil { - return nil, fmt.Errorf("hash untracked file %q: %w", file, err) - } - files = append(files, UntrackedFileSnapshot{Path: file, ObjectID: strings.TrimSpace(objectID)}) - } - return files, nil -} - -// ChangedFilesByPath returns raw Git paths changed from the supplied base. -// Git owns rename, deletion, and quoted-path handling; NUL delimiters preserve -// paths containing whitespace or other special characters. -func (m *Manager) ChangedFilesByPath(ctx context.Context, path, baseSHA string) ([]string, error) { - managed, err := m.managedPath(path) - if err != nil { - return nil, err - } - return m.changedFilesByPath(ctx, managed, baseSHA) -} - -// ChangedFilesWorkspace revalidates workspace authority before listing files. -func (m *Manager) ChangedFilesWorkspace(ctx context.Context, ws *Workspace) ([]string, error) { - path, err := m.authorizedPath(ctx, ws) - if err != nil { - return nil, err - } - return m.changedFilesByPath(ctx, path, ws.BaseSHA) -} - -func (m *Manager) changedFilesByPath(ctx context.Context, path, baseSHA string) ([]string, error) { - args := []string{"diff", "--name-only", "--find-renames", "-z"} - if baseSHA != "" { - args = append(args, baseSHA) - } - args = append(args, "--") - out, err := m.git(ctx, path, args...) - if err != nil { - return nil, fmt.Errorf("list changed files: %w", err) - } - parts := strings.Split(out, "\x00") - files := make([]string, 0, len(parts)) - for _, path := range parts { - if path != "" { - files = append(files, path) - } - } - return files, nil -} - -// HasUntrackedByPath reports whether a managed workspace contains untracked, -// non-ignored files. Callers preparing a complete diff must handle these -// explicitly because git diff does not include them. -func (m *Manager) HasUntrackedByPath(ctx context.Context, path string) (bool, error) { - managed, err := m.managedPath(path) - if err != nil { - return false, err - } - return m.hasUntracked(ctx, managed) -} - -// HasUntrackedWorkspace revalidates workspace authority before reading files. -func (m *Manager) HasUntrackedWorkspace(ctx context.Context, ws *Workspace) (bool, error) { - path, err := m.authorizedPath(ctx, ws) - if err != nil { - return false, err - } - return m.hasUntracked(ctx, path) -} - -func (m *Manager) hasUntracked(ctx context.Context, path string) (bool, error) { - out, err := m.git(ctx, path, "ls-files", "--others", "--exclude-standard", "-z") - if err != nil { - return false, fmt.Errorf("list untracked files: %w", err) - } - return len(out) > 0, nil -} - -// ValidateWorkspacePath verifies that path exists within the managed worktree -// subtree without invoking Git or changing filesystem state. Mirrors and other -// manager state are deliberately excluded from executable capabilities. -func (m *Manager) ValidateWorkspacePath(path string) error { - resolved, err := m.managedPath(path) - if err != nil { - return err - } - if !containsPath(filepath.Join(m.root, "workspaces"), resolved) { - return ErrNotManaged - } - return nil -} - -func (m *Manager) managedPath(path string) (string, error) { - abs, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("resolve path: %w", err) - } - resolved, err := filepath.EvalSymlinks(filepath.Clean(abs)) - if err != nil { - return "", fmt.Errorf("resolve managed path symlinks: %w", err) - } - if !m.contains(resolved) { - return "", ErrNotManaged - } - return resolved, nil -} - -func (m *Manager) contains(path string) bool { - return containsPath(m.root, path) -} - -func containsPath(root, path string) bool { - rel, err := filepath.Rel(root, path) - if err != nil { - return false - } - if filepath.IsAbs(rel) { - return false - } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -}