diff --git a/docs/architecture.md b/docs/architecture.md index f80c6999..16f5dfbf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -346,6 +346,12 @@ remain write operations. Catalog changes require realistic multi-call agent evaluations, including held-out queries, tool-call count, errors, latency, and context size; scripted schema checks alone do not establish good tool choice. +Recovery-plan actions are sealed variants. The concrete input type derives the +action discriminator, the output schema advertises the variants with `oneOf`, +and decoding rejects unknown, mismatched, or multiple payloads. Application +code therefore receives a typed action rather than validating a discriminator +against a nullable argument bag. + The canonical source-audit workflow is a machine-readable contract exposed by `workflow.get_source_audit_contract`: @@ -430,6 +436,42 @@ rate capacity. Only replayable reads are retried. Backoff honors GitHub rate headers, is bounded, observes context cancellation, and redacts URL userinfo before retry metadata is persisted. +Repository identities are parsed at input, provider, and persistence +boundaries into a private, comparable `domain.RepoRef`. Interior code cannot +construct an owner without a repository name, carry whitespace, or bypass the +owner and repository grammar; it receives a parsed identity and uses explicit +accessors. The zero value is reserved for optional scope and must be tested +with `IsValid`. JSON decoding reparses the identity, and larger domain records +hold it in named fields so its codec cannot be promoted over the enclosing +record. + +Pull-request merge knowledge is likewise a parsed `domain.MergeStatus`, not +independent `merged`, `merged_known`, and `merged_at` fields. Constructors make +unknown, observed-unmerged, and observed-merged outcomes explicit. SQLite and +GitHub adapter reads reject contradictions such as an unknown outcome marked +merged or an unmerged outcome with a merge timestamp; interior code cannot +create those combinations. The relational schema keeps scalar columns for +querying, but rows are reparsed before they enter application models. + +Durable run and job lifecycles are read through private state values that bind +statuses to their timestamps. Running work cannot be completed, queued jobs +cannot already be started, terminal work requires a completion time, and only +cancelled or cancellation-requested jobs carry a cancellation time. Terminal +run transitions are conditional on the stored running state, while job +transitions update the status and required timestamps atomically. Corrupt or +unknown persisted combinations fail at the corpus boundary. + +JSON inputs that express alternatives remain wire-compatible discriminated +objects, but they are parsed before any durable job is submitted. Thread sync +becomes either repository discovery with repository-only filters or an exact +thread set. Portfolio sync becomes either authored discovery or an explicit +pull-request set. Actor identity becomes either a canonical login or a node ID, +and coverage becomes either a repository target or an exact typed thread. +Workers receive these private variants rather than the original field bags, so +mode-specific fields cannot be silently ignored and identity strings are +canonicalized before duplicate detection. The normalized wire form, not the +caller's mutable slices or pointers, is what the durable job records. + ## Acquisition and workspaces Acquisition and workspace packages invoke `git` directly with prompts, hooks, @@ -453,6 +495,30 @@ host paths. The application resolves each ID and verifies that it belongs to the selected investigation before persisting executable state. The explicit CLI remains a local-user interface and may accept a directly supplied path. +Observation definitions cross command and MCP boundaries as untrusted specs. +The application parses a complete base-and-candidate contract before it enters +the evidence service. Parsed observations have a private representation: their +source and artifact-path relationship is established once, default occurrence +is normalized, and regular expressions are compiled once for execution. +Persistence decodes through the same parser, so malformed stored contracts do +not re-enter the trusted model. Execution therefore consumes parsed values and +does not repeat structural validation or regular-expression compilation. + +Durable workflow JSON is parsed again on read. Concern, investigation, +hypothesis, opportunity, validation, and evidence discriminators cannot enter +application logic as unchecked strings; legacy empty states are canonicalized +only where their historical meaning is unambiguous. Telemetry metrics decode as +either an available value or an unavailable reason and reject payloads claiming +both. External validation receipts atomically store their synthetic definition +and run, while external evidence manifests atomically store the complete claim +set. A failed import therefore leaves no orphan definition or partial manifest. + +Bulk local-metadata and collection inputs are fully parsed before writable +corpus access. Collection references are stored in canonical repository, +thread, or UUID form, and malformed later members cannot follow earlier writes. +Thread projections similarly parse kind, lifecycle state, repository key, +and number before a transaction begins and again when SQLite rows are read. + ## Search and analysis Search uses the local SQLite corpus and FTS5 indexes; agents query bounded @@ -468,6 +534,14 @@ Snapshots created before manifests were introduced report `indexed_coverage_unknown`; their zero skip counts are never presented as proof of complete coverage. +Repository coverage uses collection membership to represent presence: a +returned `domain.FacetCoverage` is necessarily present, while a missing facet is +absent from the collection. Its private constructor binds the facet name, +observation time, completeness, and non-negative count. Immutable code-index +artifacts similarly use their digest-bound manifest as the sole in-memory +authority; duplicated query columns are checked against that manifest while +decoding and discarded rather than exposed as a second source of truth. + Title, labels, body, and hydrated evidence are materialized into one search document per thread and ranked by one BM25 invocation. Ranks from the legacy thread and facet indexes are never compared; the facet index is used only to diff --git a/internal/acquire/acquire.go b/internal/acquire/acquire.go index 3400c067..117e5879 100644 --- a/internal/acquire/acquire.go +++ b/internal/acquire/acquire.go @@ -168,8 +168,8 @@ func NewManager(root string, runner runner) (*Manager, error) { // clean checkout at the resolved default branch. The returned Acquisition // records remote URL, default branch, commit SHA, and acquisition time. func (m *Manager) Acquire(ctx context.Context, owner, repo, remote string) (*Acquisition, error) { - ref := domain.RepoRef{Owner: owner, Repo: repo} - if err := ref.Validate(); err != nil { + _, err := domain.NewRepoRef(owner, repo) + if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidRepo, err) } if err := validateRemote(remote); err != nil { diff --git a/internal/app/acquisition.go b/internal/app/acquisition.go index d8f91d9d..8a1da672 100644 --- a/internal/app/acquisition.go +++ b/internal/app/acquisition.go @@ -17,13 +17,13 @@ import ( // resolved remote URL/default branch/commit SHA/acquired time, and indexes the // clean checkout into the corpus. It does not execute repository code. func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote string) (result *contracts.AcquisitionResult, returnErr error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } remote = strings.TrimSpace(remote) if remote == "" { - remote = fmt.Sprintf("https://github.com/%s/%s.git", ref.Owner, ref.Repo) + remote = fmt.Sprintf("https://github.com/%s/%s.git", ref.Owner(), ref.Repo()) } cacheRoot, err := s.paths.AcquisitionCacheDir() @@ -36,7 +36,7 @@ func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote st return nil, fmt.Errorf("create acquisition manager: %w", err) } - acq, err := mgr.Acquire(ctx, ref.Owner, ref.Repo, remote) + acq, err := mgr.Acquire(ctx, ref.Owner(), ref.Repo(), remote) if err != nil { return nil, fmt.Errorf("acquire %s: %w", ref, err) } diff --git a/internal/app/acquisition_test.go b/internal/app/acquisition_test.go index d3455044..bf856bd5 100644 --- a/internal/app/acquisition_test.go +++ b/internal/app/acquisition_test.go @@ -83,7 +83,7 @@ func TestAcquireSuccess(t *testing.T) { if err != nil { t.Fatalf("open corpus: %v", err) } - snap, err := c.LatestCodeSnapshot(ctx, domain.RepoRef{Owner: "testowner", Repo: "testrepo"}) + snap, err := c.LatestCodeSnapshot(ctx, domain.MustRepoRef("testowner", "testrepo")) if err != nil { t.Fatalf("latest snapshot: %v", err) } @@ -125,7 +125,7 @@ func TestAcquireRepeatFetch(t *testing.T) { if err != nil { t.Fatalf("open corpus: %v", err) } - snap, err := c.LatestCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}) + snap, err := c.LatestCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo")) if err != nil { t.Fatalf("latest snapshot: %v", err) } @@ -163,7 +163,7 @@ func TestAcquireUnchangedCommitReusesCurrentSnapshot(t *testing.T) { }, } if _, _, err := svc.corpus.StoreCodeSnapshot( - ctx, domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, replacement, + ctx, domain.MustRepoRef(ref.Owner, ref.Repo), replacement, ); err != nil { t.Fatalf("replace snapshot: %v", err) } @@ -176,7 +176,7 @@ func TestAcquireUnchangedCommitReusesCurrentSnapshot(t *testing.T) { t.Fatalf("second acquire = %+v", second) } matches, err := svc.corpus.SearchCode( - ctx, "sentinel", domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, 10, + ctx, "sentinel", domain.MustRepoRef(ref.Owner, ref.Repo), 10, ) if err != nil { t.Fatalf("search preserved snapshot: %v", err) diff --git a/internal/app/actor_selector.go b/internal/app/actor_selector.go new file mode 100644 index 00000000..13c08394 --- /dev/null +++ b/internal/app/actor_selector.go @@ -0,0 +1,87 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +// parsedActorSelector is the executable form of ActorSelector's JSON union. +// Implementations contain exactly one identity, so downstream acquisition does +// not need to keep re-checking the discriminator and mutually exclusive fields. +type parsedActorSelector interface { + key() string + resolveLogin(context.Context, *corpus.Corpus) (string, error) +} + +type actorLogin string + +func (login actorLogin) key() string { return strings.ToLower(string(login)) } +func (login actorLogin) resolveLogin(context.Context, *corpus.Corpus) (string, error) { + return string(login), nil +} + +type actorNodeID string + +func (nodeID actorNodeID) key() string { return string(nodeID) } +func (nodeID actorNodeID) resolveLogin(ctx context.Context, c *corpus.Corpus) (string, error) { + actor, err := c.GetActor(ctx, string(nodeID)) + if err != nil { + return "", err + } + if actor == nil || actor.Login == "" { + return "", fmt.Errorf("node ID %q is not stored; search or sync by login first", nodeID) + } + return actor.Login, nil +} + +func parseActorSelectors(inputs []mcpcontract.ActorSelector) ([]parsedActorSelector, []mcpcontract.ActorSelector, error) { + selectors := make([]parsedActorSelector, len(inputs)) + normalized := make([]mcpcontract.ActorSelector, len(inputs)) + seen := make(map[string]struct{}, len(inputs)) + for i, input := range inputs { + switch input.Type { + case "login": + login := strings.TrimSpace(input.Login) + if login == "" || input.NodeID != "" { + return nil, nil, errors.New("login selectors require login and forbid node_id") + } + selectors[i] = actorLogin(login) + normalized[i] = mcpcontract.ActorSelector{Type: "login", Login: login} + case "node_id": + nodeID := strings.TrimSpace(input.NodeID) + if nodeID == "" || input.Login != "" { + return nil, nil, errors.New("node_id selectors require node_id and forbid login") + } + selectors[i] = actorNodeID(nodeID) + normalized[i] = mcpcontract.ActorSelector{Type: "node_id", NodeID: nodeID} + default: + return nil, nil, errors.New("actor selector type must be login or node_id") + } + key := selectors[i].key() + if _, ok := seen[key]; ok { + return nil, nil, fmt.Errorf("duplicate actor selector %q", key) + } + seen[key] = struct{}{} + } + return selectors, normalized, nil +} + +func storedActorForSelector(ctx context.Context, c *corpus.Corpus, selector parsedActorSelector) (*corpus.Actor, string, error) { + login, err := selector.resolveLogin(ctx, c) + if err != nil { + return nil, "", err + } + actor, err := c.GetActor(ctx, login) + if err != nil { + return nil, "", err + } + if actor == nil { + return nil, "", fmt.Errorf("actor %q has no stored identity; call github.sync_users first", login) + } + return actor, login, nil +} diff --git a/internal/app/app.go b/internal/app/app.go index 2f6c3adc..470f745d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -219,7 +219,7 @@ func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) { if err != nil { return nil, err } - if inspection.Exists { + if inspection.Exists() { switch inspection.State { case corpus.SchemaMigrationRequired: return nil, &corpus.MigrationRequiredError{Current: inspection.Current, Target: inspection.Target} @@ -438,7 +438,7 @@ func (s *Service) Init(ctx context.Context) (*contracts.InitResult, error) { if err != nil { return nil, err } - if inspection.Exists { + if inspection.Exists() { switch inspection.State { case corpus.SchemaMigrationRequired: return nil, &corpus.MigrationRequiredError{Current: inspection.Current, Target: inspection.Target} @@ -645,8 +645,8 @@ func corpusRepoFromGitHub(r github.Repository) corpus.Repository { // Dossier builds a deterministic, local-corpus-backed repository dossier. func (s *Service) Dossier(ctx context.Context, repo contracts.RepoRef) (*contracts.DossierResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } if _, err := s.openReadOnlyCorpus(ctx); err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e3a8ca99..c513059a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -285,7 +285,7 @@ func TestContributionGuidanceDoesNotClaimUnfetchedSource(t *testing.T) { if _, err := svc.RepositoryContextSync(ctx, contracts.RepoRef{Owner: "octocat", Repo: "test"}, 0); err != nil { t.Fatal(err) } - guidance, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.RepoRef{Owner: "octocat", Repo: "test"}) + guidance, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.MustRepoRef("octocat", "test")) if err != nil { t.Fatal(err) } @@ -340,12 +340,12 @@ func TestMCPReaderLocalReads(t *testing.T) { _, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"}) var dossierErr *mcpcontract.ToolError - if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type != "get_repositories" { + if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type() != "get_repositories" { t.Fatalf("MCP dossier before build error = %+v", err) } _, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "missing"}) var repositoryErr *mcpcontract.ToolError - if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Type != "sync_repository_context" { + if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("MCP dossier for missing repository error = %+v", err) } if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil { @@ -392,7 +392,7 @@ func TestSearchCodeUsesStoredSnapshotWithoutNetwork(t *testing.T) { if _, err := svc.Init(ctx); err != nil { t.Fatal(err) } - _, _, err = svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{ + _, _, err = svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc", CreatedAt: time.Now(), TotalBytes: 20, Documents: []codeindex.Document{{Path: "parser.go", Content: "func searchableParser() {}", Bytes: 25, LanguageHint: "go"}}, }) diff --git a/internal/app/clustering.go b/internal/app/clustering.go index 0fac7873..d0d100d6 100644 --- a/internal/app/clustering.go +++ b/internal/app/clustering.go @@ -18,7 +18,10 @@ import ( // ListClusters reads the current stored duplicate-candidate projection. It does // not compute or write cluster state. func (s *Service) ListClusters(ctx context.Context, repo contracts.RepoRef, limit int) (*contracts.ClusterListResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { + return nil, err + } if err := validateClusterList(ref, limit); err != nil { return nil, err } @@ -36,8 +39,8 @@ func (s *Service) ListClusters(ctx context.Context, repo contracts.RepoRef, limi // RefreshClusters explicitly computes and persists the duplicate-candidate // projection for a repository. func (s *Service) RefreshClusters(ctx context.Context, repo contracts.RepoRef) (*contracts.ClusterRefreshResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } c, err := s.openCorpus(ctx) @@ -97,8 +100,8 @@ func clusterRefreshToCLI(repo contracts.RepoRef, disposition string, identity cl } func validateClusterList(ref domain.RepoRef, limit int) error { - if err := ref.Validate(); err != nil { - return err + if !ref.IsValid() { + return errors.New("repository reference is not parsed") } if limit < 1 || limit > 1000 { return errors.New("cluster limit must be between 1 and 1000") diff --git a/internal/app/collection.go b/internal/app/collection.go index 915e1e60..79dbd490 100644 --- a/internal/app/collection.go +++ b/internal/app/collection.go @@ -40,16 +40,17 @@ func (s *Service) AddCollectionMembers(ctx context.Context, name string, members return nil, errors.New("at least one member is required") } - c, err := s.openCorpus(ctx) - if err != nil { - return nil, err - } cm := make([]corpus.CollectionMember, len(members)) for i, m := range members { - if err := validateCollectionMember(m); err != nil { + parsed, err := parseCollectionMember(m) + if err != nil { return nil, fmt.Errorf("member %d: %w", i+1, err) } - cm[i] = corpus.CollectionMember{Kind: m.Kind, Ref: m.Ref} + cm[i] = parsed + } + c, err := s.openCorpus(ctx) + if err != nil { + return nil, err } if err := c.AddCollectionMembers(ctx, name, cm); err != nil { return nil, fmt.Errorf("add collection members: %w", err) @@ -65,51 +66,53 @@ func (s *Service) AddCollectionMembers(ctx context.Context, name string, members return collectionResult(col), nil } -func validateCollectionMember(member contracts.CollectionMember) error { +func parseCollectionMember(member contracts.CollectionMember) (corpus.CollectionMember, error) { kind := strings.TrimSpace(member.Kind) ref := strings.TrimSpace(member.Ref) if ref == "" { - return errors.New("collection member reference is required") + return corpus.CollectionMember{}, errors.New("collection member reference is required") } switch kind { case "repository": - return validateCollectionRepoRef(ref) + parsed, err := domain.ParseRepoRef(ref) + if err != nil { + return corpus.CollectionMember{}, err + } + return corpus.CollectionMember{Kind: kind, Ref: parsed.String()}, nil case "issue", "pull_request", "thread": - return validateCollectionThreadRef(kind, ref) + parsed, err := parseCollectionThreadRef(kind, ref) + if err != nil { + return corpus.CollectionMember{}, err + } + return corpus.CollectionMember{Kind: kind, Ref: parsed}, nil case "opportunity", "investigation": if len(ref) > 64 { - return fmt.Errorf("invalid %s reference %q: exceeds 64 bytes", kind, ref) + return corpus.CollectionMember{}, fmt.Errorf("invalid %s reference %q: exceeds 64 bytes", kind, ref) } - if _, err := uuid.Parse(ref); err != nil { - return fmt.Errorf("invalid %s reference %q: expected durable id", kind, ref) + id, err := uuid.Parse(ref) + if err != nil { + return corpus.CollectionMember{}, fmt.Errorf("invalid %s reference %q: expected durable id", kind, ref) } - return nil + return corpus.CollectionMember{Kind: kind, Ref: id.String()}, nil default: - return fmt.Errorf("unsupported collection member kind %q", kind) + return corpus.CollectionMember{}, fmt.Errorf("unsupported collection member kind %q", kind) } } -func validateCollectionThreadRef(kind, ref string) error { +func parseCollectionThreadRef(kind, ref string) (string, error) { if strings.Count(ref, "#") != 1 { - return fmt.Errorf("invalid %s reference %q: expected OWNER/REPO#NUMBER", kind, ref) + return "", fmt.Errorf("invalid %s reference %q: expected OWNER/REPO#NUMBER", kind, ref) } repoRef, numberText, _ := strings.Cut(ref, "#") - if err := validateCollectionRepoRef(repoRef); err != nil { - return fmt.Errorf("invalid %s reference %q: %w", kind, ref, err) + parsedRepo, err := domain.ParseRepoRef(repoRef) + if err != nil { + return "", 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 nil -} - -func validateCollectionRepoRef(ref string) error { - if strings.Count(ref, "/") != 1 { - return fmt.Errorf("invalid repository reference %q", ref) + return "", fmt.Errorf("invalid %s reference %q: expected positive number", kind, ref) } - owner, repo, _ := strings.Cut(ref, "/") - return (domain.RepoRef{Owner: strings.TrimSpace(owner), Repo: strings.TrimSpace(repo)}).Validate() + return fmt.Sprintf("%s#%d", parsedRepo, number), nil } // ListCollections returns all named collections. diff --git a/internal/app/concerns.go b/internal/app/concerns.go index 8bffee39..76121579 100644 --- a/internal/app/concerns.go +++ b/internal/app/concerns.go @@ -33,8 +33,12 @@ func (s *Service) readConcernService(ctx context.Context) (*concern.Service, err // CreateConcern records one local concern without external access. func (s *Service) CreateConcern(ctx context.Context, opts contracts.ConcernCreateOptions) (*contracts.ConcernResult, error) { + ref, err := domain.NewRepoRef(opts.Repo.Owner, opts.Repo.Repo) + if err != nil { + return nil, err + } return s.createConcern(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: opts.Repo.Owner, Repo: opts.Repo.Repo}, CommitSHA: opts.CommitSHA, WorkspaceID: opts.WorkspaceID, + Repo: ref, CommitSHA: opts.CommitSHA, WorkspaceID: opts.WorkspaceID, Title: opts.Title, ProblemStatement: opts.ProblemStatement, SuspectedOwner: opts.SuspectedOwner, Confidence: opts.Confidence, Unknowns: opts.Unknowns, SuccessCriterion: opts.SuccessCriterion, Notes: opts.Notes, EvidenceIDs: opts.EvidenceIDs, @@ -55,12 +59,16 @@ func (s *Service) createConcern(ctx context.Context, input *concern.Concern) (*c // ListConcerns performs a bounded offline concern list or search. func (s *Service) ListConcerns(ctx context.Context, opts contracts.ConcernListOptions) (*contracts.ConcernListResult, error) { + ref, err := optionalRepoRef(opts.Repo.Owner, opts.Repo.Repo) + if err != nil { + return nil, err + } svc, err := s.readConcernService(ctx) if err != nil { return nil, err } page, err := svc.List(ctx, concern.Filter{ - Repo: domain.RepoRef{Owner: opts.Repo.Owner, Repo: opts.Repo.Repo}, Status: concern.Status(opts.Status), Query: opts.Query, Limit: opts.Limit, Offset: opts.Offset, + Repo: ref, Status: concern.Status(opts.Status), Query: opts.Query, Limit: opts.Limit, Offset: opts.Offset, }) if err != nil { return nil, mapConcernError(err) @@ -225,7 +233,7 @@ func (s *Service) concernResult(ctx context.Context, item *concern.Concern) (*co } } result := &contracts.ConcernResult{ - ID: item.ID, Repo: contracts.RepoRef{Owner: item.Repo.Owner, Repo: item.Repo.Repo}, CommitSHA: item.CommitSHA, WorkspaceID: item.WorkspaceID, + ID: item.ID, Repo: contracts.RepoRef{Owner: item.Repo.Owner(), Repo: item.Repo.Repo()}, CommitSHA: item.CommitSHA, WorkspaceID: item.WorkspaceID, Title: item.Title, ProblemStatement: item.ProblemStatement, SuspectedOwner: item.SuspectedOwner, Confidence: item.Confidence, Unknowns: append([]string(nil), item.Unknowns...), SuccessCriterion: item.SuccessCriterion, Notes: item.Notes, EvidenceIDs: append([]string(nil), item.EvidenceIDs...), SourceRefCount: len(item.SourceRefs), Freshness: string(freshness.Status), diff --git a/internal/app/concerns_test.go b/internal/app/concerns_test.go index a4afb3fd..1831e4c3 100644 --- a/internal/app/concerns_test.go +++ b/internal/app/concerns_test.go @@ -116,10 +116,10 @@ func TestMCPConcernListProvidesNextOffsetRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - if page.Offset != 0 || page.NextOffset != 2 || !page.Truncated || page.Recovery == nil || len(page.Recovery.Then) != 1 || page.Recovery.Then[0].Type != "list_concerns" { + if page.Offset != 0 || page.NextOffset != 2 || !page.Truncated || page.Recovery == nil || len(page.Recovery.Then) != 1 || page.Recovery.Then[0].Type() != "list_concerns" { t.Fatalf("first concern page = %+v", page) } - if next := page.Recovery.Then[0].ListConcerns; next == nil || next.Offset != 2 || next.Limit != 2 { + if next, ok := mcpcontract.RecoveryInput[mcpcontract.ListConcernsInput](page.Recovery.Then[0]); !ok || next.Offset != 2 || next.Limit != 2 { t.Fatalf("next concern action = %+v", page.Recovery) } @@ -149,7 +149,7 @@ func TestConcernFreshnessIsDerivedFromCurrentCorpus(t *testing.T) { t.Fatal(err) } created, err := svc.createConcern(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, CommitSHA: "abc", Title: "metadata", ProblemStatement: "may be stale", + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "abc", Title: "metadata", ProblemStatement: "may be stale", Confidence: 0.2, SourceProvenance: []evidence.SourceRevision{*revision}, }) if err != nil || created.Freshness != "fresh" { diff --git a/internal/app/contribution.go b/internal/app/contribution.go index 27cde0f9..06aedcef 100644 --- a/internal/app/contribution.go +++ b/internal/app/contribution.go @@ -187,8 +187,8 @@ func (s *Service) workspaceDiff(ctx context.Context, workspaceID string, inv *in return "", err } if inv == nil || ws.InvestigationID != inv.ID || - !strings.EqualFold(ws.RepoOwner, inv.Repo.Owner) || - !strings.EqualFold(ws.RepoName, inv.Repo.Repo) { + !strings.EqualFold(ws.RepoOwner, inv.Repo.Owner()) || + !strings.EqualFold(ws.RepoName, inv.Repo.Repo()) { return "", errors.New("workspace does not belong to the opportunity investigation and repository") } mgr, err := s.workspaceReader() @@ -228,7 +228,7 @@ func (s *Service) PrepareReviewReport(ctx context.Context, input contracts.Prepa } report.OpportunityStatus = string(opp.Status) report.CollisionStatus = string(opp.CollisionStatus) - report.Repo = contracts.RepoRef{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo} + report.Repo = contracts.RepoRef{Owner: inv.Repo.Owner(), Repo: inv.Repo.Repo()} opportunityEvidence, err = s.evidenceForOpportunity(ctx, input.OpportunityID) if err != nil { @@ -253,8 +253,8 @@ func (s *Service) PrepareReviewReport(ctx context.Context, input contracts.Prepa return nil, mapWorkspaceError(err) } if inv != nil && (ws.InvestigationID != inv.ID || - !strings.EqualFold(ws.RepoOwner, inv.Repo.Owner) || - !strings.EqualFold(ws.RepoName, inv.Repo.Repo)) { + !strings.EqualFold(ws.RepoOwner, inv.Repo.Owner()) || + !strings.EqualFold(ws.RepoName, inv.Repo.Repo())) { return nil, errors.New("workspace does not belong to the opportunity investigation and repository") } diff, err := s.WorkspaceDiff(ctx, input.WorkspaceID) diff --git a/internal/app/contribution_test.go b/internal/app/contribution_test.go index a9422406..2ff1406a 100644 --- a/internal/app/contribution_test.go +++ b/internal/app/contribution_test.go @@ -15,7 +15,7 @@ func TestLoadOpportunityEvidenceIncludesMatchedValidationObservation(t *testing. ctx := fixture.ctx svc := fixture.svc started, err := svc.StartInvestigationFromThread(ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatalf("start investigation: %v", err) @@ -33,13 +33,20 @@ func TestLoadOpportunityEvidenceIncludesMatchedValidationObservation(t *testing. if err := svc.corpus.SaveValidationDefinition(ctx, def); err != nil { t.Fatalf("save definition: %v", err) } + expected, err := evidence.ParseExpectedObservation(evidence.ExpectedObservationSpec{ + Name: "undersized buffer", Source: evidence.ObservationStdout, + Matcher: evidence.ObservationExact, Pattern: "!buffer<3>", Occurrence: evidence.ObservationPresent, + }) + if err != nil { + t.Fatal(err) + } run := &evidence.ValidationRun{ ID: "run", DefinitionID: def.ID, InvestigationID: started.Investigation.ID, HypothesisID: started.Hypothesis.ID, OpportunityID: opp.ID, Kind: evidence.RunKindBase, Classification: evidence.RunClassificationFailing, ObservationStatus: evidence.ObservationMatched, Observations: []evidence.ObservationResult{{ - ExpectedObservation: evidence.ExpectedObservation{Name: "undersized buffer"}, + ExpectedObservation: expected, Status: evidence.ObservationMatched, Excerpt: "!buffer<3>", }}, StartedAt: now, CompletedAt: now, diff --git a/internal/app/corpus_lifecycle.go b/internal/app/corpus_lifecycle.go index 0b1de3fe..9d55e6ed 100644 --- a/internal/app/corpus_lifecycle.go +++ b/internal/app/corpus_lifecycle.go @@ -36,7 +36,7 @@ func (s *Service) InventoryCorpus(ctx context.Context, repo string) (*contracts. if err != nil { return nil, err } - inv, err := c.Inventory(ctx, ref.Owner, ref.Repo) + inv, err := c.Inventory(ctx, ref.Owner(), ref.Repo()) if errors.Is(err, corpus.ErrRepositoryNotFound) { return nil, failure.NotFound(fmt.Errorf("repository %s is not stored", repo)) } @@ -81,8 +81,12 @@ func (s *Service) ListCorpusInventory(ctx context.Context) (*contracts.CorpusInv SizeAttribution: "SQLite database and WAL pages are shared; observation payload and code content bytes are logical measurements, not page allocation", } for i, item := range inv.Repositories { + ref, err := domain.NewRepoRef(item.RepoOwner, item.RepoName) + if err != nil { + return nil, fmt.Errorf("decode inventory repository: %w", err) + } result := contracts.CorpusRepositoryInventoryResult{ - Repo: domain.RepoRef{Owner: item.RepoOwner, Repo: item.RepoName}.String(), + Repo: ref.String(), Issues: item.Issues, PullRequests: item.PullRequests, Threads: item.Threads, RepositoryObservations: item.RepositoryObservations, ThreadObservations: item.ThreadObservations, FacetObservations: item.FacetObservations, FacetCoverage: item.FacetCoverage, @@ -132,7 +136,7 @@ func (s *Service) PlanCodePrune(ctx context.Context, repo string, keepLatest int if err != nil { return nil, err } - plan, err := c.PlanCodeSnapshotPrune(ctx, domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, keepLatest) + plan, err := c.PlanCodeSnapshotPrune(ctx, ref, keepLatest) if err != nil { return nil, err } @@ -149,8 +153,7 @@ func (s *Service) ApplyCodePrune(ctx context.Context, repo string, keepLatest in if err != nil { return nil, err } - domainRef := domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo} - plan, err := c.PlanCodeSnapshotPrune(ctx, domainRef, keepLatest) + plan, err := c.PlanCodeSnapshotPrune(ctx, ref, keepLatest) if err != nil { return nil, err } @@ -162,7 +165,7 @@ func (s *Service) ApplyCodePrune(ctx context.Context, repo string, keepLatest in return nil, corpus.ErrCodeSnapshotPrunePlanStale } } - result, err := c.ApplyCodeSnapshotPrune(ctx, domainRef, plan) + result, err := c.ApplyCodeSnapshotPrune(ctx, ref, plan) if err != nil { return nil, err } @@ -311,7 +314,7 @@ func projectionResult(state corpus.ProjectionState) contracts.CorpusProjectionRe func corpusInspectionResult(inspection corpus.SchemaInspection) *contracts.CorpusInspectionResult { result := &contracts.CorpusInspectionResult{ - Path: inspection.Path, Exists: inspection.Exists, SizeBytes: inspection.SizeBytes, WALBytes: inspection.WALBytes, + Path: inspection.Path, Exists: inspection.Exists(), SizeBytes: inspection.SizeBytes, WALBytes: inspection.WALBytes, State: string(inspection.State), Current: inspection.Current, Target: inspection.Target, Repositories: inspection.Repository, Threads: inspection.Threads, Problem: inspection.Problem, @@ -362,7 +365,7 @@ func (s *Service) RestoreCorpus(ctx context.Context, source, safetyBackup string if err := s.releaseCorpusForMigration(); err != nil { return nil, err } - if before.Exists { + if before.Exists() { if safetyBackup == "" { stamp := s.now().UTC().Format("20060102T150405.000000000Z") safetyBackup = filepath.Join(filepath.Dir(before.Path), fmt.Sprintf("%s.before-restore.%s.bak", filepath.Base(before.Path), stamp)) @@ -422,7 +425,7 @@ func (s *Service) MigrateCorpus(ctx context.Context, opts contracts.CorpusMigrat return nil, err } var destination string - if before.Exists && !opts.NoBackup { + if before.Exists() && !opts.NoBackup { destination = opts.BackupPath if destination == "" { stamp := s.now().UTC().Format("20060102T150405.000000000Z") diff --git a/internal/app/coverage_target.go b/internal/app/coverage_target.go new file mode 100644 index 00000000..430c9eb1 --- /dev/null +++ b/internal/app/coverage_target.go @@ -0,0 +1,85 @@ +package app + +import ( + "fmt" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/facets" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type parsedCoverageTarget interface { + repository() domain.RepoRef + thread() (domain.ThreadKind, int, bool) + wire() mcpcontract.CoverageTarget + key() string + expectedFacets() []string +} + +type repositoryCoverageTarget struct{ repo domain.RepoRef } + +func (t repositoryCoverageTarget) repository() domain.RepoRef { return t.repo } +func (repositoryCoverageTarget) thread() (domain.ThreadKind, int, bool) { + return "", 0, false +} +func (t repositoryCoverageTarget) wire() mcpcontract.CoverageTarget { + return mcpcontract.CoverageTarget{Type: mcpcontract.CoverageTargetRepository, Repository: mcpcontract.RepositoryRef{Owner: t.repo.Owner(), Repo: t.repo.Repo()}} +} +func (t repositoryCoverageTarget) key() string { return t.repo.String() } +func (repositoryCoverageTarget) expectedFacets() []string { + return []string{"metadata", "threads", FacetContributionGuidance} +} + +type threadCoverageTarget struct { + repo domain.RepoRef + kind domain.ThreadKind + number int +} + +func (t threadCoverageTarget) repository() domain.RepoRef { return t.repo } +func (t threadCoverageTarget) thread() (domain.ThreadKind, int, bool) { + return t.kind, t.number, true +} +func (t threadCoverageTarget) wire() mcpcontract.CoverageTarget { + return mcpcontract.CoverageTarget{ + Type: mcpcontract.CoverageTargetExactThread, + Repository: mcpcontract.RepositoryRef{Owner: t.repo.Owner(), Repo: t.repo.Repo()}, + Thread: &mcpcontract.ExactCoverageThread{Kind: string(t.kind), Number: t.number}, + } +} +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 parseCoverageTarget(input mcpcontract.CoverageTarget) (parsedCoverageTarget, mcpcontract.CoverageTarget, error) { + repo, err := domain.NewRepoRef(input.Repository.Owner, input.Repository.Repo) + if err != nil { + return nil, mcpcontract.CoverageTarget{}, fmt.Errorf("%w: %w", errInvalidCoverageTarget, err) + } + switch input.Type { + case mcpcontract.CoverageTargetRepository: + if input.Thread != nil { + return nil, mcpcontract.CoverageTarget{}, errInvalidCoverageTarget + } + parsed := repositoryCoverageTarget{repo: repo} + return parsed, parsed.wire(), nil + case mcpcontract.CoverageTargetExactThread: + if input.Thread == nil || input.Thread.Number <= 0 { + return nil, mcpcontract.CoverageTarget{}, errInvalidCoverageTarget + } + var kind domain.ThreadKind + switch input.Thread.Kind { + case string(domain.IssueKind): + kind = domain.IssueKind + case string(domain.PullRequestKind): + kind = domain.PullRequestKind + default: + return nil, mcpcontract.CoverageTarget{}, errInvalidCoverageTarget + } + parsed := threadCoverageTarget{repo: repo, kind: kind, number: input.Thread.Number} + return parsed, parsed.wire(), nil + default: + return nil, mcpcontract.CoverageTarget{}, errInvalidCoverageTarget + } +} diff --git a/internal/app/discovery.go b/internal/app/discovery.go index 4d6be837..99de5e28 100644 --- a/internal/app/discovery.go +++ b/internal/app/discovery.go @@ -79,8 +79,8 @@ func (s *Service) AddRepoSource(ctx context.Context, name string, refs []contrac } domainRefs := make([]domain.RepoRef, len(refs)) for i, ref := range refs { - r := domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo} - if err := r.Validate(); err != nil { + r, err := domain.NewRepoRef(ref.Owner, ref.Repo) + if err != nil { return nil, fmt.Errorf("invalid repository %s: %w", ref, err) } domainRefs[i] = r @@ -362,8 +362,8 @@ func (s *Service) crawlRepoSource(ctx context.Context, c *corpus.Corpus, source return nil, err } repo := corpus.Repository{ - Owner: ref.Owner, - Name: ref.Repo, + Owner: ref.Owner(), + Name: ref.Repo(), // Explicit sources have no network freshness; use a zero // source_updated_at so canonical GitHub syncs always win. SourceUpdatedAt: time.Time{}, @@ -372,8 +372,8 @@ func (s *Service) crawlRepoSource(ctx context.Context, c *corpus.Corpus, source 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", + 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 @@ -568,14 +568,14 @@ func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source repoRefs = append(repoRefs, ref) } sort.Slice(repoRefs, func(i, j int) bool { - if repoRefs[i].Owner != repoRefs[j].Owner { - return repoRefs[i].Owner < repoRefs[j].Owner + if repoRefs[i].Owner() != repoRefs[j].Owner() { + return repoRefs[i].Owner() < repoRefs[j].Owner() } - return repoRefs[i].Repo < repoRefs[j].Repo + return repoRefs[i].Repo() < repoRefs[j].Repo() }) for _, ref := range repoRefs { sig := hourSigs.repoSigs[ref] - existing, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + existing, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return err } @@ -591,8 +591,8 @@ 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", + 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 { @@ -606,10 +606,10 @@ func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source } sort.Slice(threadKeys, func(i, j int) bool { if threadKeys[i].ref != threadKeys[j].ref { - if threadKeys[i].ref.Owner != threadKeys[j].ref.Owner { - return threadKeys[i].ref.Owner < threadKeys[j].ref.Owner + if threadKeys[i].ref.Owner() != threadKeys[j].ref.Owner() { + return threadKeys[i].ref.Owner() < threadKeys[j].ref.Owner() } - return threadKeys[i].ref.Repo < threadKeys[j].ref.Repo + return threadKeys[i].ref.Repo() < threadKeys[j].ref.Repo() } if threadKeys[i].kind != threadKeys[j].kind { return threadKeys[i].kind < threadKeys[j].kind @@ -620,13 +620,16 @@ func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source sig := hourSigs.threadSigs[key] repoID, ok := repoIDByRef[key.ref] if !ok { - return fmt.Errorf("missing repository id for thread %s/%s#%d", key.ref.Owner, key.ref.Repo, key.number) + return fmt.Errorf("missing repository id for thread %s#%d", key.ref, key.number) } existing, err := c.GetThread(ctx, repoID, key.kind, key.number) if err != nil { return err } - thread := mergeArchiveThread(sig, repoID, existing) + thread, representable := mergeArchiveThread(sig, repoID, existing) + if !representable { + continue + } payload, err := json.Marshal(sig) if err != nil { return err @@ -641,8 +644,8 @@ func (s *Service) flushArchiveHour(ctx context.Context, c *corpus.Corpus, source func mergeArchiveRepo(sig discovery.Signal, existing *corpus.Repository) corpus.Repository { r := corpus.Repository{ - Owner: sig.Repo.Owner, - Name: sig.Repo.Repo, + Owner: sig.Repo.Owner(), + Name: sig.Repo.Repo(), ExternalID: fmt.Sprintf("%d", sig.RepoID), } if existing == nil { @@ -661,7 +664,7 @@ func mergeArchiveRepo(sig discovery.Signal, existing *corpus.Repository) corpus. return r } -func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thread) corpus.Thread { +func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thread) (corpus.Thread, bool) { t := corpus.Thread{ RepositoryID: repoID, Kind: string(sig.ThreadKind), @@ -671,7 +674,7 @@ func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thr if !existing.SourceUpdatedAt.IsZero() { t = *existing t.SourceUpdatedAt = time.Time{} - return t + return t, true } t = *existing t.RepositoryID = repoID @@ -680,6 +683,16 @@ func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thr } if sig.ThreadState != "" { t.State = string(sig.ThreadState) + } else if t.State == "" { + switch strings.ToLower(sig.Action) { + case "opened", "reopened": + t.State = string(domain.OpenState) + case "closed": + t.State = string(domain.ClosedState) + } + } + if t.State == "" { + return corpus.Thread{}, false } if sig.ThreadTitle != "" { t.Title = sig.ThreadTitle @@ -688,10 +701,10 @@ func mergeArchiveThread(sig discovery.Signal, repoID int64, existing *corpus.Thr t.Author = sig.ThreadAuthor } if sig.ThreadKind == domain.PullRequestKind && sig.Merged { - t.Merged = true + t.Merge = domain.MergedStatus(time.Time{}) } t.SourceUpdatedAt = time.Time{} - return t + return t, true } type budgetedRepositorySearch struct { diff --git a/internal/app/discovery_test.go b/internal/app/discovery_test.go index f5d0420b..1184730f 100644 --- a/internal/app/discovery_test.go +++ b/internal/app/discovery_test.go @@ -501,7 +501,7 @@ func TestGHArchiveCrawlMalformedArchive(t *testing.T) { } c, _ := svc.openCorpus(ctx) runs, err := c.ListRuns(ctx, 1) - if err != nil || len(runs) != 1 || runs[0].Status != corpus.RunStatusFailed { + if err != nil || len(runs) != 1 || runs[0].State.Status() != corpus.RunStatusFailed { t.Fatalf("latest run = %+v, err=%v; want failed", runs, err) } } @@ -543,7 +543,7 @@ func TestGHArchiveCrawlFetchFailureContinues(t *testing.T) { } c, _ := svc.openCorpus(ctx) runs, err := c.ListRuns(ctx, 1) - if err != nil || len(runs) != 1 || runs[0].Status != corpus.RunStatusPartial { + if err != nil || len(runs) != 1 || runs[0].State.Status() != corpus.RunStatusPartial { t.Fatalf("latest run = %+v, err=%v; want partial", runs, err) } } @@ -552,7 +552,7 @@ func TestArchiveMergePreservesNewerProjection(t *testing.T) { t.Parallel() newer := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) signal := discovery.Signal{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, RepoID: 42, + Repo: domain.MustRepoRef("owner", "repo"), RepoID: 42, ThreadKind: domain.IssueKind, ThreadNumber: 7, ThreadState: domain.ThreadState("closed"), ObservedAt: newer.Add(-time.Hour), } @@ -561,9 +561,12 @@ func TestArchiveMergePreservesNewerProjection(t *testing.T) { t.Fatalf("repository regressed: %+v", got) } thread := corpus.Thread{ID: 2, RepositoryID: 1, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "current", SourceUpdatedAt: newer} - if got := mergeArchiveThread(signal, 1, &thread); got.State != "open" || got.Title != "current" || !got.SourceUpdatedAt.IsZero() { + if got, ok := mergeArchiveThread(signal, 1, &thread); !ok || got.State != "open" || got.Title != "current" || !got.SourceUpdatedAt.IsZero() { t.Fatalf("thread regressed: %+v", got) } + if _, ok := mergeArchiveThread(discovery.Signal{ThreadKind: domain.IssueKind, ThreadNumber: 8, Action: "labeled"}, 1, nil); ok { + t.Fatal("sparse archive event produced a thread projection without a known state") + } } func TestArchiveDiscoveryCannotOutrankCanonicalSync(t *testing.T) { @@ -574,15 +577,19 @@ func TestArchiveDiscoveryCannotOutrankCanonicalSync(t *testing.T) { c, _ := svc.openCorpus(ctx) signal := discovery.Signal{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, RepoID: 42, - ThreadKind: domain.IssueKind, ThreadNumber: 7, ThreadTitle: "sparse archive title", + Repo: domain.MustRepoRef("owner", "repo"), RepoID: 42, + ThreadKind: domain.IssueKind, ThreadNumber: 7, ThreadState: domain.OpenState, ThreadTitle: "sparse archive title", ObservedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), } archiveRepo, err := c.UpsertRepository(ctx, mergeArchiveRepo(signal, nil), `{"source":"archive"}`) if err != nil { t.Fatal(err) } - if _, err := c.UpsertThread(ctx, mergeArchiveThread(signal, archiveRepo.ID, nil), `{"source":"archive"}`); err != nil { + archiveThread, ok := mergeArchiveThread(signal, archiveRepo.ID, nil) + if !ok { + t.Fatal("archive signal with an explicit state was not representable") + } + if _, err := c.UpsertThread(ctx, archiveThread, `{"source":"archive"}`); err != nil { t.Fatal(err) } @@ -594,7 +601,7 @@ func TestArchiveDiscoveryCannotOutrankCanonicalSync(t *testing.T) { t.Fatal(err) } if _, err := c.UpsertThread(ctx, corpus.Thread{ - RepositoryID: canonicalRepo.ID, Kind: corpus.ThreadKindIssue, Number: 7, Title: "canonical title", Body: "canonical body", SourceUpdatedAt: canonicalTime, + RepositoryID: canonicalRepo.ID, Kind: corpus.ThreadKindIssue, Number: 7, State: "open", Title: "canonical title", Body: "canonical body", SourceUpdatedAt: canonicalTime, }, `{"source":"github"}`); err != nil { t.Fatal(err) } diff --git a/internal/app/dossier.go b/internal/app/dossier.go index b6be5958..97a898b0 100644 --- a/internal/app/dossier.go +++ b/internal/app/dossier.go @@ -45,8 +45,8 @@ var rejectionLabels = map[string]struct{}{ // BuildRepositoryDossier builds a deterministic dossier from local corpus data, // persists it safely, and returns the result. func (s *Service) BuildRepositoryDossier(ctx context.Context, repo contracts.RepoRef) (*domain.Dossier, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -60,7 +60,7 @@ func (s *Service) BuildRepositoryDossier(ctx context.Context, repo contracts.Rep return nil, err } - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } @@ -88,7 +88,7 @@ func (s *Service) BuildRepositoryDossier(ctx context.Context, repo contracts.Rep } generatedAt := s.now() - id, inserted, err := c.RefreshDossier(ctx, repoProjection.ID, ref.Owner, ref.Repo, d.CommitSHA, d.AsOf, string(sectionMetaJSON), string(snapshot), generatedAt, d.SourceRefs) + id, inserted, err := c.RefreshDossier(ctx, repoProjection.ID, ref.Owner(), ref.Repo(), d.CommitSHA, d.AsOf, string(sectionMetaJSON), string(snapshot), generatedAt, d.SourceRefs) if err != nil { return nil, fmt.Errorf("refresh dossier: %w", err) } @@ -96,7 +96,7 @@ func (s *Service) BuildRepositoryDossier(ctx context.Context, repo contracts.Rep return d, nil } - record, sources, err := c.GetDossier(ctx, ref.Owner, ref.Repo) + record, sources, err := c.GetDossier(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get dossier: %w", err) } @@ -108,8 +108,8 @@ func (s *Service) BuildRepositoryDossier(ctx context.Context, repo contracts.Rep // GetRepositoryDossier returns the most recently persisted dossier for a repository. func (s *Service) GetRepositoryDossier(ctx context.Context, repo contracts.RepoRef) (*domain.Dossier, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -117,7 +117,7 @@ func (s *Service) GetRepositoryDossier(ctx context.Context, repo contracts.RepoR if err != nil { return nil, err } - record, sources, err := c.GetDossier(ctx, ref.Owner, ref.Repo) + record, sources, err := c.GetDossier(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get dossier: %w", err) } @@ -153,8 +153,8 @@ func dossierFromRecord(record *corpus.DossierRecord, sources []corpus.DossierSou // positive and negative outcome evidence; issue-only context is opt-in. It // performs no network access. func (s *Service) ExtractSeeds(ctx context.Context, repo contracts.RepoRef, opts domain.ExtractSeedsOptions) ([]domain.Seed, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -170,7 +170,7 @@ func (s *Service) ExtractSeeds(ctx context.Context, repo contracts.RepoRef, opts return nil, err } - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } @@ -254,10 +254,10 @@ func selectedSeedPolarities(polarities []domain.SeedPolarity) (map[domain.SeedPo func classForThread(t corpus.Thread) (domain.SeedSourceClass, bool) { switch t.Kind { case corpus.ThreadKindPullRequest: - if t.Merged { + if t.Merge.IsMerged() { return domain.SeedSourceClassMergedPR, true } - if t.State == "closed" && t.MergedKnown { + if t.State == "closed" && t.Merge.Known() { return domain.SeedSourceClassClosedUnmergedPR, true } return "", false @@ -302,7 +302,7 @@ func buildSeed(ctx context.Context, c *corpus.Corpus, t corpus.Thread, class dom CreatedAt: t.SourceCreatedAt, UpdatedAt: t.SourceUpdatedAt, ClosedAt: t.ClosedAt, - MergedAt: t.MergedAt, + MergedAt: t.Merge.MergedAt(), } prPayload, err := latestPRPayload(ctx, c, t) diff --git a/internal/app/dossier_test.go b/internal/app/dossier_test.go index a36a377f..7b58d68d 100644 --- a/internal/app/dossier_test.go +++ b/internal/app/dossier_test.go @@ -31,7 +31,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { t.Fatal(err) } - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{ RepoPath: "/repo", @@ -43,8 +43,8 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { } repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, - Name: ref.Repo, + Owner: ref.Owner(), + Name: ref.Repo(), Description: "A test repo", Language: "Go", DefaultBranch: "main", @@ -69,8 +69,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { SourceCreatedAt: base, SourceUpdatedAt: base.Add(4 * time.Hour), ClosedAt: base.Add(2 * time.Hour), - MergedAt: base.Add(2 * time.Hour), - Merged: true, + Merge: domain.MergedStatus(base.Add(2 * time.Hour)), }, prPayload(2, 120, 45)); err != nil { t.Fatalf("upsert merged pr: %v", err) } @@ -87,8 +86,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { SourceCreatedAt: base, SourceUpdatedAt: base.Add(3 * time.Hour), ClosedAt: base.Add(1 * time.Hour), - Merged: false, - MergedKnown: true, + Merge: domain.UnmergedStatus(), }, prPayload(0, 0, 0)); err != nil { t.Fatalf("upsert closed pr: %v", err) } @@ -121,7 +119,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { t.Fatalf("upsert issue: %v", err) } - d, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}) + d, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}) if err != nil { t.Fatalf("build dossier: %v", err) } @@ -138,7 +136,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { t.Fatalf("unexpected unknown-merge PRs: count=%d recent=%+v", d.ClosedPullRequestUnknownCount, d.RecentClosedUnknownPullRequests) } - got, err := svc.GetRepositoryDossier(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}) + got, err := svc.GetRepositoryDossier(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}) if err != nil { t.Fatalf("get dossier: %v", err) } @@ -153,12 +151,12 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { } if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, Name: ref.Repo, Description: "A changed repo", Stars: 99, + Owner: ref.Owner(), Name: ref.Repo(), Description: "A changed repo", Stars: 99, SourceUpdatedAt: time.Unix(3000, 0).UTC(), }, `{}`); err != nil { t.Fatalf("update repository after dossier build: %v", err) } - mcpDossier, err := svc.MCPReader().Dossier(ctx, mcpcontract.RepoInput{Owner: ref.Owner, Repo: ref.Repo}) + mcpDossier, err := svc.MCPReader().Dossier(ctx, mcpcontract.RepoInput{Owner: ref.Owner(), Repo: ref.Repo()}) if err != nil { t.Fatalf("read persisted MCP dossier: %v", err) } @@ -166,7 +164,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) { t.Fatalf("MCP dossier stars = %v, want persisted value 10", stars) } - res, err := svc.Dossier(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}) + res, err := svc.Dossier(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}) if err != nil { t.Fatalf("dossier summary: %v", err) } @@ -200,14 +198,14 @@ func TestCorpusReaderDoesNotTruncateRepositoriesAboveOneThousandThreads(t *testi } reader := &corpusReader{s: svc} - stored, _, err := reader.ReadRepository(ctx, domain.RepoRef{Owner: "owner", Repo: "large"}) + stored, _, err := reader.ReadRepository(ctx, domain.MustRepoRef("owner", "large")) if err != nil { t.Fatal(err) } if stored.OpenIssueCount != 1001 { t.Fatalf("open issue count = %d, want 1001", stored.OpenIssueCount) } - threads, _, err := reader.ReadThreads(ctx, domain.RepoRef{Owner: "owner", Repo: "large"}, dossier.ThreadQuery{ + threads, _, err := reader.ReadThreads(ctx, domain.MustRepoRef("owner", "large"), dossier.ThreadQuery{ Kind: domain.IssueKind, State: domain.OpenState, Limit: 1001, @@ -233,10 +231,10 @@ func TestExtractSeeds(t *testing.T) { t.Fatal(err) } - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, - Name: ref.Repo, + Owner: ref.Owner(), + Name: ref.Repo(), SourceUpdatedAt: time.Unix(1000, 0).UTC(), }, `{}`) if err != nil { @@ -256,8 +254,7 @@ func TestExtractSeeds(t *testing.T) { SourceCreatedAt: base, SourceUpdatedAt: base.Add(4 * time.Hour), ClosedAt: base.Add(2 * time.Hour), - MergedAt: base.Add(2 * time.Hour), - Merged: true, + Merge: domain.MergedStatus(base.Add(2 * time.Hour)), }, prPayload(2, 120, 45)); err != nil { t.Fatalf("upsert merged pr: %v", err) } @@ -274,8 +271,7 @@ func TestExtractSeeds(t *testing.T) { SourceCreatedAt: base, SourceUpdatedAt: base.Add(3 * time.Hour), ClosedAt: base.Add(1 * time.Hour), - Merged: false, - MergedKnown: true, + Merge: domain.UnmergedStatus(), }, prPayload(0, 0, 0)); err != nil { t.Fatalf("upsert closed pr: %v", err) } @@ -316,7 +312,7 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("upsert not-planned issue: %v", err) } - seeds, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{}) + seeds, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{}) if err != nil { t.Fatalf("extract seeds: %v", err) } @@ -364,7 +360,7 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("not-planned issue polarity/evidence = %+v", notPlanned) } - contextOnly, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{ + contextOnly, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{ Classes: []domain.SeedSourceClass{domain.SeedSourceClassIssue}, Polarities: []domain.SeedPolarity{domain.SeedPolarityContext}, Limit: 10, @@ -376,7 +372,7 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("expected open issue context, got %+v", contextOnly) } - negativeIssues, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{ + negativeIssues, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{ Classes: []domain.SeedSourceClass{domain.SeedSourceClassIssue}, Polarities: []domain.SeedPolarity{domain.SeedPolarityNegative}, Limit: 10, @@ -387,7 +383,7 @@ func TestExtractSeeds(t *testing.T) { if len(negativeIssues) != 1 || negativeIssues[0].Number != 2 { t.Fatalf("expected not-planned issue only, got %+v", negativeIssues) } - empty, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{ + empty, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{ Classes: []domain.SeedSourceClass{domain.SeedSourceClassMergedPR}, Polarities: []domain.SeedPolarity{domain.SeedPolarityContext}, Limit: 10, @@ -399,7 +395,7 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("empty seed selection = %#v, want non-nil empty list", empty) } - bounded, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{Limit: 1}) + bounded, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{Limit: 1}) if err != nil { t.Fatalf("extract bounded: %v", err) } @@ -407,10 +403,10 @@ func TestExtractSeeds(t *testing.T) { t.Fatalf("expected 1 seed with limit 1, got %d", len(bounded)) } - if _, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{Polarities: []domain.SeedPolarity{"invented"}}); err == nil || !strings.Contains(err.Error(), "unknown seed polarity") { + if _, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{Polarities: []domain.SeedPolarity{"invented"}}); err == nil || !strings.Contains(err.Error(), "unknown seed polarity") { t.Fatalf("invalid polarity error = %v", err) } - if _, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{Classes: []domain.SeedSourceClass{"invented"}}); err == nil || !strings.Contains(err.Error(), "unknown seed source class") { + if _, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{Classes: []domain.SeedSourceClass{"invented"}}); err == nil || !strings.Contains(err.Error(), "unknown seed source class") { t.Fatalf("invalid source class error = %v", err) } } @@ -426,14 +422,14 @@ func TestSeedPolarityUsesOnlyStructuredOutcomeEvidence(t *testing.T) { }{ { name: "merged PR remains positive despite rejection text", - thread: corpus.Thread{Kind: corpus.ThreadKindPullRequest, State: "closed", Merged: true, Title: "rejected experiment"}, + thread: corpus.Thread{Kind: corpus.ThreadKindPullRequest, 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", MergedKnown: true}, + thread: corpus.Thread{Kind: corpus.ThreadKindPullRequest, State: "closed", Merge: domain.UnmergedStatus()}, class: domain.SeedSourceClassClosedUnmergedPR, want: domain.SeedPolarityNegative, wantReason: "GitHub reports this pull request was closed without merging", @@ -491,10 +487,10 @@ func TestExtractSeedsRequiresNoNetwork(t *testing.T) { t.Fatal(err) } - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, - Name: ref.Repo, + Owner: ref.Owner(), + Name: ref.Repo(), SourceUpdatedAt: time.Unix(1000, 0).UTC(), }, `{}`) if err != nil { @@ -515,7 +511,7 @@ func TestExtractSeedsRequiresNoNetwork(t *testing.T) { t.Fatalf("upsert thread: %v", err) } - seeds, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, domain.ExtractSeedsOptions{Polarities: []domain.SeedPolarity{domain.SeedPolarityContext}}) + seeds, err := svc.ExtractSeeds(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, domain.ExtractSeedsOptions{Polarities: []domain.SeedPolarity{domain.SeedPolarityContext}}) if err != nil { t.Fatalf("extract seeds without network reader: %v", err) } diff --git a/internal/app/draft_verification_test.go b/internal/app/draft_verification_test.go index c72ea05e..50267af7 100644 --- a/internal/app/draft_verification_test.go +++ b/internal/app/draft_verification_test.go @@ -14,7 +14,7 @@ import ( func TestVerifyPublishedDraftExactMismatchAndStale(t *testing.T) { fixture := newResearchFixture(t) started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatal(err) diff --git a/internal/app/evidence.go b/internal/app/evidence.go index 83cd7be3..4f97e446 100644 --- a/internal/app/evidence.go +++ b/internal/app/evidence.go @@ -38,6 +38,13 @@ func (s *Service) DefineValidation(ctx context.Context, investigationID string, if opts.WorkspaceID == "" && opts.BaseWorkspaceID == "" && opts.CandidateWorkspaceID == "" && opts.WorkingDir == "" && (opts.BaseWorkingDir == "" || opts.CandidateDir == "") { return nil, errors.New("validation working directory is required") } + var observation *evidence.ObservationContract + if opts.Observation != nil { + observation, err = observationContractToEvidence(*opts.Observation) + if err != nil { + return nil, err + } + } c, err := s.openCorpus(ctx) if err != nil { @@ -46,7 +53,6 @@ func (s *Service) DefineValidation(ctx context.Context, investigationID string, if err := s.resolveValidationWorkspaces(ctx, c, inv.ID, &opts); err != nil { return nil, err } - def := &evidence.ValidationDefinition{ InvestigationID: inv.ID, Name: opts.Kind, @@ -61,7 +67,7 @@ func (s *Service) DefineValidation(ctx context.Context, investigationID string, Env: opts.Env, Timeout: opts.Timeout, MaxOutputBytes: opts.MaxOutputBytes, - Observation: observationContractToEvidence(opts.Observation), + Observation: observation, Protocol: evidence.ValidationProtocol(opts.Protocol), ReadinessTimeout: opts.ReadinessTimeout, } @@ -563,9 +569,9 @@ func validationProcessIdentity(value evidence.ProcessIdentity) contracts.Validat func validationResources(value evidence.ResourceTelemetry) contracts.ValidationResourceTelemetry { return contracts.ValidationResourceTelemetry{ Provider: value.Provider, Platform: value.Platform, SampleInterval: value.SampleInterval.String(), SampleCount: value.SampleCount, - CPUTimeMillis: contracts.ValidationInt64Metric{Value: value.CPUTimeMillis.Value, UnavailableReason: value.CPUTimeMillis.UnavailableReason}, - PeakRSSBytes: contracts.ValidationUint64Metric{Value: value.PeakRSSBytes.Value, UnavailableReason: value.PeakRSSBytes.UnavailableReason}, - PeakChildCount: contracts.ValidationInt64Metric{Value: value.PeakChildCount.Value, UnavailableReason: value.PeakChildCount.UnavailableReason}, + CPUTimeMillis: contracts.ValidationInt64Metric{Value: value.CPUTimeMillis.ValuePointer(), UnavailableReason: value.CPUTimeMillis.UnavailableReason()}, + PeakRSSBytes: contracts.ValidationUint64Metric{Value: value.PeakRSSBytes.ValuePointer(), UnavailableReason: value.PeakRSSBytes.UnavailableReason()}, + PeakChildCount: contracts.ValidationInt64Metric{Value: value.PeakChildCount.ValuePointer(), UnavailableReason: value.PeakChildCount.UnavailableReason()}, SamplerOverheadNanoseconds: value.SamplerOverheadNanoseconds, } } @@ -578,21 +584,22 @@ func validationCleanup(value evidence.CleanupResult) contracts.ValidationCleanup return result } -func observationContractToEvidence(contract *contracts.ValidationObservationContract) *evidence.ObservationContract { - if contract == nil { - return nil - } - return &evidence.ObservationContract{ +func observationContractToEvidence(contract contracts.ValidationObservationContract) (*evidence.ObservationContract, error) { + parsed, err := evidence.ParseObservationContract(evidence.ObservationContractSpec{ Intent: contract.Intent, - Base: expectedObservationsToEvidence(contract.Base), - Candidate: expectedObservationsToEvidence(contract.Candidate), + Base: expectedObservationSpecs(contract.Base), + Candidate: expectedObservationSpecs(contract.Candidate), + }) + if err != nil { + return nil, fmt.Errorf("parse observation contract: %w", err) } + return parsed, nil } -func expectedObservationsToEvidence(items []contracts.ValidationExpectedObservation) []evidence.ExpectedObservation { - out := make([]evidence.ExpectedObservation, len(items)) +func expectedObservationSpecs(items []contracts.ValidationExpectedObservation) []evidence.ExpectedObservationSpec { + out := make([]evidence.ExpectedObservationSpec, len(items)) for i, item := range items { - out[i] = evidence.ExpectedObservation{ + out[i] = evidence.ExpectedObservationSpec{ Name: item.Name, Source: evidence.ObservationSource(item.Source), Matcher: evidence.ObservationMatcher(item.Matcher), Pattern: item.Pattern, Occurrence: evidence.ObservationOccurrence(item.Occurrence), @@ -607,18 +614,19 @@ func observationContractToCLI(contract *evidence.ObservationContract) *contracts return nil } return &contracts.ValidationObservationContract{ - Intent: contract.Intent, - Base: expectedObservationsToCLI(contract.Base), - Candidate: expectedObservationsToCLI(contract.Candidate), + Intent: contract.Intent(), + Base: expectedObservationsToCLI(contract.Base()), + Candidate: expectedObservationsToCLI(contract.Candidate()), } } func expectedObservationsToCLI(items []evidence.ExpectedObservation) []contracts.ValidationExpectedObservation { out := make([]contracts.ValidationExpectedObservation, len(items)) for i, item := range items { + spec := item.Spec() out[i] = contracts.ValidationExpectedObservation{ - Name: item.Name, Source: string(item.Source), Matcher: string(item.Matcher), - Pattern: item.Pattern, Occurrence: string(item.Occurrence), Path: item.Path, + Name: spec.Name, Source: string(spec.Source), Matcher: string(spec.Matcher), + Pattern: spec.Pattern, Occurrence: string(spec.Occurrence), Path: spec.Path, } } return out diff --git a/internal/app/evidence_freshness.go b/internal/app/evidence_freshness.go index dfcc6435..29c89f86 100644 --- a/internal/app/evidence_freshness.go +++ b/internal/app/evidence_freshness.go @@ -27,7 +27,7 @@ func evidenceItemResult(ctx context.Context, c *corpus.Corpus, item *evidence.Ev func sourceRevisionFromThreadBaseline(baseline investigation.ThreadBaseline) evidence.SourceRevision { return evidence.SourceRevision{ Subject: evidence.SourceSubject{ - Kind: evidence.SourceSubjectThread, Owner: baseline.Repo.Owner, Repo: baseline.Repo.Repo, + Kind: evidence.SourceSubjectThread, Owner: baseline.Repo.Owner(), Repo: baseline.Repo.Repo(), ThreadKind: string(baseline.Kind), Number: baseline.Number, }, SourceUpdatedAt: baseline.SourceUpdatedAt, diff --git a/internal/app/evidence_freshness_test.go b/internal/app/evidence_freshness_test.go index 91bfc623..a015c18e 100644 --- a/internal/app/evidence_freshness_test.go +++ b/internal/app/evidence_freshness_test.go @@ -15,7 +15,7 @@ import ( func TestEvidenceFreshnessFromThreadInvestigationBaseline(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) - ref := research.ThreadRef{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1} + ref := research.ThreadRef{Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1} started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, ref) if err != nil { t.Fatalf("start from thread: %v", err) @@ -78,7 +78,7 @@ func TestEvidenceFreshnessManualObservationIsNotApplicable(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatalf("start from thread: %v", err) diff --git a/internal/app/guidance.go b/internal/app/guidance.go index dcc368a5..4b27ccec 100644 --- a/internal/app/guidance.go +++ b/internal/app/guidance.go @@ -56,7 +56,7 @@ func syncRepositoryGuidance( if err := budget.take(); err != nil { return err } - resolution, _, err := resolver.ResolveRepositoryRef(ctx, ref.Owner, ref.Repo, requestedRef) + resolution, _, err := resolver.ResolveRepositoryRef(ctx, ref.Owner(), ref.Repo(), requestedRef) if err != nil { return fmt.Errorf("resolve contribution guidance ref %q: %w", requestedRef, err) } @@ -69,7 +69,7 @@ func syncRepositoryGuidance( if err := budget.take(); err != nil { return err } - file, _, err := fileReader.GetRepositoryFileAtResolvedRef(ctx, ref.Owner, ref.Repo, path, resolution) + file, _, err := fileReader.GetRepositoryFileAtResolvedRef(ctx, ref.Owner(), ref.Repo(), path, resolution) if err != nil { var notFound *github.NotFoundError if errors.As(err, ¬Found) { diff --git a/internal/app/guidance_test.go b/internal/app/guidance_test.go index cca05d76..0196fc2b 100644 --- a/internal/app/guidance_test.go +++ b/internal/app/guidance_test.go @@ -46,7 +46,7 @@ func TestRepositoryContextSyncPersistsSourceBackedContributionGuidance(t *testin t.Fatal(err) } - text, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.RepoRef{Owner: "octocat", Repo: "guided"}) + text, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.MustRepoRef("octocat", "guided")) if err != nil { t.Fatal(err) } @@ -165,7 +165,7 @@ func TestGuidanceRetrievalReplacesSnapshotOnlyAfterAllPathsComplete(t *testing.T t.Fatal(err) } - err = syncRepositoryGuidance(fixture.ctx, fixture.svc.corpus, interruptedGuidanceReader{}, *repo, domain.RepoRef{Owner: "owner", Repo: "repo"}, fixture.now, 0, newSyncRequestBudget(maxSyncRequests)) + err = syncRepositoryGuidance(fixture.ctx, fixture.svc.corpus, interruptedGuidanceReader{}, *repo, domain.MustRepoRef("owner", "repo"), fixture.now, 0, newSyncRequestBudget(maxSyncRequests)) if err == nil || !strings.Contains(err.Error(), "interrupted guidance retrieval") { t.Fatalf("sync error = %v", err) } diff --git a/internal/app/health.go b/internal/app/health.go index b0e28c51..8a40aa7b 100644 --- a/internal/app/health.go +++ b/internal/app/health.go @@ -19,8 +19,8 @@ func (s *Service) RepositoryHealth(ctx context.Context, repo contracts.RepoRef) // 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) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -29,7 +29,7 @@ func (s *Service) RepositoryHealthWithOptions(ctx context.Context, repo contract return nil, err } - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } diff --git a/internal/app/health_test.go b/internal/app/health_test.go index 76cf3dda..71d4262c 100644 --- a/internal/app/health_test.go +++ b/internal/app/health_test.go @@ -9,6 +9,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" "github.com/morluto/gitcontribute/internal/github" "github.com/morluto/gitcontribute/internal/health" ) @@ -78,8 +79,7 @@ func TestRepositoryHealth(t *testing.T) { AuthorAssociation: "OWNER", SourceCreatedAt: now.Add(-12 * 24 * time.Hour), SourceUpdatedAt: now.Add(-3 * 24 * time.Hour), - MergedAt: now.Add(-3 * 24 * time.Hour), - Merged: true, + Merge: domain.MergedStatus(now.Add(-3 * 24 * time.Hour)), }, map[string]string{"Author": "owner1", "AuthorAssociation": "OWNER"}) if err != nil { t.Fatalf("upsert merged pr: %v", err) diff --git a/internal/app/hydration.go b/internal/app/hydration.go index a650ed65..455784e5 100644 --- a/internal/app/hydration.go +++ b/internal/app/hydration.go @@ -69,8 +69,8 @@ type HydrateOptions struct { // 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 := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } if number <= 0 { @@ -102,7 +102,7 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num }) }() - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { hydrateErr = fmt.Errorf("get repository: %w", err) return nil, hydrateErr @@ -268,7 +268,7 @@ func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) { if err := f.ctx.Err(); err != nil { return HydratedFacet{}, err } - res, err := reader.ListIssueTimeline(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number, opts) + res, err := reader.ListIssueTimeline(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number, opts) if err != nil { return HydratedFacet{}, err } @@ -480,7 +480,7 @@ func (f *facetRunner) hydrateIssueComments() (HydratedFacet, error) { facet: FacetIssueComments, marshalContext: "issue comments", fetch: func(opts github.PageOptions) (github.ListResult[github.IssueComment], error) { - return f.reader.ListIssueComments(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number, opts) + return f.reader.ListIssueComments(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number, opts) }, latest: latestFromIssueComments, searchText: issueCommentsSearchText, @@ -492,7 +492,7 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { if err != nil { return HydratedFacet{}, err } - pr, _, err := f.reader.GetPullRequestDetails(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number) + pr, _, err := f.reader.GetPullRequestDetails(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number) if err != nil { return HydratedFacet{}, err } @@ -517,7 +517,9 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { return HydratedFacet{Facet: FacetPRDetails, Count: 1, Pages: 1, Complete: true}, nil } projection := *f.thread - projection.State = pr.State + if pr.State != "" { + projection.State = pr.State + } projection.Title = pr.Title projection.Body = pr.Body projection.Draft = pr.Draft @@ -527,8 +529,11 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { projection.Labels = append([]string(nil), pr.Labels...) projection.Assignees = append([]string(nil), pr.Assignees...) projection.Milestone = pr.Milestone - projection.Merged = pr.Merged - projection.MergedKnown = true + merge, err := parseGitHubMergeStatus(pr) + if err != nil { + return HydratedFacet{}, fmt.Errorf("parse pull-request merge status: %w", err) + } + projection.Merge = merge projection.SourceUpdatedAt = updatedAt if !pr.CreatedAt.IsZero() { projection.SourceCreatedAt = pr.CreatedAt @@ -538,11 +543,6 @@ func (f *facetRunner) hydratePullRequestDetails() (HydratedFacet, error) { } else { projection.ClosedAt = time.Time{} } - if pr.MergedAt != nil { - projection.MergedAt = *pr.MergedAt - } else { - projection.MergedAt = time.Time{} - } stored, err := corpus.RetryBusyValue(f.ctx, func(ctx context.Context) (*corpus.Thread, error) { return f.c.UpsertThread(ctx, projection, string(payload)) }) @@ -559,7 +559,7 @@ func (f *facetRunner) hydratePullRequestReviews() (HydratedFacet, error) { facet: FacetPRReviews, marshalContext: "pr reviews", fetch: func(opts github.PageOptions) (github.ListResult[github.Review], error) { - return f.reader.ListPullRequestReviews(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number, opts) + return f.reader.ListPullRequestReviews(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number, opts) }, latest: latestFromReviews, searchText: pullRequestReviewsSearchText, @@ -571,7 +571,7 @@ func (f *facetRunner) hydratePullRequestReviewComments() (HydratedFacet, error) facet: FacetPRReviewComments, marshalContext: "pr review comments", fetch: func(opts github.PageOptions) (github.ListResult[github.ReviewComment], error) { - return f.reader.ListPullRequestComments(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number, opts) + return f.reader.ListPullRequestComments(f.ctx, f.ref.Owner(), f.ref.Repo(), f.thread.Number, opts) }, latest: latestFromReviewComments, searchText: reviewCommentsSearchText, diff --git a/internal/app/hydration_refresh.go b/internal/app/hydration_refresh.go index 7d1bfce8..481d2b43 100644 --- a/internal/app/hydration_refresh.go +++ b/internal/app/hydration_refresh.go @@ -13,8 +13,8 @@ import ( // 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 := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return err } if number <= 0 { @@ -31,7 +31,7 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac if err != nil { return err } - repository, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repository, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return fmt.Errorf("get repository: %w", err) } @@ -48,8 +48,8 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac writer := &syncThreadWriter{ ctx: ctx, corpus: c, - owner: ref.Owner, - repo: ref.Repo, + owner: ref.Owner(), + repo: ref.Repo(), repositoryID: repository.ID, kind: kind, } diff --git a/internal/app/hydration_repo.go b/internal/app/hydration_repo.go index 416b53de..4378a13e 100644 --- a/internal/app/hydration_repo.go +++ b/internal/app/hydration_repo.go @@ -23,8 +23,8 @@ type HydrateRepositoryOptions struct { // 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 := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -33,7 +33,7 @@ func (s *Service) HydrateRepository(ctx context.Context, repo contracts.RepoRef, return nil, err } - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } diff --git a/internal/app/hydration_test.go b/internal/app/hydration_test.go index 2fa14a2c..2f79198f 100644 --- a/internal/app/hydration_test.go +++ b/internal/app/hydration_test.go @@ -11,6 +11,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/github" ) @@ -271,7 +272,7 @@ func TestHydratePullRequestFacets(t *testing.T) { } } projected, err := c.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, thread.Number) - if err != nil || projected == nil || !projected.MergedKnown || projected.Merged { + if err != nil || projected == nil || !projected.Merge.Known() || projected.Merge.IsMerged() { t.Fatalf("projected PR merge state = %+v, %v", projected, err) } for query, source := range map[string]string{"architectural approval": FacetPRReviews, "nit": FacetPRReviewComments} { @@ -289,9 +290,7 @@ func TestHydratePullRequestDetailsDoesNotProjectStaleSnapshot(t *testing.T) { defer func() { _ = svc.Close() }() repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindPullRequest, 2) - thread.Merged = true - thread.MergedKnown = true - thread.MergedAt = thread.SourceUpdatedAt + thread.Merge = domain.MergedStatus(thread.SourceUpdatedAt) stored, err := svc.corpus.UpsertThread(ctx, *thread, `{"Merged":true}`) if err != nil { t.Fatalf("store known projection: %v", err) @@ -317,7 +316,7 @@ func TestHydratePullRequestDetailsDoesNotProjectStaleSnapshot(t *testing.T) { if err != nil { t.Fatal(err) } - if projected == nil || !projected.MergedKnown || !projected.Merged || !projected.MergedAt.Equal(stored.MergedAt) { + if projected == nil || !projected.Merge.Known() || !projected.Merge.IsMerged() || !projected.Merge.MergedAt().Equal(stored.Merge.MergedAt()) { t.Fatalf("stale detail response replaced known projection: %+v", projected) } observations, err := svc.corpus.ListFacetObservations(ctx, repo.ID, &stored.ID, FacetPRDetails) @@ -499,8 +498,8 @@ func TestHydrateRecordsRunFailure(t *testing.T) { for _, r := range runs { if r.Kind == "hydrate" { found = true - if r.Status != corpus.RunStatusFailed { - t.Fatalf("run status = %q, want failed", r.Status) + if r.State.Status() != corpus.RunStatusFailed { + t.Fatalf("run status = %q, want failed", r.State.Status()) } } } diff --git a/internal/app/index.go b/internal/app/index.go index a77eaf47..2bb8dc0c 100644 --- a/internal/app/index.go +++ b/internal/app/index.go @@ -10,8 +10,8 @@ import ( // Index records a bounded immutable code snapshot from a clean local checkout. func (s *Service) Index(ctx context.Context, repo contracts.RepoRef, path string) (*contracts.IndexResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } repoPath, commit, err := codeindex.Probe(ctx, path) diff --git a/internal/app/index_test.go b/internal/app/index_test.go index f9fa131e..1f657a51 100644 --- a/internal/app/index_test.go +++ b/internal/app/index_test.go @@ -46,7 +46,7 @@ func TestIndexUnchangedCommitReusesCurrentSnapshot(t *testing.T) { }, } if _, _, err := svc.corpus.StoreCodeSnapshot( - ctx, domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, replacement, + ctx, domain.MustRepoRef(ref.Owner, ref.Repo), replacement, ); err != nil { t.Fatalf("replace snapshot: %v", err) } @@ -59,7 +59,7 @@ func TestIndexUnchangedCommitReusesCurrentSnapshot(t *testing.T) { t.Fatalf("second index = %+v", second) } matches, err := svc.corpus.SearchCode( - ctx, "sentinel", domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, 10, + ctx, "sentinel", domain.MustRepoRef(ref.Owner, ref.Repo), 10, ) if err != nil { t.Fatalf("search preserved snapshot: %v", err) diff --git a/internal/app/investigation.go b/internal/app/investigation.go index 915e81b5..9b9e9f01 100644 --- a/internal/app/investigation.go +++ b/internal/app/investigation.go @@ -15,8 +15,8 @@ import ( // StartInvestigation creates a new investigation scoped to a repository. func (s *Service) StartInvestigation(ctx context.Context, repo contracts.RepoRef, commitSHA, lens string) (*contracts.InvestigationResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } invSvc, err := s.writeInvestigationSvc(ctx) @@ -285,7 +285,7 @@ func (s *Service) readInvestigationSvc(ctx context.Context) (*investigation.Serv func investigationResult(inv *investigation.Investigation) *contracts.InvestigationResult { return &contracts.InvestigationResult{ - ID: inv.ID, Repo: contracts.RepoRef{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo}, + ID: inv.ID, Repo: contracts.RepoRef{Owner: inv.Repo.Owner(), Repo: inv.Repo.Repo()}, CommitSHA: inv.CommitSHA, Lens: inv.Lens, Status: string(inv.Status), ThreadBaseline: threadBaselineResult(inv.ThreadBaseline), SeedHypothesisID: inv.SeedHypothesisID, AuditTrail: workflowAuditResults(inv.AuditTrail), diff --git a/internal/app/investigation_thread.go b/internal/app/investigation_thread.go index 1c7f5aa0..a0045bb4 100644 --- a/internal/app/investigation_thread.go +++ b/internal/app/investigation_thread.go @@ -31,7 +31,7 @@ func (s *Service) StartInvestigationFromThread(ctx context.Context, requested re if err != nil { return nil, err } - repo, err := c.GetRepository(ctx, requested.Repo.Owner, requested.Repo.Repo) + repo, err := c.GetRepository(ctx, requested.Repo.Owner(), requested.Repo.Repo()) if err != nil { return nil, fmt.Errorf("get thread investigation repository: %w", err) } diff --git a/internal/app/investigation_thread_test.go b/internal/app/investigation_thread_test.go index 5e478365..946d3259 100644 --- a/internal/app/investigation_thread_test.go +++ b/internal/app/investigation_thread_test.go @@ -20,7 +20,7 @@ import ( func TestStartInvestigationFromThreadPreservesExactBaselineAndReusesOpenPair(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) - ref := research.ThreadRef{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1} + ref := research.ThreadRef{Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1} thread, err := fixture.svc.corpus.GetThreadByNumber(fixture.ctx, fixture.repoID, 1) if err != nil { t.Fatal(err) @@ -79,7 +79,7 @@ func TestStartInvestigationFromThreadBoundsDescription(t *testing.T) { t.Fatal(err) } result, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Number: 1, }) if err != nil { t.Fatal(err) @@ -93,7 +93,7 @@ func TestStartInvestigationFromPullRequestUsesResolvedKind(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) result, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Number: 9, + Repo: domain.MustRepoRef("owner", "repo"), Number: 9, }) if err != nil { t.Fatal(err) @@ -122,7 +122,7 @@ func TestMCPStartInvestigationFromStoredThreadCreatesBaselineHypothesis(t *testi func TestStartInvestigationFromThreadErrorsAndCancellation(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") _, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{Repo: repo, Kind: domain.IssueKind, Number: 9}) if !failure.Is(err, failure.KindNotFound) || !errors.Is(err, research.ErrThreadKindMismatch) { t.Fatalf("kind mismatch error = %v", err) @@ -132,7 +132,7 @@ func TestStartInvestigationFromThreadErrorsAndCancellation(t *testing.T) { t.Fatalf("missing thread error = %v", err) } _, err = fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "other", Repo: "repo"}, Number: 1, + Repo: domain.MustRepoRef("other", "repo"), Number: 1, }) if !failure.Is(err, failure.KindNotFound) { t.Fatalf("repository isolation error = %v", err) diff --git a/internal/app/job_executor.go b/internal/app/job_executor.go index 3c8449b4..1aed5d75 100644 --- a/internal/app/job_executor.go +++ b/internal/app/job_executor.go @@ -383,10 +383,10 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc _ = e.corpus.TransitionJob(writeCtx, id, corpus.JobStatusQueued, corpus.JobStatusFailed, "", message) return } - if job != nil && !isTerminalJobStatus(job.Status) { + if job != nil && !isTerminalJobStatus(job.State.Status()) { // Best effort: preserve the original start error in durable job state. //nolint:errcheck - _ = e.corpus.TransitionJob(writeCtx, id, job.Status, corpus.JobStatusFailed, "", err.Error()) + _ = e.corpus.TransitionJob(writeCtx, id, job.State.Status(), corpus.JobStatusFailed, "", err.Error()) } return } @@ -408,7 +408,7 @@ func (e *JobExecutor) run(jobCtx context.Context, id string, cancel context.Canc _ = e.finishJob(writeCtx, id, corpus.JobStatusFailed, "", fmt.Errorf("get job after execution: %w", err).Error()) return } - if job != nil && job.CancelledAt != nil && !job.CancelledAt.IsZero() { + if job != nil && job.State.CancellationRequested() { _ = e.finishJob(writeCtx, id, corpus.JobStatusCancelled, "", "cancelled by request") return } diff --git a/internal/app/job_executor_lifecycle_test.go b/internal/app/job_executor_lifecycle_test.go index 9368bbc5..9a62ee1a 100644 --- a/internal/app/job_executor_lifecycle_test.go +++ b/internal/app/job_executor_lifecycle_test.go @@ -67,8 +67,8 @@ func TestJobExecutorCloseCancelsAndWaits(t *testing.T) { if err != nil { t.Fatalf("get after close: %v", err) } - if job.Status != corpus.JobStatusCancelled { - t.Fatalf("status = %q, want %q", job.Status, corpus.JobStatusCancelled) + if job.State.Status() != corpus.JobStatusCancelled { + t.Fatalf("status = %q, want %q", job.State.Status(), corpus.JobStatusCancelled) } } diff --git a/internal/app/job_executor_reconciliation_test.go b/internal/app/job_executor_reconciliation_test.go index 4b87060d..684a7e08 100644 --- a/internal/app/job_executor_reconciliation_test.go +++ b/internal/app/job_executor_reconciliation_test.go @@ -58,8 +58,8 @@ func TestStartupReconciliation(t *testing.T) { if reconciled == nil { t.Fatal("reconciled job not found") } - if reconciled.Status != corpus.JobStatusFailed { - t.Fatalf("status = %q, want %q", reconciled.Status, corpus.JobStatusFailed) + if reconciled.State.Status() != corpus.JobStatusFailed { + t.Fatalf("status = %q, want %q", reconciled.State.Status(), corpus.JobStatusFailed) } if reconciled.Error != "interrupted by restart" { t.Fatalf("error = %q, want %q", reconciled.Error, "interrupted by restart") @@ -107,8 +107,8 @@ func TestLiveOwnerNotReconciledByAnotherExecutor(t *testing.T) { if err != nil { t.Fatalf("get job: %v", err) } - if job.Status != corpus.JobStatusRunning { - t.Fatalf("live job was reconciled: status=%q", job.Status) + if job.State.Status() != corpus.JobStatusRunning { + t.Fatalf("live job was reconciled: status=%q", job.State.Status()) } } @@ -156,8 +156,8 @@ func TestAbandonedOwnerReconciledByNewExecutor(t *testing.T) { if err != nil { t.Fatalf("get job: %v", err) } - if job.Status != corpus.JobStatusFailed { - t.Fatalf("abandoned job status = %q, want %q", job.Status, corpus.JobStatusFailed) + if job.State.Status() != corpus.JobStatusFailed { + t.Fatalf("abandoned job status = %q, want %q", job.State.Status(), corpus.JobStatusFailed) } if job.Error != "interrupted by restart" { t.Fatalf("abandoned job error = %q", job.Error) @@ -218,8 +218,8 @@ func TestReadOnlyCorpusOpenDoesNotReconcileJobs(t *testing.T) { if err != nil { t.Fatalf("get job: %v", err) } - if j.Status != corpus.JobStatusRunning { - t.Fatalf("read-only open reconciled job: status=%q", j.Status) + if j.State.Status() != corpus.JobStatusRunning { + t.Fatalf("read-only open reconciled job: status=%q", j.State.Status()) } } @@ -269,7 +269,7 @@ func TestReconcileConcurrentWithHeartbeat(t *testing.T) { if err != nil { t.Fatalf("get job: %v", err) } - if job.Status != corpus.JobStatusRunning { - t.Fatalf("live job was reconciled during concurrent heartbeat: status=%q", job.Status) + if job.State.Status() != corpus.JobStatusRunning { + t.Fatalf("live job was reconciled during concurrent heartbeat: status=%q", job.State.Status()) } } diff --git a/internal/app/job_executor_test.go b/internal/app/job_executor_test.go index ae206d86..b02cc360 100644 --- a/internal/app/job_executor_test.go +++ b/internal/app/job_executor_test.go @@ -100,7 +100,7 @@ func waitForJobStatus(t *testing.T, jobs *JobExecutor, id, want string, timeout if job == nil { t.Fatal("job not found") } - if job.Status == want { + if job.State.Status() == want { return } time.Sleep(10 * time.Millisecond) @@ -117,7 +117,7 @@ func waitForCorpusJobStatus(t *testing.T, c *corpus.Corpus, id, want string, tim if err != nil { t.Fatalf("get job: %v", err) } - if job != nil && job.Status == want { + if job != nil && job.State.Status() == want { return } time.Sleep(10 * time.Millisecond) @@ -200,8 +200,8 @@ func TestJobExecutorBoundsConcurrentJobs(t *testing.T) { if err != nil { t.Fatal(err) } - if second.Status != corpus.JobStatusQueued { - t.Fatalf("waiting job status = %q, want queued", second.Status) + if second.State.Status() != corpus.JobStatusQueued { + t.Fatalf("waiting job status = %q, want queued", second.State.Status()) } close(releaseFirst) @@ -253,8 +253,8 @@ func TestJobExecutorCloseCancelsQueuedJobs(t *testing.T) { if err != nil { t.Fatalf("get %s: %v", id, err) } - if job.Status != corpus.JobStatusCancelled { - t.Fatalf("job %s status = %q, want cancelled", id, job.Status) + if job.State.Status() != corpus.JobStatusCancelled { + t.Fatalf("job %s status = %q, want cancelled", id, job.State.Status()) } } } @@ -390,8 +390,8 @@ func TestJobCancellation(t *testing.T) { if err != nil { t.Fatalf("get: %v", err) } - if job.Status != corpus.JobStatusCancelled { - t.Fatalf("status = %q, want %q", job.Status, corpus.JobStatusCancelled) + if job.State.Status() != corpus.JobStatusCancelled { + t.Fatalf("status = %q, want %q", job.State.Status(), corpus.JobStatusCancelled) } if job.Error == "" { t.Fatal("cancelled job has no error message") diff --git a/internal/app/jobs.go b/internal/app/jobs.go index 0ff58e21..19aeb635 100644 --- a/internal/app/jobs.go +++ b/internal/app/jobs.go @@ -70,19 +70,19 @@ 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.Status, Request: job.Request, + ID: job.ID, Kind: job.Kind, Status: job.State.Status(), Request: job.Request, Result: job.Result, Error: job.Error, Progress: job.Progress, Statistics: job.Statistics, CreatedAt: formatTime(job.CreatedAt), - Cancellation: job.CancelledAt != nil, + Cancellation: job.State.CancellationRequested(), } - if job.StartedAt != nil { - result.StartedAt = formatTime(*job.StartedAt) + if startedAt, ok := job.State.StartedAt(); ok { + result.StartedAt = formatTime(startedAt) } - if job.CompletedAt != nil { - result.CompletedAt = formatTime(*job.CompletedAt) + if completedAt, ok := job.State.CompletedAt(); ok { + result.CompletedAt = formatTime(completedAt) } - if job.CancelledAt != nil { - result.CancelledAt = formatTime(*job.CancelledAt) + if cancelledAt, ok := job.State.CancelledAt(); ok { + result.CancelledAt = formatTime(cancelledAt) } return result } diff --git a/internal/app/lens.go b/internal/app/lens.go index b61b3f9b..90e4d7e0 100644 --- a/internal/app/lens.go +++ b/internal/app/lens.go @@ -205,7 +205,7 @@ func (s *Service) resolveRepoLensTarget(ctx context.Context, c *corpus.Corpus, r if err != nil { return searchMatch{}, "", err } - repo, err := c.GetRepository(ctx, repoRef.Owner, repoRef.Repo) + repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()) if err != nil { return searchMatch{}, "", err } @@ -213,7 +213,7 @@ func (s *Service) resolveRepoLensTarget(ctx context.Context, c *corpus.Corpus, r return searchMatch{}, "", failure.NotFound(fmt.Errorf("repository %q not found", repoRef)) } return searchMatch{ - Repo: domain.RepoRef{Owner: repo.Owner, Repo: repo.Name}, + Repo: repoRef, Kind: "repo", Title: repoRef.String(), Body: repo.Description, @@ -233,7 +233,7 @@ func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, if err != nil { return searchMatch{}, "", err } - repo, err := c.GetRepository(ctx, repoRef.Owner, repoRef.Repo) + repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()) if err != nil { return searchMatch{}, "", err } @@ -253,7 +253,7 @@ func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, } m := searchMatch{ - Repo: domain.RepoRef{Owner: repo.Owner, Repo: repo.Name}, + Repo: repoRef, Kind: thread.Kind, Number: thread.Number, State: thread.State, @@ -265,9 +265,7 @@ func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, Assignees: thread.Assignees, Draft: thread.Draft, ClosedAt: thread.ClosedAt, - MergedAt: thread.MergedAt, - Merged: thread.Merged, - MergedKnown: thread.MergedKnown, + Merge: thread.Merge, Language: repo.Language, Archived: repo.Archived, Stars: repo.Stars, @@ -275,7 +273,7 @@ func (s *Service) resolveThreadLensTarget(ctx context.Context, c *corpus.Corpus, Forks: repo.Forks, UpdatedAt: thread.SourceUpdatedAt, Freshness: thread.SourceUpdatedAt, - URL: threadURL(domain.RepoRef{Owner: repo.Owner, Repo: repo.Name}, thread.Kind, thread.Number), + URL: threadURL(repoRef, thread.Kind, thread.Number), } if thread.Kind == corpus.ThreadKindPullRequest { return m, "prs", nil @@ -288,9 +286,12 @@ func (s *Service) resolveCodeLensTarget(ctx context.Context, c *corpus.Corpus, r if len(parts) < 3 { return searchMatch{}, "", fmt.Errorf("invalid code reference %q: expected owner/repo/path", ref) } - repoRef := domain.RepoRef{Owner: parts[0], Repo: parts[1]} + repoRef, err := domain.NewRepoRef(parts[0], parts[1]) + if err != nil { + return searchMatch{}, "", fmt.Errorf("invalid code reference %q: %w", ref, err) + } path := parts[2] - repo, err := c.GetRepository(ctx, repoRef.Owner, repoRef.Repo) + repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()) if err != nil { return searchMatch{}, "", err } @@ -323,12 +324,8 @@ func (s *Service) resolveCodeLensTarget(ctx context.Context, c *corpus.Corpus, r } func parseRepoRef(ref string) (domain.RepoRef, error) { - parts := strings.Split(ref, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return domain.RepoRef{}, fmt.Errorf("invalid repository reference %q", ref) - } - r := domain.RepoRef{Owner: parts[0], Repo: parts[1]} - if err := r.Validate(); err != nil { + r, err := domain.ParseRepoRef(ref) + if err != nil { return domain.RepoRef{}, fmt.Errorf("invalid repository reference %q: %w", ref, err) } return r, nil @@ -364,7 +361,7 @@ func buildLensExplainResult(record *corpus.LensRecord, found lens.Result, match result.Candidate = contracts.LensExplainCandidate{ Kind: match.Kind, - Repo: contracts.RepoRef{Owner: match.Repo.Owner, Repo: match.Repo.Repo}, + Repo: contracts.RepoRef{Owner: match.Repo.Owner(), Repo: match.Repo.Repo()}, Number: match.Number, Title: match.Title, State: match.State, diff --git a/internal/app/manifest.go b/internal/app/manifest.go index 15e9fdda..311b5ccb 100644 --- a/internal/app/manifest.go +++ b/internal/app/manifest.go @@ -109,14 +109,14 @@ func (s *Service) assembleContributionManifest(ctx context.Context, c *corpus.Co now := s.now().UTC() predicate := manifest.Predicate{ GeneratedAt: now, - Repository: manifest.RepositoryIdentity{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo, CommitSHA: inv.CommitSHA}, + Repository: manifest.RepositoryIdentity{Owner: inv.Repo.Owner(), Repo: inv.Repo.Repo(), CommitSHA: inv.CommitSHA}, 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...), }, } - if err := s.addManifestWorkspace(ctx, c, inv.ID, inv.Repo.Owner, inv.Repo.Repo, opts.WorkspaceID, &predicate); err != nil { + if err := s.addManifestWorkspace(ctx, c, inv.ID, inv.Repo.Owner(), inv.Repo.Repo(), opts.WorkspaceID, &predicate); err != nil { return nil, err } if err := addManifestValidations(ctx, c, &predicate); err != nil { @@ -129,7 +129,7 @@ func (s *Service) assembleContributionManifest(ctx context.Context, c *corpus.Co return nil, err } if opts.PullRequest != nil { - _, err = s.addManifestPullRequest(ctx, c, inv.Repo.Owner, inv.Repo.Repo, *opts.PullRequest, now, &predicate) + _, err = s.addManifestPullRequest(ctx, c, inv.Repo.Owner(), inv.Repo.Repo(), *opts.PullRequest, now, &predicate) if err != nil { return nil, err } diff --git a/internal/app/manifest_test.go b/internal/app/manifest_test.go index c5934bf4..15523b31 100644 --- a/internal/app/manifest_test.go +++ b/internal/app/manifest_test.go @@ -161,7 +161,7 @@ func selectedValidationCount(records []manifest.ValidationRecord) int { func TestContributionManifestKeepsMissingPullRequestFacetsIncomplete(t *testing.T) { fixture := newResearchFixture(t) started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatal(err) diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 97159ec8..279eb191 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -31,12 +31,12 @@ func (s *Service) MCPReader() mcpcontract.Reader { return &MCPReader{s} } // Repository reads a repository projection from the local corpus. func (r *MCPReader) Repository(ctx context.Context, in mcpcontract.RepoInput) (mcpcontract.RepositoryOutput, error) { - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.RepositoryOutput{}, err } batch, err := r.GetRepositories(ctx, mcpcontract.GetRepositoriesInput{ - Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}}, + Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, }) if err != nil { return mcpcontract.RepositoryOutput{}, err @@ -49,8 +49,8 @@ func (r *MCPReader) Repository(ctx context.Context, in mcpcontract.RepoInput) (m // Thread reads one issue or pull request from the local corpus. func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcpcontract.ThreadOutput, error) { - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.ThreadOutput{}, err } if in.Kind != "issue" && in.Kind != "pull_request" { @@ -67,7 +67,7 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp if err != nil { return mcpcontract.ThreadOutput{}, err } - repo, err := c.GetRepository(ctx, in.Owner, in.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.ThreadOutput{}, fmt.Errorf("get repository: %w", err) } @@ -82,8 +82,8 @@ func (r *MCPReader) Thread(ctx context.Context, in mcpcontract.ThreadInput) (mcp return mcpcontract.ThreadOutput{}, failure.NotFound(nil) } out := corpusThreadToMCPOutput(thread) - out.Owner = in.Owner - out.Repo = in.Repo + out.Owner = ref.Owner() + out.Repo = ref.Repo() out.SnapshotToken = snapshotIdentity(in.SnapshotToken, revision) if err := finishCorpusRead(ctx, c, revision); err != nil { return mcpcontract.ThreadOutput{}, err @@ -105,7 +105,7 @@ 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.MergedAt), Merged: knownMergePointer(t.Merged, t.MergedKnown), + Draft: t.Draft, ClosedAt: formatTime(t.ClosedAt), MergedAt: formatTime(t.Merge.MergedAt()), Merged: knownMergePointer(t.Merge.IsMerged(), t.Merge.Known()), UpdatedAt: formatTime(t.SourceUpdatedAt), } } @@ -119,15 +119,15 @@ func knownMergePointer(merged, known bool) *bool { // Dossier returns the latest persisted source-backed repository dossier. func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpcontract.DossierOutput, error) { - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.DossierOutput{}, err } c, err := r.openReadOnlyCorpus(ctx) if err != nil { return mcpcontract.DossierOutput{}, err } - repository, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repository, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.DossierOutput{}, err } @@ -135,10 +135,10 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc return mcpcontract.DossierOutput{}, mcpcontract.Unavailable( "repository_not_indexed", fmt.Sprintf("Repository %s is not present in the local corpus.", ref), - mcpcontract.RecoveryAction(mcpcontract.SyncRepositoryContextInput{Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}}}), + mcpcontract.RecoveryAction(mcpcontract.SyncRepositoryContextInput{Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}}), ) } - record, sources, err := c.GetDossier(ctx, ref.Owner, ref.Repo) + record, sources, err := c.GetDossier(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.DossierOutput{}, fmt.Errorf("get dossier: %w", err) } @@ -146,7 +146,7 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc return mcpcontract.DossierOutput{}, mcpcontract.Unavailable( "dossier_not_persisted", fmt.Sprintf("No persisted dossier exists for %s.", ref), - mcpcontract.RecoveryAction(mcpcontract.GetRepositoriesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}}}), + mcpcontract.RecoveryAction(mcpcontract.GetRepositoriesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}}), ) } d, err := dossierFromRecord(record, sources) @@ -203,8 +203,8 @@ func (r *MCPReader) Investigation(ctx context.Context, in mcpcontract.Investigat } return mcpcontract.InvestigationOutput{ ID: inv.ID, - Owner: inv.Repo.Owner, - Repo: inv.Repo.Repo, + Owner: inv.Repo.Owner(), + Repo: inv.Repo.Repo(), CommitSHA: inv.CommitSHA, Lens: inv.Lens, Status: string(inv.Status), @@ -415,7 +415,7 @@ func dossierToMCPOutput(d *domain.Dossier) mcpcontract.DossierOutput { d.ClosedPullRequestUnknownCount > len(d.RecentClosedUnknownPullRequests) || d.OpenIssueCount+d.ClosedIssueCount > len(d.RecentIssues) return mcpcontract.DossierOutput{ - Owner: d.Repo.Owner, Repo: d.Repo.Repo, AsOf: d.AsOf.Format(time.RFC3339), + Owner: d.Repo.Owner(), Repo: d.Repo.Repo(), AsOf: d.AsOf.Format(time.RFC3339), RecentItemsLimit: mcpcontract.NonNegativeInt(recentLimit), RecentItemsTruncated: recentTruncated, Sections: mcpcontract.DossierSections{ Description: d.Repository.Description, Language: firstLanguage(d.Repository.Languages), @@ -500,7 +500,15 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI } key := coverageTargetKey(target) item := mcpcontract.BatchItem[mcpcontract.CoverageOutput]{Key: key, Status: "complete"} - value, reason, err := readCoverageTarget(ctx, c, target) + parsed, normalized, parseErr := parseCoverageTarget(target) + var value mcpcontract.CoverageOutput + var reason string + if parseErr == nil { + target, item.Key = normalized, parsed.key() + value, reason, err = readParsedCoverageTarget(ctx, c, parsed) + } else { + err = parseErr + } if errors.Is(err, errInvalidCoverageTarget) { item.Status, item.Reason = "unavailable", "invalid_reference" item.Message = "owner/repo and optional kind/number must identify a repository or exact thread" @@ -521,13 +529,13 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI } out.Status = "partial" } else { - value = withExpectedCoverageFacets(target, value) + value = withExpectedCoverageFacets(parsed, value) item.Value = &value if coverageNeedsRecovery(value) { item.Status = "retryable" item.Reason = "coverage_incomplete" item.Message = "one or more required coverage facets are missing or incomplete" - item.Recovery = coverageRecoveryPlan(target, value) + item.Recovery = coverageRecoveryPlan(parsed, value) out.Status = "partial" } } @@ -566,19 +574,16 @@ func coverageTargetKey(target mcpcontract.CoverageTarget) string { var errInvalidCoverageTarget = errors.New("invalid coverage target") func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontract.CoverageTarget) (mcpcontract.CoverageOutput, string, error) { - ref := domain.RepoRef{Owner: target.Repository.Owner, Repo: target.Repository.Repo} - if err := ref.Validate(); err != nil { - return mcpcontract.CoverageOutput{}, "invalid_reference", fmt.Errorf("%w: %w", errInvalidCoverageTarget, err) - } - isThread := target.Type == mcpcontract.CoverageTargetExactThread - valid := target.Type == mcpcontract.CoverageTargetRepository && target.Thread == nil - if isThread && target.Thread != nil { - valid = (target.Thread.Kind == "issue" || target.Thread.Kind == "pull_request") && target.Thread.Number > 0 - } - if !valid { - return mcpcontract.CoverageOutput{}, "invalid_reference", errInvalidCoverageTarget + parsed, _, err := parseCoverageTarget(target) + if err != nil { + return mcpcontract.CoverageOutput{}, "invalid_reference", err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + return readParsedCoverageTarget(ctx, c, parsed) +} + +func readParsedCoverageTarget(ctx context.Context, c *corpus.Corpus, target parsedCoverageTarget) (mcpcontract.CoverageOutput, string, error) { + ref := target.repository() + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get repository: %w", err) } @@ -587,8 +592,9 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac } var threadID *int64 asOf := repo.SourceUpdatedAt + kind, number, isThread := target.thread() if isThread { - thread, err := c.GetThread(ctx, repo.ID, target.Thread.Kind, target.Thread.Number) + thread, err := c.GetThread(ctx, repo.ID, string(kind), number) if err != nil { return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get thread: %w", err) } @@ -602,9 +608,9 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac if err != nil { return mcpcontract.CoverageOutput{}, "", fmt.Errorf("list coverage: %w", err) } - out := mcpcontract.CoverageOutput{Owner: target.Repository.Owner, Repo: target.Repository.Repo, AsOf: formatTime(asOf), Facets: make([]mcpcontract.FacetCoverageOutput, 0, len(covs))} - if target.Thread != nil { - out.Kind, out.Number = target.Thread.Kind, target.Thread.Number + out := mcpcontract.CoverageOutput{Owner: ref.Owner(), Repo: ref.Repo(), AsOf: formatTime(asOf), Facets: make([]mcpcontract.FacetCoverageOutput, 0, len(covs))} + if isThread { + out.Kind, out.Number = string(kind), number } for _, cov := range covs { if cov.SourceUpdatedAt.After(asOf) { @@ -625,12 +631,12 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac return out, "", nil } -func withExpectedCoverageFacets(target mcpcontract.CoverageTarget, value mcpcontract.CoverageOutput) mcpcontract.CoverageOutput { +func withExpectedCoverageFacets(target parsedCoverageTarget, value mcpcontract.CoverageOutput) mcpcontract.CoverageOutput { byFacet := make(map[string]struct{}, len(value.Facets)) for _, facet := range value.Facets { byFacet[facet.Facet] = struct{}{} } - for _, name := range coverageFacetNames(target) { + for _, name := range target.expectedFacets() { if _, ok := byFacet[name]; ok { continue } @@ -639,16 +645,6 @@ func withExpectedCoverageFacets(target mcpcontract.CoverageTarget, value mcpcont return value } -func coverageFacetNames(target mcpcontract.CoverageTarget) []string { - if target.Type == mcpcontract.CoverageTargetRepository { - return []string{"metadata", "threads", FacetContributionGuidance} - } - if target.Thread == nil { - return nil - } - return facets.DefaultFor(target.Thread.Kind) -} - func coverageNeedsRecovery(value mcpcontract.CoverageOutput) bool { if len(value.Facets) == 0 { return true @@ -661,18 +657,17 @@ func coverageNeedsRecovery(value mcpcontract.CoverageOutput) bool { return false } -func coverageRecoveryPlan(target mcpcontract.CoverageTarget, value mcpcontract.CoverageOutput) *mcpcontract.RecoveryPlan { +func coverageRecoveryPlan(target parsedCoverageTarget, value mcpcontract.CoverageOutput) *mcpcontract.RecoveryPlan { message := "Refresh the missing or incomplete coverage facets, then reread corpus.get_coverage." - if target.Type == mcpcontract.CoverageTargetRepository { - return recoveryPlan("coverage_incomplete", message, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target})) - } - if target.Thread == nil { - return recoveryPlan("coverage_incomplete", message, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target})) + kind, number, exactThread := target.thread() + if !exactThread { + return recoveryPlan("coverage_incomplete", message, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target.wire()})) } - ref := mcpcontract.ThreadRef{Owner: target.Repository.Owner, Repo: target.Repository.Repo, Kind: target.Thread.Kind, Number: target.Thread.Number} - selectable := make(map[string]struct{}, len(facets.SelectableFor(target.Thread.Kind))) - for _, name := range facets.SelectableFor(target.Thread.Kind) { + 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[name] = struct{}{} } known := make(map[string]struct{}, len(facets.AllNames())) @@ -700,14 +695,14 @@ func coverageRecoveryPlan(target mcpcontract.CoverageTarget, value mcpcontract.C calls := []mcpcontract.ToolCall{syncThreadFacetsCall(ref, selected)} calls = append(calls, additional...) if needsEnsure { - calls = append(calls, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target})) + calls = append(calls, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target.wire()})) } return recoveryPlan("coverage_incomplete", message, calls...) } if len(additional) > 0 && !needsEnsure { return recoveryPlan("coverage_incomplete", message, additional...) } - return recoveryPlan("coverage_incomplete", message, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target})) + return recoveryPlan("coverage_incomplete", message, mcpcontract.RecoveryAction(mcpcontract.EnsureCoverageInput{Target: target.wire()})) } // Lens reads a saved lens definition from the local corpus. diff --git a/internal/app/mcp_actor_facets.go b/internal/app/mcp_actor_facets.go index ec602c8e..2597bc92 100644 --- a/internal/app/mcp_actor_facets.go +++ b/internal/app/mcp_actor_facets.go @@ -14,7 +14,7 @@ import ( ) 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 mcpcontract.ActorSelector) (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) (map[string]any, error) { source, ok := reader.(github.UserSocialAccountReader) if !ok { return nil, errors.New("GitHub social-account reads are unavailable") @@ -50,7 +50,7 @@ func (r *MCPReader) SyncUserSocialAccounts(ctx context.Context, in mcpcontract.S } 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 mcpcontract.ActorSelector) (map[string]any, 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) { source, ok := reader.(github.UserOrganizationReader) if !ok { return nil, errors.New("GitHub organization reads are unavailable") @@ -89,9 +89,11 @@ func (r *MCPReader) SyncUserPinnedItems(ctx context.Context, in mcpcontract.Sync if len(in.Users) < 1 || len(in.Users) > 50 { return mcpcontract.JobReference{}, errors.New("users must contain 1 to 50 items") } - if err := validateActorSelectors(in.Users); err != nil { + selectors, normalized, err := parseActorSelectors(in.Users) + if err != nil { return mcpcontract.JobReference{}, err } + in.Users = normalized if in.Limit == 0 { in.Limit = 6 } @@ -117,7 +119,7 @@ func (r *MCPReader) SyncUserPinnedItems(ctx context.Context, in mcpcontract.Sync if err != nil { return nil, err } - return r.runActorFacetItems(ctx, in.Users, "pinned_items", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "pinned_items", report, func(selector parsedActorSelector) (map[string]any, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -148,9 +150,11 @@ func (r *MCPReader) SyncUserRepositories(ctx context.Context, in mcpcontract.Syn if len(in.Users) < 1 || len(in.Users) > 50 { return mcpcontract.JobReference{}, errors.New("users must contain 1 to 50 items") } - if err := validateActorSelectors(in.Users); err != nil { + selectors, normalized, err := parseActorSelectors(in.Users) + if err != nil { return mcpcontract.JobReference{}, err } + in.Users = normalized if in.Relationship != "owned" && in.Relationship != "affiliated" && in.Relationship != "contributed" { return mcpcontract.JobReference{}, errors.New("relationship must be owned, affiliated, or contributed") } @@ -170,7 +174,7 @@ func (r *MCPReader) SyncUserRepositories(ctx context.Context, in mcpcontract.Syn if err != nil { return nil, err } - return r.runActorFacetItems(ctx, in.Users, "repositories", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "repositories", report, func(selector parsedActorSelector) (map[string]any, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -225,9 +229,11 @@ func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.Sy if len(in.Users) < 1 || len(in.Users) > 20 { return mcpcontract.JobReference{}, errors.New("users must contain 1 to 20 items") } - if err := validateActorSelectors(in.Users); err != nil { + selectors, normalized, err := parseActorSelectors(in.Users) + if err != nil { return mcpcontract.JobReference{}, err } + in.Users = normalized from, err := time.Parse(time.RFC3339, in.From) if err != nil { return mcpcontract.JobReference{}, errors.New("from must be RFC 3339") @@ -264,7 +270,7 @@ func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.Sy if err != nil { return nil, err } - return r.runActorFacetItems(ctx, in.Users, "contributions", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + return r.runActorFacetItems(ctx, selectors, "contributions", report, func(selector parsedActorSelector) (map[string]any, error) { actor, login, err := storedActorForSelector(ctx, c, selector) if err != nil { return nil, err @@ -432,13 +438,15 @@ 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, mcpcontract.ActorSelector) (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) (map[string]any, 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") } - if err := validateActorSelectors(in.Users); err != nil { + selectors, normalized, err := parseActorSelectors(in.Users) + if err != nil { return mcpcontract.JobReference{}, err } + in.Users = normalized if err := normalizeFacetBounds(&in.MaxPages, &in.MaxItems, &in.MaxRequests, len(in.Users)); err != nil { return mcpcontract.JobReference{}, err } @@ -451,7 +459,7 @@ func (r *MCPReader) submitUserFacetJob(ctx context.Context, kind string, in mcpc if err != nil { return nil, err } - return r.runActorFacetItems(ctx, in.Users, kind, report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { return run(ctx, c, reader, selector) }) + return r.runActorFacetItems(ctx, selectors, kind, report, func(selector parsedActorSelector) (map[string]any, error) { return run(ctx, c, reader, selector) }) }) if err != nil { return mcpcontract.JobReference{}, err @@ -459,7 +467,7 @@ 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 []mcpcontract.ActorSelector, phase string, report func(string, string) error, run func(mcpcontract.ActorSelector) (map[string]any, error)) (map[string]any, error) { +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)) complete := 0 if err := report(phase, jobProgressCounts(0, len(selectors))); err != nil { @@ -472,9 +480,9 @@ func (r *MCPReader) runActorFacetItems(ctx context.Context, selectors []mcpcontr value, err := run(selector) if err != nil { itemStatus, reason, message, retry := githubBatchError(err) - items[i] = map[string]any{"key": actorSelectorKey(selector), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} + items[i] = map[string]any{"key": selector.key(), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} } else { - value["key"] = actorSelectorKey(selector) + value["key"] = selector.key() value["status"] = "complete" items[i] = value complete++ @@ -490,20 +498,6 @@ func (r *MCPReader) runActorFacetItems(ctx context.Context, selectors []mcpcontr return map[string]any{"status": status, "items": items, "completed": complete, "total": len(selectors)}, nil } -func storedActorForSelector(ctx context.Context, c *corpus.Corpus, selector mcpcontract.ActorSelector) (*corpus.Actor, string, error) { - login, err := resolveActorSelectorLogin(ctx, c, selector) - if err != nil { - return nil, "", err - } - actor, err := c.GetActor(ctx, login) - if err != nil { - return nil, "", err - } - if actor == nil { - return nil, "", fmt.Errorf("actor %q has no stored identity; call github.sync_users first", login) - } - return actor, login, nil -} func normalizeFacetBounds(maxPages, maxItems, maxRequests *int, userCount int) error { if *maxPages == 0 { *maxPages = 1 diff --git a/internal/app/mcp_actors.go b/internal/app/mcp_actors.go index d623b334..9875daa0 100644 --- a/internal/app/mcp_actors.go +++ b/internal/app/mcp_actors.go @@ -86,11 +86,13 @@ func (r *MCPReader) SyncUsers(ctx context.Context, in mcpcontract.SyncUsersInput if in.MaxRequests < len(in.Users) || in.MaxRequests > 100 { return mcpcontract.JobReference{}, errors.New("max_requests must admit every user and cannot exceed 100") } - if err := validateActorSelectors(in.Users); err != nil { + selectors, normalized, err := parseActorSelectors(in.Users) + if err != nil { return mcpcontract.JobReference{}, err } + in.Users = normalized id, err := r.submitJob(ctx, "sync_users", in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.syncUsers(ctx, in, report) + return r.syncUsers(ctx, selectors, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -98,7 +100,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, in mcpcontract.SyncUsersInput, report func(string, string) error) (map[string]any, error) { +func (r *MCPReader) syncUsers(ctx context.Context, selectors []parsedActorSelector, report func(string, string) error) (map[string]any, error) { reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. if err != nil { return nil, err @@ -111,19 +113,19 @@ func (r *MCPReader) syncUsers(ctx context.Context, in mcpcontract.SyncUsersInput if err != nil { return nil, err } - items := make([]map[string]any, len(in.Users)) + items := make([]map[string]any, len(selectors)) complete := 0 - if err := report("profiles", jobProgressCounts(0, len(in.Users))); err != nil { + if err := report("profiles", jobProgressCounts(0, len(selectors))); err != nil { return nil, err } - for index, selector := range in.Users { + for index, selector := range selectors { if err := ctx.Err(); err != nil { return nil, err } - login, resolveErr := resolveActorSelectorLogin(ctx, c, selector) + login, resolveErr := selector.resolveLogin(ctx, c) if resolveErr != nil { - items[index] = map[string]any{"key": actorSelectorKey(selector), "status": "unavailable", "reason": "actor_login_unknown", "message": resolveErr.Error()} - if err := report("profiles", jobProgressCounts(index+1, len(in.Users))); err != nil { + items[index] = map[string]any{"key": selector.key(), "status": "unavailable", "reason": "actor_login_unknown", "message": resolveErr.Error()} + if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } continue @@ -131,8 +133,8 @@ func (r *MCPReader) syncUsers(ctx context.Context, in mcpcontract.SyncUsersInput actor, _, readErr := profiles.GetUser(ctx, login) if readErr != nil { itemStatus, reason, message, retry := githubBatchError(readErr) - items[index] = map[string]any{"key": actorSelectorKey(selector), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} - if err := report("profiles", jobProgressCounts(index+1, len(in.Users))); err != nil { + items[index] = map[string]any{"key": selector.key(), "status": itemStatus, "reason": reason, "message": message, "retry_after_ms": retry} + if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } continue @@ -147,63 +149,17 @@ func (r *MCPReader) syncUsers(ctx context.Context, in mcpcontract.SyncUsersInput if persistErr != nil { return nil, persistErr } - items[index] = map[string]any{"key": actorSelectorKey(selector), "status": "complete", "actor_id": stored.Key, "login": stored.Login} + items[index] = map[string]any{"key": selector.key(), "status": "complete", "actor_id": stored.Key, "login": stored.Login} complete++ - if err := report("profiles", jobProgressCounts(index+1, len(in.Users))); err != nil { + if err := report("profiles", jobProgressCounts(index+1, len(selectors))); err != nil { return nil, err } } status := "complete" - if complete != len(in.Users) { + if complete != len(selectors) { status = "partial" } - return map[string]any{"status": status, "items": items, "completed": complete, "total": len(in.Users)}, nil -} - -func validateActorSelectors(selectors []mcpcontract.ActorSelector) error { - seen := make(map[string]struct{}, len(selectors)) - for _, selector := range selectors { - key := actorSelectorKey(selector) - switch selector.Type { - case "login": - if strings.TrimSpace(selector.Login) == "" || selector.NodeID != "" { - return errors.New("login selectors require login and forbid node_id") - } - case "node_id": - if strings.TrimSpace(selector.NodeID) == "" || selector.Login != "" { - return errors.New("node_id selectors require node_id and forbid login") - } - default: - return errors.New("actor selector type must be login or node_id") - } - if _, ok := seen[key]; ok { - return fmt.Errorf("duplicate actor selector %q", key) - } - seen[key] = struct{}{} - } - return nil -} - -func actorSelectorKey(selector mcpcontract.ActorSelector) string { - if selector.Type == "node_id" { - return strings.TrimSpace(selector.NodeID) - } - return strings.ToLower(strings.TrimSpace(selector.Login)) -} - -func resolveActorSelectorLogin(ctx context.Context, c *corpus.Corpus, selector mcpcontract.ActorSelector) (string, error) { - if selector.Type == "login" { - return strings.TrimSpace(selector.Login), nil - } - nodeID := strings.TrimSpace(selector.NodeID) - actor, err := c.GetActor(ctx, nodeID) - if err != nil { - return "", err - } - if actor == nil || actor.Login == "" { - return "", fmt.Errorf("node ID %q is not stored; search or sync by login first", nodeID) - } - return actor.Login, nil + return map[string]any{"status": status, "items": items, "completed": complete, "total": len(selectors)}, nil } func normalizeActorKind(kind string) string { diff --git a/internal/app/mcp_actors_test.go b/internal/app/mcp_actors_test.go index e9cfcb03..ed69279a 100644 --- a/internal/app/mcp_actors_test.go +++ b/internal/app/mcp_actors_test.go @@ -26,14 +26,18 @@ func TestSearchContributionsReportsMissingActorCoverage(t *testing.T) { func TestActorSelectorsNormalizeWhitespaceBeforeDuplicateDetection(t *testing.T) { t.Parallel() - err := validateActorSelectors([]mcpcontract.ActorSelector{ + _, _, err := parseActorSelectors([]mcpcontract.ActorSelector{ {Type: "login", Login: "alice"}, {Type: "login", Login: " Alice "}, }) if err == nil { t.Fatal("equivalent login selectors were not rejected as duplicates") } - if got := actorSelectorKey(mcpcontract.ActorSelector{Type: "node_id", NodeID: " U_1 "}); got != "U_1" { - t.Fatalf("normalized node selector = %q", got) + selectors, normalized, err := parseActorSelectors([]mcpcontract.ActorSelector{{Type: "node_id", NodeID: " U_1 "}}) + if err != nil { + t.Fatal(err) + } + if got := selectors[0].key(); got != "U_1" || normalized[0].NodeID != "U_1" { + t.Fatalf("normalized node selector = %q, wire = %+v", got, normalized[0]) } } diff --git a/internal/app/mcp_advanced_reads.go b/internal/app/mcp_advanced_reads.go index 18930676..e22b3425 100644 --- a/internal/app/mcp_advanced_reads.go +++ b/internal/app/mcp_advanced_reads.go @@ -88,8 +88,11 @@ func (r *MCPReader) FindClusters(ctx context.Context, in mcpcontract.FindCluster } func findClustersTarget(ctx context.Context, c *corpus.Corpus, target mcpcontract.ClusterTarget, limit int) (mcpcontract.ClusterSetOutput, error) { - ref := domain.RepoRef{Owner: target.Owner, Repo: target.Repo} - repository, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + ref, err := domain.NewRepoRef(target.Owner, target.Repo) + if err != nil { + return mcpcontract.ClusterSetOutput{}, err + } + repository, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.ClusterSetOutput{}, err } @@ -139,7 +142,7 @@ func findClustersTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac } func validateClusterTarget(target mcpcontract.ClusterTarget) error { - if err := (domain.RepoRef{Owner: target.Owner, Repo: target.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(target.Owner, target.Repo); err != nil { return err } if (target.Kind == "") != (target.Number == 0) { @@ -241,7 +244,7 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb } func validateSimilarityThread(thread mcpcontract.ThreadRef) error { - if err := (domain.RepoRef{Owner: thread.Owner, Repo: thread.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(thread.Owner, thread.Repo); err != nil { return err } if thread.Kind != "issue" && thread.Kind != "pull_request" { diff --git a/internal/app/mcp_authored_sync.go b/internal/app/mcp_authored_sync.go index 31626b81..1d4fe172 100644 --- a/internal/app/mcp_authored_sync.go +++ b/internal/app/mcp_authored_sync.go @@ -16,7 +16,7 @@ import ( type authoredPullRequestSyncOptions struct { Repository *mcpcontract.RepositoryRef State string - UpdatedAfter string + UpdatedAfter time.Time Limit int MaxRequests int } @@ -46,13 +46,6 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in authoredPullR if err != nil { return nil, err } - var updatedAfter time.Time - if in.UpdatedAfter != "" { - updatedAfter, err = time.Parse(time.RFC3339, in.UpdatedAfter) - if err != nil { - return nil, errors.New("updated_after must be RFC 3339") - } - } page := 1 byRepo := make(map[string][]github.Issue) order := make([]string, 0) @@ -69,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: updatedAfter, PageOptions: github.PageOptions{Page: page, PerPage: perPage}} + options := github.AuthoredPullRequestSearchOptions{Login: identity.Login, State: in.State, UpdatedAfter: in.UpdatedAfter, PageOptions: github.PageOptions{Page: page, PerPage: perPage}} if in.Repository != nil { options.RepositoryOwner = in.Repository.Owner options.RepositoryName = in.Repository.Repo diff --git a/internal/app/mcp_code_index.go b/internal/app/mcp_code_index.go index a688613d..2bc1653e 100644 --- a/internal/app/mcp_code_index.go +++ b/internal/app/mcp_code_index.go @@ -29,21 +29,21 @@ func (r *MCPReader) CodeIndexArtifact(ctx context.Context, digest string) (mcpco func codeIndexArtifact(record corpus.CodeIndexArtifactRecord) mcpcontract.CodeIndexArtifact { uri := "gitcontribute://artifact/code-index/" + record.Digest - followUp := &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpAction{Type: "read_resource", ReadResource: &mcpcontract.ResourceReadAction{URI: uri}}, Reason: "Read this exact digest-bound artifact through MCP resources/read."} + followUp := &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.ResourceReadAction{URI: uri}), Reason: "Read this exact digest-bound artifact through MCP resources/read."} documents := make([]mcpcontract.CodeIndexDocumentOutput, len(record.Documents)) for i, document := range record.Documents { documents[i] = mcpcontract.CodeIndexDocumentOutput{Path: document.Path, SHA256: document.SHA256, Bytes: mcpcontract.NonNegativeInt(document.Bytes), Language: document.Language} } return mcpcontract.CodeIndexArtifact{ Kind: "code_index", ID: "code-index:" + record.Digest, - Repository: mcpcontract.RepositoryRef{Owner: record.Repo.Owner, Repo: record.Repo.Repo}, + Repository: mcpcontract.RepositoryRef{Owner: record.Repo.Owner(), Repo: record.Repo.Repo()}, CommitSHA: record.CommitSHA, SnapshotToken: record.SnapshotToken, ManifestID: "code-index-manifest:" + record.ManifestSHA256, ManifestSHA256: record.ManifestSHA256, - CoverageKnown: record.CoverageKnown, + CoverageKnown: record.IndexManifest.CoverageKnown, Manifest: mcpcontract.CodeIndexManifestOutput{FormatVersion: record.IndexManifest.FormatVersion, CoverageKnown: record.IndexManifest.CoverageKnown, TrackedEntries: record.IndexManifest.TrackedEntries, IndexedFiles: record.IndexManifest.IndexedFiles, SkippedInvalidPath: record.IndexManifest.SkippedInvalidPath, SkippedExcluded: record.IndexManifest.SkippedExcluded, SkippedNonRegular: record.IndexManifest.SkippedNonRegular, SkippedOversize: record.IndexManifest.SkippedOversize, SkippedTotalBudget: record.IndexManifest.SkippedTotalBudget, SkippedNonText: record.IndexManifest.SkippedNonText, SkippedFileLimit: record.IndexManifest.SkippedFileLimit, Truncated: record.IndexManifest.Truncated}, SchemaVersion: record.SchemaVersion, TotalBytes: mcpcontract.NonNegativeInt(record.TotalBytes), Documents: documents, CreatedAt: record.CreatedAt.Format(time.RFC3339Nano), Provenance: maps.Clone(record.Provenance), - FileCount: mcpcontract.NonNegativeInt(record.IndexedFiles), TrackedEntries: mcpcontract.NonNegativeInt(record.TrackedEntries), - Truncated: record.Truncated, ResourceURI: uri, FollowUp: followUp, + FileCount: mcpcontract.NonNegativeInt(record.IndexManifest.IndexedFiles), TrackedEntries: mcpcontract.NonNegativeInt(record.IndexManifest.TrackedEntries), + Truncated: record.IndexManifest.Truncated, ResourceURI: uri, FollowUp: followUp, } } diff --git a/internal/app/mcp_code_index_test.go b/internal/app/mcp_code_index_test.go index 7838213e..db10ee26 100644 --- a/internal/app/mcp_code_index_test.go +++ b/internal/app/mcp_code_index_test.go @@ -14,7 +14,7 @@ func TestCodeIndexArtifactsRemainDistinctAcrossCommitsAndExposeResourceHandoff(t t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") for _, snapshot := range []codeindex.Snapshot{ {RepoPath: "/repo", Commit: "commit-a", CreatedAt: time.Unix(1, 0), TotalBytes: 5, Documents: []codeindex.Document{{Path: "a.go", Content: "alpha", Bytes: 5}}, Manifest: codeindex.Manifest{CoverageKnown: true, TrackedEntries: 1, IndexedFiles: 1}}, {RepoPath: "/repo", Commit: "commit-b", CreatedAt: time.Unix(2, 0), TotalBytes: 4, Documents: []codeindex.Document{{Path: "b.go", Content: "beta", Bytes: 4}}, Manifest: codeindex.Manifest{CoverageKnown: true, TrackedEntries: 1, IndexedFiles: 1}}, @@ -43,7 +43,8 @@ func TestCodeIndexArtifactsRemainDistinctAcrossCommitsAndExposeResourceHandoff(t if a.ID == b.ID || a.ManifestID == b.ManifestID || a.ResourceURI == b.ResourceURI || a.CommitSHA == b.CommitSHA { t.Fatalf("commit identities collapsed: a=%+v b=%+v", a, b) } - if a.ResourceURI != "gitcontribute://artifact/code-index/"+aRecord.Digest || a.FollowUp == nil || a.FollowUp.Action.ReadResource == nil || a.FollowUp.Action.ReadResource.URI != a.ResourceURI { + read, ok := mcpcontract.RecoveryInput[mcpcontract.ResourceReadAction](a.FollowUp.Action) + if a.ResourceURI != "gitcontribute://artifact/code-index/"+aRecord.Digest || a.FollowUp == nil || !ok || read.URI != a.ResourceURI { t.Fatalf("resource handoff = %+v", a) } if a.Kind != "code_index" || a.FileCount != mcpcontract.NonNegativeInt(1) || a.TrackedEntries != mcpcontract.NonNegativeInt(1) || a.ManifestSHA256 == "" { diff --git a/internal/app/mcp_code_search.go b/internal/app/mcp_code_search.go index a3b3b6e7..56db61e0 100644 --- a/internal/app/mcp_code_search.go +++ b/internal/app/mcp_code_search.go @@ -57,15 +57,9 @@ func validateSearchCodeInput(in *mcpcontract.SearchCodeInput) (domain.RepoRef, e if in.Limit < 1 || in.Limit > 100 { return domain.RepoRef{}, errors.New("limit must be between 1 and 100") } - var ref domain.RepoRef - if in.Owner != "" || in.Repo != "" { - if (in.Owner == "") != (in.Repo == "") { - return domain.RepoRef{}, errors.New("owner and repo must be provided together") - } - ref = domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { - return domain.RepoRef{}, err - } + ref, err := optionalRepoRef(in.Owner, in.Repo) + if err != nil { + return domain.RepoRef{}, err } return ref, nil } @@ -175,8 +169,8 @@ func (r *MCPReader) SearchCodeBatch(ctx context.Context, in mcpcontract.SearchCo if in.Owner == "" || in.Repo == "" { return mcpcontract.SearchCodeBatchOutput{}, errors.New("owner and repo are required") } - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.SearchCodeBatchOutput{}, err } queries := make([]string, len(in.Queries)) diff --git a/internal/app/mcp_concerns.go b/internal/app/mcp_concerns.go index fd6f0f38..c4cda818 100644 --- a/internal/app/mcp_concerns.go +++ b/internal/app/mcp_concerns.go @@ -14,12 +14,16 @@ import ( // CreateConcern implements the MCP local concern-write capability. func (r *MCPReader) CreateConcern(ctx context.Context, in mcpcontract.CreateConcernInput) (mcpcontract.ConcernOutput, error) { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { + return mcpcontract.ConcernOutput{}, err + } provenance, err := concernSourceProvenance(in.SourceProvenance) if err != nil { return mcpcontract.ConcernOutput{}, err } result, err := r.createConcern(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: in.Owner, Repo: in.Repo}, CommitSHA: in.CommitSHA, WorkspaceID: in.WorkspaceID, + Repo: ref, CommitSHA: in.CommitSHA, WorkspaceID: in.WorkspaceID, Title: in.Title, ProblemStatement: in.ProblemStatement, SuspectedOwner: in.SuspectedOwner, Confidence: float64(in.Confidence), Unknowns: in.Unknowns, SuccessCriterion: in.SuccessCriterion, Notes: in.Notes, EvidenceIDs: in.EvidenceIDs, SourceProvenance: provenance, diff --git a/internal/app/mcp_contribution_preflight.go b/internal/app/mcp_contribution_preflight.go index d1ef9e0a..fe20e2c3 100644 --- a/internal/app/mcp_contribution_preflight.go +++ b/internal/app/mcp_contribution_preflight.go @@ -24,17 +24,19 @@ const ( // described by workflow.preflight_contribution. Unlike portfolio sync, it does // not write observations, create jobs, create worktrees, or adopt paths. func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.ContributionPreflightInput) (mcpcontract.ContributionPreflightOutput, error) { - if err := (domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo}).Validate(); err != nil { + repository, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { return mcpcontract.ContributionPreflightOutput{}, err } - in.Repository.Owner = strings.TrimSpace(in.Repository.Owner) - in.Repository.Repo = strings.TrimSpace(in.Repository.Repo) + in.Repository.Owner = repository.Owner() + in.Repository.Repo = repository.Repo() if in.Fork != nil { - in.Fork.Owner = strings.TrimSpace(in.Fork.Owner) - in.Fork.Repo = strings.TrimSpace(in.Fork.Repo) - if err := (domain.RepoRef{Owner: in.Fork.Owner, Repo: in.Fork.Repo}).Validate(); err != nil { + fork, err := domain.NewRepoRef(in.Fork.Owner, in.Fork.Repo) + if err != nil { return mcpcontract.ContributionPreflightOutput{}, fmt.Errorf("validate fork repository: %w", err) } + in.Fork.Owner = fork.Owner() + in.Fork.Repo = fork.Repo() if sameGitHubRepository(in.Fork.Owner, in.Fork.Repo, in.Repository) { return mcpcontract.ContributionPreflightOutput{}, errors.New("fork repository must differ from the upstream repository") } diff --git a/internal/app/mcp_deepwiki_test.go b/internal/app/mcp_deepwiki_test.go index 445196c2..8631a06e 100644 --- a/internal/app/mcp_deepwiki_test.go +++ b/internal/app/mcp_deepwiki_test.go @@ -26,7 +26,7 @@ func (f *fakeDeepWikiReader) Read(_ context.Context, request deepwiki.Request) ( func TestDeepWikiReturnsDerivedProvenanceAndBoundsOutput(t *testing.T) { t.Parallel() svc := newSearchTestService(t) - fake := &fakeDeepWikiReader{response: deepwiki.Response{Available: true, Text: strings.Repeat("x", 2048), SourceURL: "https://deepwiki.com/acme/rocket"}} + fake := &fakeDeepWikiReader{response: deepwiki.AvailableResponse(strings.Repeat("x", 2048), "https://deepwiki.com/acme/rocket")} svc.SetDeepWikiReader(fake) out, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpcontract.DeepWikiInput{Action: "question", Repositories: []string{"acme/rocket"}, Question: "architecture?", MaxOutputBytes: 1024}) if err != nil { @@ -40,11 +40,7 @@ func TestDeepWikiReturnsDerivedProvenanceAndBoundsOutput(t *testing.T) { func TestDeepWikiUsesBoundedDefaultAndSteersFocusedRecovery(t *testing.T) { t.Parallel() svc := newSearchTestService(t) - fake := &fakeDeepWikiReader{response: deepwiki.Response{ - Available: true, - Text: strings.Repeat("x", mcpcontract.DeepWikiDefaultOutputBytes+1), - SourceURL: "https://deepwiki.com/acme/rocket", - }} + fake := &fakeDeepWikiReader{response: deepwiki.AvailableResponse(strings.Repeat("x", mcpcontract.DeepWikiDefaultOutputBytes+1), "https://deepwiki.com/acme/rocket")} svc.SetDeepWikiReader(fake) out, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpcontract.DeepWikiInput{Action: "contents", Repository: "acme/rocket"}) @@ -54,7 +50,7 @@ func TestDeepWikiUsesBoundedDefaultAndSteersFocusedRecovery(t *testing.T) { if len(out.Result) != mcpcontract.DeepWikiDefaultOutputBytes || !out.Truncated { t.Fatalf("default DeepWiki bound = %d bytes, truncated=%v", len(out.Result), out.Truncated) } - if out.Reason != "output_limit" || out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type != "query_deepwiki" { + if out.Reason != "output_limit" || out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type() != "query_deepwiki" { t.Fatalf("missing truncation recovery guidance: %+v", out) } } @@ -62,7 +58,7 @@ func TestDeepWikiUsesBoundedDefaultAndSteersFocusedRecovery(t *testing.T) { func TestDeepWikiUsesNormalizedRepositoriesForRequestAndOutput(t *testing.T) { t.Parallel() svc := newSearchTestService(t) - fake := &fakeDeepWikiReader{response: deepwiki.Response{Available: true, Text: "ok"}} + 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, @@ -79,7 +75,7 @@ func TestDeepWikiUsesNormalizedRepositoriesForRequestAndOutput(t *testing.T) { func TestDeepWikiTruncationPreservesUTF8(t *testing.T) { t.Parallel() svc := newSearchTestService(t) - fake := &fakeDeepWikiReader{response: deepwiki.Response{Available: true, Text: strings.Repeat("x", 1023) + "€", SourceURL: "https://deepwiki.com/acme/rocket"}} + fake := &fakeDeepWikiReader{response: deepwiki.AvailableResponse(strings.Repeat("x", 1023)+"€", "https://deepwiki.com/acme/rocket")} svc.SetDeepWikiReader(fake) out, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpcontract.DeepWikiInput{Action: "contents", Repository: "acme/rocket", MaxOutputBytes: 1024}) if err != nil { diff --git a/internal/app/mcp_ensure_coverage.go b/internal/app/mcp_ensure_coverage.go index a055a171..d313aadc 100644 --- a/internal/app/mcp_ensure_coverage.go +++ b/internal/app/mcp_ensure_coverage.go @@ -8,7 +8,6 @@ import ( "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" @@ -38,10 +37,13 @@ func (r *MCPReader) EnsureCoverage(ctx context.Context, in mcpcontract.EnsureCov if in.LimitPerRepository < 1 || in.LimitPerRepository > 1000 { return mcpcontract.JobReference{}, errors.New("limit_per_repository must be between 1 and 1000") } - if err := validateEnsureCoverageTarget(in.Target); err != nil { + target, normalizedTarget, err := parseCoverageTarget(in.Target) + if err != nil { return mcpcontract.JobReference{}, err } - if in.Target.Type == mcpcontract.CoverageTargetRepository && len(in.Facets) > 0 { + in.Target = normalizedTarget + _, _, exactThread := target.thread() + if !exactThread && len(in.Facets) > 0 { return mcpcontract.JobReference{}, errors.New("facets can be selected only for exact-thread coverage") } allowedFacets := make(map[string]struct{}) @@ -59,7 +61,7 @@ func (r *MCPReader) EnsureCoverage(ctx context.Context, in mcpcontract.EnsureCov seenFacets[name] = struct{}{} } id, err := r.submitJob(ctx, jobKindEnsureCoverage, in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.ensureCoverage(ctx, in, report) + return r.ensureCoverage(ctx, in, target, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -67,19 +69,6 @@ func (r *MCPReader) EnsureCoverage(ctx context.Context, in mcpcontract.EnsureCov return queuedJobReference(id, jobKindEnsureCoverage, "coverage workflow started"), nil } -func validateEnsureCoverageTarget(target mcpcontract.CoverageTarget) error { - if err := (domain.RepoRef{Owner: target.Repository.Owner, Repo: target.Repository.Repo}).Validate(); err != nil { - return err - } - if target.Type == mcpcontract.CoverageTargetRepository && target.Thread == nil { - return nil - } - if target.Type == mcpcontract.CoverageTargetExactThread && target.Thread != nil && (target.Thread.Kind == "issue" || target.Thread.Kind == "pull_request") && target.Thread.Number > 0 { - return nil - } - return errInvalidCoverageTarget -} - func (r *MCPReader) ReadSnapshot(ctx context.Context, token string) (mcpcontract.CorpusSnapshotArtifact, error) { c, err := r.openReadOnlyCorpus(ctx) if err != nil { @@ -125,12 +114,12 @@ func (r *MCPReader) ReadSnapshot(ctx context.Context, token string) (mcpcontract }, nil } -func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCoverageInput, report func(string, string) error) (mcpcontract.EnsureCoverageJobResult, error) { +func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCoverageInput, target parsedCoverageTarget, report func(string, string) error) (mcpcontract.EnsureCoverageJobResult, error) { c, err := r.openCorpus(ctx) if err != nil { return mcpcontract.EnsureCoverageJobResult{}, err } - before, reason, err := readCoverageTarget(ctx, c, in.Target) + before, reason, err := readParsedCoverageTarget(ctx, c, target) if err != nil { return mcpcontract.EnsureCoverageJobResult{}, err } @@ -139,7 +128,8 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov result.CoverageBefore = &before } remaining := in.MaxRequests - repo := in.Target.Repository + repoRef := target.repository() + repo := mcpcontract.RepositoryRef{Owner: repoRef.Owner(), Repo: repoRef.Repo()} stage := func(name, status, message string) { result.CompletedStages = append(result.CompletedStages, name) result.StageOutcomes = append(result.StageOutcomes, mcpcontract.CoverageStageOutcome{Stage: name, Status: status, Message: message}) @@ -161,20 +151,25 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov stage("repository_context", "skipped", "repository identity already present") } headerRequests := 1 - if in.Target.Type == mcpcontract.CoverageTargetRepository { + kind, number, exactThread := target.thread() + if !exactThread { headerRequests = 2 * ((in.LimitPerRepository + 99) / 100) } if remaining < headerRequests { return result, errors.New("max_requests exhausted before thread synchronization") } threadInput := mcpcontract.SyncThreadsInput{MaxRequests: headerRequests} - if in.Target.Type == mcpcontract.CoverageTargetExactThread { + if exactThread { threadInput.Selection = "threads" - threadInput.Threads = []mcpcontract.ThreadRef{{Owner: repo.Owner, Repo: repo.Repo, Kind: in.Target.Thread.Kind, Number: in.Target.Thread.Number}} + threadInput.Threads = []mcpcontract.ThreadRef{{Owner: repo.Owner, Repo: repo.Repo, Kind: string(kind), Number: number}} } else { threadInput.Selection, threadInput.Repositories, threadInput.Kind, threadInput.State, threadInput.LimitPerRepository = "repositories", []mcpcontract.RepositoryRef{repo}, "both", "all", in.LimitPerRepository } - threadResult, err := r.syncThreadsBatch(ctx, threadInput, report) + threadRequest, _, err := parseSyncThreadsInput(threadInput) + if err != nil { + return result, err + } + threadResult, err := r.syncThreadsBatch(ctx, threadRequest, report) if err != nil { return result, err } @@ -184,7 +179,7 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov result.Status, result.Incomplete = "partial", true } stage("thread_headers", threadStatus, "thread headers synchronized after repository bootstrap") - if in.Target.Type == mcpcontract.CoverageTargetExactThread && len(in.Facets) > 0 { + if exactThread && len(in.Facets) > 0 { pages := in.MaxPages if bound := remaining / len(in.Facets); bound < pages { pages = bound @@ -204,7 +199,7 @@ func (r *MCPReader) ensureCoverage(ctx context.Context, in mcpcontract.EnsureCov } else { stage("selected_facets", "skipped", "no exact-thread facets requested") } - after, afterReason, err := readCoverageTarget(ctx, c, in.Target) + after, afterReason, err := readParsedCoverageTarget(ctx, c, target) if err != nil { return result, err } @@ -218,12 +213,12 @@ 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: in.Target, 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}) + 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}) if err != nil { return result, err } result.SnapshotToken, result.ArtifactDigest = snapshot.Token, snapshot.ArtifactDigest - result.NextAction = mcpcontract.FollowUpAction{Type: "read_snapshot", ReadSnapshot: &mcpcontract.SnapshotReadAction{SnapshotToken: snapshot.Token}} + result.NextAction = mcpcontract.FollowUpActionFor(mcpcontract.SnapshotReadAction{SnapshotToken: snapshot.Token}) stage("snapshot_materialization", "complete", "immutable coverage snapshot created") return result, nil } diff --git a/internal/app/mcp_ensure_coverage_test.go b/internal/app/mcp_ensure_coverage_test.go index 7696ea5d..9b98c065 100644 --- a/internal/app/mcp_ensure_coverage_test.go +++ b/internal/app/mcp_ensure_coverage_test.go @@ -20,3 +20,32 @@ func TestEnsureCoverageRejectsThreadFacetsForRepositoryTarget(t *testing.T) { t.Fatalf("repository coverage with facets error = %v", err) } } + +func TestParseCoverageTargetRejectsMixedVariantsAndOwnsCanonicalIdentity(t *testing.T) { + t.Parallel() + thread := &mcpcontract.ExactCoverageThread{Kind: "issue", Number: 7} + for _, target := range []mcpcontract.CoverageTarget{ + {Type: mcpcontract.CoverageTargetRepository, Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Thread: thread}, + {Type: mcpcontract.CoverageTargetExactThread, Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}}, + {Type: mcpcontract.CoverageTargetExactThread, Repository: mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"}, Thread: &mcpcontract.ExactCoverageThread{Kind: "both", Number: 7}}, + } { + if _, _, err := parseCoverageTarget(target); err == nil { + t.Fatalf("parseCoverageTarget accepted mixed target: %+v", target) + } + } + + target := mcpcontract.CoverageTarget{ + Type: mcpcontract.CoverageTargetExactThread, + Repository: mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "}, + Thread: thread, + } + parsed, normalized, err := parseCoverageTarget(target) + if err != nil { + t.Fatal(err) + } + thread.Kind = "pull_request" + kind, number, exact := parsed.thread() + if !exact || kind != "issue" || number != 7 || normalized.Repository.Owner != "acme" || normalized.Thread.Kind != "issue" { + t.Fatalf("parsed target = %s#%d exact=%v, normalized=%+v", kind, number, exact, normalized) + } +} diff --git a/internal/app/mcp_explain_evidence_test.go b/internal/app/mcp_explain_evidence_test.go index 0ab14fe8..8a870eb0 100644 --- a/internal/app/mcp_explain_evidence_test.go +++ b/internal/app/mcp_explain_evidence_test.go @@ -16,9 +16,9 @@ func TestMCPReaderExplainMatchReturnsMatchingExcerpt(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") repo, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, Name: ref.Repo, Description: "unrelated description", + Owner: ref.Owner(), Name: ref.Repo(), Description: "unrelated description", Topics: []string{"synthwave"}, SourceUpdatedAt: time.Unix(1, 0).UTC(), }, `{}`) if err != nil { @@ -42,7 +42,7 @@ func TestMCPReaderExplainMatchReturnsMatchingExcerpt(t *testing.T) { reader := svc.MCPReader() threadOut, err := reader.ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "issue", Number: thread.Number, Query: "deepthreadneedle", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "issue", Number: thread.Number, Query: "deepthreadneedle", }) if err != nil { t.Fatal(err) @@ -52,7 +52,7 @@ func TestMCPReaderExplainMatchReturnsMatchingExcerpt(t *testing.T) { } codeOut, err := reader.ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Path: "deep.go", Commit: "abc123", Query: "deepcodeneedle", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Path: "deep.go", Commit: "abc123", Query: "deepcodeneedle", }) if err != nil { t.Fatal(err) @@ -62,7 +62,7 @@ func TestMCPReaderExplainMatchReturnsMatchingExcerpt(t *testing.T) { } repoOut, err := reader.ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "repo", Query: "synthwave", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "repo", Query: "synthwave", }) if err != nil { t.Fatal(err) diff --git a/internal/app/mcp_fix_patterns.go b/internal/app/mcp_fix_patterns.go index baa81654..ddfa04ea 100644 --- a/internal/app/mcp_fix_patterns.go +++ b/internal/app/mcp_fix_patterns.go @@ -124,10 +124,12 @@ func (r *MCPReader) GetFixPatternReport(ctx context.Context, id string) (mcpcont } func normalizeFixPatternInput(in mcpcontract.MineRepositoryFixPatternsInput) (mcpcontract.MineRepositoryFixPatternsInput, error) { - ref := domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { return 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") @@ -269,8 +271,11 @@ func selectFixPatternHydration(a fixPatternAnalysis, in mcpcontract.MineReposito } 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 + } var c *corpus.Corpus - var err error if operation == fixPatternPreview { c, err = r.openReadOnlyCorpus(ctx) } else { @@ -367,7 +372,7 @@ func (r *MCPReader) runFixPatternOperation(ctx context.Context, in mcpcontract.M if candidates[id].unknownBefore { cluster.UnknownBefore++ } - classification := classifyFixPattern(thread, in.Repository) + classification := classifyFixPattern(thread, repoRef) outcome := fixPatternOutcome(thread, classification.superseded) incrementFixPatternOutcome(&cluster.Outcomes, outcome) if outcome == "unknown" { @@ -455,16 +460,16 @@ func countUnknownCandidates(candidates map[int64]*fixPatternCandidate) int { } func needsMergeHydration(thread corpus.Thread) bool { - return thread.State == "closed" && !thread.MergedKnown + return thread.State == "closed" && !thread.Merge.Known() } func fixPatternOutcome(thread corpus.Thread, superseded bool) mcpcontract.FixPatternOutcome { switch { - case thread.MergedKnown && thread.Merged: + case thread.Merge.IsMerged(): return "merged" case thread.State != "closed": return "open" - case !thread.MergedKnown: + case !thread.Merge.Known(): return "unknown" case superseded: return "superseded" @@ -515,8 +520,8 @@ func buildFixPatternExample(ctx context.Context, c *corpus.Corpus, repoID int64, return example } -func classifyFixPattern(thread corpus.Thread, repository mcpcontract.RepositoryRef) fixPatternClassification { - refs := relatedwork.Extract(thread.Body, domain.RepoRef{Owner: repository.Owner, Repo: repository.Repo}) +func classifyFixPattern(thread corpus.Thread, repository domain.RepoRef) fixPatternClassification { + refs := relatedwork.Extract(thread.Body, repository) classification := fixPatternClassification{relationship: "similarity_only"} bestPriority := 0 for _, ref := range refs { @@ -529,7 +534,7 @@ func classifyFixPattern(thread corpus.Thread, repository mcpcontract.RepositoryR } bestPriority = priority classification.related = &mcpcontract.ThreadRef{ - Owner: ref.Repo.Owner, Repo: ref.Repo.Repo, Kind: string(ref.Kind), Number: ref.Number, + Owner: ref.Repo.Owner(), Repo: ref.Repo.Repo(), Kind: string(ref.Kind), Number: ref.Number, } classification.evidence = ref.Evidence switch ref.Relation { diff --git a/internal/app/mcp_fix_patterns_test.go b/internal/app/mcp_fix_patterns_test.go index 78e852cd..91047ad1 100644 --- a/internal/app/mcp_fix_patterns_test.go +++ b/internal/app/mcp_fix_patterns_test.go @@ -9,6 +9,7 @@ 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" ) @@ -24,10 +25,10 @@ func TestMineRepositoryFixPatternsSeparatesAcceptedFixesFromSimilarity(t *testin } 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.", Merged: true, MergedKnown: true, 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.", MergedKnown: true, SourceUpdatedAt: now.Add(2 * time.Hour)}, + {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.", MergedKnown: true, 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)}, } { if _, err := svc.corpus.UpsertThread(ctx, thread, `{}`); err != nil { @@ -56,7 +57,7 @@ func TestMineRepositoryFixPatternsSeparatesAcceptedFixesFromSimilarity(t *testin if report.Status != "partial" || report.Coverage.UniqueCandidates != 5 || report.Coverage.UnknownBefore != 1 || report.Coverage.UnknownAfter != 1 { t.Fatalf("coverage = %+v, status = %q", report.Coverage, report.Status) } - if report.Recovery == nil || len(report.Recovery.Then) != 1 || report.Recovery.Then[0].Type != "mine_repository_fix_patterns" { + if report.Recovery == nil || len(report.Recovery.Then) != 1 || report.Recovery.Then[0].Type() != "mine_repository_fix_patterns" { t.Fatalf("fix-pattern recovery = %+v", report.Recovery) } if len(report.Clusters) != 1 || len(report.Clusters[0].Examples) != 5 { @@ -209,7 +210,11 @@ func TestGetFixPatternReportRejectsLegacyUnboundArtifact(t *testing.T) { if !errors.As(err, &toolErr) || toolErr.Code != "legacy_artifact" { t.Fatalf("legacy report error = %v", err) } - if toolErr.Recovery == nil || len(toolErr.Recovery.Then) != 1 || toolErr.Recovery.Then[0].MineFixPatterns == nil || toolErr.Recovery.Then[0].MineFixPatterns.Repository != request.Repository { + if toolErr.Recovery == nil || len(toolErr.Recovery.Then) != 1 { + t.Fatalf("legacy report recovery = %+v", toolErr.Recovery) + } + next, ok := mcpcontract.RecoveryInput[mcpcontract.MineRepositoryFixPatternsInput](toolErr.Recovery.Then[0]) + if !ok || next.Repository != request.Repository { t.Fatalf("legacy report recovery = %+v", toolErr.Recovery) } } diff --git a/internal/app/mcp_github_acquisition.go b/internal/app/mcp_github_acquisition.go index 21532c65..ab4154b4 100644 --- a/internal/app/mcp_github_acquisition.go +++ b/internal/app/mcp_github_acquisition.go @@ -51,7 +51,7 @@ func (r *MCPReader) SearchGitHubThreads(ctx context.Context, in mcpcontract.Sear } func validateGitHubThreadSearchInput(in *mcpcontract.SearchGitHubThreadsInput) error { - if err := (domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo); err != nil { return err } in.Query = strings.TrimSpace(in.Query) @@ -248,7 +248,7 @@ func (r *MCPReader) ReadSourceFiles(ctx context.Context, in mcpcontract.ReadSour } func validateReadSourceFilesInput(in *mcpcontract.ReadSourceFilesInput) error { - if err := (domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo); err != nil { return err } in.Ref = strings.TrimSpace(in.Ref) diff --git a/internal/app/mcp_github_acquisition_test.go b/internal/app/mcp_github_acquisition_test.go index 0f2b4b5a..d460ba46 100644 --- a/internal/app/mcp_github_acquisition_test.go +++ b/internal/app/mcp_github_acquisition_test.go @@ -74,9 +74,13 @@ func TestMCPReaderSearchGitHubThreadsPersistsArtifactWithoutFullCoverage(t *test if searchCalls != 1 || out.Status != "partial" || out.NextPage != 2 || out.Total != 4 || out.Coverage != "repository_thread_coverage_incomplete" || out.ArtifactDigest == "" { t.Fatalf("search output = %+v", out) } - if len(out.RecoveryPlans) != 1 || out.RecoveryPlans[0].Then[0].Type != "search_github_threads" || out.RecoveryPlans[0].Then[0].SearchGitHubThreads == nil || out.RecoveryPlans[0].Then[0].SearchGitHubThreads.Page != 2 { + if len(out.RecoveryPlans) != 1 || out.RecoveryPlans[0].Then[0].Type() != "search_github_threads" { t.Fatalf("search recovery plans = %+v", out.RecoveryPlans) } + next, ok := mcpcontract.RecoveryInput[mcpcontract.SearchGitHubThreadsInput](out.RecoveryPlans[0].Then[0]) + if !ok || next.Page != 2 { + t.Fatalf("search recovery input = %+v", out.RecoveryPlans) + } if len(out.Items) != 1 || out.Items[0].Value == nil || out.Items[0].Value.Owner != "acme" || out.Items[0].Value.Number != 9 { t.Fatalf("search items = %+v", out.Items) } @@ -179,7 +183,7 @@ func TestValidateReadSourceFilesInputTrimsRef(t *testing.T) { func TestMCPReaderSearchCodeBatchUsesOneOfflineRevisionAndPreservesQueryOrder(t *testing.T) { ctx := context.Background() svc := newSearchTestService(t) - if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "acme", Repo: "rocket"}, codeindex.Snapshot{ + if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("acme", "rocket"), codeindex.Snapshot{ RepoPath: "/rocket", Commit: "commit-1", CreatedAt: time.Now().UTC(), TotalBytes: 40, Documents: []codeindex.Document{ {Path: "parser.go", Content: "func parser() {}", Bytes: 16, LanguageHint: "go"}, @@ -197,7 +201,7 @@ func TestMCPReaderSearchCodeBatchUsesOneOfflineRevisionAndPreservesQueryOrder(t if out.Status != "partial" || len(out.Items) != 2 || out.Items[0].Key != "parser" || out.Items[1].Key != "func" || out.SnapshotToken == "" { t.Fatalf("batch output = %+v", out) } - if out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type != "search_code" { + if out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type() != "search_code" { t.Fatalf("batch page recovery = %+v", out.Recovery) } for i, item := range out.Items { diff --git a/internal/app/mcp_issue_set.go b/internal/app/mcp_issue_set.go index c07b0d91..f20e8688 100644 --- a/internal/app/mcp_issue_set.go +++ b/internal/app/mcp_issue_set.go @@ -25,8 +25,8 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI if err := normalizePrepareIssueSetInput(&in); err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } c, err := r.openReadOnlyCorpus(ctx) @@ -38,12 +38,12 @@ func (r *MCPReader) PrepareIssueSet(ctx context.Context, in mcpcontract.PrepareI return mcpcontract.PrepareIssueSetOutput{}, err } out := mcpcontract.PrepareIssueSetOutput{ - Status: "complete", Owner: ref.Owner, Repo: ref.Repo, ResponseFormat: in.ResponseFormat, + Status: "complete", Owner: ref.Owner(), Repo: ref.Repo(), ResponseFormat: in.ResponseFormat, Items: make([]mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence], len(in.IssueNumbers)), Coverage: []mcpcontract.FacetCoverageOutput{}, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), } - stored, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.PrepareIssueSetOutput{}, err } @@ -51,7 +51,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), nil + return unavailableIssueSet(in, out, ref), nil } threadsCoverage, err := c.GetCoverage(ctx, stored.ID, nil, "threads") if err != nil { @@ -148,7 +148,7 @@ 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}) + key := threadRefKey(mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: corpus.ThreadKindIssue, Number: number}) item := mcpcontract.BatchItem[mcpcontract.PreparedIssueEvidence]{Key: key, Status: "complete"} issue, ok := issuesByNumber[number] if !ok { @@ -220,15 +220,15 @@ func normalizePrepareIssueSetInput(in *mcpcontract.PrepareIssueSetInput) error { return nil } -func unavailableIssueSet(in mcpcontract.PrepareIssueSetInput, out mcpcontract.PrepareIssueSetOutput) mcpcontract.PrepareIssueSetOutput { +func unavailableIssueSet(in mcpcontract.PrepareIssueSetInput, out mcpcontract.PrepareIssueSetOutput, ref domain.RepoRef) mcpcontract.PrepareIssueSetOutput { out.Status = "partial" for i, number := range in.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", 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(domain.RepoRef{Owner: in.Owner, Repo: in.Repo}, number)), + Recovery: 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(in.Owner, in.Repo), issueSyncAction(domain.RepoRef{Owner: in.Owner, Repo: in.Repo}, 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))) } return out } @@ -470,11 +470,11 @@ func issueSetRelatedWork(work radar.RelatedWork, ref domain.RepoRef, pullRequest } 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.MergedKnown { - merged := pullRequest.Merged + if pullRequest.Merge.Known() { + merged := pullRequest.Merge.IsMerged() out.Merged = &merged } - out.MergedAt = formatTime(pullRequest.MergedAt) + out.MergedAt = formatTime(pullRequest.Merge.MergedAt()) } if responseFormat == "detailed" { seen := map[string]struct{}{} @@ -507,13 +507,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: corpus.ThreadKindIssue, 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: corpus.ThreadKindIssue, Number: number}}, Facets: []string{facet}, }) } @@ -521,7 +521,7 @@ func issueHydrateAction(ref domain.RepoRef, number int, facet string) mcpcontrac func repositoryPullRequestSyncAction(ref domain.RepoRef) mcpcontract.ToolCall { return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ Selection: "repositories", - Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}}, + Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, Kind: corpus.ThreadKindPullRequest, State: "all", }) @@ -530,7 +530,7 @@ func repositoryPullRequestSyncAction(ref domain.RepoRef) mcpcontract.ToolCall { func repositoryHistorySyncAction(ref domain.RepoRef) mcpcontract.ToolCall { return mcpcontract.RecoveryAction(mcpcontract.SyncThreadsInput{ Selection: "repositories", - Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}}, + Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner(), Repo: ref.Repo()}}, Kind: "both", State: "closed", }) diff --git a/internal/app/mcp_issue_set_test.go b/internal/app/mcp_issue_set_test.go index 872fdb0c..bd96f27f 100644 --- a/internal/app/mcp_issue_set_test.go +++ b/internal/app/mcp_issue_set_test.go @@ -32,18 +32,17 @@ func TestPrepareIssueSetComposesStoredEvidenceWithoutClaimingClosure(t *testing. if err != nil { t.Fatal(err) } - merged := true if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 21, State: "closed", Title: "Avoid duplicate cache work in readers", Body: "This advances #7 by caching repository reads.", - Merged: merged, MergedKnown: true, MergedAt: now.Add(-time.Hour), SourceUpdatedAt: now.Add(-time.Hour), + 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", Title: "Avoid duplicate cache work", Body: "Cache identical requests once.", - MergedKnown: true, SourceUpdatedAt: now.Add(-30 * time.Minute), + Merge: domain.UnmergedStatus(), SourceUpdatedAt: now.Add(-30 * time.Minute), }, `{}`); err != nil { t.Fatal(err) } @@ -86,7 +85,7 @@ func TestPrepareIssueSetComposesStoredEvidenceWithoutClaimingClosure(t *testing. if value.Linkage.Relation != "related" || !value.Linkage.RequiresConfirmation { t.Fatalf("linkage = %+v", value.Linkage) } - if len(value.Gaps) != 1 || value.Gaps[0].Facet != FacetIssueTimeline || value.Gaps[0].Recovery == nil || len(value.Gaps[0].Recovery.Then) != 1 || value.Gaps[0].Recovery.Then[0].Type != "hydrate_threads" { + if len(value.Gaps) != 1 || value.Gaps[0].Facet != FacetIssueTimeline || value.Gaps[0].Recovery == nil || len(value.Gaps[0].Recovery.Then) != 1 || value.Gaps[0].Recovery.Then[0].Type() != "hydrate_threads" { t.Fatalf("gaps = %+v", value.Gaps) } detailed, err := (&MCPReader{svc}).PrepareIssueSet(ctx, mcpcontract.PrepareIssueSetInput{ @@ -123,17 +122,17 @@ func TestPrepareIssueSetPreservesUnknownAndExactRecovery(t *testing.T) { if out.Status != "partial" || len(out.Items) != 2 { t.Fatalf("result = %+v", out) } - if len(out.Gaps) != 1 || out.Gaps[0].Code != "relationship_population_unknown" || out.Gaps[0].Recovery == nil || len(out.Gaps[0].Recovery.Then) != 1 || out.Gaps[0].Recovery.Then[0].Type != "sync_threads" { + if len(out.Gaps) != 1 || out.Gaps[0].Code != "relationship_population_unknown" || out.Gaps[0].Recovery == nil || len(out.Gaps[0].Recovery.Then) != 1 || out.Gaps[0].Recovery.Then[0].Type() != "sync_threads" { t.Fatalf("relationship gaps = %+v", out.Gaps) } if got := out.Items[0].Value; got == nil || got.BodyStatus != "unknown" || len(got.Gaps) != 3 { t.Fatalf("known issue = %+v", got) } missing := out.Items[1] - if missing.Status != "unavailable" || missing.Reason != "thread_not_indexed" || missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type != "sync_threads" { + if missing.Status != "unavailable" || missing.Reason != "thread_not_indexed" || missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type() != "sync_threads" { t.Fatalf("missing issue = %+v", missing) } - if len(out.RecoveryPlans) == 0 || len(out.RecoveryPlans[0].Then) != 1 || out.RecoveryPlans[0].Then[0].Type != "sync_threads" { + if len(out.RecoveryPlans) == 0 || len(out.RecoveryPlans[0].Then) != 1 || out.RecoveryPlans[0].Then[0].Type() != "sync_threads" { t.Fatalf("recovery plans = %+v", out.RecoveryPlans) } } @@ -213,8 +212,8 @@ func TestIssueSetRelatedWorkDoesNotBorrowMergeStateAcrossRepositories(t *testing t.Parallel() out := issueSetRelatedWork( radar.RelatedWork{Ref: "pull_request:other/repo#21", Kind: corpus.ThreadKindPullRequest, Number: 21}, - domain.RepoRef{Owner: "acme", Repo: "rocket"}, - map[int]corpus.Thread{21: {Number: 21, Merged: true, MergedKnown: true}}, + domain.MustRepoRef("acme", "rocket"), + map[int]corpus.Thread{21: {Number: 21, Merge: domain.MergedStatus(time.Time{})}}, "concise", ) if out.Merged != nil || out.MergedAt != "" { diff --git a/internal/app/mcp_job_artifacts.go b/internal/app/mcp_job_artifacts.go index 8504fcfc..309f676d 100644 --- a/internal/app/mcp_job_artifacts.go +++ b/internal/app/mcp_job_artifacts.go @@ -56,7 +56,7 @@ func ensureCoverageJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtif } func resourceFollowUp(uri, reason string) *mcpcontract.JobFollowUp { - return &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpAction{Type: "read_resource", ReadResource: &mcpcontract.ResourceReadAction{URI: uri}}, Reason: reason} + return &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.ResourceReadAction{URI: uri}), Reason: reason} } func fixPatternJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { @@ -87,7 +87,7 @@ func workspaceJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactRe } return []mcpcontract.JobArtifactReference{{Kind: "workspace", ID: result.ID}}, &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "inspect_commit_changes", InspectCommitChanges: &mcpcontract.InspectCommitChangesInput{WorkspaceID: result.ID}}, + Action: mcpcontract.FollowUpActionFor(mcpcontract.InspectCommitChangesInput{WorkspaceID: result.ID}), Reason: "Inspect the managed workspace before planning commits.", } } @@ -173,7 +173,7 @@ func repositoryBatchJobArtifact(job *contracts.JobResult, total int) ([]mcpcontr var follow *mcpcontract.JobFollowUp if json.Unmarshal([]byte(job.Request), &request) == nil && len(request.Repositories) > 0 { follow = &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "get_repositories", GetRepositories: &mcpcontract.GetRepositoriesInput{Repositories: append([]mcpcontract.RepositoryRef(nil), request.Repositories...)}}, + Action: mcpcontract.FollowUpActionFor(mcpcontract.GetRepositoriesInput{Repositories: append([]mcpcontract.RepositoryRef(nil), request.Repositories...)}), Reason: "Read synchronized repository facts and coverage from the offline corpus.", } } @@ -190,7 +190,7 @@ func threadBatchJobArtifact(job *contracts.JobResult, total int) ([]mcpcontract. var follow *mcpcontract.JobFollowUp if len(threadRefs) > 0 { follow = &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "get_threads", GetThreads: &mcpcontract.GetThreadsInput{Threads: threadRefs}}, + Action: mcpcontract.FollowUpActionFor(mcpcontract.GetThreadsInput{Threads: threadRefs}), Reason: "Read synchronized thread facts and coverage from the offline corpus.", } } @@ -280,7 +280,7 @@ func portfolioJobArtifact(job *contracts.JobResult) ([]mcpcontract.JobArtifactRe var follow *mcpcontract.JobFollowUp if arguments := portfolioReadFollowUpArguments(request, result.Login, references); arguments != nil { follow = &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "list_pull_request_portfolio", ListPortfolio: arguments}, + Action: mcpcontract.FollowUpActionFor(*arguments), Reason: "Read these refreshed pull requests from the offline portfolio.", } } @@ -405,7 +405,7 @@ func pullRequestFeedbackIndexJobArtifact(job *contracts.JobResult) ([]mcpcontrac } } 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} - follow := &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpAction{Type: "search_pull_request_feedback", SearchFeedback: &mcpcontract.SearchPullRequestFeedbackInput{Repository: request.Repository}}, Reason: "Search the indexed pull-request feedback through the offline corpus."} + 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 } @@ -498,7 +498,7 @@ func firstCodeIndexFollowUp(artifacts []mcpcontract.JobArtifactReference) *mcpco } artifact := reference.CodeIndex return &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "read_resource", ReadResource: &mcpcontract.ResourceReadAction{URI: artifact.ResourceURI}}, + Action: mcpcontract.FollowUpActionFor(mcpcontract.ResourceReadAction{URI: artifact.ResourceURI}), Reason: "Read the exact indexed-commit artifact through MCP resources/read.", } } diff --git a/internal/app/mcp_jobs.go b/internal/app/mcp_jobs.go index e9b56146..5de9f42e 100644 --- a/internal/app/mcp_jobs.go +++ b/internal/app/mcp_jobs.go @@ -122,7 +122,7 @@ func jobResultToMCP(job *contracts.JobResult, includeDetails bool) mcpcontract.G out.Artifacts, out.FollowUp = jobArtifactsAndFollowUp(job, total) case "queued", "running": out.FollowUp = &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "poll_job", PollJob: &mcpcontract.GetJobsInput{IDs: []string{job.ID}}}, RetryAfterMS: mcpcontract.NonNegativeInt(retryAfter), Reason: "Poll this job until execution_state is terminal.", + Action: mcpcontract.FollowUpActionFor(mcpcontract.GetJobsInput{IDs: []string{job.ID}}), RetryAfterMS: mcpcontract.NonNegativeInt(retryAfter), Reason: "Poll this job until execution_state is terminal.", } } } @@ -224,7 +224,7 @@ func portfolioReadFollowUpArguments(request mcpcontract.SyncPortfolioInput, logi func facetBatchArtifact(refs []mcpcontract.ThreadRef, facetNames []string) ([]mcpcontract.JobArtifactReference, *mcpcontract.JobFollowUp) { value := mcpcontract.NonNegativeInt(len(refs)) follow := &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "get_thread_facets", GetThreadFacets: &mcpcontract.GetThreadFacetsInput{Threads: refs, Facets: append([]string(nil), facetNames...)}}, + Action: mcpcontract.FollowUpActionFor(mcpcontract.GetThreadFacetsInput{Threads: refs, Facets: append([]string(nil), facetNames...)}), Reason: "Read the synchronized facet coverage and canonical facet resources from the offline corpus.", } return []mcpcontract.JobArtifactReference{{Kind: "thread_facet_batch", Count: &value, References: threadRefKeys(refs)}}, follow diff --git a/internal/app/mcp_jobs_test.go b/internal/app/mcp_jobs_test.go index a5820022..7781c2af 100644 --- a/internal/app/mcp_jobs_test.go +++ b/internal/app/mcp_jobs_test.go @@ -122,7 +122,11 @@ func TestPortfolioArtifactBoundsPersistedTerminalLists(t *testing.T) { if len(artifact.References) != maxJobArtifactItems || !artifact.ReferencesTruncated || len(artifact.Failures) != maxJobArtifactItems || !artifact.FailuresTruncated { t.Fatalf("bounded portfolio artifact = %+v", artifact) } - if artifact.Recovery == nil || len(artifact.Recovery.Then) != 1 || artifact.Recovery.Then[0].SyncPortfolio == nil || len(artifact.Recovery.Then[0].SyncPortfolio.PullRequests) != maxJobArtifactItems { + if artifact.Recovery == nil || len(artifact.Recovery.Then) != 1 { + t.Fatalf("portfolio recovery exceeds bounded artifact scope: %+v", artifact.Recovery) + } + next, ok := mcpcontract.RecoveryInput[mcpcontract.SyncPortfolioInput](artifact.Recovery.Then[0]) + if !ok || len(next.PullRequests) != maxJobArtifactItems { t.Fatalf("portfolio recovery exceeds bounded artifact scope: %+v", artifact.Recovery) } } @@ -259,10 +263,11 @@ func TestThreadSyncFollowUpUsesResolvedExactThreads(t *testing.T) { Result: `{"status":"complete","items":[{"key":"acme/rocket","status":"complete","threads":[{"owner":"acme","repo":"rocket","kind":"pull_request","number":7}]}]}`, } artifacts, follow := jobArtifactsAndFollowUp(job, 1) - if len(artifacts) != 1 || follow == nil || follow.Action.Type != "get_threads" || follow.Action.GetThreads == nil { + if len(artifacts) != 1 || follow == nil || follow.Action.Type() != "get_threads" { t.Fatalf("thread sync handoff = artifacts:%+v follow:%+v", artifacts, follow) } - if len(follow.Action.GetThreads.Threads) != 1 || follow.Action.GetThreads.Threads[0].Kind != "pull_request" || follow.Action.GetThreads.Threads[0].Number != 7 { + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.GetThreadsInput](follow.Action) + if !ok || len(arguments.Threads) != 1 || arguments.Threads[0].Kind != "pull_request" || arguments.Threads[0].Number != 7 { t.Fatalf("thread sync follow-up arguments = %+v", follow.Action) } if len(artifacts[0].References) != 1 || artifacts[0].References[0] != "acme/rocket/pull_request#7" { @@ -277,7 +282,11 @@ func TestPersistedWorkflowFollowUpReadsResourceWithoutResubmittingMutation(t *te Result: `{"status":"complete","items":[{"key":"acme/rocket/pull_request#7","item_status":"complete","resource_uri":"gitcontribute://pull-request-feedback/acme/rocket/7"}]}`, } _, follow := jobArtifactsAndFollowUp(job, 1) - if follow == nil || follow.Action.Type != "read_resource" || follow.Action.ReadResource == nil || follow.Action.ReadResource.URI != "gitcontribute://pull-request-feedback/acme/rocket/7" { + if follow == nil { + t.Fatal("resource handoff is nil") + } + read, ok := mcpcontract.RecoveryInput[mcpcontract.ResourceReadAction](follow.Action) + if follow.Action.Type() != "read_resource" || !ok || read.URI != "gitcontribute://pull-request-feedback/acme/rocket/7" { t.Fatalf("resource handoff = %+v", follow) } } @@ -290,10 +299,14 @@ func TestPortfolioFollowUpUsesPortfolioReadArguments(t *testing.T) { Result: `{"status":"complete","login":"alice","pull_requests":["acme/rocket/pull_request#7"],"refreshed":1}`, } _, follow := jobArtifactsAndFollowUp(job, 1) - if follow == nil || follow.Action.Type != "list_pull_request_portfolio" || follow.Action.ListPortfolio == nil { + if follow == nil { + t.Fatal("portfolio handoff is nil") + } + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.ListPullRequestPortfolioInput](follow.Action) + if follow.Action.Type() != "list_pull_request_portfolio" || !ok { t.Fatalf("portfolio handoff = %+v", follow) } - if follow.Action.ListPortfolio.Repository == nil || follow.Action.ListPortfolio.Repository.Owner != "acme" || follow.Action.ListPortfolio.Repository.Repo != "rocket" || len(follow.Action.ListPortfolio.Authors) != 1 || follow.Action.ListPortfolio.Authors[0] != "alice" || follow.Action.ListPortfolio.State != "closed" || follow.Action.ListPortfolio.Limit != 10 || follow.Action.ListPortfolio.View != "compact" { + if arguments.Repository == nil || arguments.Repository.Owner != "acme" || arguments.Repository.Repo != "rocket" || len(arguments.Authors) != 1 || arguments.Authors[0] != "alice" || arguments.State != "closed" || arguments.Limit != 10 || arguments.View != "compact" { t.Fatalf("portfolio follow-up arguments = %+v", follow.Action) } } @@ -306,10 +319,14 @@ func TestExplicitPortfolioFollowUpPreservesExactReferences(t *testing.T) { Result: `{"status":"complete","pull_requests":["acme/rocket/pull_request#7"],"refreshed":1,"discovery_status":"complete"}`, } _, follow := jobArtifactsAndFollowUp(job, 1) - if follow == nil || follow.Action.ListPortfolio == nil || len(follow.Action.ListPortfolio.PullRequests) != 1 { + if follow == nil { + t.Fatal("portfolio handoff is nil") + } + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.ListPullRequestPortfolioInput](follow.Action) + if !ok || len(arguments.PullRequests) != 1 { t.Fatalf("portfolio handoff = %+v", follow) } - ref := follow.Action.ListPortfolio.PullRequests[0] + ref := arguments.PullRequests[0] if ref.Owner != "acme" || ref.Repo != "rocket" || ref.Kind != "pull_request" || ref.Number != 7 { t.Fatalf("exact portfolio handoff = %+v", ref) } @@ -323,7 +340,11 @@ func TestLegacyAuthoredPortfolioFollowUpUsesObservedLogin(t *testing.T) { Result: `{"status":"complete","login":"alice","pull_requests":["acme/rocket/pull_request#7"],"refreshed":1,"discovery_status":"complete"}`, } _, follow := jobArtifactsAndFollowUp(job, 1) - if follow == nil || follow.Action.ListPortfolio == nil || len(follow.Action.ListPortfolio.Authors) != 1 || follow.Action.ListPortfolio.Authors[0] != "alice" { + if follow == nil { + t.Fatal("legacy authored portfolio handoff is nil") + } + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.ListPullRequestPortfolioInput](follow.Action) + if !ok || len(arguments.Authors) != 1 || arguments.Authors[0] != "alice" { t.Fatalf("legacy authored portfolio handoff = %+v", follow) } } @@ -336,7 +357,11 @@ func TestLegacyExplicitPortfolioFollowUpPreservesResultReferences(t *testing.T) Result: `{"status":"complete","pull_requests":["acme/rocket/pull_request#7"],"refreshed":1,"discovery_status":"complete"}`, } _, follow := jobArtifactsAndFollowUp(job, 1) - if follow == nil || follow.Action.ListPortfolio == nil || len(follow.Action.ListPortfolio.PullRequests) != 1 { + if follow == nil { + t.Fatal("legacy explicit portfolio handoff is nil") + } + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.ListPullRequestPortfolioInput](follow.Action) + if !ok || len(arguments.PullRequests) != 1 { t.Fatalf("legacy explicit portfolio handoff = %+v", follow) } } @@ -411,13 +436,14 @@ func TestPullRequestFeedbackIndexJobOffersOfflineSearchFollowUp(t *testing.T) { job := &contracts.JobResult{ Kind: jobKindIndexPullRequestFeedback, Status: "succeeded", Request: `{"repository":{"owner":"acme","repo":"rocket"}}`, - Result: `{"status":"partial","discovery_status":"partial","items":[{"key":"acme/rocket/pull_request#7","item_status":"complete"}],"recovery":{"version":"recovery.v1","reason":"feedback_discovery_incomplete","message":"continue"}}`, + Result: `{"status":"partial","discovery_status":"partial","items":[{"key":"acme/rocket/pull_request#7","item_status":"complete"}],"recovery":{"version":"gitcontribute.recovery.v1","reason":"feedback_discovery_incomplete","message":"continue"}}`, } artifacts, follow := jobArtifactsAndFollowUp(job, 1) if len(artifacts) != 1 || artifacts[0].Kind != "pull_request_feedback_index" || artifacts[0].DiscoveryStatus != "partial" || follow == nil { t.Fatalf("feedback index artifact = %+v follow=%+v", artifacts, follow) } - if follow.Action.Type != "search_pull_request_feedback" || follow.Action.SearchFeedback == nil || follow.Action.SearchFeedback.Repository.Owner != "acme" || follow.Action.SearchFeedback.Repository.Repo != "rocket" { + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.SearchPullRequestFeedbackInput](follow.Action) + if follow.Action.Type() != "search_pull_request_feedback" || !ok || arguments.Repository.Owner != "acme" || arguments.Repository.Repo != "rocket" { t.Fatalf("feedback index follow-up = %+v", follow) } } diff --git a/internal/app/mcp_local_repository_search.go b/internal/app/mcp_local_repository_search.go index a2b500b2..a19dc9da 100644 --- a/internal/app/mcp_local_repository_search.go +++ b/internal/app/mcp_local_repository_search.go @@ -5,19 +5,18 @@ import ( "strings" "github.com/morluto/gitcontribute/internal/contracts" - "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) // 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 := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} + repoRef, err := optionalRepoRef(in.Owner, in.Repo) + if err != nil { + return mcpcontract.SearchRepositoriesOutput{}, err + } repoFilter := "" - if in.Owner != "" || in.Repo != "" { - if err := repoRef.Validate(); err != nil { - return mcpcontract.SearchRepositoriesOutput{}, err - } + if repoRef.IsValid() { repoFilter = repoRef.String() } @@ -44,7 +43,7 @@ func (r *MCPReader) SearchRepositories(ctx context.Context, in mcpcontract.Searc refs := make([]mcpcontract.RepositoryRef, len(res.Matches)) for i, m := range res.Matches { - refs[i] = mcpcontract.RepositoryRef{Owner: m.Repo.Owner, Repo: m.Repo.Repo} + refs[i] = mcpcontract.RepositoryRef{Owner: m.Repo.Owner(), Repo: m.Repo.Repo()} } batch, err := r.GetRepositories(ctx, mcpcontract.GetRepositoriesInput{Repositories: refs, SnapshotToken: in.SnapshotToken}) if err != nil { diff --git a/internal/app/mcp_portfolio_reads.go b/internal/app/mcp_portfolio_reads.go index b91f4407..3e0dc89c 100644 --- a/internal/app/mcp_portfolio_reads.go +++ b/internal/app/mcp_portfolio_reads.go @@ -23,20 +23,19 @@ func (r *MCPReader) ListPullRequestPortfolio(ctx context.Context, in mcpcontract if in.Repository != nil || len(in.Authors) > 0 || in.State != "" || in.Limit != 0 { return mcpcontract.ListPullRequestPortfolioOutput{}, errors.New("pull_requests cannot be combined with repository, authors, state, or limit") } - in.PullRequests = canonicalPullRequestRefs(in.PullRequests) - if err := rejectDuplicateThreadRefs(in.PullRequests); err != nil { - return mcpcontract.ListPullRequestPortfolioOutput{}, err - } - if err := validatePullRequestRefs(in.PullRequests, "pull_requests"); err != nil { + refs, err := parsePullRequestRefs(in.PullRequests, "pull_requests") + if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, err } + in.PullRequests = refs } if in.Repository != nil { - in.Repository.Owner = strings.TrimSpace(in.Repository.Owner) - in.Repository.Repo = strings.TrimSpace(in.Repository.Repo) - if err := (domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo}).Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { return mcpcontract.ListPullRequestPortfolioOutput{}, err } + in.Repository.Owner = ref.Owner() + in.Repository.Repo = ref.Repo() } if in.State == "" { in.State = "open" diff --git a/internal/app/mcp_portfolio_refs.go b/internal/app/mcp_portfolio_refs.go index 9b702dfb..0f3b732c 100644 --- a/internal/app/mcp_portfolio_refs.go +++ b/internal/app/mcp_portfolio_refs.go @@ -1,19 +1,39 @@ package app import ( + "fmt" + "strings" + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/mcpcontract" ) -// canonicalPullRequestRefs makes the optional kind explicit before callers -// compare references. A blank kind means pull_request for portfolio operations, -// so it must not create a second identity for the same pull request. -func canonicalPullRequestRefs(inputs []mcpcontract.ThreadRef) []mcpcontract.ThreadRef { +// parsePullRequestRefs canonicalizes identities and makes the optional kind +// explicit before duplicate detection. A blank kind means pull_request for +// pull-request-only operations. +func parsePullRequestRefs(inputs []mcpcontract.ThreadRef, path string) ([]mcpcontract.ThreadRef, error) { refs := append([]mcpcontract.ThreadRef(nil), inputs...) for i := range refs { - if refs[i].Kind == "" { - refs[i].Kind = corpus.ThreadKindPullRequest + itemPath := fmt.Sprintf("%s[%d]", path, i) + ref, err := domain.NewRepoRef(refs[i].Owner, refs[i].Repo) + if err != nil { + return nil, mcpcontract.InvalidArgument(itemPath, err.Error(), nil) + } + if refs[i].Number <= 0 { + return nil, mcpcontract.InvalidArgument(itemPath+".number", "must be positive", nil) + } + kind := strings.TrimSpace(refs[i].Kind) + if kind == "" { + kind = corpus.ThreadKindPullRequest } + if kind != corpus.ThreadKindPullRequest { + 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} + } + if err := rejectDuplicateThreadRefs(refs); err != nil { + return nil, err } - return refs + return refs, nil } diff --git a/internal/app/mcp_portfolio_sync.go b/internal/app/mcp_portfolio_sync.go index 21d9bfa0..3406d9be 100644 --- a/internal/app/mcp_portfolio_sync.go +++ b/internal/app/mcp_portfolio_sync.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -14,12 +13,12 @@ import ( // SyncPortfolio submits one bounded job that discovers pull requests authored // by the active credential and refreshes health for the resulting stored set. func (r *MCPReader) SyncPortfolio(ctx context.Context, in mcpcontract.SyncPortfolioInput) (mcpcontract.JobReference, error) { - in, err := normalizeSyncPortfolioInput(in) + request, normalized, err := parseSyncPortfolioInput(in) if err != nil { return mcpcontract.JobReference{}, err } - id, err := r.submitJob(ctx, jobKindSyncPullRequestPortfolio, in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.runPortfolioSync(ctx, in, report) + id, err := r.submitJob(ctx, jobKindSyncPullRequestPortfolio, normalized, func(ctx context.Context, report func(string, string) error) (any, error) { + return r.runPortfolioSync(ctx, request, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -27,109 +26,148 @@ func (r *MCPReader) SyncPortfolio(ctx context.Context, in mcpcontract.SyncPortfo return queuedJobReference(id, jobKindSyncPullRequestPortfolio, "portfolio synchronization job started"), nil } -func normalizeSyncPortfolioInput(in mcpcontract.SyncPortfolioInput) (mcpcontract.SyncPortfolioInput, error) { +type syncPortfolioRequest struct { + selection syncPortfolioSelection + statusMaxPages int +} + +type syncPortfolioSelection interface { + isSyncPortfolioSelection() +} + +type explicitPortfolioSelection struct { + pullRequests []mcpcontract.ThreadRef +} + +func (explicitPortfolioSelection) isSyncPortfolioSelection() {} + +type authoredPortfolioSelection struct { + repository *mcpcontract.RepositoryRef + state string + updatedAfter time.Time + limit int + maxRequests int +} + +func (authoredPortfolioSelection) isSyncPortfolioSelection() {} + +func parseSyncPortfolioInput(in mcpcontract.SyncPortfolioInput) (syncPortfolioRequest, mcpcontract.SyncPortfolioInput, error) { if in.Selection == "" { - return mcpcontract.SyncPortfolioInput{}, errors.New("selection is required: choose authored or explicit") + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, errors.New("selection is required: choose authored or explicit") } if in.Selection != "authored" && in.Selection != "explicit" { - return mcpcontract.SyncPortfolioInput{}, errors.New("selection must be authored or explicit") + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, errors.New("selection must be authored or explicit") + } + if in.StatusMaxPages == 0 { + in.StatusMaxPages = 3 + } + if in.StatusMaxPages < 1 || in.StatusMaxPages > 20 { + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, errors.New("status_max_pages must be between 1 and 20") } if in.Selection == "explicit" { - return normalizeExplicitPortfolioInput(in) + selection, normalized, err := parseExplicitPortfolioSelection(in) + if err != nil { + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, err + } + return syncPortfolioRequest{selection: selection, statusMaxPages: in.StatusMaxPages}, normalized, nil } if len(in.PullRequests) > 0 { - return mcpcontract.SyncPortfolioInput{}, errors.New("pull_requests is only valid in explicit mode") + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, errors.New("pull_requests is only valid in explicit mode") + } + selection, normalized, err := parseAuthoredPortfolioSelection(in) + if err != nil { + return syncPortfolioRequest{}, mcpcontract.SyncPortfolioInput{}, err } - return normalizeAuthoredPortfolioInput(in) + return syncPortfolioRequest{selection: selection, statusMaxPages: in.StatusMaxPages}, normalized, nil } -func normalizeExplicitPortfolioInput(in mcpcontract.SyncPortfolioInput) (mcpcontract.SyncPortfolioInput, error) { +func parseExplicitPortfolioSelection(in mcpcontract.SyncPortfolioInput) (explicitPortfolioSelection, mcpcontract.SyncPortfolioInput, error) { if len(in.PullRequests) < 1 || len(in.PullRequests) > 100 { - return mcpcontract.SyncPortfolioInput{}, errors.New("pull_requests must contain 1 to 100 items in explicit mode") - } - in.PullRequests = canonicalPullRequestRefs(in.PullRequests) - if err := rejectDuplicateThreadRefs(in.PullRequests); err != nil { - return mcpcontract.SyncPortfolioInput{}, err + return explicitPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("pull_requests must contain 1 to 100 items in explicit mode") } - if err := validatePullRequestRefs(in.PullRequests, "pull_requests"); err != nil { - return mcpcontract.SyncPortfolioInput{}, err + refs, err := parsePullRequestRefs(in.PullRequests, "pull_requests") + if err != nil { + return explicitPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, err } + in.PullRequests = refs if in.State != "" || in.UpdatedAfter != "" || in.Limit != 0 || in.DiscoveryMaxRequests != 0 { - return mcpcontract.SyncPortfolioInput{}, errors.New("state, updated_after, limit, and discovery_max_requests are only valid in authored mode") + return explicitPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("state, updated_after, limit, and discovery_max_requests are only valid in authored mode") } if in.Repository != nil { - return mcpcontract.SyncPortfolioInput{}, errors.New("repository is only valid in authored mode") + return explicitPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("repository is only valid in authored mode") } - return normalizePortfolioStatusMaxPages(in) + return explicitPortfolioSelection{pullRequests: append([]mcpcontract.ThreadRef(nil), in.PullRequests...)}, in, nil } -func normalizeAuthoredPortfolioInput(in mcpcontract.SyncPortfolioInput) (mcpcontract.SyncPortfolioInput, error) { +func parseAuthoredPortfolioSelection(in mcpcontract.SyncPortfolioInput) (authoredPortfolioSelection, mcpcontract.SyncPortfolioInput, error) { if in.Repository != nil { - in.Repository.Owner = strings.TrimSpace(in.Repository.Owner) - in.Repository.Repo = strings.TrimSpace(in.Repository.Repo) - if err := (domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo}).Validate(); err != nil { - return mcpcontract.SyncPortfolioInput{}, err + ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, err } + in.Repository = &mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()} } if in.State == "" { in.State = "open" } if in.State != "open" && in.State != "closed" && in.State != "all" { - return mcpcontract.SyncPortfolioInput{}, errors.New("state must be open, closed, or all") + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("state must be open, closed, or all") } + var updatedAfter time.Time if in.UpdatedAfter != "" { - if _, err := time.Parse(time.RFC3339, in.UpdatedAfter); err != nil { - return mcpcontract.SyncPortfolioInput{}, errors.New("updated_after must be RFC 3339") + parsed, err := time.Parse(time.RFC3339, in.UpdatedAfter) + if err != nil { + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("updated_after must be RFC 3339") } + updatedAfter = parsed } if in.Limit == 0 { in.Limit = 100 } if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.SyncPortfolioInput{}, errors.New("limit must be between 1 and 100") + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, errors.New("limit must be between 1 and 100") } if in.DiscoveryMaxRequests == 0 { in.DiscoveryMaxRequests = defaultSyncBatchMaxRequests } if in.DiscoveryMaxRequests < 2 || in.DiscoveryMaxRequests > defaultSyncBatchMaxRequests { - return mcpcontract.SyncPortfolioInput{}, fmt.Errorf("discovery_max_requests must be between 2 and %d", defaultSyncBatchMaxRequests) + return authoredPortfolioSelection{}, mcpcontract.SyncPortfolioInput{}, fmt.Errorf("discovery_max_requests must be between 2 and %d", defaultSyncBatchMaxRequests) } - return normalizePortfolioStatusMaxPages(in) -} - -func normalizePortfolioStatusMaxPages(in mcpcontract.SyncPortfolioInput) (mcpcontract.SyncPortfolioInput, error) { - if in.StatusMaxPages == 0 { - in.StatusMaxPages = 3 - } - if in.StatusMaxPages < 1 || in.StatusMaxPages > 20 { - return mcpcontract.SyncPortfolioInput{}, errors.New("status_max_pages must be between 1 and 20") + var repository *mcpcontract.RepositoryRef + if in.Repository != nil { + copy := *in.Repository + repository = © } - return in, nil + return authoredPortfolioSelection{repository: repository, state: in.State, updatedAfter: updatedAfter, limit: in.Limit, maxRequests: in.DiscoveryMaxRequests}, in, nil } -func (r *MCPReader) runPortfolioSync(ctx context.Context, in mcpcontract.SyncPortfolioInput, report func(string, string) error) (syncPortfolioResult, error) { - if in.Selection == "explicit" { - return r.syncExplicitPortfolio(ctx, in, report) +func (r *MCPReader) runPortfolioSync(ctx context.Context, request syncPortfolioRequest, report func(string, string) error) (syncPortfolioResult, error) { + switch selection := request.selection.(type) { + case explicitPortfolioSelection: + return r.syncExplicitPortfolio(ctx, selection, request.statusMaxPages, report) + case authoredPortfolioSelection: + return r.syncAuthoredPortfolio(ctx, selection, request.statusMaxPages, report) + default: + return syncPortfolioResult{}, errors.New("unsupported parsed portfolio selection") } - return r.syncAuthoredPortfolio(ctx, in, report) } -func (r *MCPReader) syncExplicitPortfolio(ctx context.Context, in mcpcontract.SyncPortfolioInput, report func(string, string) error) (syncPortfolioResult, error) { - refreshed, failures, status, err := r.syncPortfolioStatusBatches(ctx, in.PullRequests, in.StatusMaxPages, report) +func (r *MCPReader) syncExplicitPortfolio(ctx context.Context, selection explicitPortfolioSelection, statusMaxPages int, report func(string, string) error) (syncPortfolioResult, error) { + refreshed, failures, status, err := r.syncPortfolioStatusBatches(ctx, selection.pullRequests, statusMaxPages, report) if err != nil { return syncPortfolioResult{}, err } - return syncPortfolioResult{Status: status, Discovered: len(in.PullRequests), Refreshed: refreshed, PullRequests: threadRefKeys(in.PullRequests), Failures: failures, DiscoveryStatus: "complete"}, nil + return syncPortfolioResult{Status: status, Discovered: len(selection.pullRequests), Refreshed: refreshed, PullRequests: threadRefKeys(selection.pullRequests), Failures: failures, DiscoveryStatus: "complete"}, nil } -func (r *MCPReader) syncAuthoredPortfolio(ctx context.Context, in mcpcontract.SyncPortfolioInput, report func(string, string) error) (syncPortfolioResult, error) { +func (r *MCPReader) syncAuthoredPortfolio(ctx context.Context, selection authoredPortfolioSelection, statusMaxPages int, report func(string, string) error) (syncPortfolioResult, error) { discovery, err := r.syncAuthoredPullRequests(ctx, authoredPullRequestSyncOptions{ - Repository: in.Repository, State: in.State, UpdatedAfter: in.UpdatedAfter, Limit: in.Limit, MaxRequests: in.DiscoveryMaxRequests, + Repository: selection.repository, State: selection.state, UpdatedAfter: selection.updatedAfter, Limit: selection.limit, MaxRequests: selection.maxRequests, }, report) if err != nil { return syncPortfolioResult{}, err } - refreshed, failures, status, err := r.syncPortfolioStatusBatches(ctx, discovery.PullRequestTargets, in.StatusMaxPages, report) + refreshed, failures, status, err := r.syncPortfolioStatusBatches(ctx, discovery.PullRequestTargets, statusMaxPages, report) if err != nil { return syncPortfolioResult{}, err } diff --git a/internal/app/mcp_portfolio_test.go b/internal/app/mcp_portfolio_test.go index e4ca5976..073251b4 100644 --- a/internal/app/mcp_portfolio_test.go +++ b/internal/app/mcp_portfolio_test.go @@ -8,6 +8,7 @@ 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" ) @@ -52,7 +53,7 @@ func TestPullRequestPortfolioDerivesConflictAndPreservesUnknownCoverage(t *testi if byNumber[unknown.Number].Attention != "unknown" || byNumber[unknown.Number].StatusCoverage != "missing" { t.Fatalf("unknown coverage collapsed: %+v", byNumber[unknown.Number]) } - if byNumber[unknown.Number].Recovery == nil || len(byNumber[unknown.Number].Recovery.Then) == 0 || byNumber[unknown.Number].Recovery.Then[0].Type != "sync_portfolio" { + if byNumber[unknown.Number].Recovery == nil || len(byNumber[unknown.Number].Recovery.Then) == 0 || byNumber[unknown.Number].Recovery.Then[0].Type() != "sync_portfolio" { t.Fatalf("unknown portfolio recovery = %+v", byNumber[unknown.Number].Recovery) } concise, err := (&MCPReader{svc}).ListPullRequestPortfolio(ctx, mcpcontract.ListPullRequestPortfolioInput{Authors: []string{"alice"}, State: "open", Limit: 10}) @@ -85,7 +86,7 @@ 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", MergedKnown: true, SourceUpdatedAt: now}, `{}`) + 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}, `{}`) if err != nil { t.Fatal(err) } @@ -123,10 +124,11 @@ func TestPullRequestPortfolioKeepsComputingMergeabilityUnknown(t *testing.T) { if err != nil { t.Fatal(err) } + unknownMergeability := "UNKNOWN" values := map[string]any{ FacetPRDetails: github.PullRequestDetails{Number: 10, UpdatedAt: now}, FacetPRReviews: []github.Review{}, - FacetPRMergeState: github.PullRequestMergeState{MergeStateStatus: "UNKNOWN", Mergeable: "UNKNOWN", MergeableKnown: false}, + FacetPRMergeState: github.NewPullRequestMergeState("UNKNOWN", &unknownMergeability), FacetPRMergeQueue: (*github.PullRequestMergeQueueEntry)(nil), FacetPRChecks: []github.PullRequestCheck{}, FacetPRReviewThreads: []github.PullRequestReviewThread{}, @@ -204,7 +206,11 @@ func TestPullRequestPortfolioRepositoryScopePreservesTotalAndTruncationRecovery( if out.Total != 2 || !out.Truncated || len(out.PullRequests) != 1 || out.PullRequests[0].Repo != "rocket" || out.PullRequests[0].Number != 2 { t.Fatalf("scoped portfolio = %+v", out) } - if out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].ListPortfolio == nil || out.Recovery.Then[0].ListPortfolio.Repository == nil || *out.Recovery.Then[0].ListPortfolio.Repository != *scope { + if out.Recovery == nil || len(out.Recovery.Then) != 1 { + t.Fatalf("scoped truncation recovery = %+v", out.Recovery) + } + next, ok := mcpcontract.RecoveryInput[mcpcontract.ListPullRequestPortfolioInput](out.Recovery.Then[0]) + if !ok || next.Repository == nil || *next.Repository != *scope { t.Fatalf("scoped truncation recovery = %+v", out.Recovery) } if _, err := (&MCPReader{svc}).ListPullRequestPortfolio(ctx, mcpcontract.ListPullRequestPortfolioInput{Repository: scope, PullRequests: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 1}}}); err == nil { diff --git a/internal/app/mcp_pr_check_wait.go b/internal/app/mcp_pr_check_wait.go index 5b17a27e..dc4fd8a3 100644 --- a/internal/app/mcp_pr_check_wait.go +++ b/internal/app/mcp_pr_check_wait.go @@ -41,7 +41,7 @@ func (r *MCPReader) WaitPullRequestChecks(ctx context.Context, in mcpcontract.Wa } func validatePullRequestCheckWaitInput(in *mcpcontract.WaitPullRequestChecksInput) error { - if err := (domain.RepoRef{Owner: in.Owner, Repo: in.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(in.Owner, in.Repo); err != nil { return err } if in.Number < 1 { diff --git a/internal/app/mcp_pr_health.go b/internal/app/mcp_pr_health.go index 16195477..2ac2b5a9 100644 --- a/internal/app/mcp_pr_health.go +++ b/internal/app/mcp_pr_health.go @@ -222,7 +222,7 @@ func persistOneHealthFacet(ctx context.Context, c *corpus.Corpus, repo corpus.Re 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})...) } - if target.name == FacetPRMergeState && !remote.MergeState.MergeableKnown { + 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})...) } diff --git a/internal/app/mcp_pr_workflows.go b/internal/app/mcp_pr_workflows.go index a262cab0..ff9b7152 100644 --- a/internal/app/mcp_pr_workflows.go +++ b/internal/app/mcp_pr_workflows.go @@ -44,12 +44,11 @@ type pullRequestWorkflowResult struct { } func (r *MCPReader) SyncPullRequestFeedback(ctx context.Context, in mcpcontract.SyncPullRequestFeedbackInput) (mcpcontract.JobReference, error) { - if err := rejectDuplicateThreadRefs(in.PullRequests); err != nil { - return mcpcontract.JobReference{}, err - } - if err := validatePullRequestRefs(in.PullRequests, "pull_requests"); err != nil { + refs, err := parsePullRequestRefs(in.PullRequests, "pull_requests") + if err != nil { return mcpcontract.JobReference{}, err } + in.PullRequests = refs if len(in.PullRequests) < 1 || len(in.PullRequests) > 50 { return mcpcontract.JobReference{}, errors.New("pull_requests must contain 1 to 50 items") } @@ -225,12 +224,18 @@ func (r *MCPReader) persistPullRequestIdentity(ctx context.Context, ref mcpcontr } } - thread := threadFromPullRequestDetails(header, repo.ID) + thread, err := threadFromPullRequestDetails(header, repo.ID) + if err != nil { + return "pull_request_header_unavailable", err + } existing, err := c.GetThread(ctx, repo.ID, corpus.ThreadKindPullRequest, ref.Number) if err != nil { return "persistence_retryable", fmt.Errorf("get pull request identity: %w", err) } if existing != nil { + if thread.State == "" { + thread.State = existing.State + } // The feedback header does not carry GitHub's state reason. Keep that // richer observation instead of replacing it with an empty value. thread.StateReason = existing.StateReason @@ -247,7 +252,11 @@ func (r *MCPReader) persistPullRequestIdentity(ctx context.Context, ref mcpcontr return "", nil } -func threadFromPullRequestDetails(header github.PullRequestDetails, repositoryID int64) corpus.Thread { +func threadFromPullRequestDetails(header github.PullRequestDetails, repositoryID int64) (corpus.Thread, error) { + merge, err := parseGitHubMergeStatus(header) + if err != nil { + return corpus.Thread{}, fmt.Errorf("parse pull-request merge status: %w", err) + } thread := corpus.Thread{ RepositoryID: repositoryID, Kind: corpus.ThreadKindPullRequest, @@ -262,18 +271,14 @@ func threadFromPullRequestDetails(header github.PullRequestDetails, repositoryID Draft: header.Draft, Locked: header.Locked, Milestone: header.Milestone, - Merged: header.Merged, - MergedKnown: true, + Merge: merge, SourceCreatedAt: header.CreatedAt, SourceUpdatedAt: header.UpdatedAt, } if header.ClosedAt != nil { thread.ClosedAt = *header.ClosedAt } - if header.MergedAt != nil { - thread.MergedAt = *header.MergedAt - } - return thread + return thread, nil } func coveredFeedbackChannels(requested []string, coverage map[string]github.FeedbackCoverage) []string { @@ -324,12 +329,11 @@ func (r *MCPReader) persistPullRequestFeedback(ctx context.Context, ref mcpcontr } func (r *MCPReader) SyncCIFailures(ctx context.Context, in mcpcontract.SyncCIFailuresInput) (mcpcontract.JobReference, error) { - if err := rejectDuplicateThreadRefs(in.PullRequests); err != nil { - return mcpcontract.JobReference{}, err - } - if err := validatePullRequestRefs(in.PullRequests, "pull_requests"); err != nil { + refs, err := parsePullRequestRefs(in.PullRequests, "pull_requests") + if err != nil { return mcpcontract.JobReference{}, err } + in.PullRequests = refs if len(in.PullRequests) < 1 || len(in.PullRequests) > 20 { return mcpcontract.JobReference{}, errors.New("pull_requests must contain 1 to 20 items") } diff --git a/internal/app/mcp_pr_workflows_test.go b/internal/app/mcp_pr_workflows_test.go index 494cbd96..7b7997e2 100644 --- a/internal/app/mcp_pr_workflows_test.go +++ b/internal/app/mcp_pr_workflows_test.go @@ -27,7 +27,7 @@ func TestIncompletePullRequestFacetPreservesLastCompleteObservation(t *testing.T } thread, err := stored.UpsertThread(ctx, corpus.Thread{ RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, - SourceUpdatedAt: completeAt, + State: "open", SourceUpdatedAt: completeAt, }, `{}`) if err != nil { t.Fatal(err) @@ -201,7 +201,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, SourceUpdatedAt: now, + RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", SourceUpdatedAt: now, }, `{}`) if err != nil { t.Fatal(err) @@ -234,7 +234,11 @@ func TestBoundedWorkflowSnapshotsReturnRetryablePartialItems(t *testing.T) { if feedback.BatchStatus != "partial" || feedback.Items[0].Status != "retryable" || feedback.Items[0].ResourceURI != "" { t.Fatalf("feedback result = %+v", feedback) } - if feedback.Items[0].Recovery == nil || len(feedback.Items[0].Recovery.Then) != 1 || feedback.Items[0].Recovery.Then[0].SyncFeedback == nil || feedback.Items[0].Recovery.Then[0].SyncFeedback.MaxItemsPerChannel != 20 { + if feedback.Items[0].Recovery == nil || len(feedback.Items[0].Recovery.Then) != 1 { + t.Fatalf("feedback recovery = %+v", feedback.Items[0].Recovery) + } + nextFeedback, ok := mcpcontract.RecoveryInput[mcpcontract.SyncPullRequestFeedbackInput](feedback.Items[0].Recovery.Then[0]) + if !ok || nextFeedback.MaxItemsPerChannel != 20 { t.Fatalf("feedback recovery = %+v", feedback.Items[0].Recovery) } @@ -278,7 +282,11 @@ func TestFeedbackCoverageRecoveryRespectsAdvertisedItemLimit(t *testing.T) { t.Fatalf("hard-limit recovery = %+v, want nil", plan) } plan := feedbackCoverageRecovery(ref, mcpcontract.SyncPullRequestFeedbackInput{MaxItemsPerChannel: maxFeedbackItemsPerChannel - 1}, "incomplete") - if plan == nil || len(plan.Then) != 1 || plan.Then[0].SyncFeedback == nil || plan.Then[0].SyncFeedback.MaxItemsPerChannel != maxFeedbackItemsPerChannel { + if plan == nil || len(plan.Then) != 1 { + t.Fatalf("capped recovery = %+v", plan) + } + nextFeedback, ok := mcpcontract.RecoveryInput[mcpcontract.SyncPullRequestFeedbackInput](plan.Then[0]) + if !ok || nextFeedback.MaxItemsPerChannel != maxFeedbackItemsPerChannel { t.Fatalf("capped recovery = %+v", plan) } } @@ -308,7 +316,7 @@ func TestFeedbackResourceUsesPublicChannelsAndPreservesThreadSelection(t *testin t.Fatal(err) } if _, err := svc.corpus.UpsertThread(ctx, corpus.Thread{ - RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, SourceUpdatedAt: now, + RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", SourceUpdatedAt: now, }, `{}`); err != nil { t.Fatal(err) } @@ -361,10 +369,14 @@ func TestFeedbackSearchRecoveryRefreshesAllThreadState(t *testing.T) { 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 { t.Fatal(err) } - plan := feedbackSearchRecovery(ctx, svc.corpus, repo.ID, domain.RepoRef{Owner: "acme", Repo: "rocket"}, mcpcontract.SearchPullRequestFeedbackInput{ + 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}}) - if plan == nil || len(plan.Then) != 1 || plan.Then[0].SyncFeedback == nil || plan.Then[0].SyncFeedback.ThreadState != "all" { + if plan == nil || len(plan.Then) != 1 { + t.Fatalf("feedback recovery plan = %+v", plan) + } + nextFeedback, ok := mcpcontract.RecoveryInput[mcpcontract.SyncPullRequestFeedbackInput](plan.Then[0]) + if !ok || nextFeedback.ThreadState != "all" { t.Fatalf("feedback recovery plan = %+v", plan) } } @@ -378,15 +390,19 @@ func TestFeedbackSearchRecoveryBoundsMergeStateHydration(t *testing.T) { for number := 51; number <= 150; number++ { items = append(items, corpus.PullRequestFeedbackProjection{PullRequestNumber: number}) } - plan := feedbackSearchRecovery(context.Background(), nil, 0, domain.RepoRef{Owner: "acme", Repo: "rocket"}, mcpcontract.SearchPullRequestFeedbackInput{}, corpus.FeedbackSearchPage{ + plan := feedbackSearchRecovery(context.Background(), nil, 0, domain.MustRepoRef("acme", "rocket"), mcpcontract.SearchPullRequestFeedbackInput{}, corpus.FeedbackSearchPage{ Coverage: corpus.FeedbackCoverageSummary{Status: "complete", DiscoveryComplete: true}, UnknownMergePullRequests: unknown, Items: items, }) - if plan == nil || len(plan.Then) != 1 || plan.Then[0].HydrateThreads == nil { + if plan == nil || len(plan.Then) != 1 { + t.Fatalf("merge-state recovery plan = %+v", plan) + } + nextHydration, ok := mcpcontract.RecoveryInput[mcpcontract.HydrateThreadsInput](plan.Then[0]) + if !ok { t.Fatalf("merge-state recovery plan = %+v", plan) } - threads := plan.Then[0].HydrateThreads.Threads + threads := nextHydration.Threads if len(threads) != maxFeedbackMergeStateRecoveryThreads { t.Fatalf("recovery thread count = %d, want %d", len(threads), maxFeedbackMergeStateRecoveryThreads) } diff --git a/internal/app/mcp_precedent_reads.go b/internal/app/mcp_precedent_reads.go index b6966f23..ef52d6db 100644 --- a/internal/app/mcp_precedent_reads.go +++ b/internal/app/mcp_precedent_reads.go @@ -38,7 +38,11 @@ func (r *MCPReader) FindPrecedents(ctx context.Context, in mcpcontract.FindPrece } refs := make([]precedent.SourceRef, len(in.Threads)) for i, input := range in.Threads { - refs[i] = precedent.SourceRef{Repository: domain.RepoRef{Owner: input.Owner, Repo: input.Repo}, Number: input.Number} + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { + return mcpcontract.FindPrecedentsOutput{}, fmt.Errorf("threads[%d]: %w", i, err) + } + refs[i] = precedent.SourceRef{Repository: ref, Number: input.Number} } snapshots, err := c.LoadPrecedentRepositories(ctx, refs, 2000) if err != nil { @@ -66,7 +70,7 @@ func (r *MCPReader) FindPrecedents(ctx context.Context, in mcpcontract.FindPrece item := mcpcontract.BatchItem[mcpcontract.PrecedentSet]{Key: key, Status: "complete"} repoKey := precedent.RepositoryKey(refs[i].Repository) snapshot := snapshotsByRepo[repoKey] - if !snapshot.Available { + if !snapshot.Available() { item.Status, item.Reason = "unavailable", "repository_not_indexed" item.Message = "repository history is not present in the local corpus" item.Recovery = precedentRecoveryPlan(input, item.Reason) @@ -163,7 +167,7 @@ func betterPrecedent(a, b mcpcontract.PrecedentOutput) bool { func precedentToMCP(source, owner, repo string, t precedent.Thread, score float64) mcpcontract.PrecedentOutput { reasons := []string{"similar stored title or body"} - if t.Merged { + if t.Merge.IsMerged() { reasons = append(reasons, "pull request merged") } if t.StateReason != "" { @@ -175,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.MergedAt)} + 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())} } diff --git a/internal/app/mcp_pull_request_feedback_index.go b/internal/app/mcp_pull_request_feedback_index.go index 80d96879..1a0ce689 100644 --- a/internal/app/mcp_pull_request_feedback_index.go +++ b/internal/app/mcp_pull_request_feedback_index.go @@ -44,10 +44,12 @@ type pullRequestFeedbackIndexResult struct { // feedback job. Repeating the same repository resumes its stored provider // page when the previous run was bounded or interrupted. func (r *MCPReader) IndexPullRequestFeedback(ctx context.Context, in mcpcontract.IndexPullRequestFeedbackInput) (mcpcontract.JobReference, error) { - ref := domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { return mcpcontract.JobReference{}, err } + 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"} } diff --git a/internal/app/mcp_pull_request_feedback_search.go b/internal/app/mcp_pull_request_feedback_search.go index 06b68456..2c525ccc 100644 --- a/internal/app/mcp_pull_request_feedback_search.go +++ b/internal/app/mcp_pull_request_feedback_search.go @@ -19,8 +19,8 @@ const maxFeedbackMergeStateRecoveryThreads = 100 // SearchPullRequestFeedback is an offline read over the repository feedback // projection. Coverage state is returned independently from match count. func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontract.SearchPullRequestFeedbackInput) (mcpcontract.SearchPullRequestFeedbackOutput, error) { - ref := domain.RepoRef{Owner: in.Repository.Owner, Repo: in.Repository.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Repository.Owner, in.Repository.Repo) + if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err } createdAfter, err := parseFeedbackDate("created_after", in.CreatedAfter) @@ -53,12 +53,12 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.SearchPullRequestFeedbackOutput{}, err } 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}})) + 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, @@ -68,7 +68,7 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac }) if err != nil { if errors.Is(err, corpus.ErrProjectionStale) { - return mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.Unavailable("feedback_projection_stale", "The normalized feedback projection is missing or stale. Continue the repository feedback index job, then retry this offline search.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner, Repo: ref.Repo}, Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) + return mcpcontract.SearchPullRequestFeedbackOutput{}, mcpcontract.Unavailable("feedback_projection_stale", "The normalized feedback projection is missing or stale. Continue the repository feedback index job, then retry this offline search.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}, Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) } return mcpcontract.SearchPullRequestFeedbackOutput{}, err } @@ -80,14 +80,13 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac } for _, item := range page.Items { merged := (*bool)(nil) - if item.PullRequestMergedKnown { - value := item.PullRequestMerged + if item.PullRequestMerge.Known() { + value := item.PullRequestMerge.IsMerged() merged = &value } resolved := (*bool)(nil) resolutionState := "unknown" - if item.ResolvedKnown { - value := item.Resolved + if value, known := item.Resolution.Value(); known { resolved = &value if value { resolutionState = "resolved" @@ -95,15 +94,15 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac resolutionState = "unresolved" } } - pr := mcpcontract.ThreadRef{Owner: ref.Owner, Repo: ref.Repo, Kind: "pull_request", Number: item.PullRequestNumber} - prReference := fmt.Sprintf("%s/%s#%d", ref.Owner, ref.Repo, item.PullRequestNumber) - threadReference := fmt.Sprintf("gitcontribute://pull-request-feedback/%s/%s/%d", ref.Owner, ref.Repo, item.PullRequestNumber) + pr := mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: "pull_request", Number: item.PullRequestNumber} + prReference := fmt.Sprintf("%s/%s#%d", ref.Owner(), ref.Repo(), item.PullRequestNumber) + threadReference := fmt.Sprintf("gitcontribute://pull-request-feedback/%s/%s/%d", ref.Owner(), ref.Repo(), item.PullRequestNumber) out.Matches = append(out.Matches, mcpcontract.PullRequestFeedbackMatch{ Repository: in.Repository, PullRequest: pr, PullRequestReference: prReference, PullRequestAuthor: item.PullRequestAuthor, PullRequestState: item.PullRequestState, Merged: merged, Channel: item.Channel, FeedbackID: item.FeedbackID, FeedbackNodeID: item.FeedbackNodeID, ThreadID: item.ThreadExternalID, ThreadReference: threadReference, - CommentReference: fmt.Sprintf("gitcontribute://pull-request-feedback/%s/%s/%d/%s/%s", ref.Owner, ref.Repo, item.PullRequestNumber, url.PathEscape(item.Channel), url.PathEscape(item.FeedbackID)), + CommentReference: fmt.Sprintf("gitcontribute://pull-request-feedback/%s/%s/%d/%s/%s", ref.Owner(), ref.Repo(), item.PullRequestNumber, url.PathEscape(item.Channel), url.PathEscape(item.FeedbackID)), InReplyToID: item.InReplyToID, FeedbackAuthor: item.Author, ReviewState: item.ReviewState, Body: compactFeedbackBody(item.Body), Path: item.Path, Line: item.Line, StartLine: item.StartLine, Side: item.Side, StartSide: item.StartSide, Outdated: item.Outdated, Resolved: resolved, ResolutionState: resolutionState, ResolvedBy: item.ResolvedBy, CreatedAt: formatTime(item.CreatedAt), UpdatedAt: formatTime(item.UpdatedAt), HeadSHA: item.HeadSHA, SourceObservationID: item.SourceObservationID, @@ -112,7 +111,7 @@ func (r *MCPReader) SearchPullRequestFeedback(ctx context.Context, in mcpcontrac unknownMergeState := len(page.UnknownMergePullRequests) > 0 if !unknownMergeState { for _, item := range page.Items { - if !item.PullRequestMergedKnown { + if !item.PullRequestMerge.Known() { unknownMergeState = true break } @@ -153,7 +152,7 @@ func compactFeedbackBody(value string) string { func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID int64, ref domain.RepoRef, in mcpcontract.SearchPullRequestFeedbackInput, page corpus.FeedbackSearchPage) *mcpcontract.RecoveryPlan { if !page.Coverage.DiscoveryComplete { - return recoveryPlan("feedback_discovery_incomplete", "Discovery is incomplete; continue the repository feedback index job before treating an empty result as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner, Repo: ref.Repo}, Channels: []string{"issue_comments", "submitted_reviews", "inline_comments", "review_threads"}, ThreadState: "all"})) + return recoveryPlan("feedback_discovery_incomplete", "Discovery is incomplete; 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} if in.Channel == "" { @@ -168,20 +167,20 @@ func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID if err == nil && len(threads) > 0 { refs := make([]mcpcontract.ThreadRef, 0, len(threads)) for _, thread := range threads { - refs = append(refs, mcpcontract.ThreadRef{Owner: ref.Owner, Repo: ref.Repo, Kind: "pull_request", Number: thread.Number}) + refs = append(refs, mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: "pull_request", Number: thread.Number}) } return recoveryPlan("feedback_facet_incomplete", "Some pull-request feedback facets are incomplete; retry the exact feedback synchronization, then reread this search.", mcpcontract.RecoveryAction(mcpcontract.SyncPullRequestFeedbackInput{PullRequests: refs, Channels: channels, ThreadState: "all", MaxItemsPerChannel: 1000, MaxRequests: 1000})) } } unknown := make([]mcpcontract.ThreadRef, 0, len(page.UnknownMergePullRequests)+len(page.Items)) for _, number := range page.UnknownMergePullRequests { - unknown = append(unknown, mcpcontract.ThreadRef{Owner: ref.Owner, Repo: ref.Repo, Kind: "pull_request", Number: number}) + unknown = append(unknown, mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: "pull_request", Number: number}) } for _, item := range page.Items { - if item.PullRequestMergedKnown { + if item.PullRequestMerge.Known() { continue } - unknown = append(unknown, mcpcontract.ThreadRef{Owner: ref.Owner, Repo: ref.Repo, Kind: "pull_request", Number: item.PullRequestNumber}) + unknown = append(unknown, mcpcontract.ThreadRef{Owner: ref.Owner(), Repo: ref.Repo(), Kind: "pull_request", Number: item.PullRequestNumber}) } if len(unknown) > 0 { threads := uniqueThreadRefs(unknown) @@ -190,7 +189,7 @@ func feedbackSearchRecovery(ctx context.Context, c *corpus.Corpus, repositoryID } return recoveryPlan("merge_state_unknown", "Some matching pull requests have no observed merge state; refresh the exact PR-details facet before filtering on merge state.", mcpcontract.RecoveryAction(mcpcontract.HydrateThreadsInput{Threads: threads, Facets: []string{facets.PRDetails}, MaxPages: 1})) } - return recoveryPlan("feedback_coverage_partial", "Feedback coverage is partial; continue indexing or retry the returned exact synchronization before treating missing feedback as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner, Repo: ref.Repo}})) + return recoveryPlan("feedback_coverage_partial", "Feedback coverage is partial; continue indexing or retry the returned exact synchronization before treating missing feedback as absence.", mcpcontract.RecoveryAction(mcpcontract.IndexPullRequestFeedbackInput{Repository: mcpcontract.RepositoryRef{Owner: ref.Owner(), Repo: ref.Repo()}})) } func uniqueThreadRefs(values []mcpcontract.ThreadRef) []mcpcontract.ThreadRef { diff --git a/internal/app/mcp_recovery_test.go b/internal/app/mcp_recovery_test.go index 906ce15d..69aaf096 100644 --- a/internal/app/mcp_recovery_test.go +++ b/internal/app/mcp_recovery_test.go @@ -20,10 +20,10 @@ 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.Recovery == nil || len(threads.Recovery.Then) != 1 || threads.Recovery.Then[0].Type() != "ensure_coverage" { t.Fatalf("thread search recovery = %+v", threads) } - if got := threads.Recovery.Then[0].EnsureCoverage; got == nil || got.Target.Repository.Owner != "owner" || got.Target.Repository.Repo != "repo" { + if got, ok := mcpcontract.RecoveryInput[mcpcontract.EnsureCoverageInput](threads.Recovery.Then[0]); !ok || got.Target.Repository.Owner != "owner" || got.Target.Repository.Repo != "repo" { t.Fatalf("thread recovery target = %+v", got) } @@ -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.Recovery == nil || len(repositories.Recovery.Then) != 1 || repositories.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("repository search recovery = %+v", repositories) } } @@ -74,11 +74,11 @@ func TestMCPRelatedWorkDoesNotTreatAbsentRepositoryAsNoFindings(t *testing.T) { func assertRelatedWorkRecovery(t *testing.T, output mcpcontract.CheckOutput, kind string) { t.Helper() - if output.Status != "unavailable" || output.Coverage != "unknown" || output.Total != 0 || output.Recovery == nil || len(output.Recovery.Then) != 1 || output.Recovery.Then[0].Type != "sync_repository_context" { + if output.Status != "unavailable" || output.Coverage != "unknown" || output.Total != 0 || output.Recovery == nil || len(output.Recovery.Then) != 1 || output.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("%s output = %+v", kind, output) } - action := output.Recovery.Then[0].SyncRepositoryContext - if action == nil || len(action.Repositories) != 1 || action.Repositories[0].Owner != "owner" || action.Repositories[0].Repo != "absent" { + action, ok := mcpcontract.RecoveryInput[mcpcontract.SyncRepositoryContextInput](output.Recovery.Then[0]) + if !ok || len(action.Repositories) != 1 || action.Repositories[0].Owner != "owner" || action.Repositories[0].Repo != "absent" { t.Fatalf("%s recovery = %+v", kind, output.Recovery) } } diff --git a/internal/app/mcp_related_work.go b/internal/app/mcp_related_work.go index 45b590d2..48d9200a 100644 --- a/internal/app/mcp_related_work.go +++ b/internal/app/mcp_related_work.go @@ -62,11 +62,11 @@ func (r *MCPReader) checkRelatedWork(ctx context.Context, target, id string, lim } message := fmt.Sprintf("The repository is absent from the local corpus, so an empty %s result would not be evidence of absence.", kind) if !indexed { - return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner, repo.Repo)), nil + return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner(), repo.Repo())), nil } result, err := run() if errors.Is(err, errRepositoryNotFound) { - return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner, repo.Repo)), nil + return unavailableRelatedWorkOutput(target, id, repo, limit, "repository_not_indexed", message, syncRepositoryContextCall(repo.Owner(), repo.Repo())), nil } return result, err } @@ -162,7 +162,7 @@ func (r *MCPReader) relatedWorkRepositoryIndexed(ctx context.Context, repo domai if err != nil { return false, err } - stored, err := c.GetRepository(ctx, repo.Owner, repo.Repo) + stored, err := c.GetRepository(ctx, repo.Owner(), repo.Repo()) if err != nil { return false, err } diff --git a/internal/app/mcp_resource_reads.go b/internal/app/mcp_resource_reads.go index fdcb8299..d25e9978 100644 --- a/internal/app/mcp_resource_reads.go +++ b/internal/app/mcp_resource_reads.go @@ -87,14 +87,14 @@ func (r *MCPReader) PullRequestFeedbackItemResource(ctx context.Context, owner, return nil, failure.NotFound(fmt.Errorf("pull-request feedback item %s is not stored", feedbackID)) } merged := any(nil) - if item.PullRequestMergedKnown { - merged = item.PullRequestMerged + if value, known := item.PullRequestMerge.IsMerged(), item.PullRequestMerge.Known(); known { + merged = value } resolved := any(nil) resolutionState := "unknown" - if item.ResolvedKnown { - resolved = item.Resolved - if item.Resolved { + if value, known := item.Resolution.Value(); known { + resolved = value + if value { resolutionState = "resolved" } else { resolutionState = "unresolved" diff --git a/internal/app/mcp_scalable_inputs_test.go b/internal/app/mcp_scalable_inputs_test.go new file mode 100644 index 00000000..94b6ad52 --- /dev/null +++ b/internal/app/mcp_scalable_inputs_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "strings" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestScalableBatchInputsRejectDuplicatesInsteadOfDroppingOutcomes(t *testing.T) { + t.Parallel() + if err := rejectDuplicateRepositoryRefs([]mcpcontract.RepositoryRef{{Owner: "one", Repo: "repo"}, {Owner: "ONE", Repo: "repo"}}); err == nil { + t.Fatal("duplicate repositories were silently accepted") + } + if err := rejectDuplicateThreadRefs([]mcpcontract.ThreadRef{{Owner: "one", Repo: "repo", Number: 1}, {Owner: "one", Repo: "repo", Number: 1}}); err == nil { + t.Fatal("duplicate threads were silently accepted") + } + if err := rejectDuplicateThreadRefs([]mcpcontract.ThreadRef{{Owner: "one", Repo: "repo", Kind: "issue", Number: 1}, {Owner: "one", Repo: "repo", Kind: "pull_request", Number: 1}}); err != nil { + t.Fatalf("issue and pull request with the same number were conflated: %v", err) + } + if err := rejectDuplicateIndexRepositoryInputs([]mcpcontract.IndexRepositoryInput{{Owner: "one", Repo: "repo", Remote: "first"}, {Owner: "one", Repo: "repo", Remote: "second"}}); err == nil { + t.Fatal("conflicting repository remotes were silently accepted") + } +} + +func TestPullRequestWorkflowsRejectMalformedReferencesBeforeSubmission(t *testing.T) { + t.Parallel() + reader := &MCPReader{newSearchTestService(t)} + if _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{}); err == nil || !strings.Contains(err.Error(), "selection is required") { + t.Fatalf("missing portfolio selection error = %v", err) + } + for _, ref := range []mcpcontract.ThreadRef{ + {Owner: " ", Repo: "rocket", Number: 1}, + {Owner: "acme", Repo: " ", Number: 1}, + {Owner: "acme", Repo: "rocket", Number: 0}, + {Owner: "acme", Repo: "rocket", Kind: "issue", Number: 1}, + } { + if _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{ + Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{ref}, + }); err == nil { + t.Fatalf("SyncPortfolio accepted malformed pull request %+v", ref) + } + } +} + +func TestSyncPortfolioRejectsDuplicateDefaultKindReferences(t *testing.T) { + t.Parallel() + reader := &MCPReader{newSearchTestService(t)} + _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{ + Selection: "explicit", + PullRequests: []mcpcontract.ThreadRef{ + {Owner: "acme", Repo: "rocket", Number: 7}, + {Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7}, + }, + }) + if err == nil { + t.Fatal("expected duplicate pull-request references to be rejected") + } +} + +func TestParsedSyncInputsRejectDuplicatesAfterCanonicalization(t *testing.T) { + t.Parallel() + if _, _, err := parseSyncThreadsInput(mcpcontract.SyncThreadsInput{ + Selection: "repositories", + Repositories: []mcpcontract.RepositoryRef{ + {Owner: "acme", Repo: "rocket"}, + {Owner: " ACME ", Repo: " rocket "}, + }, + }); err == nil { + t.Fatal("repository duplicates created by canonicalization were accepted") + } + if _, _, err := parseSyncPortfolioInput(mcpcontract.SyncPortfolioInput{ + Selection: "explicit", + PullRequests: []mcpcontract.ThreadRef{ + {Owner: "acme", Repo: "rocket", Number: 7}, + {Owner: " ACME ", Repo: " rocket ", Kind: " pull_request ", Number: 7}, + }, + }); err == nil { + t.Fatal("pull-request duplicates created by canonicalization were accepted") + } +} + +func TestSyncInputsRejectFieldsFromTheOtherSelectionVariant(t *testing.T) { + t.Parallel() + repository := mcpcontract.RepositoryRef{Owner: "acme", Repo: "rocket"} + thread := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7} + threadCases := []mcpcontract.SyncThreadsInput{ + {Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{repository}, Threads: []mcpcontract.ThreadRef{thread}}, + {Selection: "threads", Threads: []mcpcontract.ThreadRef{thread}, Repositories: []mcpcontract.RepositoryRef{repository}}, + {Selection: "threads", Threads: []mcpcontract.ThreadRef{thread}, State: "open"}, + {Selection: "threads", Threads: []mcpcontract.ThreadRef{thread}, LimitPerRepository: 1}, + } + for _, input := range threadCases { + if _, _, err := parseSyncThreadsInput(input); err == nil { + t.Fatalf("parseSyncThreadsInput accepted mixed variants: %+v", input) + } + } + portfolioCases := []mcpcontract.SyncPortfolioInput{ + {Selection: "authored", PullRequests: []mcpcontract.ThreadRef{thread}}, + {Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{thread}, Repository: &repository}, + {Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{thread}, State: "open"}, + } + for _, input := range portfolioCases { + if _, _, err := parseSyncPortfolioInput(input); err == nil { + t.Fatalf("parseSyncPortfolioInput accepted mixed variants: %+v", input) + } + } +} + +func TestParsedSyncInputsOwnCanonicalCopies(t *testing.T) { + t.Parallel() + repositories := []mcpcontract.RepositoryRef{{Owner: " acme ", Repo: " rocket "}} + request, normalized, err := parseSyncThreadsInput(mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: repositories}) + if err != nil { + t.Fatal(err) + } + 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" { + t.Fatalf("parsed repository selection = %+v, normalized = %+v", request.selection, normalized) + } + + inputRepository := &mcpcontract.RepositoryRef{Owner: " acme ", Repo: " rocket "} + portfolio, normalizedPortfolio, err := parseSyncPortfolioInput(mcpcontract.SyncPortfolioInput{Selection: "authored", Repository: inputRepository}) + if err != nil { + t.Fatal(err) + } + inputRepository.Owner = "changed" + authored, ok := portfolio.selection.(authoredPortfolioSelection) + if !ok || authored.repository.Owner != "acme" || normalizedPortfolio.Repository.Owner != "acme" { + t.Fatalf("parsed authored selection = %+v, normalized = %+v", portfolio.selection, normalizedPortfolio) + } +} + +func TestScalableRuntimeRejectsPageBoundsBeforeSubmittingJob(t *testing.T) { + t.Parallel() + reader := &MCPReader{newSearchTestService(t)} + ctx := context.Background() + thread := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Number: 1} + for _, maxPages := range []int{-1, 101} { + if _, err := reader.HydrateThreads(ctx, mcpcontract.HydrateThreadsInput{Threads: []mcpcontract.ThreadRef{thread}, Facets: []string{"issue_comments"}, MaxPages: maxPages}); err == nil { + t.Fatalf("HydrateThreads accepted max_pages=%d", maxPages) + } + } + for _, maxPages := range []int{-1, 21} { + if _, err := reader.SyncPortfolio(ctx, mcpcontract.SyncPortfolioInput{Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{thread}, StatusMaxPages: maxPages}); err == nil { + t.Fatalf("SyncPortfolio accepted status_max_pages=%d", maxPages) + } + } + for _, limit := range []int{-1, 1001} { + if _, err := reader.SyncThreads(ctx, mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, LimitPerRepository: limit}); err == nil { + t.Fatalf("SyncThreads accepted limit_per_repository=%d", limit) + } + } + if _, err := reader.SyncPortfolio(ctx, mcpcontract.SyncPortfolioInput{Selection: "authored", Limit: 1, DiscoveryMaxRequests: 1}); err == nil { + t.Fatal("SyncPortfolio accepted a budget that cannot fund identity and discovery") + } +} + +func TestScalableRuntimeBoundsMatchSchemas(t *testing.T) { + t.Parallel() + reader := &MCPReader{newSearchTestService(t)} + if _, err := reader.RankOpportunities(context.Background(), mcpcontract.RankOpportunitiesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 101}); err == nil { + t.Fatal("rank opportunities accepted limit above schema maximum") + } + if _, err := reader.FindPrecedents(context.Background(), mcpcontract.FindPrecedentsInput{Threads: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Number: 1}}, Limit: 101}); err == nil { + t.Fatal("find precedents accepted limit above schema maximum") + } +} diff --git a/internal/app/mcp_scalable_operations.go b/internal/app/mcp_scalable_operations.go index ac75287c..490b6e0c 100644 --- a/internal/app/mcp_scalable_operations.go +++ b/internal/app/mcp_scalable_operations.go @@ -30,7 +30,7 @@ func (r *MCPReader) SyncRepositoryContext(ctx context.Context, in mcpcontract.Sy return mcpcontract.JobReference{}, errors.New("repositories must contain 1 to 100 items") } for _, input := range in.Repositories { - if err := (domain.RepoRef{Owner: input.Owner, Repo: input.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(input.Owner, input.Repo); err != nil { return mcpcontract.JobReference{}, err } } @@ -55,39 +55,12 @@ func (r *MCPReader) SyncRepositoryContext(ctx context.Context, in mcpcontract.Sy // SyncThreads submits a durable bounded GitHub read for thread headers in // repositories that already have local identities. func (r *MCPReader) SyncThreads(ctx context.Context, in mcpcontract.SyncThreadsInput) (mcpcontract.JobReference, error) { - if in.Selection != "repositories" && in.Selection != "threads" { - return mcpcontract.JobReference{}, errors.New("selection must be repositories or threads") - } - if err := rejectDuplicateRepositoryRefs(in.Repositories); err != nil { - return mcpcontract.JobReference{}, err - } - if err := rejectDuplicateThreadRefs(in.Threads); err != nil { + request, normalized, err := parseSyncThreadsInput(in) + if err != nil { return mcpcontract.JobReference{}, err } - if in.Selection == "repositories" && (len(in.Repositories) < 1 || len(in.Repositories) > 50) { - return mcpcontract.JobReference{}, errors.New("repositories must contain 1 to 50 items") - } - if in.Selection == "threads" && (len(in.Threads) < 1 || len(in.Threads) > 100) { - return mcpcontract.JobReference{}, errors.New("threads must contain 1 to 100 items") - } - if in.Selection == "repositories" { - if in.LimitPerRepository == 0 { - in.LimitPerRepository = 100 - } - if in.LimitPerRepository < 1 || in.LimitPerRepository > 1000 { - return mcpcontract.JobReference{}, errors.New("limit_per_repository must be between 1 and 1000") - } - } else if in.LimitPerRepository != 0 { - return mcpcontract.JobReference{}, errors.New("limit_per_repository is only valid in repository selection mode") - } - if in.MaxRequests == 0 { - in.MaxRequests = defaultSyncBatchMaxRequests - } - if in.MaxRequests < 1 || in.MaxRequests > defaultSyncBatchMaxRequests { - return mcpcontract.JobReference{}, fmt.Errorf("max requests must be between 1 and %d", defaultSyncBatchMaxRequests) - } - id, err := r.submitJob(ctx, "sync_threads", in, func(ctx context.Context, report func(string, string) error) (any, error) { - return r.syncThreadsBatch(ctx, in, report) + id, err := r.submitJob(ctx, "sync_threads", normalized, func(ctx context.Context, report func(string, string) error) (any, error) { + return r.syncThreadsBatch(ctx, request, report) }) if err != nil { return mcpcontract.JobReference{}, err @@ -99,7 +72,7 @@ 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, in mcpcontract.SyncThreadsInput, report func(string, string) error) (map[string]any, error) { +func (s *Service) syncThreadsBatch(ctx context.Context, request syncThreadsRequest, report func(string, string) error) (map[string]any, error) { type task struct { key string ref contracts.RepoRef @@ -108,14 +81,24 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpcontract.SyncThrea inputIndexes []int maxRequests int } - var tasks []task - if in.Selection == "repositories" { - for _, ref := range in.Repositories { + var ( + tasks []task + exactThreads []mcpcontract.ThreadRef + kind = "both" + state = "all" + since time.Time + limitPerRepository int + ) + switch selection := request.selection.(type) { + 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}}) } - } else { + case exactThreadSelection: + exactThreads = selection.threads grouped := make(map[string]int) - for inputIndex, thread := range in.Threads { + for inputIndex, thread := range selection.threads { kind := thread.Kind if kind == "" { kind = "both" @@ -130,40 +113,26 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpcontract.SyncThrea tasks[index].numbers = append(tasks[index].numbers, thread.Number) tasks[index].inputIndexes = append(tasks[index].inputIndexes, inputIndex) } + default: + return nil, errors.New("unsupported parsed thread selection") } resultCount := len(tasks) - if in.Selection == "threads" { - resultCount = len(in.Threads) + if exactThreads != nil { + resultCount = len(exactThreads) } if err := report("thread_headers", jobProgressCounts(0, resultCount)); err != nil { return nil, err } - state := in.State - if state == "" { - state = "open" - } - kind := in.Kind - if kind == "" { - kind = "both" - } maxPages := 1 - if in.LimitPerRepository > 100 { - maxPages = (in.LimitPerRepository + 99) / 100 - } - var since time.Time - if in.UpdatedAfter != "" { - parsed, err := time.Parse(time.RFC3339, in.UpdatedAfter) - if err != nil { - return nil, errors.New("updated_after must be RFC 3339") - } - since = parsed + if limitPerRepository > 100 { + maxPages = (limitPerRepository + 99) / 100 } taskResults := make([]map[string]any, len(tasks)) c, err := s.openCorpus(ctx) if err != nil { return nil, err } - remainingRequests := in.MaxRequests + remainingRequests := request.maxRequests plannedRequests := 0 runnable := make([]int, 0, len(tasks)) for index := range tasks { @@ -211,10 +180,10 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpcontract.SyncThrea for index := range jobs { current := tasks[index] currentKind := kind - if in.Selection == "threads" { + if exactThreads != nil { currentKind = current.kind } - opts := SyncOptions{Kind: currentKind, State: state, Since: since, Numbers: current.numbers, MaxItems: in.LimitPerRepository, MaxPages: maxPages, MaxRequests: current.maxRequests} + 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{} @@ -245,14 +214,14 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpcontract.SyncThrea close(jobs) wg.Wait() results := taskResults - if in.Selection == "threads" { - results = make([]map[string]any, len(in.Threads)) + if exactThreads != nil { + results = make([]map[string]any, len(exactThreads)) for taskIndex, current := range tasks { for _, inputIndex := range current.inputIndexes { item := maps.Clone(taskResults[taskIndex]) delete(item, "requests") delete(item, "updated") - thread := in.Threads[inputIndex] + thread := exactThreads[inputIndex] item["key"] = threadRefKey(thread) if resolved, ok := taskResults[taskIndex]["threads"].([]mcpcontract.ThreadRef); ok { item["threads"] = resolved @@ -283,7 +252,7 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpcontract.SyncThrea } return map[string]any{ "status": status, "items": results, "completed": completed, "total": resultCount, - "requests": requests, "request_budget": in.MaxRequests, "planned_requests": plannedRequests, + "requests": requests, "request_budget": request.maxRequests, "planned_requests": plannedRequests, }, nil } @@ -324,7 +293,7 @@ func (r *MCPReader) IndexRepositories(ctx context.Context, in mcpcontract.IndexR return mcpcontract.JobReference{}, errors.New("repositories must contain 1 to 10 items") } for _, input := range in.Repositories { - if err := (domain.RepoRef{Owner: input.Owner, Repo: input.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(input.Owner, input.Repo); err != nil { return mcpcontract.JobReference{}, err } } @@ -341,7 +310,7 @@ func queuedJobReference(id, kind, message string) mcpcontract.JobReference { return mcpcontract.JobReference{ ID: id, Ref: "job:" + id, Kind: kind, Status: "queued", Message: message, PollAfterMS: 1000, FollowUp: &mcpcontract.JobFollowUp{ - Action: mcpcontract.FollowUpAction{Type: "poll_job", PollJob: &mcpcontract.GetJobsInput{IDs: []string{id}}}, RetryAfterMS: 1000, Reason: "Poll this job ID after the suggested delay.", + Action: mcpcontract.FollowUpActionFor(mcpcontract.GetJobsInput{IDs: []string{id}}), RetryAfterMS: 1000, Reason: "Poll this job ID after the suggested delay.", }, } } @@ -591,7 +560,11 @@ func (s *Service) syncRepositoryContext(ctx context.Context, in mcpcontract.Sync remaining -= required planned += required budget := newSyncRequestBudget(required) - ref := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} + 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 { @@ -703,8 +676,8 @@ func (r *MCPReader) DeepWiki(ctx context.Context, in mcpcontract.DeepWikiInput) 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 := 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 @@ -746,25 +719,6 @@ func rejectDuplicateThreadRefs(inputs []mcpcontract.ThreadRef) error { return nil } -func validatePullRequestRefs(inputs []mcpcontract.ThreadRef, path string) error { - for i, input := range inputs { - itemPath := fmt.Sprintf("%s[%d]", path, i) - if strings.TrimSpace(input.Owner) == "" { - return mcpcontract.InvalidArgument(itemPath+".owner", "must not be blank", nil) - } - if strings.TrimSpace(input.Repo) == "" { - return mcpcontract.InvalidArgument(itemPath+".repo", "must not be blank", nil) - } - if input.Number <= 0 { - return mcpcontract.InvalidArgument(itemPath+".number", "must be positive", nil) - } - if input.Kind != "" && input.Kind != corpus.ThreadKindPullRequest { - return mcpcontract.InvalidArgument(itemPath+".kind", "must be pull_request when provided", nil) - } - } - return nil -} - func rejectDuplicateIndexRepositoryInputs(inputs []mcpcontract.IndexRepositoryInput) error { seen := make(map[string]struct{}, len(inputs)) for _, input := range inputs { diff --git a/internal/app/mcp_scalable_reads.go b/internal/app/mcp_scalable_reads.go index 94c275d8..c0d990d8 100644 --- a/internal/app/mcp_scalable_reads.go +++ b/internal/app/mcp_scalable_reads.go @@ -34,9 +34,8 @@ func (r *MCPReader) GetRepositories(ctx context.Context, in mcpcontract.GetRepos out := mcpcontract.GetRepositoriesOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.TypedRepositoryOutput], len(in.Repositories)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Repositories)) for _, input := range in.Repositories { - ref := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} - if ref.Validate() == nil { - repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: ref.Owner, Name: ref.Repo}) + if ref, err := domain.NewRepoRef(input.Owner, input.Repo); err == nil { + repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}) } } repositories, err := c.GetRepositoriesBatch(ctx, repositoryKeys) @@ -58,14 +57,14 @@ 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"} - ref := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil { item.Status, item.Reason, item.Message = "failed", "invalid_reference", err.Error() out.Items[i] = item out.Status = "partial" continue } - repo := repositories[corpus.RepositoryKey{Owner: ref.Owner, Name: ref.Repo}] + repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] 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)) @@ -148,9 +147,8 @@ func (r *MCPReader) GetThreads(ctx context.Context, in mcpcontract.GetThreadsInp out := mcpcontract.GetThreadsOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.ThreadOutput], len(in.Threads)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Threads)) for _, input := range in.Threads { - ref := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} - if ref.Validate() == nil && input.Number > 0 { - repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: ref.Owner, Name: ref.Repo}) + 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()}) } } repositories, err := c.GetRepositoriesBatch(ctx, repositoryKeys) @@ -159,7 +157,11 @@ func (r *MCPReader) GetThreads(ctx context.Context, in mcpcontract.GetThreadsInp } threadKeys := make([]corpus.ThreadKey, 0, len(in.Threads)) for _, input := range in.Threads { - repo := repositories[corpus.RepositoryKey{Owner: input.Owner, Name: input.Repo}] + ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) + if parseErr != 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}) } @@ -171,14 +173,14 @@ 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 := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} - if err := ref.Validate(); err != nil || input.Number < 1 { + ref, err := domain.NewRepoRef(input.Owner, input.Repo) + if err != nil || input.Number < 1 { 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}] + repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] 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)) @@ -195,7 +197,7 @@ func (r *MCPReader) GetThreads(ctx context.Context, in mcpcontract.GetThreadsInp continue } value := corpusThreadToMCPOutput(thread) - value.Owner, value.Repo = ref.Owner, ref.Repo + value.Owner, value.Repo = ref.Owner(), ref.Repo() value.SnapshotToken = snapshotIdentity(in.SnapshotToken, revision) if in.View == "compact" { value.Body = "" @@ -414,9 +416,9 @@ func applyPortfolioHealth(out *mcpcontract.PullRequestPortfolioItem, threadID in return false, err } out.MergeStateStatus = strings.ToLower(value.MergeStateStatus) - if value.MergeableKnown { + if mergeability, known := value.Mergeability(); known { mergeabilityKnown = true - mergeable := strings.EqualFold(value.Mergeable, "MERGEABLE") + mergeable := strings.EqualFold(mergeability, "MERGEABLE") out.Mergeable = &mergeable } } @@ -492,10 +494,10 @@ func setPortfolioAttention(out *mcpcontract.PullRequestPortfolioItem, thread cor detailCoverage := coverage[FacetPRDetails] healthComplete := completePortfolioHealthCoverage(coverage, mergeabilityKnown) switch { - case thread.Merged: + case thread.Merge.IsMerged(): out.Attention = "merged" out.Reasons = append([]string{"pull request is merged"}, out.Reasons...) - case thread.State == "closed" && thread.MergedKnown: + case thread.State == "closed" && thread.Merge.Known(): out.Attention = "closed_unmerged" out.Reasons = append([]string{"pull request is closed and GitHub reports it was not merged"}, out.Reasons...) case thread.State == "closed": diff --git a/internal/app/mcp_scalable_test.go b/internal/app/mcp_scalable_test.go index 19df0e6c..84200ef4 100644 --- a/internal/app/mcp_scalable_test.go +++ b/internal/app/mcp_scalable_test.go @@ -11,6 +11,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/github" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/radar" @@ -46,7 +47,7 @@ func TestRankOpportunitiesReportsBoundedNonPaginatedTruncation(t *testing.T) { if bounded.Total != 5 || len(bounded.Candidates) != 2 || !bounded.Truncated { t.Fatalf("bounded radar result = %+v", bounded) } - if bounded.Recovery == nil || len(bounded.Recovery.Then) != 2 || bounded.Recovery.Then[0].Type != "sync_threads" || bounded.Recovery.Then[1].Type != "rank_opportunities" { + if bounded.Recovery == nil || len(bounded.Recovery.Then) != 2 || bounded.Recovery.Then[0].Type() != "sync_threads" || bounded.Recovery.Then[1].Type() != "rank_opportunities" { t.Fatalf("bounded radar recovery = %+v", bounded.Recovery) } if summary := bounded.Repositories[0].Value; summary == nil || summary.Considered != 5 || summary.Returned != 5 || summary.Truncated || summary.PopulationCapped { @@ -205,7 +206,7 @@ func TestGetCoveragePreservesTargetOrderAndMissingItems(t *testing.T) { if out.Items[0].Key != "acme/rocket" || out.Items[0].Status != "retryable" || out.Items[0].Reason != "coverage_incomplete" || out.Items[0].Value == nil || out.Items[0].Value.Facets[0].Facet != "metadata" { t.Fatalf("repository coverage = %+v", out.Items[0]) } - if out.Items[0].Recovery == nil || len(out.Items[0].Recovery.Then) != 1 || out.Items[0].Recovery.Then[0].Type != "ensure_coverage" { + if out.Items[0].Recovery == nil || len(out.Items[0].Recovery.Then) != 1 || out.Items[0].Recovery.Then[0].Type() != "ensure_coverage" { t.Fatalf("repository coverage recovery = %+v", out.Items[0].Recovery) } if out.Items[1].Key != "acme/missing" || out.Items[1].Status != "unavailable" || out.Items[1].Reason != "repository_not_indexed" { @@ -214,7 +215,7 @@ func TestGetCoveragePreservesTargetOrderAndMissingItems(t *testing.T) { if out.Items[2].Status != "retryable" || out.Items[2].Value == nil || out.Items[2].Value.Kind != "issue" || out.Items[2].Value.Number != 7 || out.Items[2].Value.Facets[0].Status != "incomplete" { t.Fatalf("thread coverage = %+v", out.Items[2]) } - if out.Items[2].Recovery == nil || len(out.Items[2].Recovery.Then) == 0 || out.Items[2].Recovery.Then[0].Type != "hydrate_threads" { + if out.Items[2].Recovery == nil || len(out.Items[2].Recovery.Then) == 0 || out.Items[2].Recovery.Then[0].Type() != "hydrate_threads" { t.Fatalf("thread coverage recovery = %+v", out.Items[2].Recovery) } if out.Items[3].Status != "unavailable" || out.Items[3].Reason != "invalid_reference" { @@ -235,7 +236,7 @@ func TestGetThreadsPreservesUnknownAndObservedFalseMergeState(t *testing.T) { } 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", MergedKnown: true, SourceUpdatedAt: time.Unix(2, 0).UTC()}, + {RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, 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) @@ -416,7 +417,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { 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" || - partial.Artifacts[0].Recovery == nil || len(partial.Artifacts[0].Recovery.Then) != 1 || partial.Artifacts[0].Recovery.Then[0].Type != "sync_portfolio" { + partial.Artifacts[0].Recovery == nil || len(partial.Artifacts[0].Recovery.Then) != 1 || partial.Artifacts[0].Recovery.Then[0].Type() != "sync_portfolio" { t.Fatalf("partial portfolio outcome = %+v", partial) } @@ -434,7 +435,7 @@ func TestJobResultToMCPPreservesEmptyAndPartialTypedOutcomes(t *testing.T) { ID: "job-running-patterns", Kind: "mine_repository_fix_patterns", Status: "running", }, true) if len(runningPatterns.Artifacts) != 0 || runningPatterns.FollowUp == nil || - runningPatterns.FollowUp.Action.Type != "poll_job" { + runningPatterns.FollowUp.Action.Type() != "poll_job" { t.Fatalf("running fix-pattern job advertised unavailable artifacts: %+v", runningPatterns) } } @@ -470,11 +471,13 @@ func TestGetJobsDetailedReturnsTypedArtifactsWithoutStoredPayloads(t *testing.T) } value := detailed.Items[0].Value if value == nil || len(value.Artifacts) != 1 || value.Artifacts[0].Kind != "dossier" || - value.Artifacts[0].URI != "gitcontribute://dossier/acme/rocket" || - value.FollowUp == nil || value.FollowUp.Action.Type != "read_resource" || value.FollowUp.Action.ReadResource == nil || - value.FollowUp.Action.ReadResource.URI != "gitcontribute://dossier/acme/rocket" { + value.Artifacts[0].URI != "gitcontribute://dossier/acme/rocket" || value.FollowUp == nil { t.Fatalf("detailed jobs output lost typed artifact reference: %+v", detailed) } + read, ok := mcpcontract.RecoveryInput[mcpcontract.ResourceReadAction](value.FollowUp.Action) + if value.FollowUp.Action.Type() != "read_resource" || !ok || read.URI != "gitcontribute://dossier/acme/rocket" { + t.Fatalf("detailed jobs output lost typed follow-up: %+v", detailed) + } } type fakeRepositorySearchReader struct { @@ -507,7 +510,7 @@ func TestSearchGitHubRepositoriesPersistsObservedMetadata(t *testing.T) { 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" { + 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" { @@ -625,7 +628,7 @@ func TestFindPrecedentsUsesClosedAndMergedHistory(t *testing.T) { } 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", Merged: true, MergedAt: time.Unix(20, 0).UTC(), ClosedAt: time.Unix(20, 0).UTC(), SourceUpdatedAt: time.Unix(20, 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()}, } for _, thread := range threads { @@ -669,99 +672,12 @@ func TestFindPrecedentsReturnsRecoveryForMissingHistory(t *testing.T) { t.Fatalf("precedent recovery output = %+v", out) } for _, item := range out.Items { - if item.Status != "unavailable" || item.Recovery == nil || len(item.Recovery.Then) != 1 || item.Recovery.Then[0].Type != "ensure_coverage" { + if item.Status != "unavailable" || item.Recovery == nil || len(item.Recovery.Then) != 1 || item.Recovery.Then[0].Type() != "ensure_coverage" { t.Fatalf("missing precedent recovery = %+v", item) } - ensure := item.Recovery.Then[0].EnsureCoverage - if ensure == nil || ensure.Target.Type != mcpcontract.CoverageTargetRepository || ensure.Target.Repository.Owner != "acme" || ensure.LimitPerRepository != 1000 { + ensure, ok := mcpcontract.RecoveryInput[mcpcontract.EnsureCoverageInput](item.Recovery.Then[0]) + if !ok || ensure.Target.Type != mcpcontract.CoverageTargetRepository || ensure.Target.Repository.Owner != "acme" || ensure.LimitPerRepository != 1000 { t.Fatalf("precedent ensure-coverage target = %+v", ensure) } } } - -func TestScalableBatchInputsRejectDuplicatesInsteadOfDroppingOutcomes(t *testing.T) { - t.Parallel() - if err := rejectDuplicateRepositoryRefs([]mcpcontract.RepositoryRef{{Owner: "one", Repo: "repo"}, {Owner: "ONE", Repo: "repo"}}); err == nil { - t.Fatal("duplicate repositories were silently accepted") - } - if err := rejectDuplicateThreadRefs([]mcpcontract.ThreadRef{{Owner: "one", Repo: "repo", Number: 1}, {Owner: "one", Repo: "repo", Number: 1}}); err == nil { - t.Fatal("duplicate threads were silently accepted") - } - if err := rejectDuplicateThreadRefs([]mcpcontract.ThreadRef{{Owner: "one", Repo: "repo", Kind: "issue", Number: 1}, {Owner: "one", Repo: "repo", Kind: "pull_request", Number: 1}}); err != nil { - t.Fatalf("issue and pull request with the same number were conflated: %v", err) - } - if err := rejectDuplicateIndexRepositoryInputs([]mcpcontract.IndexRepositoryInput{{Owner: "one", Repo: "repo", Remote: "first"}, {Owner: "one", Repo: "repo", Remote: "second"}}); err == nil { - t.Fatal("conflicting repository remotes were silently accepted") - } -} - -func TestPullRequestWorkflowsRejectMalformedReferencesBeforeSubmission(t *testing.T) { - t.Parallel() - reader := &MCPReader{newSearchTestService(t)} - if _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{}); err == nil || !strings.Contains(err.Error(), "selection is required") { - t.Fatalf("missing portfolio selection error = %v", err) - } - for _, ref := range []mcpcontract.ThreadRef{ - {Owner: " ", Repo: "rocket", Number: 1}, - {Owner: "acme", Repo: " ", Number: 1}, - {Owner: "acme", Repo: "rocket", Number: 0}, - {Owner: "acme", Repo: "rocket", Kind: "issue", Number: 1}, - } { - if _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{ - Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{ref}, - }); err == nil { - t.Fatalf("SyncPortfolio accepted malformed pull request %+v", ref) - } - } -} - -func TestSyncPortfolioRejectsDuplicateDefaultKindReferences(t *testing.T) { - t.Parallel() - reader := &MCPReader{newSearchTestService(t)} - _, err := reader.SyncPortfolio(context.Background(), mcpcontract.SyncPortfolioInput{ - Selection: "explicit", - PullRequests: []mcpcontract.ThreadRef{ - {Owner: "acme", Repo: "rocket", Number: 7}, - {Owner: "acme", Repo: "rocket", Kind: "pull_request", Number: 7}, - }, - }) - if err == nil { - t.Fatal("expected duplicate pull-request references to be rejected") - } -} - -func TestScalableRuntimeRejectsPageBoundsBeforeSubmittingJob(t *testing.T) { - t.Parallel() - reader := &MCPReader{newSearchTestService(t)} - ctx := context.Background() - thread := mcpcontract.ThreadRef{Owner: "acme", Repo: "rocket", Number: 1} - for _, maxPages := range []int{-1, 101} { - if _, err := reader.HydrateThreads(ctx, mcpcontract.HydrateThreadsInput{Threads: []mcpcontract.ThreadRef{thread}, Facets: []string{"issue_comments"}, MaxPages: maxPages}); err == nil { - t.Fatalf("HydrateThreads accepted max_pages=%d", maxPages) - } - } - for _, maxPages := range []int{-1, 21} { - if _, err := reader.SyncPortfolio(ctx, mcpcontract.SyncPortfolioInput{Selection: "explicit", PullRequests: []mcpcontract.ThreadRef{thread}, StatusMaxPages: maxPages}); err == nil { - t.Fatalf("SyncPortfolio accepted status_max_pages=%d", maxPages) - } - } - for _, limit := range []int{-1, 1001} { - if _, err := reader.SyncThreads(ctx, mcpcontract.SyncThreadsInput{Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, LimitPerRepository: limit}); err == nil { - t.Fatalf("SyncThreads accepted limit_per_repository=%d", limit) - } - } - if _, err := reader.SyncPortfolio(ctx, mcpcontract.SyncPortfolioInput{Selection: "authored", Limit: 1, DiscoveryMaxRequests: 1}); err == nil { - t.Fatal("SyncPortfolio accepted a budget that cannot fund identity and discovery") - } -} - -func TestScalableRuntimeBoundsMatchSchemas(t *testing.T) { - t.Parallel() - reader := &MCPReader{newSearchTestService(t)} - if _, err := reader.RankOpportunities(context.Background(), mcpcontract.RankOpportunitiesInput{Repositories: []mcpcontract.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, Limit: 101}); err == nil { - t.Fatal("rank opportunities accepted limit above schema maximum") - } - if _, err := reader.FindPrecedents(context.Background(), mcpcontract.FindPrecedentsInput{Threads: []mcpcontract.ThreadRef{{Owner: "acme", Repo: "rocket", Number: 1}}, Limit: 101}); err == nil { - t.Fatal("find precedents accepted limit above schema maximum") - } -} diff --git a/internal/app/mcp_snapshot_token_test.go b/internal/app/mcp_snapshot_token_test.go index fcef6c0d..955ae904 100644 --- a/internal/app/mcp_snapshot_token_test.go +++ b/internal/app/mcp_snapshot_token_test.go @@ -54,7 +54,8 @@ func TestSnapshotTokenReadFailsClosedAfterCorpusMutation(t *testing.T) { func TestManifestSnapshotRecoveryDropsExpiredToken(t *testing.T) { action := manifestSnapshotRecovery(mcpcontract.ExportManifestInput{OpportunityID: "opp-1", WorkspaceID: "ws-1", SnapshotToken: "snapshot-stale"}) - if action.Type != "export_manifest" || action.ExportManifest == nil || action.ExportManifest.SnapshotToken != "" || action.ExportManifest.OpportunityID != "opp-1" || action.ExportManifest.WorkspaceID != "ws-1" { + input, ok := mcpcontract.RecoveryInput[mcpcontract.ExportManifestInput](action) + if action.Type() != "export_manifest" || !ok || input.SnapshotToken != "" || input.OpportunityID != "opp-1" || input.WorkspaceID != "ws-1" { t.Fatalf("manifest stale-snapshot recovery = %+v", action) } } diff --git a/internal/app/mcp_stdio_e2e_test.go b/internal/app/mcp_stdio_e2e_test.go index 6b0e75d8..55d1a27c 100644 --- a/internal/app/mcp_stdio_e2e_test.go +++ b/internal/app/mcp_stdio_e2e_test.go @@ -15,6 +15,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/morluto/gitcontribute/internal/config" "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/mcpserver" @@ -23,6 +24,7 @@ import ( const ( mcpE2EHomeEnv = "GITCONTRIBUTE_MCP_E2E_HOME" mcpE2EGitHubEnv = "GITCONTRIBUTE_MCP_E2E_GITHUB_URL" + mcpE2ETimeout = 60 * time.Second ) // TestMCPStdioHelper is the subprocess entry point used by @@ -64,7 +66,7 @@ func TestMCPStdioHelper(t *testing.T) { //nolint:cyclop func TestMCPStdioScalableResearchFlow(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), mcpE2ETimeout) defer cancel() home := t.TempDir() seedMCPStdioCorpus(ctx, t, home) @@ -140,7 +142,7 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { func TestMCPStdioPullRequestPortfolioFlow(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), mcpE2ETimeout) defer cancel() home := t.TempDir() seedMCPStdioEmptyCorpus(ctx, t, home) @@ -179,7 +181,7 @@ func TestMCPStdioPullRequestPortfolioFlow(t *testing.T) { func TestMCPStdioExactThreadSyncFlow(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), mcpE2ETimeout) defer cancel() home := t.TempDir() seedMCPStdioEmptyCorpus(ctx, t, home) @@ -281,7 +283,7 @@ func assertExactThreadJobItems(t *testing.T, jobs mcpcontract.GetJobsOutput, wan if value.ExecutionState != "terminal" || value.Outcome != "succeeded" || len(value.Artifacts) != 1 || value.Artifacts[0].Kind != "thread_batch" || value.Artifacts[0].Count == nil || int(*value.Artifacts[0].Count) != len(wantKeys) || - value.FollowUp == nil || value.FollowUp.Action.Type != "get_threads" { + value.FollowUp == nil || value.FollowUp.Action.Type() != "get_threads" { t.Fatalf("typed thread job summary = %+v", value) } if !slices.Equal(value.Artifacts[0].References, wantKeys) { @@ -291,7 +293,7 @@ func assertExactThreadJobItems(t *testing.T, jobs mcpcontract.GetJobsOutput, wan func TestMCPStdioEnsureCoverageBootstrapsAndReturnsSnapshot(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), mcpE2ETimeout) defer cancel() home := t.TempDir() seedMCPStdioEmptyCorpus(ctx, t, home) @@ -326,7 +328,7 @@ func TestMCPStdioEnsureCoverageBootstrapsAndReturnsSnapshot(t *testing.T) { func TestMCPStdioCoverageRecoveryFollowsReturnedAction(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), mcpE2ETimeout) defer cancel() home := t.TempDir() seedMCPStdioEmptyCorpus(ctx, t, home) @@ -366,21 +368,17 @@ func TestMCPStdioCoverageRecoveryFollowsReturnedAction(t *testing.T) { func replayMCPRecoveryAction(t *testing.T, action mcpcontract.ToolCall) (string, map[string]any) { t.Helper() var name string - var value any - switch action.Type { + switch action.Type() { case "ensure_coverage": - name, value = mcpcontract.ToolEnsureCoverage, action.EnsureCoverage + name = mcpcontract.ToolEnsureCoverage case "sync_threads": - name, value = mcpcontract.ToolSyncThreads, action.SyncThreads + name = mcpcontract.ToolSyncThreads case "hydrate_threads": - name, value = mcpcontract.ToolHydrateThreads, action.HydrateThreads + name = mcpcontract.ToolHydrateThreads default: - t.Fatalf("unsupported recovery action in integration test: %q", action.Type) + t.Fatalf("unsupported recovery action in integration test: %q", action.Type()) } - if value == nil { - t.Fatalf("recovery action %q has no typed input", action.Type) - } - data, err := json.Marshal(value) + data, err := json.Marshal(action.Input()) if err != nil { t.Fatal(err) } @@ -411,7 +409,7 @@ func seedMCPStdioCorpus(ctx context.Context, t *testing.T, home string) { } 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", Merged: true, MergedAt: now.Add(-time.Hour), ClosedAt: now.Add(-time.Hour), SourceUpdatedAt: now.Add(-time.Hour)}, + {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}, } for _, row := range rows { diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index 7c83484a..6c540553 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -25,7 +25,7 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { svc := newTestService(t, srv) defer func() { _ = svc.Close() }() - if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{ + if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc123", CreatedAt: time.Now().UTC(), TotalBytes: 25, Documents: []codeindex.Document{{Path: "parser.go", Content: "func searchableParser() {}", Bytes: 25, LanguageHint: "go"}}, Manifest: codeindex.Manifest{CoverageKnown: true, TrackedEntries: 3, IndexedFiles: 1, SkippedExcluded: 2, Truncated: true}, @@ -64,7 +64,7 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { if len(missing.Matches) != 0 || len(missing.Coverage) != 1 || missing.Coverage[0].Status != "indexed" || !missing.Coverage[0].Truncated { t.Fatalf("zero-match search lost index coverage: %+v", missing) } - if missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type != "index_repositories" || missing.Coverage[0].Recovery == nil { + if missing.Recovery == nil || len(missing.Recovery.Then) != 1 || missing.Recovery.Then[0].Type() != "index_repositories" || missing.Coverage[0].Recovery == nil { t.Fatalf("truncated code search recovery = %+v", missing) } unindexed, err := reader.SearchCode(ctx, mcpcontract.SearchCodeInput{Owner: "owner", Repo: "unindexed", Query: "anything", Limit: 10}) @@ -74,10 +74,10 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { if len(unindexed.Coverage) != 1 || unindexed.Coverage[0].Status != "missing" { t.Fatalf("unindexed repository coverage = %+v", unindexed.Coverage) } - if unindexed.Recovery == nil || unindexed.Recovery.Then[0].Type != "index_repositories" { + if unindexed.Recovery == nil || unindexed.Recovery.Then[0].Type() != "index_repositories" { t.Fatalf("unindexed code search recovery = %+v", unindexed.Recovery) } - if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "legacy"}, codeindex.Snapshot{ + if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "legacy"), codeindex.Snapshot{ RepoPath: "/legacy", Commit: "old123", CreatedAt: time.Now().UTC(), }); err != nil { t.Fatal(err) @@ -89,7 +89,7 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) { if len(legacy.Coverage) != 1 || legacy.Coverage[0].Status != "indexed_coverage_unknown" { t.Fatalf("legacy snapshot coverage = %+v", legacy.Coverage) } - if legacy.Recovery == nil || legacy.Recovery.Then[0].Type != "index_repositories" { + if legacy.Recovery == nil || legacy.Recovery.Then[0].Type() != "index_repositories" { t.Fatalf("legacy code search recovery = %+v", legacy.Recovery) } } @@ -152,7 +152,7 @@ func TestMCPReaderRepositorySearchPreservesIncompleteNestedProjection(t *testing if err != nil { t.Fatal(err) } - if len(out.Matches) != 1 || !out.Incomplete || out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type != "sync_repository_context" { + if len(out.Matches) != 1 || !out.Incomplete || out.Recovery == nil || len(out.Recovery.Then) != 1 || out.Recovery.Then[0].Type() != "sync_repository_context" { t.Fatalf("incomplete nested repository result = %+v", out) } } @@ -239,7 +239,7 @@ func TestMCPReaderSearchReportsUnknownMergeState(t *testing.T) { for _, thread := range []corpus.Thread{ { RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 1, State: "closed", - Title: "shared term", Merged: true, MergedKnown: true, SourceUpdatedAt: time.Unix(10, 0).UTC(), + Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(10, 0).UTC(), }, { RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 2, State: "closed", @@ -271,8 +271,8 @@ func TestMCPReaderExplainCodeRejectsDifferentRequestedPath(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner, Name: ref.Repo}, `{}`); err != nil { + ref := domain.MustRepoRef("owner", "repo") + if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner(), Name: ref.Repo()}, `{}`); err != nil { t.Fatalf("store repository: %v", err) } if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{ @@ -283,7 +283,7 @@ func TestMCPReaderExplainCodeRejectsDifferentRequestedPath(t *testing.T) { } _, err := svc.MCPReader().ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Query: "searchableParser", Path: "missing.go", Limit: 10, + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Query: "searchableParser", Path: "missing.go", Limit: 10, }) if !errors.Is(err, mcpcontract.ErrNotFound) { t.Fatalf("explain different path error = %v, want ErrNotFound", err) @@ -294,8 +294,8 @@ func TestMCPReaderExplainCodeExactPathNotOnFirstSearchPage(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner, Name: ref.Repo}, `{}`); err != nil { + ref := domain.MustRepoRef("owner", "repo") + if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner(), Name: ref.Repo()}, `{}`); err != nil { t.Fatalf("store repository: %v", err) } @@ -321,7 +321,7 @@ func TestMCPReaderExplainCodeExactPathNotOnFirstSearchPage(t *testing.T) { // Confirm the target is not on the first search page. searchOut, err := svc.MCPReader().SearchCode(ctx, mcpcontract.SearchCodeInput{ - Owner: ref.Owner, Repo: ref.Repo, Query: "searchableParser", Limit: 20, + Owner: ref.Owner(), Repo: ref.Repo(), Query: "searchableParser", Limit: 20, }) if err != nil { t.Fatalf("search code: %v", err) @@ -333,7 +333,7 @@ func TestMCPReaderExplainCodeExactPathNotOnFirstSearchPage(t *testing.T) { } out, err := svc.MCPReader().ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Query: "searchableParser", Path: "target.go", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Query: "searchableParser", Path: "target.go", }) if err != nil { t.Fatalf("explain match: %v", err) @@ -347,8 +347,8 @@ func TestMCPReaderExplainCodeRejectsNonMatchingQuery(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner, Name: ref.Repo}, `{}`); err != nil { + ref := domain.MustRepoRef("owner", "repo") + if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner(), Name: ref.Repo()}, `{}`); err != nil { t.Fatalf("store repository: %v", err) } if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{ @@ -359,7 +359,7 @@ func TestMCPReaderExplainCodeRejectsNonMatchingQuery(t *testing.T) { } _, err := svc.MCPReader().ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Query: "searchableParser", Path: "parser.go", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Query: "searchableParser", Path: "parser.go", }) if !errors.Is(err, mcpcontract.ErrNotFound) { t.Fatalf("expected ErrNotFound for non-matching query, got %v", err) @@ -370,8 +370,8 @@ func TestMCPReaderExplainCodeRejectsWrongCommit(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner, Name: ref.Repo}, `{}`); err != nil { + ref := domain.MustRepoRef("owner", "repo") + if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner(), Name: ref.Repo()}, `{}`); err != nil { t.Fatalf("store repository: %v", err) } if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{ @@ -382,14 +382,14 @@ func TestMCPReaderExplainCodeRejectsWrongCommit(t *testing.T) { } _, err := svc.MCPReader().ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Path: "parser.go", Commit: "deadbeef", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Path: "parser.go", Commit: "deadbeef", }) if !errors.Is(err, mcpcontract.ErrNotFound) { t.Fatalf("expected ErrNotFound for wrong commit, got %v", err) } out, err := svc.MCPReader().ExplainMatch(ctx, mcpcontract.ExplainMatchInput{ - Owner: ref.Owner, Repo: ref.Repo, Kind: "code", Path: "parser.go", Commit: "abc123", + Owner: ref.Owner(), Repo: ref.Repo(), Kind: "code", Path: "parser.go", Commit: "abc123", }) if err != nil { t.Fatalf("explain match: %v", err) @@ -434,7 +434,7 @@ func TestMCPReaderInvestigationWorkflow(t *testing.T) { invSvc := investigation.NewService(svc.corpus, svc.corpus) evSvc := evidence.NewService(svc.corpus, nil) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") inv, err := invSvc.StartInvestigation(ctx, ref, "deadbeef", "") if err != nil { t.Fatalf("start investigation: %v", err) diff --git a/internal/app/mcp_thread_facets.go b/internal/app/mcp_thread_facets.go index 07b4d613..2bffa2d0 100644 --- a/internal/app/mcp_thread_facets.go +++ b/internal/app/mcp_thread_facets.go @@ -36,8 +36,8 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea out := mcpcontract.GetThreadFacetsOutput{Status: "complete", Items: make([]mcpcontract.BatchItem[mcpcontract.ThreadFacetsOutput], len(in.Threads)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} repositoryKeys := make([]corpus.RepositoryKey, 0, len(in.Threads)) for _, input := range in.Threads { - if (domain.RepoRef{Owner: input.Owner, Repo: input.Repo}).Validate() == nil && input.Number > 0 { - repositoryKeys = append(repositoryKeys, corpus.RepositoryKey{Owner: input.Owner, Name: input.Repo}) + 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()}) } } repositories, err := c.GetRepositoriesBatch(ctx, repositoryKeys) @@ -46,7 +46,11 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea } threadKeys := make([]corpus.ThreadKey, 0, len(in.Threads)) for _, input := range in.Threads { - if repo := repositories[corpus.RepositoryKey{Owner: input.Owner, Name: input.Repo}]; repo != nil && input.Number > 0 { + ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) + if parseErr != 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}) } } @@ -68,14 +72,14 @@ 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 := domain.RepoRef{Owner: input.Owner, Repo: input.Repo} - if ref.Validate() != nil || (input.Kind != corpus.ThreadKindIssue && input.Kind != corpus.ThreadKindPullRequest) || input.Number < 1 { + ref, parseErr := domain.NewRepoRef(input.Owner, input.Repo) + if parseErr != nil || (input.Kind != corpus.ThreadKindIssue && input.Kind != corpus.ThreadKindPullRequest) || input.Number < 1 { 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}] + repo := repositories[corpus.RepositoryKey{Owner: ref.Owner(), Name: ref.Repo()}] 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)) @@ -91,10 +95,10 @@ func (r *MCPReader) GetThreadFacets(ctx context.Context, in mcpcontract.GetThrea 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.Owner(), Repo: ref.Repo(), Kind: 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.Owner(), ref.Repo(), thread.Kind, thread.Number, facet)} if cov := coverage[key]; cov != nil { entry.Complete, entry.SourceUpdatedAt = cov.Complete, formatTime(cov.SourceUpdatedAt) entry.Status = "complete" diff --git a/internal/app/mcp_thread_facets_test.go b/internal/app/mcp_thread_facets_test.go index 6eeece07..952ac46a 100644 --- a/internal/app/mcp_thread_facets_test.go +++ b/internal/app/mcp_thread_facets_test.go @@ -21,11 +21,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, Title: "issue"}, `{}`) + issue, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindIssue, 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, Title: "pull request"}, `{}`) + pullRequest, err := svc.corpus.UpsertThread(ctx, corpus.Thread{RepositoryID: repo.ID, Kind: corpus.ThreadKindPullRequest, Number: 7, State: "open", Title: "pull request"}, `{}`) if err != nil { t.Fatal(err) } @@ -67,7 +67,7 @@ func TestGetThreadFacetsIsOfflineAndReturnsCanonicalResources(t *testing.T) { if err != nil { t.Fatal(err) } - if missing.Items[0].Value == nil || missing.Items[0].Value.Facets[0].Status != "not_observed" || missing.Items[0].Value.Facets[0].Recovery == nil || missing.Items[0].Value.Facets[0].Recovery.Reason != "facet_not_observed" || missing.Items[0].Value.Facets[0].Recovery.Then[0].Type != "hydrate_threads" { + if missing.Items[0].Value == nil || missing.Items[0].Value.Facets[0].Status != "not_observed" || missing.Items[0].Value.Facets[0].Recovery == nil || missing.Items[0].Value.Facets[0].Recovery.Reason != "facet_not_observed" || missing.Items[0].Value.Facets[0].Recovery.Then[0].Type() != "hydrate_threads" { t.Fatalf("missing facet recovery = %+v", missing.Items[0]) } @@ -93,10 +93,11 @@ func TestFacetJobFollowUpReadsFacetSurface(t *testing.T) { Result: `{"status":"complete","items":[]}`, } artifacts, follow := jobArtifactsAndFollowUp(job, 1) - if len(artifacts) != 1 || artifacts[0].Kind != "thread_facet_batch" || follow == nil || follow.Action.Type != "get_thread_facets" || follow.Action.GetThreadFacets == nil { + arguments, ok := mcpcontract.RecoveryInput[mcpcontract.GetThreadFacetsInput](follow.Action) + if len(artifacts) != 1 || artifacts[0].Kind != "thread_facet_batch" || follow == nil || follow.Action.Type() != "get_thread_facets" || !ok { t.Fatalf("facet job result = artifacts:%+v follow:%+v", artifacts, follow) } - if len(follow.Action.GetThreadFacets.Threads) != 1 || follow.Action.GetThreadFacets.Threads[0].Kind != "pull_request" || len(follow.Action.GetThreadFacets.Facets) != 1 || follow.Action.GetThreadFacets.Facets[0] != "pr_details" { + if len(arguments.Threads) != 1 || arguments.Threads[0].Kind != "pull_request" || len(arguments.Facets) != 1 || arguments.Facets[0] != "pr_details" { t.Fatalf("facet follow-up arguments = %+v", follow.Action) } } diff --git a/internal/app/mcp_thread_search.go b/internal/app/mcp_thread_search.go index b7de7e45..a3ad1038 100644 --- a/internal/app/mcp_thread_search.go +++ b/internal/app/mcp_thread_search.go @@ -81,8 +81,8 @@ func (r *MCPReader) Search(ctx context.Context, in mcpcontract.SearchInput) (mcp updatedAt = m.UpdatedAt.Format(time.RFC3339) } matches[i] = mcpcontract.ThreadOutput{ - Owner: m.Repo.Owner, - Repo: m.Repo.Repo, + Owner: m.Repo.Owner(), + Repo: m.Repo.Repo(), Kind: m.Kind, Number: m.Number, State: m.State, @@ -93,7 +93,7 @@ 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.MergedAt), Merged: knownMergePointer(m.Merged, m.MergedKnown), + Draft: m.Draft, ClosedAt: formatTime(m.ClosedAt), MergedAt: formatTime(m.Merge.MergedAt()), Merged: knownMergePointer(m.Merge.IsMerged(), m.Merge.Known()), UpdatedAt: updatedAt, MatchSource: m.MatchSource, MatchExcerpt: m.MatchExcerpt, diff --git a/internal/app/mcp_v1.go b/internal/app/mcp_v1.go index 804ea9ce..93f63d71 100644 --- a/internal/app/mcp_v1.go +++ b/internal/app/mcp_v1.go @@ -20,8 +20,8 @@ import ( // ThreadByNumber reads an issue or pull request by repository and number only. func (r *MCPReader) ThreadByNumber(ctx context.Context, in mcpcontract.ThreadByNumberInput) (mcpcontract.ThreadOutput, error) { - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.ThreadOutput{}, err } if in.Number < 1 { @@ -35,7 +35,7 @@ func (r *MCPReader) ThreadByNumber(ctx context.Context, in mcpcontract.ThreadByN if err != nil { return mcpcontract.ThreadOutput{}, err } - repo, err := c.GetRepository(ctx, in.Owner, in.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.ThreadOutput{}, fmt.Errorf("get repository: %w", err) } @@ -50,8 +50,8 @@ func (r *MCPReader) ThreadByNumber(ctx context.Context, in mcpcontract.ThreadByN return mcpcontract.ThreadOutput{}, failure.NotFound(nil) } out := corpusThreadToMCPOutput(thread) - out.Owner = in.Owner - out.Repo = in.Repo + out.Owner = ref.Owner() + out.Repo = ref.Repo() out.SnapshotToken = snapshotIdentity(in.SnapshotToken, revision) if err := finishCorpusRead(ctx, c, revision); err != nil { return mcpcontract.ThreadOutput{}, err @@ -62,8 +62,8 @@ func (r *MCPReader) ThreadByNumber(ctx context.Context, in mcpcontract.ThreadByN // ExplainMatch explains why a search result matched. func (r *MCPReader) ExplainMatch(ctx context.Context, in mcpcontract.ExplainMatchInput) (mcpcontract.ExplainMatchOutput, error) { in.Query = strings.TrimSpace(in.Query) - ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { return mcpcontract.ExplainMatchOutput{}, err } @@ -71,7 +71,7 @@ func (r *MCPReader) ExplainMatch(ctx context.Context, in mcpcontract.ExplainMatc if err != nil { return mcpcontract.ExplainMatchOutput{}, err } - repo, err := c.GetRepository(ctx, in.Owner, in.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return mcpcontract.ExplainMatchOutput{}, fmt.Errorf("get repository: %w", err) } @@ -294,9 +294,13 @@ func (r *MCPReader) AdoptWorkspace(ctx context.Context, in mcpcontract.AdoptWork // RunValidation submits a durable validation run. // StartInvestigation creates a new investigation workspace. func (r *MCPReader) StartInvestigation(ctx context.Context, in mcpcontract.StartInvestigationInput) (mcpcontract.InvestigationOutput, error) { + ref, err := domain.NewRepoRef(in.Owner, in.Repo) + if err != nil { + return mcpcontract.InvestigationOutput{}, err + } if in.Number > 0 { res, err := r.StartInvestigationFromThread(ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: in.Owner, Repo: in.Repo}, Kind: domain.ThreadKind(in.Kind), Number: in.Number, + Repo: ref, Kind: domain.ThreadKind(in.Kind), Number: in.Number, }) if err != nil { return mcpcontract.InvestigationOutput{}, err @@ -306,7 +310,7 @@ func (r *MCPReader) StartInvestigation(ctx context.Context, in mcpcontract.Start out.Hypotheses = []mcpcontract.HypothesisSummary{{ID: res.Hypothesis.ID, Title: res.Hypothesis.Title, Category: res.Hypothesis.Category}} return out, nil } - res, err := r.application().StartInvestigation(ctx, contracts.RepoRef{Owner: in.Owner, Repo: in.Repo}, in.CommitSHA, in.Lens) + res, err := r.application().StartInvestigation(ctx, contracts.RepoRef{Owner: ref.Owner(), Repo: ref.Repo()}, in.CommitSHA, in.Lens) if err != nil { return mcpcontract.InvestigationOutput{}, err } diff --git a/internal/app/merge_status.go b/internal/app/merge_status.go new file mode 100644 index 00000000..166e1396 --- /dev/null +++ b/internal/app/merge_status.go @@ -0,0 +1,23 @@ +package app + +import ( + "errors" + "time" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/github" +) + +func parseGitHubMergeStatus(details github.PullRequestDetails) (domain.MergeStatus, error) { + if !details.Merged { + if details.MergedAt != nil { + return domain.MergeStatus{}, errors.New("GitHub pull request is unmerged but has a merge time") + } + return domain.UnmergedStatus(), nil + } + mergedAt := time.Time{} + if details.MergedAt != nil { + mergedAt = *details.MergedAt + } + return domain.MergedStatus(mergedAt), nil +} diff --git a/internal/app/merge_status_test.go b/internal/app/merge_status_test.go new file mode 100644 index 00000000..d97c9eb3 --- /dev/null +++ b/internal/app/merge_status_test.go @@ -0,0 +1,16 @@ +package app + +import ( + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/github" +) + +func TestParseGitHubMergeStatusRejectsContradiction(t *testing.T) { + t.Parallel() + at := time.Unix(1, 0).UTC() + if _, err := parseGitHubMergeStatus(github.PullRequestDetails{MergedAt: &at}); err == nil { + t.Fatal("unmerged pull request with merge time parsed") + } +} diff --git a/internal/app/neighbors.go b/internal/app/neighbors.go index 19eda98e..7030afa8 100644 --- a/internal/app/neighbors.go +++ b/internal/app/neighbors.go @@ -46,7 +46,7 @@ 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, dref.Owner(), dref.Repo()) if err != nil { return nil, err } @@ -67,13 +67,13 @@ func (s *Service) Neighbors(ctx context.Context, repo contracts.RepoRef, kind st return nil, err } - queryCand := candidateFromThread(*repository, *query) + queryCand := candidateFromThread(dref, *query) candidates := make([]clustering.Candidate, 0, len(threads)) for _, t := range threads { if t.ID == query.ID { continue } - candidates = append(candidates, candidateFromThread(*repository, t)) + candidates = append(candidates, candidateFromThread(dref, t)) } scored, err := clustering.Neighbors(ctx, queryCand, candidates, limit) @@ -118,7 +118,7 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe return nil, err } - repository, err := c.GetRepository(ctx, dref.Owner, dref.Repo) + repository, err := c.GetRepository(ctx, dref.Owner(), dref.Repo()) if err != nil { return nil, err } @@ -195,8 +195,8 @@ func (s *Service) DuplicateCandidates(ctx context.Context, repo contracts.RepoRe } func validateThreadQuery(repo contracts.RepoRef, kind string, number int) (clustering.MemberRef, domain.RepoRef, error) { - dref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := dref.Validate(); err != nil { + dref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return clustering.MemberRef{}, dref, err } @@ -209,8 +209,8 @@ func validateThreadQuery(repo contracts.RepoRef, kind string, number int) (clust } return clustering.MemberRef{ - Owner: dref.Owner, - Repo: dref.Repo, + Owner: dref.Owner(), + Repo: dref.Repo(), Kind: normalized, Number: number, }, dref, nil @@ -226,10 +226,10 @@ func normalizeThreadKind(kind string) (string, error) { return "", fmt.Errorf("unsupported thread kind %q", kind) } -func candidateFromThread(repo corpus.Repository, t corpus.Thread) clustering.Candidate { +func candidateFromThread(repo domain.RepoRef, t corpus.Thread) clustering.Candidate { return clustering.Candidate{ ThreadID: t.ID, - Repo: domain.RepoRef{Owner: repo.Owner, Repo: repo.Name}, + Repo: repo, Kind: t.Kind, Number: t.Number, State: t.State, @@ -315,8 +315,8 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo if number <= 0 { return nil, errors.New("pull request number must be positive") } - dref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := dref.Validate(); err != nil { + dref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } @@ -325,7 +325,7 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo return nil, err } - repository, err := c.GetRepository(ctx, dref.Owner, dref.Repo) + repository, err := c.GetRepository(ctx, dref.Owner(), dref.Repo()) if err != nil { return nil, err } @@ -357,7 +357,7 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo return nil, err } - queryCand := candidateFromThread(*repository, *query) + queryCand := candidateFromThread(dref, *query) all := []clustering.Candidate{queryCand} var collisions []PullRequestCollision @@ -365,7 +365,7 @@ func (s *Service) PullRequestCollisions(ctx context.Context, repo contracts.Repo if t.Number == number { continue } - all = append(all, candidateFromThread(*repository, t)) + all = append(all, candidateFromThread(dref, t)) otherPayload, err := latestThreadObservationPayload(ctx, c, t.ID) if err != nil { @@ -428,8 +428,8 @@ func collisionScore(repo domain.RepoRef, queryBase string, queryRefs []clusterin } otherRefs := clustering.ExtractMemberRefs(other.Title+"\n"+other.Body, repo) - queryRef := clustering.MemberRef{Owner: repo.Owner, Repo: repo.Repo, Kind: query.Kind, Number: query.Number} - otherRef := clustering.MemberRef{Owner: repo.Owner, Repo: repo.Repo, Kind: other.Kind, Number: other.Number} + queryRef := clustering.MemberRef{Owner: repo.Owner(), Repo: repo.Repo(), Kind: query.Kind, Number: query.Number} + otherRef := clustering.MemberRef{Owner: repo.Owner(), Repo: repo.Repo(), Kind: other.Kind, Number: other.Number} if referencesThread(otherRefs, queryRef) { score += explicitRefWeight @@ -638,8 +638,8 @@ func (s *Service) collisionsForQuery(ctx context.Context, inv *investigation.Inv } func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, query clustering.Candidate, kind string, onlyOpen bool, limit int) ([]clustering.Neighbor, string, int, error) { - if err := repo.Validate(); err != nil { - return nil, "", 0, err + if !repo.IsValid() { + return nil, "", 0, errors.New("repository is required") } limit, err := normalizeSimilarityLimit(limit) if err != nil { @@ -649,7 +649,7 @@ func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, q if err != nil { return nil, "", 0, err } - repository, err := c.GetRepository(ctx, repo.Owner, repo.Repo) + repository, err := c.GetRepository(ctx, repo.Owner(), repo.Repo()) if err != nil { return nil, "", 0, err } @@ -668,7 +668,7 @@ func (s *Service) findSimilarThreads(ctx context.Context, repo domain.RepoRef, q } candidates := make([]clustering.Candidate, 0, len(threads)) for _, t := range threads { - candidates = append(candidates, candidateFromThread(*repository, t)) + candidates = append(candidates, candidateFromThread(repo, t)) } all := append([]clustering.Candidate{query}, candidates...) neighbors, err := clustering.Neighbors(ctx, query, candidates, limit) diff --git a/internal/app/radar.go b/internal/app/radar.go index 1bfb92b3..d23919cc 100644 --- a/internal/app/radar.go +++ b/internal/app/radar.go @@ -30,15 +30,15 @@ func (s *Service) ContributionRadar(ctx context.Context, opts contracts.RadarOpt // contributionRadarAt lets one cross-repository ranking use a single scoring // instant while keeping the public CLI service contract small. func (s *Service) contributionRadarAt(ctx context.Context, opts contracts.RadarOptions, evaluationTime time.Time) (*radar.Report, error) { - ref := domain.RepoRef{Owner: opts.Repo.Owner, Repo: opts.Repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(opts.Repo.Owner, opts.Repo.Repo) + if err != nil { return nil, err } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } - stored, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, err } @@ -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 != corpus.ThreadKindIssue || !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 962f111d..621354c8 100644 --- a/internal/app/radar_related_work.go +++ b/internal/app/radar_related_work.go @@ -279,9 +279,13 @@ func radarTimelineReference(event github.IssueTimelineEvent, defaultRepo domain. if event.Event != "cross-referenced" || event.SourceNumber <= 0 { return relatedwork.Reference{}, false } - sourceRepo := domain.RepoRef{Owner: event.SourceOwner, Repo: event.SourceRepository} - if sourceRepo.Owner == "" || sourceRepo.Repo == "" { - sourceRepo = defaultRepo + sourceRepo := defaultRepo + if event.SourceOwner != "" && event.SourceRepository != "" { + parsed, err := domain.NewRepoRef(event.SourceOwner, event.SourceRepository) + if err != nil { + return relatedwork.Reference{}, false + } + sourceRepo = parsed } kind := domain.ThreadKind("") if event.SourceIsPullRequest { @@ -321,7 +325,7 @@ func resolveRadarReference(ctx context.Context, c *corpus.Corpus, reference rela kind := reference.Kind state, title := "", "" sourceUpdatedAt := time.Time{} - storedRepo, err := c.GetRepository(ctx, reference.Repo.Owner, reference.Repo.Repo) + storedRepo, err := c.GetRepository(ctx, reference.Repo.Owner(), reference.Repo.Repo()) if err != nil { return radar.RelatedWork{}, fmt.Errorf("resolve related repository: %w", err) } @@ -428,7 +432,7 @@ func normalizeRadarRelationshipEvidence(values []radar.RelatedWorkEvidence) []ra } func radarReferenceKey(value relatedwork.Reference) string { - return strings.ToLower(fmt.Sprintf("%s/%s:%s#%d", value.Repo.Owner, value.Repo.Repo, value.Kind, value.Number)) + return strings.ToLower(fmt.Sprintf("%s/%s:%s#%d", value.Repo.Owner(), value.Repo.Repo(), value.Kind, value.Number)) } func radarRawEvidenceCount(values []rawRadarRelatedWork, key string) int { @@ -442,5 +446,5 @@ func radarRawEvidenceCount(values []rawRadarRelatedWork, key string) int { } func sameRepo(left, right domain.RepoRef) bool { - return strings.EqualFold(left.Owner, right.Owner) && strings.EqualFold(left.Repo, right.Repo) + return strings.EqualFold(left.Owner(), right.Owner()) && strings.EqualFold(left.Repo(), right.Repo()) } diff --git a/internal/app/radar_test.go b/internal/app/radar_test.go index fedb4950..fb086e42 100644 --- a/internal/app/radar_test.go +++ b/internal/app/radar_test.go @@ -24,7 +24,7 @@ import ( func TestRadarDuplicateClusterCapUsesProjectionMetadata(t *testing.T) { t.Parallel() clusters := make([]clustering.Cluster, 1000) - _, capped := radarDuplicateClusterFacts(domain.RepoRef{Owner: "owner", Repo: "repo"}, clusterprojection.List{ + _, capped := radarDuplicateClusterFacts(domain.MustRepoRef("owner", "repo"), clusterprojection.List{ Clusters: clusters, Total: len(clusters), Truncated: false, @@ -32,7 +32,7 @@ func TestRadarDuplicateClusterCapUsesProjectionMetadata(t *testing.T) { if capped { t.Fatal("exactly 1000 complete clusters reported as capped") } - _, capped = radarDuplicateClusterFacts(domain.RepoRef{Owner: "owner", Repo: "repo"}, clusterprojection.List{ + _, capped = radarDuplicateClusterFacts(domain.MustRepoRef("owner", "repo"), clusterprojection.List{ Clusters: clusters, Total: len(clusters) + 1, Truncated: true, @@ -253,8 +253,8 @@ func TestContributionRadarReadsStoredDuplicateCluster(t *testing.T) { func TestRadarPullRequestClosingReferenceIsPrecise(t *testing.T) { t.Parallel() fixture := newRadarTestFixture(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - stored, err := fixture.svc.corpus.GetRepository(fixture.ctx, ref.Owner, ref.Repo) + ref := domain.MustRepoRef("owner", "repo") + stored, err := fixture.svc.corpus.GetRepository(fixture.ctx, ref.Owner(), ref.Repo()) if err != nil { t.Fatal(err) } @@ -417,6 +417,20 @@ func TestContributionRadarPreservesRepeatedReferenceEvidence(t *testing.T) { } } +func TestRadarTimelineReferenceFallsBackForPartialSourceIdentity(t *testing.T) { + t.Parallel() + defaultRepo := domain.MustRepoRef("owner", "repo") + for _, event := range []github.IssueTimelineEvent{ + {Event: "cross-referenced", SourceOwner: "partial", SourceNumber: 7}, + {Event: "cross-referenced", SourceRepository: "partial", SourceNumber: 7}, + } { + ref, ok := radarTimelineReference(event, defaultRepo) + if !ok || ref.Repo != defaultRepo || ref.Number != 7 { + t.Fatalf("partial source identity = %+v, %t", ref, ok) + } + } +} + func TestNormalizeRadarRelatedWorkReportsEvidenceTruncation(t *testing.T) { t.Parallel() values := make([]radar.RelatedWork, 0, maxRadarEvidencePerRelation+1) @@ -434,7 +448,7 @@ func TestNormalizeRadarRelatedWorkReportsEvidenceTruncation(t *testing.T) { func TestRadarWorkAccumulatorKeepsStrongLateRelationshipsWithinBound(t *testing.T) { t.Parallel() - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") accumulator := newRadarWorkAccumulator(repo, 1) for number := 2; number < 2+maxRadarRelatedWork; number++ { accumulator.append(relatedwork.Reference{ diff --git a/internal/app/reader.go b/internal/app/reader.go index 10d724ce..c149cc78 100644 --- a/internal/app/reader.go +++ b/internal/app/reader.go @@ -27,7 +27,7 @@ func (r *corpusReader) ReadRepository(ctx context.Context, ref domain.RepoRef) ( if err != nil { return domain.Repository{}, nil, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return domain.Repository{}, nil, fmt.Errorf("get repository: %w", err) } @@ -68,7 +68,7 @@ func (r *corpusReader) ReadThreads(ctx context.Context, ref domain.RepoRef, q do if err != nil { return nil, nil, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, nil, fmt.Errorf("get repository: %w", err) } @@ -106,7 +106,7 @@ func (r *corpusReader) ReadCoverage(ctx context.Context, ref domain.RepoRef) (do if err != nil { return domain.Coverage{}, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return domain.Coverage{}, fmt.Errorf("get repository: %w", err) } @@ -125,17 +125,11 @@ func (r *corpusReader) ReadCoverage(ctx context.Context, ref domain.RepoRef) (do if cov.SourceUpdatedAt.After(asOf) { asOf = cov.SourceUpdatedAt } - status := domain.Fresh - if !cov.Complete { - status = domain.Stale + facet, err := domain.NewFacetCoverage(cov.Facet, cov.Complete, cov.SourceUpdatedAt, 0) + if err != nil { + return domain.Coverage{}, fmt.Errorf("parse %s coverage: %w", cov.Facet, err) } - facets = append(facets, domain.FacetCoverage{ - Facet: cov.Facet, - Present: true, - Complete: cov.Complete, - Freshness: domain.Freshness{Status: status, AsOf: cov.SourceUpdatedAt}, - Count: 0, - }) + facets = append(facets, facet) } if asOf.IsZero() { asOf = time.Now().UTC() @@ -148,7 +142,7 @@ func (r *corpusReader) ReadContributionGuidance(ctx context.Context, ref domain. if err != nil { return "", nil, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return "", nil, fmt.Errorf("get repository: %w", err) } @@ -165,7 +159,7 @@ func (r *corpusReader) ReadContributionGuidance(ctx context.Context, ref domain. func corpusRepoToDomain(ref domain.RepoRef, repo *corpus.Repository) domain.Repository { dr := domain.Repository{ - RepoRef: ref, + Ref: ref, ID: repo.ID, Description: repo.Description, Topics: repo.Topics, @@ -186,10 +180,14 @@ 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 { + typeVariant = domain.PullRequestThread(domain.PullRequestDetails{Merge: t.Merge}) + } dt := domain.Thread{ Repo: ref, ID: t.ID, - Kind: domain.ThreadKind(t.Kind), + Type: typeVariant, Number: t.Number, Title: t.Title, Body: t.Body, @@ -200,13 +198,6 @@ func corpusThreadToDomain(ref domain.RepoRef, t corpus.Thread) domain.Thread { UpdatedAt: t.SourceUpdatedAt, ClosedAt: t.ClosedAt, } - if t.Kind == corpus.ThreadKindPullRequest { - dt.PullRequest = &domain.PullRequestDetails{ - Merged: t.Merged, - MergedKnown: t.MergedKnown, - MergedAt: t.MergedAt, - } - } return dt } @@ -220,7 +211,7 @@ func firstLanguage(languages []string) string { func coverageNames(cov domain.Coverage) []string { out := make([]string, 0, len(cov.Facets)) for _, f := range cov.Facets { - out = append(out, f.Facet) + out = append(out, f.Facet()) } return out } diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 193d168f..bd074920 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -129,7 +129,7 @@ func (r *readinessEvaluator) evaluate() ([]contracts.ReadinessCheck, error) { } func (r *readinessEvaluator) load() error { - repo, err := r.corpus.GetRepository(r.ctx, r.inv.Repo.Owner, r.inv.Repo.Repo) + repo, err := r.corpus.GetRepository(r.ctx, r.inv.Repo.Owner(), r.inv.Repo.Repo()) if err != nil { return fmt.Errorf("read readiness repository: %w", err) } diff --git a/internal/app/readiness_test.go b/internal/app/readiness_test.go index faafb566..d15c2d13 100644 --- a/internal/app/readiness_test.go +++ b/internal/app/readiness_test.go @@ -44,7 +44,7 @@ func TestOpportunityReadinessReportsPassWarnBlockUnknown(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatalf("start from thread: %v", err) diff --git a/internal/app/repo_ref.go b/internal/app/repo_ref.go new file mode 100644 index 00000000..cc7b6d7e --- /dev/null +++ b/internal/app/repo_ref.go @@ -0,0 +1,17 @@ +package app + +import ( + "errors" + + "github.com/morluto/gitcontribute/internal/domain" +) + +func optionalRepoRef(owner, repo string) (domain.RepoRef, error) { + if owner == "" && repo == "" { + return domain.RepoRef{}, nil + } + if owner == "" || repo == "" { + return domain.RepoRef{}, errors.New("repository owner and name must be provided together") + } + return domain.NewRepoRef(owner, repo) +} diff --git a/internal/app/research_reader.go b/internal/app/research_reader.go index 7a60af3f..da17c038 100644 --- a/internal/app/research_reader.go +++ b/internal/app/research_reader.go @@ -55,9 +55,9 @@ func (r *corpusReader) ReadResearchThread(ctx context.Context, requested researc Ref: resolved, Title: thread.Title, Body: thread.Body, Author: thread.Author, AuthorAssociation: thread.AuthorAssociation, State: thread.State, StateReason: thread.StateReason, Labels: append([]string{}, thread.Labels...), Assignees: append([]string{}, thread.Assignees...), - Draft: thread.Draft, Locked: thread.Locked, Milestone: thread.Milestone, Merged: thread.Merged, MergedKnown: thread.MergedKnown, + Draft: thread.Draft, Locked: thread.Locked, Milestone: thread.Milestone, Merge: thread.Merge, CreatedAt: thread.SourceCreatedAt, UpdatedAt: thread.SourceUpdatedAt, ClosedAt: thread.ClosedAt, - MergedAt: thread.MergedAt, Source: source, + Source: source, }} for _, facet := range researchFacets(storedKind) { @@ -71,8 +71,7 @@ func (r *corpusReader) ReadResearchThread(ctx context.Context, requested researc evidence.Truncated = true truncated = true } - coverage.Truncated = truncated - evidence.Coverage = append(evidence.Coverage, coverage) + evidence.Coverage = append(evidence.Coverage, coverage.WithTruncated(truncated)) evidence.Truncated = evidence.Truncated || truncated continue } @@ -81,8 +80,7 @@ func (r *corpusReader) ReadResearchThread(ctx context.Context, requested researc truncated = true } evidence.Discussion = append(evidence.Discussion, items...) - coverage.Truncated = truncated - evidence.Coverage = append(evidence.Coverage, coverage) + evidence.Coverage = append(evidence.Coverage, coverage.WithTruncated(truncated)) evidence.Truncated = evidence.Truncated || truncated } sort.SliceStable(evidence.Discussion, func(i, j int) bool { @@ -144,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: string(ref.Kind), Number: ref.Number, }) if err != nil { return fmt.Errorf("get duplicate cluster: %w", err) @@ -227,16 +225,15 @@ func (r *corpusReader) ReadResearchCode(ctx context.Context, repo domain.RepoRef if err != nil { return research.CodeEvidence{}, fmt.Errorf("latest code snapshot: %w", err) } - result := research.CodeEvidence{Queries: append([]string{}, terms...), Hits: []research.CodeHit{}} if snapshot == nil { - return result, nil + return research.MissingCodeEvidence(terms), nil } - result.Present = true - result.CommitSHA = snapshot.CommitSHA - result.Source = research.SourceRef{ + source := research.SourceRef{ Source: "local:code-index", URL: fmt.Sprintf("https://github.com/%s/tree/%s", repo, snapshot.CommitSHA), CommitSHA: snapshot.CommitSHA, ObservedAt: snapshot.CreatedAt, AsOf: snapshot.CreatedAt, } + hits := []research.CodeHit{} + truncated := false seen := map[string]struct{}{} for _, term := range terms { matches, err := c.SearchCode(ctx, term, repo, researchCodeHitsPerTerm) @@ -244,7 +241,7 @@ func (r *corpusReader) ReadResearchCode(ctx context.Context, repo domain.RepoRef return research.CodeEvidence{}, fmt.Errorf("search code for %q: %w", term, err) } if len(matches) == researchCodeHitsPerTerm { - result.Truncated = true + truncated = true } for _, match := range matches { if _, ok := seen[match.Path]; ok { @@ -255,24 +252,28 @@ func (r *corpusReader) ReadResearchCode(ctx context.Context, repo domain.RepoRef Source: "local:code-index", URL: fmt.Sprintf("https://github.com/%s/blob/%s/%s", repo, match.Commit, match.Path), CommitSHA: match.Commit, ObservedAt: match.SnapshotCreatedAt, AsOf: match.SnapshotCreatedAt, } - result.Hits = append(result.Hits, research.CodeHit{ + hits = append(hits, research.CodeHit{ Path: match.Path, Language: match.Language, CommitSHA: match.Commit, MatchedTerm: term, Source: source, }) - if len(result.Hits) == maxResearchCodeHits { - result.Truncated = true + if len(hits) == maxResearchCodeHits { + truncated = true break } } - if len(result.Hits) == maxResearchCodeHits { + if len(hits) == maxResearchCodeHits { break } } - sort.Slice(result.Hits, func(i, j int) bool { - if result.Hits[i].Path != result.Hits[j].Path { - return result.Hits[i].Path < result.Hits[j].Path + sort.Slice(hits, func(i, j int) bool { + if hits[i].Path != hits[j].Path { + return hits[i].Path < hits[j].Path } - return result.Hits[i].MatchedTerm < result.Hits[j].MatchedTerm + return hits[i].MatchedTerm < hits[j].MatchedTerm }) + result, err := research.ObservedCodeEvidence(snapshot.CommitSHA, terms, hits, source, truncated) + if err != nil { + return research.CodeEvidence{}, fmt.Errorf("parse code evidence: %w", err) + } return result, nil } @@ -296,8 +297,8 @@ func (r *corpusReader) ReadResearchHealth(ctx context.Context, repo domain.RepoR source := research.SourceRef{ Source: "local:health", URL: "local://health/" + repo.String(), ObservedAt: report.GeneratedAt, AsOf: healthAsOf, } - return research.HealthEvidence{ - Available: true, Archived: report.Repository.Archived, OpenIssues: report.Issues.Open, + result, err := research.ObservedHealthEvidence(research.HealthMetrics{ + Archived: report.Repository.Archived, OpenIssues: report.Issues.Open, OpenPullRequests: report.PullRequests.Open, ExternalPRMergeRate: report.External.MergeRate, ExternalPRSampleSize: report.External.SampleSize, IssueResponseMedianHours: report.Response.Issues.Median, @@ -305,8 +306,11 @@ func (r *corpusReader) ReadResearchHealth(ctx context.Context, repo domain.RepoR IssueResponseSampleSize: report.Response.Issues.SampleSize, PullRequestResponseSampleSize: report.Response.PullRequests.SampleSize, ThreadSampleSize: report.Coverage.ThreadsSampleSize, ThreadsTruncated: report.Coverage.ThreadsTruncated, - Sources: []research.SourceRef{source}, UnknownReason: researchHealthCoverageReason(report), - }, nil + }, []research.SourceRef{source}, researchHealthCoverageReason(report)) + if err != nil { + return research.HealthEvidence{}, fmt.Errorf("parse health evidence: %w", err) + } + return result, nil } func researchHealthCoverageReason(report *health.Report) string { @@ -339,7 +343,7 @@ func (r *corpusReader) researchCorpusRepo(ctx context.Context, ref domain.RepoRe if err != nil { return nil, nil, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, nil, fmt.Errorf("get repository: %w", err) } @@ -374,7 +378,8 @@ func readResearchFacet(ctx context.Context, c *corpus.Corpus, repoID, threadID i return research.FacetCoverage{}, nil, false, fmt.Errorf("get %s coverage: %w", facet, err) } if coverage == nil { - return research.FacetCoverage{Facet: facet}, nil, false, nil + missing, err := research.MissingFacetCoverage(facet) + return missing, nil, false, err } source := research.SourceRef{ Source: "github:rest", URL: researchFacetURL(ref, facet), ObservedAt: coverage.UpdatedAt, AsOf: coverage.SourceUpdatedAt, @@ -405,9 +410,11 @@ func readResearchFacet(ctx context.Context, c *corpus.Corpus, repoID, threadID i if facet == FacetPRDetails && len(observations) > 0 { count = 1 } - return research.FacetCoverage{ - Facet: facet, Present: true, Complete: coverage.Complete, AsOf: coverage.SourceUpdatedAt, Count: count, Source: source, - }, items, truncated, nil + parsed, err := research.ObservedFacetCoverage(facet, coverage.Complete, coverage.SourceUpdatedAt, count, source) + if err != nil { + return research.FacetCoverage{}, nil, false, fmt.Errorf("parse %s coverage: %w", facet, err) + } + return parsed, items, truncated, nil } func decodeResearchFacet(observation corpus.FacetObservation, ref research.ThreadRef, facet string) ([]research.DiscussionItem, error) { @@ -493,7 +500,7 @@ func researchFacetURL(ref research.ThreadRef, facet string) string { func resolveResearchReference(ctx context.Context, c *corpus.Corpus, candidate research.Reference) (research.RelatedThread, error) { kind := candidate.Kind state, title := "", "" - repo, err := c.GetRepository(ctx, candidate.Repo.Owner, candidate.Repo.Repo) + repo, err := c.GetRepository(ctx, candidate.Repo.Owner(), candidate.Repo.Repo()) if err != nil { return research.RelatedThread{}, fmt.Errorf("resolve referenced repository: %w", err) } @@ -524,7 +531,7 @@ func researchReferenceURL(ref research.ThreadRef) string { func researchTextReferences(text string, target research.ThreadRef) bool { for _, ref := range clustering.ExtractMemberRefs(text, target.Repo) { - if strings.EqualFold(ref.Owner, target.Repo.Owner) && strings.EqualFold(ref.Repo, target.Repo.Repo) && ref.Number == target.Number { + if strings.EqualFold(ref.Owner, target.Repo.Owner()) && strings.EqualFold(ref.Repo, target.Repo.Repo()) && ref.Number == target.Number { return true } } @@ -604,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 == string(target.Kind) && member.Number == target.Number } func researchClusterRef(ref clustering.MemberRef) string { diff --git a/internal/app/research_test.go b/internal/app/research_test.go index a568a712..5b512beb 100644 --- a/internal/app/research_test.go +++ b/internal/app/research_test.go @@ -91,7 +91,7 @@ func newResearchFixture(t *testing.T) researchFixture { }}, true, 0); err != nil { t.Fatal(err) } - if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{ + if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc123", CreatedAt: now.Add(-30 * time.Minute), TotalBytes: 64, Documents: []codeindex.Document{{ Path: "internal/parser/retry.go", Content: "func retryParserWithCancellation() {}", Bytes: 41, LanguageHint: "go", @@ -110,7 +110,7 @@ func TestThreadResearchBriefUsesOnlyStoredEvidence(t *testing.T) { t.Fatal(err) } brief, err := fixture.svc.ThreadResearchBrief(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Number: 1, }) if err != nil { t.Fatalf("thread research brief: %v", err) @@ -155,7 +155,7 @@ func TestThreadResearchBriefUsesOnlyStoredEvidence(t *testing.T) { func TestThreadResearchBriefPRCoverageAndErrors(t *testing.T) { t.Parallel() fixture := newResearchFixture(t) - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") brief, err := fixture.svc.ThreadResearchBrief(fixture.ctx, research.ThreadRef{Repo: repo, Kind: domain.PullRequestKind, Number: 9}) if err != nil { t.Fatalf("PR brief: %v", err) @@ -202,7 +202,7 @@ func TestThreadResearchBriefBoundsStoredFacetPages(t *testing.T) { t.Fatal(err) } brief, err := fixture.svc.ThreadResearchBrief(fixture.ctx, research.ThreadRef{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 1, }) if err != nil { t.Fatal(err) diff --git a/internal/app/search.go b/internal/app/search.go index 9cab5c81..ec66b23f 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -29,9 +29,7 @@ type searchMatch struct { Assignees []string Draft bool ClosedAt time.Time - MergedAt time.Time - Merged bool - MergedKnown bool + Merge domain.MergeStatus Description string DefaultBranch string Language string @@ -154,12 +152,8 @@ func (s *Service) parseRepoRef(repo string) (domain.RepoRef, error) { if repo == "" { return domain.RepoRef{}, nil } - parts := strings.Split(repo, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return domain.RepoRef{}, fmt.Errorf("invalid repository filter %q", repo) - } - ref := domain.RepoRef{Owner: parts[0], Repo: parts[1]} - if err := ref.Validate(); err != nil { + ref, err := domain.ParseRepoRef(repo) + if err != nil { return domain.RepoRef{}, fmt.Errorf("invalid repository filter %q: %w", repo, err) } return ref, nil @@ -173,7 +167,7 @@ func (s *Service) resolveRepoFilter(ctx context.Context, c *corpus.Corpus, opts if err != nil { return 0, domain.RepoRef{}, err } - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return 0, domain.RepoRef{}, err } @@ -225,7 +219,10 @@ func (s *Service) searchThreads(ctx context.Context, c *corpus.Corpus, query str } coverage := mergeCoverageNames(repositoryCoverage, threadCoverage) - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Name} + ref, err := domain.NewRepoRef(repo.Owner, repo.Name) + if err != nil { + return searchResult{}, fmt.Errorf("parse stored repository: %w", err) + } m := searchMatch{ Repo: ref, Kind: t.Kind, @@ -238,7 +235,7 @@ 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, MergedAt: t.MergedAt, Merged: t.Merged, MergedKnown: t.MergedKnown, + Draft: t.Draft, ClosedAt: t.ClosedAt, Merge: t.Merge, Language: repo.Language, Archived: repo.Archived, Stars: repo.Stars, @@ -285,7 +282,10 @@ func (s *Service) searchRepositories(ctx context.Context, c *corpus.Corpus, quer } coverageCache[r.ID] = coverage } - m := repositorySearchMatch(r, coverage) + m, err := repositorySearchMatch(r, coverage) + if err != nil { + return searchResult{}, err + } matches = append(matches, m) } @@ -298,7 +298,7 @@ func (s *Service) searchRepositories(ctx context.Context, c *corpus.Corpus, quer } func (s *Service) searchRepositoryExact(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef) (searchResult, error) { - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return searchResult{}, err } @@ -321,15 +321,21 @@ func (s *Service) searchRepositoryExact(ctx context.Context, c *corpus.Corpus, q if err != nil { return searchResult{}, err } - match := repositorySearchMatch(*repo, coverage) + match, err := repositorySearchMatch(*repo, coverage) + if err != nil { + return searchResult{}, err + } if hasQuery { match.Score = bm25Score(rank) } return searchResult{Query: query, Total: 1, Matches: []searchMatch{match}}, nil } -func repositorySearchMatch(r corpus.Repository, coverage []string) searchMatch { - ref := domain.RepoRef{Owner: r.Owner, Repo: r.Name} +func repositorySearchMatch(r corpus.Repository, coverage []string) (searchMatch, error) { + ref, err := domain.NewRepoRef(r.Owner, r.Name) + if err != nil { + 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, @@ -339,7 +345,7 @@ func repositorySearchMatch(r corpus.Repository, coverage []string) searchMatch { UpdatedAt: r.SourceUpdatedAt, Freshness: r.SourceUpdatedAt, Coverage: coverage, } m.Score = bm25Score(r.Rank) - return m + return m, nil } func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string, ref domain.RepoRef, limit int, cursor string) (searchResult, error) { @@ -365,7 +371,7 @@ func (s *Service) searchCode(ctx context.Context, c *corpus.Corpus, query string } repo, ok := repoCache[match.Repo] if !ok { - repo, err = c.GetRepository(ctx, match.Repo.Owner, match.Repo.Repo) + repo, err = c.GetRepository(ctx, match.Repo.Owner(), match.Repo.Repo()) if err != nil { return searchResult{}, err } @@ -448,7 +454,7 @@ func (s *Service) collectLensMatches(ctx context.Context, c *corpus.Corpus, quer return nil, err } repoRef = ref - repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, err } @@ -711,7 +717,7 @@ func (s *Service) Search(ctx context.Context, query string, opts contracts.Searc for i, m := range res.Matches { matches[i] = contracts.SearchMatch{ Kind: m.Kind, - Repo: contracts.RepoRef{Owner: m.Repo.Owner, Repo: m.Repo.Repo}, + Repo: contracts.RepoRef{Owner: m.Repo.Owner(), Repo: m.Repo.Repo()}, Title: m.Title, Number: m.Number, State: m.State, diff --git a/internal/app/search_test.go b/internal/app/search_test.go index 97ca7e58..1eb738d1 100644 --- a/internal/app/search_test.go +++ b/internal/app/search_test.go @@ -559,8 +559,8 @@ func TestCodeLensUsesSnapshotTimeForFreshnessFilter(t *testing.T) { t.Parallel() ctx := context.Background() svc := newSearchTestService(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner, Name: ref.Repo}, `{}`); err != nil { + ref := domain.MustRepoRef("owner", "repo") + if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{Owner: ref.Owner(), Name: ref.Repo()}, `{}`); err != nil { t.Fatal(err) } now := time.Now().UTC() diff --git a/internal/app/setup.go b/internal/app/setup.go index 3816e3b8..3049e6e9 100644 --- a/internal/app/setup.go +++ b/internal/app/setup.go @@ -630,9 +630,9 @@ func setupRepoRef(value string) (contracts.RepoRef, error) { if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return contracts.RepoRef{}, fmt.Errorf("repository must be OWNER/REPO") } - ref := contracts.RepoRef{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git")} - if err := (domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}).Validate(); err != nil { + parsed, err := domain.NewRepoRef(parts[0], strings.TrimSuffix(parts[1], ".git")) + if err != nil { return contracts.RepoRef{}, err } - return ref, nil + return contracts.RepoRef{Owner: parsed.Owner(), Repo: parsed.Repo()}, nil } diff --git a/internal/app/surfaces_extra.go b/internal/app/surfaces_extra.go index 30bd73b0..97e0f5c7 100644 --- a/internal/app/surfaces_extra.go +++ b/internal/app/surfaces_extra.go @@ -20,7 +20,10 @@ func (s *Service) RepositoryContextSync(ctx context.Context, repo contracts.Repo if err != nil { return nil, err } - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} + 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 @@ -56,7 +59,7 @@ func (s *Service) PlanRepositoryContextSync(_ context.Context, repo contracts.Re } func planRepositoryContextSync(repo contracts.RepoRef, maxRequests int) (*contracts.SyncPlanResult, error) { - if err := (domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(repo.Owner, repo.Repo); err != nil { return nil, err } required := repositorycontext.RequestCost() @@ -86,8 +89,8 @@ func (s *Service) ArchiveSync(ctx context.Context, repo contracts.RepoRef, opts // PlanArchiveSync computes the conservative request ceiling before resolving a // GitHub reader or opening the corpus. func (s *Service) PlanArchiveSync(_ context.Context, repo contracts.RepoRef, opts contracts.ArchiveSyncOptions) (*contracts.SyncPlanResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + _, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } if opts.Since < 0 { @@ -131,15 +134,15 @@ func (s *Service) Hydrate(ctx context.Context, repo contracts.RepoRef, number in // Coverage returns repository-level facet coverage without network access. func (s *Service) Coverage(ctx context.Context, repo contracts.RepoRef) (*contracts.CoverageResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } - stored, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, err } @@ -176,15 +179,15 @@ func (s *Service) ArchiveThreads(ctx context.Context, repo contracts.RepoRef, ki if state != "" && state != "all" && state != "open" && state != "closed" { return nil, fmt.Errorf("unsupported thread state %q", state) } - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } c, err := s.openReadOnlyCorpus(ctx) if err != nil { return nil, err } - stored, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, err } @@ -238,10 +241,10 @@ func (s *Service) RunHistory(ctx context.Context, limit int) (*contracts.RunList out := &contracts.RunListResult{Runs: make([]contracts.RunResult, len(runs))} for i, run := range runs { out.Runs[i] = contracts.RunResult{ - ID: run.ID, Kind: run.Kind, Status: run.Status, StartedAt: formatTime(run.StartedAt), Stats: run.Stats, Error: run.Error, + ID: run.ID, Kind: run.Kind, Status: string(run.State.Status()), StartedAt: formatTime(run.StartedAt), Stats: run.Stats, Error: run.Error, } - if run.CompletedAt != nil { - out.Runs[i].CompletedAt = formatTime(*run.CompletedAt) + if completedAt, ok := run.State.CompletedAt(); ok { + out.Runs[i].CompletedAt = formatTime(completedAt) } } return out, nil @@ -268,8 +271,8 @@ func (s *Service) NeighborQuery(ctx context.Context, repo contracts.RepoRef, kin // ExportDossier builds and renders a deterministic redacted dossier bundle. func (s *Service) ExportDossier(ctx context.Context, repo contracts.RepoRef, format string) (*contracts.ExportResult, error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } if _, err := s.openReadOnlyCorpus(ctx); err != nil { diff --git a/internal/app/sync_budget_test.go b/internal/app/sync_budget_test.go index 3215f94a..e8667e47 100644 --- a/internal/app/sync_budget_test.go +++ b/internal/app/sync_budget_test.go @@ -213,10 +213,14 @@ func TestSyncThreadsBatchReportsMissingRepositoryWithoutNetworkAccess(t *testing t.Fatal(err) } defer func() { _ = svc.Close() }() - out, err := svc.syncThreadsBatch(context.Background(), mcpcontract.SyncThreadsInput{ + request, _, err := parseSyncThreadsInput(mcpcontract.SyncThreadsInput{ Selection: "repositories", Repositories: []mcpcontract.RepositoryRef{{Owner: "owner", Repo: "repo"}}, LimitPerRepository: 100, MaxRequests: 1, - }, func(string, string) error { return nil }) + }) + if err != nil { + t.Fatal(err) + } + out, err := svc.syncThreadsBatch(context.Background(), request, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } @@ -234,15 +238,18 @@ func TestSyncThreadsBatchThreadTotalCountsRequestedThreads(t *testing.T) { t.Fatal(err) } defer func() { _ = svc.Close() }() - out, err := svc.syncThreadsBatch(context.Background(), mcpcontract.SyncThreadsInput{ + request, _, err := parseSyncThreadsInput(mcpcontract.SyncThreadsInput{ Selection: "threads", Threads: []mcpcontract.ThreadRef{ {Owner: "owner", Repo: "repo", Number: 1}, {Owner: "owner", Repo: "repo", Number: 2}, }, - LimitPerRepository: 100, - MaxRequests: 1, - }, func(string, string) error { return nil }) + MaxRequests: 1, + }) + if err != nil { + t.Fatal(err) + } + out, err := svc.syncThreadsBatch(context.Background(), request, func(string, string) error { return nil }) if err != nil { t.Fatal(err) } diff --git a/internal/app/sync_headers.go b/internal/app/sync_headers.go index 72d88502..401b8526 100644 --- a/internal/app/sync_headers.go +++ b/internal/app/sync_headers.go @@ -17,7 +17,7 @@ func syncRepositoryHeader(ctx context.Context, c *corpus.Corpus, reader github.R if err := budget.take(); err != nil { return corpus.Repository{}, time.Time{}, err } - ghRepo, _, err := reader.GetRepository(ctx, ref.Owner, ref.Repo) + ghRepo, _, err := reader.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return corpus.Repository{}, time.Time{}, fmt.Errorf("get repository: %w", err) } @@ -60,7 +60,7 @@ type syncThreadWriter struct { } 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} + 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 @@ -128,7 +128,7 @@ func syncExactThreadHeaders(ctx context.Context, reader github.Reader, ref domai if err := budget.take(); err != nil { return requests, err } - issue, _, err := getter.GetIssue(ctx, ref.Owner, ref.Repo, number) + issue, _, err := getter.GetIssue(ctx, ref.Owner(), ref.Repo(), number) if err != nil { return requests, fmt.Errorf("get thread %d: %w", number, err) } @@ -158,7 +158,7 @@ func syncListedThreadHeaders(ctx context.Context, reader github.Reader, ref doma if err := budget.take(); err != nil { return syncThreadSelection{}, err } - res, err := reader.ListIssues(ctx, ref.Owner, ref.Repo, listOpts) + res, err := reader.ListIssues(ctx, ref.Owner(), ref.Repo(), listOpts) if err != nil { return syncThreadSelection{}, fmt.Errorf("list issues page %d: %w", listOpts.Page, err) } diff --git a/internal/app/sync_options_test.go b/internal/app/sync_options_test.go index 5c069e32..48976fd4 100644 --- a/internal/app/sync_options_test.go +++ b/internal/app/sync_options_test.go @@ -13,6 +13,7 @@ import ( "github.com/morluto/gitcontribute/internal/config" "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/repositorycontext" ) @@ -279,7 +280,7 @@ func TestSyncDoesNotHydratePullRequestDetails(t *testing.T) { if err != nil || pr == nil { t.Fatalf("stored PR = %+v, %v", pr, err) } - if pr.MergedKnown { + if pr.Merge.Known() { t.Fatalf("header-only PR unexpectedly has known merge state: %+v", pr) } coverage, err := c.GetCoverage(context.Background(), repo.ID, &pr.ID, FacetPRDetails) @@ -314,8 +315,7 @@ func TestSyncPreservesPreviouslyObservedPullRequestMergeState(t *testing.T) { if err != nil || pr == nil { t.Fatalf("stored PR = %+v, %v", pr, err) } - pr.Merged = true - pr.MergedAt = pr.SourceUpdatedAt + pr.Merge = domain.MergedStatus(pr.SourceUpdatedAt) if _, err := c.UpsertThread(ctx, *pr, `{"source":"previous-pr-details"}`); err != nil { t.Fatal(err) } @@ -324,7 +324,7 @@ func TestSyncPreservesPreviouslyObservedPullRequestMergeState(t *testing.T) { t.Fatal(err) } pr, err = c.GetThread(ctx, repo.ID, "pull_request", 2) - if err != nil || pr == nil || !pr.MergedKnown || !pr.Merged || pr.MergedAt.IsZero() { + if err != nil || pr == nil || !pr.Merge.Known() || !pr.Merge.IsMerged() || pr.Merge.MergedAt().IsZero() { t.Fatalf("merge state after header sync = %+v, %v", pr, err) } } diff --git a/internal/app/sync_thread_operations.go b/internal/app/sync_thread_operations.go index 821002d9..a0475bef 100644 --- a/internal/app/sync_thread_operations.go +++ b/internal/app/sync_thread_operations.go @@ -13,8 +13,8 @@ import ( ) func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts.RepoRef, issues []github.Issue) (_ *contracts.SyncResult, resultErr error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } c, err := s.openCorpus(ctx) @@ -27,17 +27,17 @@ func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts. sourceUpdatedAt = issue.UpdatedAt } } - stored, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + stored, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { 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(map[string]any{"source": "authored_pull_request_search", "owner": ref.Owner(), "repo": ref.Repo()}) if err != nil { return nil, err } stored, err = c.UpsertRepository(ctx, corpus.Repository{ - Owner: ref.Owner, Name: ref.Repo, + Owner: ref.Owner(), Name: ref.Repo(), }, string(payload)) if err != nil { return nil, fmt.Errorf("store authored repository identity: %w", err) @@ -49,7 +49,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: "pull_request", sourceUpdatedAt: sourceUpdatedAt, } if err := writer.storeAll(issues); err != nil { return nil, err @@ -67,12 +67,11 @@ func (s *Service) syncProvidedThreadHeaders(ctx context.Context, repo contracts. } func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, syncOpts SyncOptions) (_ *contracts.SyncResult, resultErr error) { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(repo.Owner, repo.Repo) + if err != nil { return nil, err } var plan syncRequestPlan - var err error syncOpts, plan, err = planThreadSyncOptions(syncOpts) if err != nil { return nil, err @@ -81,7 +80,7 @@ func (s *Service) syncThreadHeaders(ctx context.Context, repo contracts.RepoRef, if err != nil { return nil, err } - repoProjection, err := c.GetRepository(ctx, ref.Owner, ref.Repo) + repoProjection, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } diff --git a/internal/app/sync_threads_request.go b/internal/app/sync_threads_request.go new file mode 100644 index 00000000..8c52479e --- /dev/null +++ b/internal/app/sync_threads_request.go @@ -0,0 +1,152 @@ +package app + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +// syncThreadsRequest is the parsed form of SyncThreadsInput. The wire input is +// intentionally a tagged field bag for JSON compatibility; this type prevents +// repository-only filters from leaking into exact-thread execution. +type syncThreadsRequest struct { + selection syncThreadsSelection + maxRequests int +} + +type syncThreadsSelection interface { + isSyncThreadsSelection() +} + +type repositoryThreadSelection struct { + repositories []mcpcontract.RepositoryRef + kind string + state string + updatedAfter time.Time + limitPerRepository int +} + +func (repositoryThreadSelection) isSyncThreadsSelection() {} + +type exactThreadSelection struct { + threads []mcpcontract.ThreadRef +} + +func (exactThreadSelection) isSyncThreadsSelection() {} + +func parseSyncThreadsInput(in mcpcontract.SyncThreadsInput) (syncThreadsRequest, mcpcontract.SyncThreadsInput, error) { + if in.MaxRequests == 0 { + in.MaxRequests = defaultSyncBatchMaxRequests + } + if in.MaxRequests < 1 || in.MaxRequests > defaultSyncBatchMaxRequests { + return syncThreadsRequest{}, mcpcontract.SyncThreadsInput{}, fmt.Errorf("max requests must be between 1 and %d", defaultSyncBatchMaxRequests) + } + switch in.Selection { + case "repositories": + selection, normalized, err := parseRepositoryThreadSelection(in) + if err != nil { + return syncThreadsRequest{}, mcpcontract.SyncThreadsInput{}, err + } + return syncThreadsRequest{selection: selection, maxRequests: in.MaxRequests}, normalized, nil + case "threads": + selection, normalized, err := parseExactThreadSelection(in) + if err != nil { + return syncThreadsRequest{}, mcpcontract.SyncThreadsInput{}, err + } + return syncThreadsRequest{selection: selection, maxRequests: in.MaxRequests}, normalized, nil + default: + return syncThreadsRequest{}, mcpcontract.SyncThreadsInput{}, errors.New("selection must be repositories or threads") + } +} + +func parseRepositoryThreadSelection(in mcpcontract.SyncThreadsInput) (repositoryThreadSelection, mcpcontract.SyncThreadsInput, error) { + if len(in.Threads) != 0 { + return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("threads is only valid in thread selection mode") + } + if len(in.Repositories) < 1 || len(in.Repositories) > 50 { + return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("repositories must contain 1 to 50 items") + } + in.Repositories = append([]mcpcontract.RepositoryRef(nil), 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()} + } + 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") + } + 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") + } + var updatedAfter time.Time + if in.UpdatedAfter != "" { + parsed, err := time.Parse(time.RFC3339, in.UpdatedAfter) + if err != nil { + return repositoryThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("updated_after must be RFC 3339") + } + updatedAfter = parsed + } + if in.LimitPerRepository == 0 { + in.LimitPerRepository = 100 + } + if in.LimitPerRepository < 1 || in.LimitPerRepository > 1000 { + 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, + updatedAfter: updatedAfter, + limitPerRepository: in.LimitPerRepository, + } + return selection, in, nil +} + +func parseExactThreadSelection(in mcpcontract.SyncThreadsInput) (exactThreadSelection, mcpcontract.SyncThreadsInput, error) { + if len(in.Repositories) != 0 { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("repositories is only valid in repository selection mode") + } + if in.Kind != "" || in.State != "" || in.UpdatedAfter != "" || in.LimitPerRepository != 0 { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("kind, state, updated_after, and limit_per_repository are only valid in repository selection mode") + } + if len(in.Threads) < 1 || len(in.Threads) > 100 { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, errors.New("threads must contain 1 to 100 items") + } + in.Threads = append([]mcpcontract.ThreadRef(nil), in.Threads...) + for i, thread := range in.Threads { + ref, err := domain.NewRepoRef(thread.Owner, thread.Repo) + if err != nil { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, err + } + 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 { + 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 + } + if err := rejectDuplicateThreadRefs(in.Threads); err != nil { + return exactThreadSelection{}, mcpcontract.SyncThreadsInput{}, err + } + return exactThreadSelection{threads: append([]mcpcontract.ThreadRef(nil), in.Threads...)}, in, nil +} diff --git a/internal/app/tracking.go b/internal/app/tracking.go index 043a8f1b..93b7f70b 100644 --- a/internal/app/tracking.go +++ b/internal/app/tracking.go @@ -176,23 +176,19 @@ func (s *Service) ExportLocalMetadata(ctx context.Context, opts contracts.Metada // ImportLocalMetadata imports a bounded JSON bundle of local tracking metadata // idempotently. func (s *Service) ImportLocalMetadata(ctx context.Context, opts contracts.MetadataImportOptions) (*contracts.MetadataImportResult, error) { - c, err := s.openCorpus(ctx) + bundle, err := tracking.ParseBundle(opts.Data) if err != nil { return nil, err } - var bundle tracking.Bundle - if err := json.Unmarshal(opts.Data, &bundle); err != nil { - return nil, fmt.Errorf("parse local metadata: %w", err) - } - if err := tracking.NewService(c).ImportLocalMetadata(ctx, &bundle); err != nil { + c, err := s.openCorpus(ctx) + if err != nil { return nil, err } - version, err := tracking.ResolveBundleVersion(&bundle) - if err != nil { + if err := tracking.NewService(c).ImportLocalMetadata(ctx, bundle); err != nil { return nil, err } return &contracts.MetadataImportResult{ - SchemaVersion: version, + SchemaVersion: bundle.SchemaVersion, TriageEvents: len(bundle.TriageEvents), Contributions: len(bundle.Contributions), ContributionOutcomes: len(bundle.ContributionOutcomes), diff --git a/internal/app/tui.go b/internal/app/tui.go index 9aee623e..2ae70ec2 100644 --- a/internal/app/tui.go +++ b/internal/app/tui.go @@ -45,7 +45,10 @@ func (s *Service) Load(ctx context.Context) (tuicontract.Data, error) { Windows: make(map[string]tuicontract.Window), } for _, repo := range repos { - ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Name} + ref, err := domain.NewRepoRef(repo.Owner, repo.Name) + if err != nil { + return tuicontract.Data{}, fmt.Errorf("parse stored repository: %w", err) + } coverage, err := c.ListCoverage(ctx, repo.ID, nil) if err != nil { return tuicontract.Data{}, err diff --git a/internal/app/tui_sync_status.go b/internal/app/tui_sync_status.go index f9669c6b..21db1bb9 100644 --- a/internal/app/tui_sync_status.go +++ b/internal/app/tui_sync_status.go @@ -127,11 +127,11 @@ func buildTUISyncStatus( continue } summary := syncRunSummary(*run) - switch run.Status { + switch run.State.Status() { case corpus.RunStatusFailed, corpus.RunStatusPartial: status = "partial" item.Assessment.Risks = append(item.Assessment.Risks, tuicontract.Fact{ - Code: "sync_" + run.Status, Summary: summary, + Code: "sync_" + string(run.State.Status()), Summary: summary, }) case corpus.RunStatusRunning: if status == "complete" { @@ -156,7 +156,7 @@ func containsTUIFacet(facets []string, want string) bool { } func syncRunSummary(run corpus.Run) string { - summary := fmt.Sprintf("%s run %d is %s", run.Kind, run.ID, run.Status) + summary := fmt.Sprintf("%s run %d is %s", run.Kind, run.ID, run.State.Status()) if message := boundedTUIMessage(run.Error, 160); message != "" { summary += ": " + message } diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index f2840a6d..16c2be33 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "os" "path/filepath" "strings" @@ -11,10 +12,27 @@ import ( "github.com/google/go-cmp/cmp" "github.com/morluto/gitcontribute/internal/config" "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/evidence" "github.com/morluto/gitcontribute/internal/mcpcontract" "github.com/morluto/gitcontribute/internal/workspace" ) +func TestObservationContractIsParsedAtApplicationBoundary(t *testing.T) { + t.Parallel() + _, err := observationContractToEvidence(contracts.ValidationObservationContract{ + Intent: "observe output", + Base: []contracts.ValidationExpectedObservation{{ + Name: "invalid regexp", Source: "stderr", Matcher: "regexp", Pattern: "[", + }}, + Candidate: []contracts.ValidationExpectedObservation{{ + Name: "valid", Source: "stdout", Matcher: "exact", Pattern: "ok", + }}, + }) + if !errors.Is(err, evidence.ErrInvalidObservation) { + t.Fatalf("error = %v, want ErrInvalidObservation", err) + } +} + func TestMCPValidationResolvesManagedWorkspaceAndRejectsCrossInvestigation(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/app/workspace.go b/internal/app/workspace.go index 7551c9b5..8dc58f48 100644 --- a/internal/app/workspace.go +++ b/internal/app/workspace.go @@ -57,10 +57,10 @@ func (s *Service) AdoptWorkspace(ctx context.Context, investigationID string, op if err != nil { return nil, fmt.Errorf("identify origin repository: %w", err) } - if !strings.EqualFold(identity.Host, "github.com") || !strings.EqualFold(identity.Owner, inv.Repo.Owner) || !strings.EqualFold(identity.Repo, inv.Repo.Repo) { - return nil, fmt.Errorf("origin repository %s/%s does not match investigation repository %s/%s", identity.Owner, identity.Repo, inv.Repo.Owner, inv.Repo.Repo) + if !strings.EqualFold(identity.Host, "github.com") || !strings.EqualFold(identity.Owner, inv.Repo.Owner()) || !strings.EqualFold(identity.Repo, inv.Repo.Repo()) { + return nil, fmt.Errorf("origin repository %s/%s does not match investigation repository %s/%s", identity.Owner, identity.Repo, inv.Repo.Owner(), inv.Repo.Repo()) } - ws.InvestigationID, ws.RepoOwner, ws.RepoName = inv.ID, inv.Repo.Owner, inv.Repo.Repo + ws.InvestigationID, ws.RepoOwner, ws.RepoName = inv.ID, inv.Repo.Owner(), inv.Repo.Repo() c, err := s.openCorpus(ctx) if err != nil { return nil, err @@ -99,7 +99,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, investigationID string, o remote := strings.TrimSpace(opts.Remote) if remote == "" { - remote = fmt.Sprintf("https://github.com/%s/%s.git", inv.Repo.Owner, inv.Repo.Repo) + remote = fmt.Sprintf("https://github.com/%s/%s.git", inv.Repo.Owner(), inv.Repo.Repo()) } baseRef := strings.TrimSpace(opts.BaseRef) @@ -125,7 +125,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, investigationID string, o return nil, err } - mirrorName := mirrorNameFor(inv.Repo.Owner, inv.Repo.Repo, remote) + mirrorName := mirrorNameFor(inv.Repo.Owner(), inv.Repo.Repo(), remote) if err := mgr.Clone(ctx, remote, mirrorName); err != nil { return nil, fmt.Errorf("clone repository: %w", err) } @@ -147,8 +147,8 @@ func (s *Service) CreateWorkspace(ctx context.Context, investigationID string, o }() ws.InvestigationID = inv.ID - ws.RepoOwner = inv.Repo.Owner - ws.RepoName = inv.Repo.Repo + ws.RepoOwner = inv.Repo.Owner() + ws.RepoName = inv.Repo.Repo() c, err := s.openCorpus(ctx) if err != nil { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 98313d00..67e12918 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -720,7 +720,7 @@ func (c *CLI) parseRepoSourceArgs(cmd sourceAddReposCmd) ([]contracts.RepoRef, s if err != nil { return nil, "", err } - ref := contracts.RepoRef{Owner: dr.Owner, Repo: dr.Repo} + ref := contracts.RepoRef{Owner: dr.Owner(), Repo: dr.Repo()} key := strings.ToLower(ref.String()) if _, exists := seen[key]; exists { continue diff --git a/internal/cli/cli_parallel_test.go b/internal/cli/cli_parallel_test.go index 518954cb..5b375ed6 100644 --- a/internal/cli/cli_parallel_test.go +++ b/internal/cli/cli_parallel_test.go @@ -12,6 +12,7 @@ import ( "github.com/morluto/gitcontribute/internal/cli" "github.com/morluto/gitcontribute/internal/contracts" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/health" ) @@ -49,7 +50,7 @@ func TestAcquire(t *testing.T) { func TestHealth(t *testing.T) { t.Parallel() - svc := &fakeService{healthResult: &health.Report{Repo: health.RepoRef{Owner: "o", Repo: "r"}}} + svc := &fakeService{healthResult: &health.Report{Repo: domain.MustRepoRef("o", "r")}} c, stdout, _ := newTestCLI(svc, nil) requireNoErr(t, c.Run(context.Background(), []string{"health", "o/r", "--start", "2026-07-01T00:00:00Z", "--end", "2026-07-17T00:00:00Z", "--stale-after", "240h", "--json"})) if !svc.healthCalled || svc.lastIndexRepo.String() != "o/r" || svc.lastHealthOpts.StaleThreshold != 240*time.Hour || svc.lastHealthOpts.Start.IsZero() { diff --git a/internal/clustering/cluster.go b/internal/clustering/cluster.go index fb64cb1a..9db252e0 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(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/clustering_test.go b/internal/clustering/clustering_test.go index 22de2bbc..e53809f2 100644 --- a/internal/clustering/clustering_test.go +++ b/internal/clustering/clustering_test.go @@ -33,8 +33,8 @@ func TestEngineHonorsCancellationBeforeExactWork(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := engine.Cluster(ctx, []clustering.Candidate{ - {Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: "issue", Number: 1, Title: "same"}, - {Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: "issue", Number: 2, Title: "same"}, + {Repo: domain.MustRepoRef("owner", "repo"), Kind: "issue", Number: 1, Title: "same"}, + {Repo: domain.MustRepoRef("owner", "repo"), Kind: "issue", Number: 2, Title: "same"}, }) if !errors.Is(err, context.Canceled) { t.Fatalf("cluster error = %v, want context.Canceled", err) @@ -67,9 +67,9 @@ func TestEngineProducesExplainableSignals(t *testing.T) { func TestEngineUsesExplicitReferences(t *testing.T) { clusters := clusterCandidates(t, []clustering.Candidate{ - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 1, Title: "bug", Body: "first"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 2, Title: "other", Body: "duplicate of #1"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 3, Title: "unrelated", Body: "nothing"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 1, Title: "bug", Body: "first"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 2, Title: "other", Body: "duplicate of #1"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 3, Title: "unrelated", Body: "nothing"}, }) if len(clusters) != 1 || len(clusters[0].Members) != 2 || clusters[0].Canonical.Number != 1 { t.Fatalf("explicit-reference cluster = %+v", clusters) @@ -83,8 +83,8 @@ func TestEngineUsesExplicitReferences(t *testing.T) { func TestEngineRejectsUnrelatedCandidates(t *testing.T) { clusters := clusterCandidates(t, []clustering.Candidate{ - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 1, Title: "fix login crash", Body: "crash"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 2, Title: "add dark mode", Body: "theme"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 1, Title: "fix login crash", Body: "crash"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 2, Title: "add dark mode", Body: "theme"}, }) if len(clusters) != 0 { t.Fatalf("expected no clusters, got %d", len(clusters)) @@ -92,8 +92,8 @@ func TestEngineRejectsUnrelatedCandidates(t *testing.T) { } func TestStableIDIsDeterministic(t *testing.T) { - a := clustering.Candidate{Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 1, Title: "duplicate title"} - b := clustering.Candidate{Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 2, Title: "duplicate title"} + a := clustering.Candidate{Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 1, Title: "duplicate title"} + b := clustering.Candidate{Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 2, Title: "duplicate title"} first := clusterCandidates(t, []clustering.Candidate{a, b}) second := clusterCandidates(t, []clustering.Candidate{b, a}) if len(first) != 1 || len(second) != 1 || first[0].StableID != second[0].StableID { @@ -104,10 +104,10 @@ func TestStableIDIsDeterministic(t *testing.T) { func TestEngineReportsPossibleAndScoredPairs(t *testing.T) { engine := defaultEngine(t) result, err := engine.Cluster(context.Background(), []clustering.Candidate{ - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 1, Title: "fix login crash"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 2, Title: "fix login crash"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 3, Title: "different", Body: "duplicate of #1"}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 4, Title: "unrelated"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 1, Title: "fix login crash"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 2, Title: "fix login crash"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 3, Title: "different", Body: "duplicate of #1"}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 4, Title: "unrelated"}, }) if err != nil { t.Fatal(err) @@ -132,7 +132,7 @@ func TestEngineEnforcesWorstCaseComparisonBudget(t *testing.T) { } func TestSourceRevisionIncludesContentButIgnoresLabelOrder(t *testing.T) { - base := clustering.Candidate{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: "issue", Number: 1, Title: "title", Body: "original", Labels: []string{"bug", "help wanted"}} + base := clustering.Candidate{Repo: domain.MustRepoRef("owner", "repo"), Kind: "issue", Number: 1, Title: "title", Body: "original", Labels: []string{"bug", "help wanted"}} if got := len(clustering.SourceRevision([]clustering.Candidate{base})); got != 64 { t.Fatalf("source revision length = %d, want full SHA-256 hex digest", got) } @@ -150,8 +150,8 @@ func TestSourceRevisionIncludesContentButIgnoresLabelOrder(t *testing.T) { func TestDuplicateLabelsDoNotInflateSimilarity(t *testing.T) { clusters := clusterCandidates(t, []clustering.Candidate{ - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 1, Labels: []string{"bug"}}, - {Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: "issue", Number: 2, Labels: []string{"bug", "bug"}}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 1, Labels: []string{"bug"}}, + {Repo: domain.MustRepoRef("o", "r"), Kind: "issue", Number: 2, Labels: []string{"bug", "bug"}}, }) if len(clusters) != 0 { t.Fatalf("duplicate labels inflated similarity into %d cluster(s)", len(clusters)) diff --git a/internal/clustering/models.go b/internal/clustering/models.go index f09cc415..9b8e06a1 100644 --- a/internal/clustering/models.go +++ b/internal/clustering/models.go @@ -51,8 +51,8 @@ type Candidate struct { // Ref returns the member identity for the candidate. func (c Candidate) Ref() MemberRef { return MemberRef{ - Owner: c.Repo.Owner, - Repo: c.Repo.Repo, + Owner: c.Repo.Owner(), + Repo: c.Repo.Repo(), Kind: c.Kind, Number: c.Number, } diff --git a/internal/clustering/references.go b/internal/clustering/references.go index ed993b97..d39305ef 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: string(ref.Kind), Number: ref.Number} } return out } diff --git a/internal/concern/models.go b/internal/concern/models.go index 2c2301f8..d180c02b 100644 --- a/internal/concern/models.go +++ b/internal/concern/models.go @@ -3,6 +3,8 @@ package concern import ( + "errors" + "fmt" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -102,3 +104,37 @@ type Filter struct { Limit int Offset int } + +// ParseStored rejects malformed durable records before they enter concern +// workflows. Creation and transition validation remain owned by Service. +func (c *Concern) ParseStored() error { + if c == nil || c.ID == "" || !c.Repo.IsValid() { + return errors.New("concern identity and repository are required") + } + if !validStatus(c.Status) { + return fmt.Errorf("unsupported concern status %q", c.Status) + } + for i, change := range c.AuditTrail { + if !validStatus(change.From) || !validStatus(change.To) { + return fmt.Errorf("concern audit entry %d has an unsupported status", 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) + } + } + return nil +} diff --git a/internal/concern/service.go b/internal/concern/service.go index 1de20f2a..40f53a63 100644 --- a/internal/concern/service.go +++ b/internal/concern/service.go @@ -75,11 +75,6 @@ func (s *Service) List(ctx context.Context, filter Filter) (*ListResult, error) if filter.Status != "" && !validStatus(filter.Status) { return nil, ErrInvalidStatus } - if filter.Repo.Owner != "" || filter.Repo.Repo != "" { - if err := filter.Repo.Validate(); err != nil { - return nil, err - } - } return s.repo.ListConcerns(ctx, filter) } @@ -142,8 +137,8 @@ func (s *Service) Link(ctx context.Context, id string, link Link) error { } func normalizeConcern(item *Concern) error { - if err := item.Repo.Validate(); err != nil { - return fmt.Errorf("invalid concern repository: %w", err) + if !item.Repo.IsValid() { + return errors.New("invalid concern repository: repository reference is not parsed") } item.CommitSHA = strings.TrimSpace(item.CommitSHA) item.WorkspaceID = strings.TrimSpace(item.WorkspaceID) diff --git a/internal/concern/service_test.go b/internal/concern/service_test.go index 06705b57..fc08dab0 100644 --- a/internal/concern/service_test.go +++ b/internal/concern/service_test.go @@ -66,7 +66,7 @@ func TestConcernLifecycleAndLinks(t *testing.T) { repo := newMemoryRepository() svc := NewService(repo) item, err := svc.Create(context.Background(), &Concern{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, CommitSHA: "abc", + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "abc", Title: " flaky test ", ProblemStatement: " fails intermittently ", Confidence: 0.4, Unknowns: []string{" timing ", "timing"}, }) @@ -103,7 +103,7 @@ func TestConcernLifecycleAndLinks(t *testing.T) { func TestConcernRejectsUnsafeOrUnboundedInput(t *testing.T) { t.Parallel() svc := NewService(newMemoryRepository()) - base := &Concern{Repo: domain.RepoRef{Owner: "o", Repo: "r"}, CommitSHA: "abc", Title: "title", ProblemStatement: "problem"} + base := &Concern{Repo: domain.MustRepoRef("o", "r"), CommitSHA: "abc", Title: "title", ProblemStatement: "problem"} badConfidence := *base badConfidence.Confidence = 2 if _, err := svc.Create(context.Background(), &badConfidence); err == nil { diff --git a/internal/contribution/renderer.go b/internal/contribution/renderer.go index 696fcd89..f0756ab6 100644 --- a/internal/contribution/renderer.go +++ b/internal/contribution/renderer.go @@ -215,7 +215,7 @@ func writeProofRun(b *strings.Builder, label string, run *evidence.ValidationRun b.WriteString(")\n") for _, observation := range run.Observations { if observation.Status == evidence.ObservationMatched && observation.Excerpt != "" { - fmt.Fprintf(b, " - %s: %s\n", observation.Name, boundedText(observation.Excerpt, 240)) + fmt.Fprintf(b, " - %s: %s\n", observation.Name(), boundedText(observation.Excerpt, 240)) } } } diff --git a/internal/contribution/renderer_test.go b/internal/contribution/renderer_test.go index eb5f5665..35c8bd87 100644 --- a/internal/contribution/renderer_test.go +++ b/internal/contribution/renderer_test.go @@ -109,12 +109,19 @@ func TestRenderPullRequest(t *testing.T) { func TestRenderPullRequestIncludesCompatibleBeforeAfterProof(t *testing.T) { def := &evidence.ValidationDefinition{ID: "def", Command: []string{"go", "test", "./pkg/foo", "-run", "TestRace"}} + expected, err := evidence.ParseExpectedObservation(evidence.ExpectedObservationSpec{ + Name: "race reproduced", Source: evidence.ObservationStderr, + Matcher: evidence.ObservationExact, Pattern: "DATA RACE", Occurrence: evidence.ObservationPresent, + }) + if err != nil { + t.Fatal(err) + } base := &evidence.ValidationRun{ ID: "base", DefinitionID: def.ID, Kind: evidence.RunKindBase, Classification: evidence.RunClassificationFailing, ObservationStatus: evidence.ObservationMatched, WorkspaceSnapshotAfter: "base-sha", Observations: []evidence.ObservationResult{{ - ExpectedObservation: evidence.ExpectedObservation{Name: "race reproduced"}, + ExpectedObservation: expected, Status: evidence.ObservationMatched, Excerpt: "DATA RACE", }}, } diff --git a/internal/corpus/cluster_governance.go b/internal/corpus/cluster_governance.go index d1d48a26..c51b258f 100644 --- a/internal/corpus/cluster_governance.go +++ b/internal/corpus/cluster_governance.go @@ -38,16 +38,20 @@ func (c *Corpus) AddClusterOverride(ctx context.Context, clusterID int64, ref cl return err } defer rollbackSQLOnReturn(tx, &err) - var repo domain.RepoRef + var owner, name 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(&repo.Owner, &repo.Repo, &canonical.Kind, &canonical.Owner, &canonical.Repo, &canonical.Number) + FROM clusters WHERE id=?`, clusterID).Scan(&owner, &name, &canonical.Kind, &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 } + repo, err := domain.NewRepoRef(owner, name) + if err != nil { + return fmt.Errorf("decode cluster repository: %w", err) + } if action == clustering.OverrideExclude && sameClusterMemberRef(ref, canonical) { return errors.New("cannot exclude the canonical member") } @@ -63,7 +67,7 @@ func (c *Corpus) AddClusterOverride(ctx context.Context, clusterID int64, ref cl } func advanceClusterGovernanceTx(ctx context.Context, tx *sql.Tx, repo domain.RepoRef) error { - owner, name := strings.ToLower(repo.Owner), strings.ToLower(repo.Repo) + owner, name := strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo()) if _, err := tx.ExecContext(ctx, `INSERT INTO cluster_projection_state (repo_owner, repo_name, governance_revision) VALUES (?, ?, 1) ON CONFLICT(repo_owner, repo_name) DO UPDATE SET governance_revision=governance_revision+1`, owner, name); err != nil { diff --git a/internal/corpus/cluster_projection.go b/internal/corpus/cluster_projection.go index d91edb6c..9cab9854 100644 --- a/internal/corpus/cluster_projection.go +++ b/internal/corpus/cluster_projection.go @@ -18,8 +18,8 @@ import ( // ListClusterProjection reads cluster headers and all returned children from a // single read-only SQLite snapshot using two statements. func (c *Corpus) ListClusterProjection(ctx context.Context, repo domain.RepoRef, state clustering.ClusterState, limit int) (result clusterprojection.List, err error) { - if err := repo.Validate(); err != nil { - return clusterprojection.List{}, err + if !repo.IsValid() { + return clusterprojection.List{}, errors.New("repository reference is not parsed") } if limit < 1 || limit > 1000 { return clusterprojection.List{}, errors.New("cluster list limit must be between 1 and 1000") @@ -36,7 +36,7 @@ func (c *Corpus) ListClusterProjection(ctx context.Context, repo domain.RepoRef, query := `SELECT id, stable_id, state, canonical_kind, canonical_owner, canonical_repo, canonical_number, source_revision, source_window_start, source_window_end, created_at, updated_at FROM clusters WHERE repo_owner=? AND repo_name=?` - args := []any{strings.ToLower(repo.Owner), strings.ToLower(repo.Repo)} + args := []any{strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo())} if state == "" { query += ` AND state != ?` args = append(args, string(clustering.ClusterRetired)) @@ -45,7 +45,7 @@ func (c *Corpus) ListClusterProjection(ctx context.Context, repo domain.RepoRef, args = append(args, string(state)) } countQuery := `SELECT COUNT(*) FROM clusters WHERE repo_owner=? AND repo_name=?` - countArgs := []any{strings.ToLower(repo.Owner), strings.ToLower(repo.Repo)} + countArgs := []any{strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo())} if state == "" { countQuery += ` AND state != ?` countArgs = append(countArgs, string(clustering.ClusterRetired)) @@ -155,8 +155,8 @@ func (c *Corpus) GetClusterProjectionForMember(ctx context.Context, ref clusteri // GetClusterProjectionForMemberWithIdentity reads the current included cluster // containing ref together with the projection identity that produced it. func (c *Corpus) GetClusterProjectionForMemberWithIdentity(ctx context.Context, ref clustering.MemberRef) (result clusterprojection.List, err error) { - repo := domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo} - if err := repo.Validate(); err != nil { + repo, err := domain.NewRepoRef(ref.Owner, ref.Repo) + if err != nil { return clusterprojection.List{}, err } tx, err := c.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) @@ -224,8 +224,8 @@ func (c *Corpus) getClusterProjection(ctx context.Context, predicate string, arg // LoadClusterRefreshSnapshot reads every input needed by a refresh from one // SQLite snapshot and closes the transaction before CPU-heavy pair evaluation. func (c *Corpus) LoadClusterRefreshSnapshot(ctx context.Context, repo domain.RepoRef, maxCandidates int) (result clusterprojection.RefreshSnapshot, err error) { - if err := repo.Validate(); err != nil { - return clusterprojection.RefreshSnapshot{}, err + if !repo.IsValid() { + return clusterprojection.RefreshSnapshot{}, errors.New("repository reference is not parsed") } if maxCandidates < 1 { return clusterprojection.RefreshSnapshot{}, errors.New("max candidates must be positive") @@ -282,7 +282,7 @@ func loadClusterCandidatesTx(ctx context.Context, tx *sql.Tx, repo domain.RepoRe WHERE r.owner = ? AND r.name = ? ORDER BY t.source_updated_at DESC, t.number DESC LIMIT ? - `, repo.Owner, repo.Repo, maxCandidates+1) + `, repo.Owner(), repo.Repo(), maxCandidates+1) if err != nil { return nil, fmt.Errorf("load cluster candidates: %w", err) } @@ -334,7 +334,7 @@ func loadProjectionStateTx(ctx context.Context, tx *sql.Tx, repo domain.RepoRef) FROM cluster_projection_state AS state LEFT JOIN cluster_runs AS run ON run.id=state.current_run_id WHERE state.repo_owner=? AND state.repo_name=? - `, strings.ToLower(repo.Owner), strings.ToLower(repo.Repo)).Scan(&runID, &source, ¤tGovernance, &projectionGovernance, &rule) + `, strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo())).Scan(&runID, &source, ¤tGovernance, &projectionGovernance, &rule) if errors.Is(err, sql.ErrNoRows) { return nil, 0, nil } @@ -357,7 +357,7 @@ func loadProjectionClustersTx(ctx context.Context, tx *sql.Tx, repo domain.RepoR source_revision, source_window_start, source_window_end, created_at, updated_at FROM clusters WHERE repo_owner=? AND repo_name=? ORDER BY canonical_kind, canonical_owner, canonical_repo, canonical_number, stable_id - `, strings.ToLower(repo.Owner), strings.ToLower(repo.Repo)) + `, strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo())) if err != nil { return nil, err } @@ -386,15 +386,23 @@ type projectionScanner interface{ Scan(...any) error } func scanProjectionCluster(scanner projectionScanner, cluster *clustering.Cluster, includeRepo bool) error { var state 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} if includeRepo { - destinations = append(destinations, &cluster.Repo.Owner, &cluster.Repo.Repo) + destinations = append(destinations, &owner, &repo) } if err := scanner.Scan(destinations...); err != nil { return err } cluster.State = clustering.ClusterState(state) + if includeRepo { + parsed, err := domain.NewRepoRef(owner, repo) + if err != nil { + return fmt.Errorf("decode cluster repository: %w", err) + } + cluster.Repo = parsed + } cluster.WindowStart, cluster.WindowEnd = scanTime(windowStart), scanTime(windowEnd) cluster.CreatedAt, cluster.UpdatedAt = scanTime(created), scanTime(updated) return nil @@ -436,7 +444,7 @@ func loadProjectionMembersTx(ctx context.Context, tx *sql.Tx, clusters []cluster func loadProjectionOverridesTx(ctx context.Context, tx *sql.Tx, repo domain.RepoRef, byStable map[string][]clustering.MembershipOverride) (err error) { rows, err := tx.QueryContext(ctx, `SELECT c.stable_id, o.id, o.cluster_id, o.kind, o.owner, o.repo, o.number, o.action, o.reason, o.created_at FROM cluster_overrides o JOIN clusters c ON c.id=o.cluster_id - WHERE c.repo_owner=? AND c.repo_name=? ORDER BY o.id`, strings.ToLower(repo.Owner), strings.ToLower(repo.Repo)) + WHERE c.repo_owner=? AND c.repo_name=? ORDER BY o.id`, strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo())) if err != nil { return err } @@ -480,8 +488,8 @@ func (c *Corpus) CommitClusterProjection(ctx context.Context, commit clusterproj } func validateClusterProjectionCommit(commit clusterprojection.Commit) error { - if err := commit.Repo.Validate(); err != nil { - return err + if !commit.Repo.IsValid() { + return errors.New("cluster repository is required") } if commit.RuleVersion == "" { return errors.New("cluster rule version is required") @@ -496,7 +504,7 @@ func validateClusterProjectionCommit(commit clusterprojection.Commit) error { if strings.TrimSpace(cluster.StableID) == "" { return errors.New("cluster stable id is required") } - if !strings.EqualFold(cluster.Repo.Owner, commit.Repo.Owner) || !strings.EqualFold(cluster.Repo.Repo, commit.Repo.Repo) { + if !strings.EqualFold(cluster.Repo.Owner(), commit.Repo.Owner()) || !strings.EqualFold(cluster.Repo.Repo(), commit.Repo.Repo()) { return fmt.Errorf("cluster %q repository does not match commit", cluster.StableID) } if cluster.Revision != commit.ExpectedSource { @@ -507,7 +515,7 @@ func validateClusterProjectionCommit(commit clusterprojection.Commit) error { } func commitClusterProjectionTx(ctx context.Context, tx *sql.Tx, commit clusterprojection.Commit) (clusterprojection.CommitResult, error) { - owner, name := strings.ToLower(commit.Repo.Owner), strings.ToLower(commit.Repo.Repo) + owner, name := strings.ToLower(commit.Repo.Owner()), strings.ToLower(commit.Repo.Repo()) if _, err := tx.ExecContext(ctx, `INSERT INTO cluster_projection_state (repo_owner, repo_name, governance_revision) VALUES (?, ?, 0) ON CONFLICT(repo_owner, repo_name) DO NOTHING`, owner, name); err != nil { return clusterprojection.CommitResult{}, err @@ -637,7 +645,7 @@ func (s *projectionStatements) persistCluster(ctx context.Context, repo domain.R } } if cluster.ID == 0 { - result, err := s.insertCluster.ExecContext(ctx, cluster.StableID, strings.ToLower(repo.Owner), strings.ToLower(repo.Repo), string(cluster.State), cluster.Canonical.Kind, cluster.Canonical.Owner, cluster.Canonical.Repo, cluster.Canonical.Number, cluster.Revision, encodeTime(cluster.WindowStart), encodeTime(cluster.WindowEnd), encodeTime(cluster.CreatedAt), encodeTime(now)) + result, err := s.insertCluster.ExecContext(ctx, cluster.StableID, strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo()), string(cluster.State), cluster.Canonical.Kind, cluster.Canonical.Owner, cluster.Canonical.Repo, cluster.Canonical.Number, cluster.Revision, encodeTime(cluster.WindowStart), encodeTime(cluster.WindowEnd), encodeTime(cluster.CreatedAt), encodeTime(now)) if err != nil { return 0, err } @@ -666,7 +674,7 @@ func (s *projectionStatements) persistCluster(ctx context.Context, repo domain.R } func retireMissingProjectionClusters(ctx context.Context, tx *sql.Tx, deleteMembers *sql.Stmt, repo domain.RepoRef, active map[string]struct{}, now time.Time) (writes int, err error) { - rows, err := tx.QueryContext(ctx, `SELECT id, stable_id FROM clusters WHERE repo_owner=? AND repo_name=? AND state != ?`, strings.ToLower(repo.Owner), strings.ToLower(repo.Repo), string(clustering.ClusterRetired)) + rows, err := tx.QueryContext(ctx, `SELECT id, stable_id FROM clusters WHERE repo_owner=? AND repo_name=? AND state != ?`, strings.ToLower(repo.Owner()), strings.ToLower(repo.Repo()), string(clustering.ClusterRetired)) if err != nil { return 0, err } diff --git a/internal/corpus/cluster_projection_test.go b/internal/corpus/cluster_projection_test.go index f6ca4661..87e4845d 100644 --- a/internal/corpus/cluster_projection_test.go +++ b/internal/corpus/cluster_projection_test.go @@ -24,7 +24,7 @@ func TestCommitClusterProjectionRejectsChangedSource(t *testing.T) { if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "rocket"} + ref := domain.MustRepoRef("acme", "rocket") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -56,7 +56,7 @@ func TestConcurrentIdenticalEmptyProjectionHasOneCurrentRun(t *testing.T) { if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "empty"}, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "empty"} + ref := domain.MustRepoRef("acme", "empty") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -107,7 +107,7 @@ func TestCommitClusterProjectionRejectsMissingRuleVersion(t *testing.T) { if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "empty"}, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "empty"} + ref := domain.MustRepoRef("acme", "empty") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -137,7 +137,7 @@ func TestCommitClusterProjectionRejectsMissingSourceRevision(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "acme", Repo: "rocket"} + ref := domain.MustRepoRef("acme", "rocket") _, err := c.CommitClusterProjection(ctx, clusterprojection.Commit{ Repo: ref, @@ -155,7 +155,7 @@ func TestCommitClusterProjectionRejectsInvalidCandidateBound(t *testing.T) { c, _ := openTestCorpus(t) _, err := c.CommitClusterProjection(ctx, clusterprojection.Commit{ - Repo: domain.RepoRef{Owner: "acme", Repo: "rocket"}, + Repo: domain.MustRepoRef("acme", "rocket"), ExpectedSource: "revision", RuleVersion: similarity.DuplicateV1, }) @@ -170,12 +170,12 @@ func TestCommitClusterProjectionRejectsInvalidRepository(t *testing.T) { c, _ := openTestCorpus(t) _, err := c.CommitClusterProjection(ctx, clusterprojection.Commit{ - Repo: domain.RepoRef{Owner: "acme"}, + Repo: domain.RepoRef{}, ExpectedSource: "revision", RuleVersion: similarity.DuplicateV1, MaxCandidates: clustering.DefaultComparisonBudget().MaxCandidates(), }) - if err == nil || err.Error() != "repo is required" { + if err == nil || err.Error() != "cluster repository is required" { t.Fatalf("commit error = %v, want invalid repository", err) } } @@ -187,7 +187,7 @@ func TestCommitClusterProjectionRejectsClusterFromDifferentSource(t *testing.T) if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "rocket"}, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "rocket"} + ref := domain.MustRepoRef("acme", "rocket") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -222,7 +222,7 @@ func TestCommitClusterProjectionRejectsClusterWithoutStableIdentity(t *testing.T if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "rocket"}, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "rocket"} + ref := domain.MustRepoRef("acme", "rocket") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -249,7 +249,7 @@ func TestCommitClusterProjectionRejectsClusterFromDifferentRepository(t *testing if _, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "rocket"}, `{}`); err != nil { t.Fatal(err) } - ref := domain.RepoRef{Owner: "acme", Repo: "rocket"} + ref := domain.MustRepoRef("acme", "rocket") maxCandidates := clustering.DefaultComparisonBudget().MaxCandidates() snapshot, err := c.LoadClusterRefreshSnapshot(ctx, ref, maxCandidates) if err != nil { @@ -264,7 +264,7 @@ func TestCommitClusterProjectionRejectsClusterFromDifferentRepository(t *testing MaxCandidates: maxCandidates, Clusters: []clustering.Cluster{{ StableID: "cluster-1", - Repo: domain.RepoRef{Owner: "other", Repo: "repo"}, + Repo: domain.MustRepoRef("other", "repo"), Revision: snapshot.SourceRevision, }}, }) diff --git a/internal/corpus/code.go b/internal/corpus/code.go index bd717277..007d5e3b 100644 --- a/internal/corpus/code.go +++ b/internal/corpus/code.go @@ -56,10 +56,6 @@ type CodeIndexArtifactRecord struct { ManifestJSON string SnapshotToken string CorpusRevision int64 - CoverageKnown bool - IndexedFiles int - TrackedEntries int - Truncated bool SchemaVersion string Provenance map[string]string IndexManifest codeindex.Manifest @@ -103,8 +99,8 @@ func (c *Corpus) StoreCodeSnapshotWithRevision(ctx context.Context, ref domain.R } func (c *Corpus) storeCodeSnapshot(ctx context.Context, ref domain.RepoRef, snapshot codeindex.Snapshot) (int64, bool, int64, error) { - if err := ref.Validate(); err != nil { - return 0, false, 0, err + if !ref.IsValid() { + return 0, false, 0, errors.New("repository reference is not parsed") } if snapshot.Commit == "" { return 0, false, 0, errors.New("code snapshot commit is required") @@ -121,7 +117,7 @@ func (c *Corpus) storeCodeSnapshot(ctx context.Context, ref domain.RepoRef, snap var existing int64 err = tx.QueryRowContext(ctx, ` SELECT id FROM code_snapshots WHERE repo_owner=? AND repo_name=? AND commit_sha=? - `, ref.Owner, ref.Repo, snapshot.Commit).Scan(&existing) + `, ref.Owner(), ref.Repo(), snapshot.Commit).Scan(&existing) if err == nil { if _, err := tx.ExecContext(ctx, ` UPDATE code_snapshots @@ -154,7 +150,7 @@ func (c *Corpus) storeCodeSnapshot(ctx context.Context, ref domain.RepoRef, snap result, err := tx.ExecContext(ctx, ` INSERT INTO code_snapshots (repo_owner, repo_name, repo_path, commit_sha, total_bytes, created_at, manifest_json) VALUES (?, ?, ?, ?, ?, ?, ?) - `, ref.Owner, ref.Repo, snapshot.RepoPath, snapshot.Commit, snapshot.TotalBytes, encodeTime(snapshot.CreatedAt), string(manifest)) + `, ref.Owner(), ref.Repo(), snapshot.RepoPath, snapshot.Commit, snapshot.TotalBytes, encodeTime(snapshot.CreatedAt), string(manifest)) if err != nil { return 0, false, 0, fmt.Errorf("insert code snapshot: %w", err) } @@ -207,7 +203,7 @@ func storeCodeIndexArtifact(ctx context.Context, tx *sql.Tx, snapshotID int64, r INSERT INTO code_index_artifacts (digest, snapshot_id, repo_owner, repo_name, commit_sha, manifest_sha256, manifest_json, snapshot_token, corpus_revision, coverage_known, indexed_files, tracked_entries, truncated, schema_version, provenance, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, artifactDigest, snapshotID, ref.Owner, ref.Repo, snapshot.Commit, manifestDigest, string(manifestBytes), snapshotToken, revision, snapshot.Manifest.CoverageKnown, snapshot.Manifest.IndexedFiles, snapshot.Manifest.TrackedEntries, snapshot.Manifest.Truncated, codeIndexArtifactSchema, string(provenanceJSON), encodeTime(createdAt)); err != nil { + `, artifactDigest, snapshotID, ref.Owner(), ref.Repo(), snapshot.Commit, manifestDigest, string(manifestBytes), snapshotToken, revision, snapshot.Manifest.CoverageKnown, snapshot.Manifest.IndexedFiles, snapshot.Manifest.TrackedEntries, snapshot.Manifest.Truncated, codeIndexArtifactSchema, string(provenanceJSON), encodeTime(createdAt)); err != nil { return CodeIndexArtifactRecord{}, fmt.Errorf("store immutable code index artifact: %w", err) } if _, err := tx.ExecContext(ctx, ` @@ -221,8 +217,6 @@ func storeCodeIndexArtifact(ctx context.Context, tx *sql.Tx, snapshotID int64, r Digest: artifactDigest, Repo: ref, CommitSHA: snapshot.Commit, ManifestSHA256: manifestDigest, ManifestJSON: string(manifestBytes), SnapshotToken: snapshotToken, CorpusRevision: revision, - CoverageKnown: snapshot.Manifest.CoverageKnown, IndexedFiles: snapshot.Manifest.IndexedFiles, - TrackedEntries: snapshot.Manifest.TrackedEntries, Truncated: snapshot.Manifest.Truncated, SchemaVersion: codeIndexArtifactSchema, Provenance: manifest.Provenance, IndexManifest: snapshot.Manifest, TotalBytes: snapshot.TotalBytes, Documents: documents, CreatedAt: createdAt, @@ -274,8 +268,8 @@ func (c *Corpus) LatestCodeSnapshot(ctx context.Context, ref domain.RepoRef) (*C // CodeSnapshot returns the stored snapshot for an exact repository commit. func (c *Corpus) CodeSnapshot(ctx context.Context, ref domain.RepoRef, commit string) (*CodeSnapshotInfo, error) { - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } if commit == "" { return nil, errors.New("code snapshot commit is required") @@ -285,7 +279,7 @@ func (c *Corpus) CodeSnapshot(ctx context.Context, ref domain.RepoRef, commit st FROM code_snapshots WHERE repo_owner = ? AND repo_name = ? AND commit_sha = ? LIMIT 1 - `, ref.Owner, ref.Repo, commit), ref) + `, ref.Owner(), ref.Repo(), commit), ref) } // CodeIndexArtifact resolves one immutable artifact by its content digest. @@ -305,8 +299,8 @@ func (c *Corpus) CodeIndexArtifact(ctx context.Context, digest string) (*CodeInd // LatestCodeIndexArtifact returns the most recently created immutable artifact // for an exact repository commit. func (c *Corpus) LatestCodeIndexArtifact(ctx context.Context, ref domain.RepoRef, commit string) (*CodeIndexArtifactRecord, error) { - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } return scanCodeIndexArtifact(c.db.QueryRowContext(ctx, ` SELECT digest, repo_owner, repo_name, commit_sha, manifest_sha256, manifest_json, @@ -315,16 +309,18 @@ func (c *Corpus) LatestCodeIndexArtifact(ctx context.Context, ref domain.RepoRef FROM code_index_artifacts WHERE repo_owner = ? AND repo_name = ? AND commit_sha = ? ORDER BY created_at DESC, digest DESC LIMIT 1 - `, ref.Owner, ref.Repo, commit), "") + `, ref.Owner(), ref.Repo(), commit), "") } func scanCodeIndexArtifact(row *sql.Row, expectedDigest string) (*CodeIndexArtifactRecord, error) { var record CodeIndexArtifactRecord + var owner, repo string var created int64 var coverageKnown, truncated bool + var indexedFiles, trackedEntries int var provenance string if expectedDigest == "" { - if err := row.Scan(&record.Digest, &record.Repo.Owner, &record.Repo.Repo, &record.CommitSHA, &record.ManifestSHA256, &record.ManifestJSON, &record.SnapshotToken, &record.CorpusRevision, &coverageKnown, &record.IndexedFiles, &record.TrackedEntries, &truncated, &record.SchemaVersion, &provenance, &created); err != nil { + if err := row.Scan(&record.Digest, &owner, &repo, &record.CommitSHA, &record.ManifestSHA256, &record.ManifestJSON, &record.SnapshotToken, &record.CorpusRevision, &coverageKnown, &indexedFiles, &trackedEntries, &truncated, &record.SchemaVersion, &provenance, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -332,14 +328,19 @@ func scanCodeIndexArtifact(row *sql.Row, expectedDigest string) (*CodeIndexArtif } } else { record.Digest = expectedDigest - if err := row.Scan(&record.Repo.Owner, &record.Repo.Repo, &record.CommitSHA, &record.ManifestSHA256, &record.ManifestJSON, &record.SnapshotToken, &record.CorpusRevision, &coverageKnown, &record.IndexedFiles, &record.TrackedEntries, &truncated, &record.SchemaVersion, &provenance, &created); err != nil { + if err := row.Scan(&owner, &repo, &record.CommitSHA, &record.ManifestSHA256, &record.ManifestJSON, &record.SnapshotToken, &record.CorpusRevision, &coverageKnown, &indexedFiles, &trackedEntries, &truncated, &record.SchemaVersion, &provenance, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("read code index artifact: %w", err) } } - record.CoverageKnown, record.Truncated, record.CreatedAt = coverageKnown, truncated, scanTime(created) + parsed, err := domain.NewRepoRef(owner, repo) + if err != nil { + return nil, fmt.Errorf("decode code index artifact repository: %w", err) + } + record.Repo = parsed + record.CreatedAt = scanTime(created) if err := json.Unmarshal([]byte(provenance), &record.Provenance); err != nil { return nil, fmt.Errorf("decode code index artifact provenance: %w", err) } @@ -349,6 +350,9 @@ func scanCodeIndexArtifact(row *sql.Row, expectedDigest string) (*CodeIndexArtif } record.IndexManifest = completeManifest.Index record.TotalBytes, record.Documents = completeManifest.TotalBytes, completeManifest.Documents + if record.IndexManifest.CoverageKnown != coverageKnown || record.IndexManifest.IndexedFiles != indexedFiles || record.IndexManifest.TrackedEntries != trackedEntries || record.IndexManifest.Truncated != truncated { + return nil, errors.New("code index artifact projection contradicts digest-bound manifest") + } manifestHash := sha256.Sum256([]byte(record.ManifestJSON)) if hex.EncodeToString(manifestHash[:]) != record.ManifestSHA256 { return nil, errors.New("code index artifact manifest digest mismatch") @@ -371,7 +375,7 @@ func latestCodeSnapshot(ctx context.Context, queryer codeSnapshotQueryer, ref do WHERE repo_owner = ? AND repo_name = ? ORDER BY created_at DESC, id DESC LIMIT 1 - `, ref.Owner, ref.Repo), ref) + `, ref.Owner(), ref.Repo()), ref) } func scanCodeSnapshot(row *sql.Row, ref domain.RepoRef) (*CodeSnapshotInfo, error) { @@ -473,8 +477,8 @@ func (c *Corpus) prepareCodeSearch(ctx context.Context, query string, opts CodeS return opts, "", "", nil, err } if opts.Ref != (domain.RepoRef{}) { - if err := opts.Ref.Validate(); err != nil { - return opts, "", "", nil, err + if !opts.Ref.IsValid() { + return opts, "", "", nil, errors.New("repository reference is not parsed") } } repo := opts.Ref.String() @@ -496,7 +500,7 @@ func codeSearchStatement(ftsQuery string, opts CodeSearchOptions, cursor *search args := []any{ftsQuery} if opts.Ref != (domain.RepoRef{}) { statement += ` AND s.repo_owner = ? AND s.repo_name = ?` - args = append(args, opts.Ref.Owner, opts.Ref.Repo) + args = append(args, opts.Ref.Owner(), opts.Ref.Repo()) } if cursor != nil { statement += ` AND (bm25(code_documents_fts, 5.0, 1.0) > ? OR (bm25(code_documents_fts, 5.0, 1.0) = ? AND d.id > ?))` @@ -510,11 +514,17 @@ func scanCodeSearchMatches(rows *sql.Rows) ([]CodeMatch, error) { var matches []CodeMatch for rows.Next() { var match CodeMatch + var owner, repo string var createdAt int64 - if err := rows.Scan(&match.Rank, &match.DocID, &match.Repo.Owner, &match.Repo.Repo, &match.Commit, + if err := rows.Scan(&match.Rank, &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 search repository: %w", err) + } + match.Repo = parsed match.SnapshotCreatedAt = scanTime(createdAt) matches = append(matches, match) } @@ -565,7 +575,7 @@ func (c *Corpus) latestCodeSnapshotID(ctx context.Context, ref domain.RepoRef) ( WHERE repo_owner = ? AND repo_name = ? ORDER BY created_at DESC, id DESC LIMIT 1 - `, ref.Owner, ref.Repo).Scan(&id) + `, ref.Owner(), ref.Repo()).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return 0, nil } @@ -586,13 +596,14 @@ func (c *Corpus) GetCodeDocument(ctx context.Context, ref domain.RepoRef, path s return nil, nil } var match CodeMatch + var owner, repo string var createdAt int64 err = c.db.QueryRowContext(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 = ? AND d.path = ? - `, snapshotID, path).Scan(&match.DocID, &match.Repo.Owner, &match.Repo.Repo, &match.Commit, + `, snapshotID, path).Scan(&match.DocID, &owner, &repo, &match.Commit, &match.Path, &match.Content, &match.Bytes, &match.Language, &match.SnapshotID, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -600,6 +611,11 @@ func (c *Corpus) GetCodeDocument(ctx context.Context, ref domain.RepoRef, path s if err != nil { return nil, fmt.Errorf("get code document: %w", 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) return &match, nil } @@ -630,11 +646,17 @@ func (c *Corpus) ListCodeDocuments(ctx context.Context, ref domain.RepoRef) ([]C var out []CodeMatch for rows.Next() { var match CodeMatch + var owner, repo string var createdAt int64 - if err := rows.Scan(&match.DocID, &match.Repo.Owner, &match.Repo.Repo, &match.Commit, + 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) } @@ -652,9 +674,9 @@ func countCodeMatches(ctx context.Context, queryer codeSnapshotQueryer, ftsQuery WHERE newest.repo_owner = s.repo_owner AND newest.repo_name = s.repo_name ORDER BY newest.created_at DESC, newest.id DESC LIMIT 1)` args := []any{ftsQuery} - if ref.Owner != "" || ref.Repo != "" { + if ref.Owner() != "" || ref.Repo() != "" { statement += ` AND s.repo_owner = ? AND s.repo_name = ?` - args = append(args, ref.Owner, ref.Repo) + args = append(args, ref.Owner(), ref.Repo()) } var total int if err := queryer.QueryRowContext(ctx, statement, args...).Scan(&total); err != nil { diff --git a/internal/corpus/code_test.go b/internal/corpus/code_test.go index 76e0e133..d13a484b 100644 --- a/internal/corpus/code_test.go +++ b/internal/corpus/code_test.go @@ -14,7 +14,7 @@ func TestCodeSnapshotsAreAtomicDeduplicatedAndSearchLatest(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") first := codeindex.Snapshot{RepoPath: "/repo", Commit: "first", CreatedAt: time.Unix(100, 0), Documents: []codeindex.Document{{Path: "old.go", Content: "legacy needle", Bytes: 13, LanguageHint: "go"}}, TotalBytes: 13} firstID, inserted, err := c.StoreCodeSnapshot(ctx, ref, first) if err != nil || !inserted { @@ -107,7 +107,7 @@ func TestReindexSameCommitPreservesOldArtifactIdentity(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") first := codeindex.Snapshot{RepoPath: "/repo", Commit: "same", CreatedAt: time.Unix(1, 0), Documents: []codeindex.Document{{Path: "a.go", Content: "first", Bytes: 5}}, TotalBytes: 5, Manifest: codeindex.Manifest{FormatVersion: codeindex.FormatVersion, CoverageKnown: true, TrackedEntries: 1, IndexedFiles: 1}} if _, _, err := c.StoreCodeSnapshot(ctx, ref, first); err != nil { t.Fatal(err) @@ -135,6 +135,31 @@ func TestReindexSameCommitPreservesOldArtifactIdentity(t *testing.T) { } } +func TestCodeIndexArtifactRejectsProjectionThatContradictsManifest(t *testing.T) { + t.Parallel() + c, _ := openTestCorpus(t) + ctx := context.Background() + ref := domain.MustRepoRef("owner", "repo") + snapshot := codeindex.Snapshot{ + RepoPath: "/repo", Commit: "same", CreatedAt: time.Unix(1, 0), + Documents: []codeindex.Document{{Path: "a.go", Content: "first", Bytes: 5}}, TotalBytes: 5, + Manifest: codeindex.Manifest{FormatVersion: codeindex.FormatVersion, CoverageKnown: true, TrackedEntries: 1, IndexedFiles: 1}, + } + if _, _, err := c.StoreCodeSnapshot(ctx, ref, snapshot); err != nil { + t.Fatal(err) + } + artifact, err := c.LatestCodeIndexArtifact(ctx, ref, "same") + if err != nil || artifact == nil { + t.Fatalf("artifact = %+v, %v", artifact, err) + } + if _, err := c.db.ExecContext(ctx, `UPDATE code_index_artifacts SET indexed_files=2 WHERE digest=?`, artifact.Digest); err != nil { + t.Fatal(err) + } + if _, err := c.CodeIndexArtifact(ctx, artifact.Digest); err == nil || !strings.Contains(err.Error(), "contradicts digest-bound manifest") { + t.Fatalf("contradictory projection error = %v", err) + } +} + func TestStoreCodeSnapshotWithRevisionBindsWriteToCommittedIdentity(t *testing.T) { t.Parallel() ctx := context.Background() @@ -143,7 +168,7 @@ func TestStoreCodeSnapshotWithRevisionBindsWriteToCommittedIdentity(t *testing.T if err != nil { t.Fatal(err) } - _, _, revision, err := c.StoreCodeSnapshotWithRevision(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{ + _, _, revision, err := c.StoreCodeSnapshotWithRevision(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{ RepoPath: "/repo", Commit: "commit", CreatedAt: time.Unix(1, 0), Documents: []codeindex.Document{{Path: "main.go", Content: "package main", Bytes: 12}}, TotalBytes: 12, }) @@ -163,7 +188,7 @@ func TestCodeSearchWeightsPathAndReturnsBoundedSnippet(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") longContent := "music " + strings.Repeat("padding ", 500) snapshot := codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc", CreatedAt: time.Unix(100, 0), diff --git a/internal/corpus/concerns.go b/internal/corpus/concerns.go index b210136d..0840678c 100644 --- a/internal/corpus/concerns.go +++ b/internal/corpus/concerns.go @@ -38,7 +38,7 @@ func (c *Corpus) SaveConcern(ctx context.Context, item *concern.Concern) error { success_criterion=excluded.success_criterion, status=excluded.status, confidence=excluded.confidence, payload=excluded.payload, updated_at=excluded.updated_at - `, item.ID, item.Repo.Owner, item.Repo.Repo, item.CommitSHA, item.WorkspaceID, + `, item.ID, item.Repo.Owner(), item.Repo.Repo(), item.CommitSHA, item.WorkspaceID, item.Title, item.ProblemStatement, item.SuspectedOwner, strings.Join(item.Unknowns, "\n"), item.SuccessCriterion, item.Status, item.Confidence, payload, encodeTime(item.CreatedAt), encodeTime(item.UpdatedAt)) @@ -68,7 +68,7 @@ func (c *Corpus) UpdateConcern(ctx context.Context, previous, next *concern.Conc problem_statement=?, suspected_owner=?, unknowns=?, success_criterion=?, status=?, confidence=?, payload=?, updated_at=? WHERE id=? AND json_remove(payload, '$.Links')=json_remove(?, '$.Links') - `, next.Repo.Owner, next.Repo.Repo, next.CommitSHA, next.WorkspaceID, next.Title, + `, next.Repo.Owner(), next.Repo.Repo(), next.CommitSHA, next.WorkspaceID, next.Title, next.ProblemStatement, next.SuspectedOwner, strings.Join(next.Unknowns, "\n"), next.SuccessCriterion, next.Status, next.Confidence, nextPayload, encodeTime(next.UpdatedAt), next.ID, previousPayload) @@ -118,9 +118,9 @@ func (c *Corpus) ListConcerns(ctx context.Context, filter concern.Filter) (_ *co args = append(args, query) rank = "bm25(concerns_fts, 10.0, 5.0, 2.0, 1.0, 1.0)" } - if filter.Repo.Owner != "" { + if filter.Repo.IsValid() { where = append(where, "c.repo_owner=? COLLATE NOCASE", "c.repo_name=? COLLATE NOCASE") - args = append(args, filter.Repo.Owner, filter.Repo.Repo) + args = append(args, filter.Repo.Owner(), filter.Repo.Repo()) } if filter.Status != "" { where = append(where, "c.status=?") @@ -254,6 +254,9 @@ func decodeConcern(payload string) (*concern.Concern, error) { if err := json.Unmarshal([]byte(payload), &item); err != nil { return nil, fmt.Errorf("decode concern: %w", err) } + if err := item.ParseStored(); err != nil { + return nil, fmt.Errorf("parse concern: %w", err) + } return &item, nil } @@ -347,7 +350,7 @@ func insertConcernWorkflowTx(ctx context.Context, tx *sql.Tx, inv *investigation if _, err := tx.ExecContext(ctx, ` INSERT INTO investigations (id, repo_owner, repo_name, status, origin_key, payload, created_at, updated_at) VALUES (?, ?, ?, ?, '', ?, ?, ?) - `, inv.ID, inv.Repo.Owner, inv.Repo.Repo, inv.Status, invPayload, encodeTime(inv.CreatedAt), encodeTime(inv.UpdatedAt)); err != nil { + `, inv.ID, inv.Repo.Owner(), inv.Repo.Repo(), inv.Status, invPayload, encodeTime(inv.CreatedAt), encodeTime(inv.UpdatedAt)); err != nil { return fmt.Errorf("save concern investigation: %w", err) } if _, err := tx.ExecContext(ctx, ` diff --git a/internal/corpus/concerns_test.go b/internal/corpus/concerns_test.go index a5b4f594..e5ced16f 100644 --- a/internal/corpus/concerns_test.go +++ b/internal/corpus/concerns_test.go @@ -2,6 +2,7 @@ package corpus import ( "context" + "encoding/json" "errors" "path/filepath" "testing" @@ -22,7 +23,7 @@ func TestConcernPersistenceSearchAndLinks(t *testing.T) { defer func() { _ = c.Close() }() svc := concern.NewService(c) first, err := svc.Create(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, CommitSHA: "abc", + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "abc", Title: "flaky MCP test", ProblemStatement: "transport occasionally stalls", Confidence: 0.5, Unknowns: []string{"scheduler timing"}, SuccessCriterion: "100 repeated runs pass", }) @@ -30,7 +31,7 @@ func TestConcernPersistenceSearchAndLinks(t *testing.T) { t.Fatal(err) } second, err := svc.Create(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, CommitSHA: "def", + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "def", Title: "live read boundary", ProblemStatement: "offline read may contact network", Confidence: 0.7, }) if err != nil { @@ -39,7 +40,7 @@ func TestConcernPersistenceSearchAndLinks(t *testing.T) { if err := svc.Link(ctx, first.ID, concern.Link{Kind: concern.LinkRelated, TargetType: "concern", TargetID: second.ID, Note: "same adapter"}); err != nil { t.Fatal(err) } - page, err := svc.List(ctx, concern.Filter{Repo: domain.RepoRef{Owner: "OWNER", Repo: "REPO"}, Query: "scheduler", Limit: 10}) + page, err := svc.List(ctx, concern.Filter{Repo: domain.MustRepoRef("OWNER", "REPO"), Query: "scheduler", Limit: 10}) if err != nil { t.Fatal(err) } @@ -55,6 +56,18 @@ func TestConcernPersistenceSearchAndLinks(t *testing.T) { } } +func TestDecodeConcernRejectsInvalidLifecycleState(t *testing.T) { + payload, err := json.Marshal(&concern.Concern{ + ID: "concern", Repo: domain.MustRepoRef("owner", "repo"), Status: "impossible", + }) + if err != nil { + t.Fatal(err) + } + if _, err := decodeConcern(string(payload)); err == nil { + t.Fatal("invalid durable concern status was accepted") + } +} + func TestPromoteConcernIsAtomic(t *testing.T) { t.Parallel() ctx := context.Background() @@ -65,7 +78,7 @@ func TestPromoteConcernIsAtomic(t *testing.T) { defer func() { _ = c.Close() }() svc := concern.NewService(c) item, err := svc.Create(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, CommitSHA: "abc", + Repo: domain.MustRepoRef("owner", "repo"), CommitSHA: "abc", Title: "flaky test", ProblemStatement: "fails intermittently", Confidence: 0.6, EvidenceIDs: []string{"evidence-1"}, }) @@ -111,7 +124,7 @@ func TestConcernSearchTreatsFTSOperatorsLiterally(t *testing.T) { defer func() { _ = c.Close() }() svc := concern.NewService(c) if _, err := svc.Create(ctx, &concern.Concern{ - Repo: domain.RepoRef{Owner: "o", Repo: "r"}, CommitSHA: "abc", + Repo: domain.MustRepoRef("o", "r"), CommitSHA: "abc", Title: "OR token", ProblemStatement: "literal operator", Confidence: 0.1, }); err != nil { t.Fatal(err) diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go index fdcbd555..25e1537f 100644 --- a/internal/corpus/corpus_test.go +++ b/internal/corpus/corpus_test.go @@ -650,8 +650,8 @@ func TestInterruptedAndFailedRuns(t *testing.T) { if err != nil { t.Fatalf("start run: %v", err) } - if run.Status != RunStatusRunning { - t.Fatalf("run status = %q, want running", run.Status) + if run.State.Status() != RunStatusRunning { + t.Fatalf("run status = %q, want running", run.State.Status()) } if err := c.RecordRunEvent(ctx, run.ID, "warn", "interrupted by signal"); err != nil { @@ -665,13 +665,13 @@ func TestInterruptedAndFailedRuns(t *testing.T) { if err != nil { t.Fatalf("get run: %v", err) } - if run.Status != RunStatusFailed { - t.Fatalf("run status = %q, want failed", run.Status) + if run.State.Status() != RunStatusFailed { + t.Fatalf("run status = %q, want failed", run.State.Status()) } if run.Error != "interrupted by signal" { t.Fatalf("run error = %q, want %q", run.Error, "interrupted by signal") } - if run.CompletedAt == nil { + if _, ok := run.State.CompletedAt(); !ok { t.Fatal("run completed_at is nil") } @@ -787,13 +787,13 @@ func TestRunCompletionAndStats(t *testing.T) { if err != nil { t.Fatalf("get run: %v", err) } - if run.Status != RunStatusCompleted { - t.Fatalf("run status = %q, want completed", run.Status) + 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 run.CompletedAt == nil { + if _, ok := run.State.CompletedAt(); !ok { t.Fatal("run completed_at is nil") } } diff --git a/internal/corpus/evidence_storage.go b/internal/corpus/evidence_storage.go index 819ecfe1..4648af98 100644 --- a/internal/corpus/evidence_storage.go +++ b/internal/corpus/evidence_storage.go @@ -18,6 +18,24 @@ func (c *Corpus) SaveEvidence(ctx context.Context, item *evidence.Evidence) erro return nil } +// SaveEvidenceBatch atomically stores one validated manifest's claims. +func (c *Corpus) SaveEvidenceBatch(ctx context.Context, items []*evidence.Evidence) (err error) { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin evidence batch: %w", err) + } + defer rollbackSQLOnReturn(tx, &err) + for i, item := range items { + if err := c.saveEvidenceTx(ctx, tx, item); err != nil { + return fmt.Errorf("save evidence batch item %d: %w", i, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit evidence batch: %w", err) + } + return nil +} + func (c *Corpus) saveEvidenceTx(ctx context.Context, db dbExecer, item *evidence.Evidence) error { payload, provenance, err := evidenceStorage(item) if err != nil { diff --git a/internal/corpus/inventory.go b/internal/corpus/inventory.go index 7b6c2019..24935291 100644 --- a/internal/corpus/inventory.go +++ b/internal/corpus/inventory.go @@ -379,8 +379,8 @@ type CodeSnapshotPrunePlan struct { // PlanCodeSnapshotPrune returns a dry-run plan that would keep the latest N // derived code snapshots for a repository and delete the rest. func (c *Corpus) PlanCodeSnapshotPrune(ctx context.Context, ref domain.RepoRef, keepLatest int) (*CodeSnapshotPrunePlan, error) { - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } if keepLatest < 0 { return nil, errors.New("keepLatest cannot be negative") @@ -426,8 +426,8 @@ func (c *Corpus) ApplyCodeSnapshotPrune(ctx context.Context, ref domain.RepoRef, if plan == nil { return nil, errors.New("prune plan is required") } - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } if plan.Ref != ref { return nil, fmt.Errorf("prune plan scope %q does not match repository %q", plan.Ref, ref) @@ -482,7 +482,7 @@ func (c *Corpus) listCodeSnapshotRetentionRefs(ctx context.Context, ref domain.R FROM code_snapshots WHERE repo_owner = ? AND repo_name = ? ORDER BY created_at DESC, id DESC - `, ref.Owner, ref.Repo) + `, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("list code snapshots for prune: %w", err) } @@ -496,7 +496,7 @@ func listCodeSnapshotRetentionRefsTx(ctx context.Context, tx *sql.Tx, ref domain FROM code_snapshots WHERE repo_owner = ? AND repo_name = ? ORDER BY created_at DESC, id DESC - `, ref.Owner, ref.Repo) + `, ref.Owner(), ref.Repo()) if err != nil { return nil, fmt.Errorf("list code snapshots for prune: %w", err) } diff --git a/internal/corpus/inventory_test.go b/internal/corpus/inventory_test.go index e9879159..ffd1106e 100644 --- a/internal/corpus/inventory_test.go +++ b/internal/corpus/inventory_test.go @@ -15,7 +15,7 @@ func TestCodeSnapshotPruneRejectsStalePlan(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") for i, commit := range []string{"one", "two"} { snapshot := codeindex.Snapshot{RepoPath: "/repo", Commit: commit, CreatedAt: time.Unix(int64(i+1), 0), TotalBytes: 1, Documents: []codeindex.Document{{Path: commit, Content: commit, Bytes: 1}}} if _, _, err := c.StoreCodeSnapshot(ctx, ref, snapshot); err != nil { @@ -41,7 +41,7 @@ func TestRepositoryInventoryCountsAndSizes(t *testing.T) { ctx := context.Background() owner, name := "owner", "repo" - ref := domain.RepoRef{Owner: owner, Repo: name} + ref := domain.MustRepoRef(owner, name) repo, err := c.ApplyRepositoryObservation(ctx, owner, name, "1", time.Unix(1, 0).UTC(), `{}`) requireInventorySetup(t, "apply repository", err) @@ -172,7 +172,7 @@ func TestListInventoryAggregatesEveryRepositoryScopeAndFreshness(t *testing.T) { if _, err := c.db.ExecContext(ctx, `UPDATE facet_observations SET observed_at = ?`, encodeTime(time.Unix(30, 0).UTC())); err != nil { t.Fatal(err) } - if _, _, err := c.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "code", Repo: "only"}, codeindex.Snapshot{ + if _, _, err := c.StoreCodeSnapshot(ctx, domain.MustRepoRef("code", "only"), codeindex.Snapshot{ RepoPath: "/code", Commit: "abc", CreatedAt: time.Unix(40, 0).UTC(), TotalBytes: 7, Documents: []codeindex.Document{{Path: "main.go", Content: "package", Bytes: 7}}, }); err != nil { @@ -207,7 +207,7 @@ func TestCodeSnapshotPrunePreservesLatestN(t *testing.T) { ctx := context.Background() owner, name := "owner", "repo" - ref := domain.RepoRef{Owner: owner, Repo: name} + ref := domain.MustRepoRef(owner, name) repo, err := c.ApplyRepositoryObservation(ctx, owner, name, "1", time.Unix(1, 0).UTC(), `{}`) requireInventorySetup(t, "apply repository", err) @@ -297,7 +297,7 @@ func TestCodeSnapshotPruneRequiresExactRepoScope(t *testing.T) { c, _ := openTestCorpus(t) ctx := context.Background() - refA := domain.RepoRef{Owner: "owner", Repo: "repo"} + refA := domain.MustRepoRef("owner", "repo") first := codeindex.Snapshot{ RepoPath: "/repo", Commit: "first", CreatedAt: time.Unix(100, 0).UTC(), TotalBytes: 13, Documents: []codeindex.Document{{Path: "a.go", Content: "needle", Bytes: 13, LanguageHint: "go"}}, @@ -311,7 +311,7 @@ func TestCodeSnapshotPruneRequiresExactRepoScope(t *testing.T) { t.Fatalf("plan: %v", err) } - refB := domain.RepoRef{Owner: "other", Repo: "repo"} + refB := domain.MustRepoRef("other", "repo") if _, err := c.ApplyCodeSnapshotPrune(ctx, refB, plan); err == nil || !strings.Contains(err.Error(), "scope") { t.Fatalf("apply with mismatched scope should fail, got: %v", err) } diff --git a/internal/corpus/investigation_thread.go b/internal/corpus/investigation_thread.go index d99e2024..76bbf217 100644 --- a/internal/corpus/investigation_thread.go +++ b/internal/corpus/investigation_thread.go @@ -41,7 +41,7 @@ func (c *Corpus) StartThreadInvestigation(ctx context.Context, item *investigati INSERT OR IGNORE INTO investigations (id, repo_owner, repo_name, status, origin_key, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, item.ID, item.Repo.Owner, item.Repo.Repo, item.Status, originKey, + `, item.ID, item.Repo.Owner(), item.Repo.Repo(), item.Status, originKey, investigationPayload, encodeTime(item.CreatedAt), encodeTime(item.UpdatedAt)) if err != nil { return nil, nil, false, fmt.Errorf("insert thread investigation: %w", err) @@ -78,7 +78,7 @@ func validateThreadInvestigationPair(item *investigation.Investigation, hypothes if err := item.ThreadBaseline.Validate(); err != nil { return err } - if !strings.EqualFold(item.Repo.Owner, item.ThreadBaseline.Repo.Owner) || !strings.EqualFold(item.Repo.Repo, item.ThreadBaseline.Repo.Repo) { + if !strings.EqualFold(item.Repo.Owner(), item.ThreadBaseline.Repo.Owner()) || !strings.EqualFold(item.Repo.Repo(), item.ThreadBaseline.Repo.Repo()) { return fmt.Errorf("%w: investigation repository does not match its thread", investigation.ErrInvalidThreadBaseline) } if item.Status != investigation.InvestigationOpen { diff --git a/internal/corpus/investigation_thread_test.go b/internal/corpus/investigation_thread_test.go index 1d7e3db4..2000245f 100644 --- a/internal/corpus/investigation_thread_test.go +++ b/internal/corpus/investigation_thread_test.go @@ -53,7 +53,7 @@ func TestStartThreadInvestigationRollsBackLateHypothesisFailure(t *testing.T) { c, _ := openTestCorpus(t) now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) existingInvestigation := &investigation.Investigation{ - ID: "existing", Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Status: investigation.InvestigationOpen, + ID: "existing", Repo: domain.MustRepoRef("o", "r"), Status: investigation.InvestigationOpen, CreatedAt: now, UpdatedAt: now, } if err := c.SaveInvestigation(ctx, existingInvestigation); err != nil { @@ -136,7 +136,7 @@ func TestStartThreadInvestigationRejectsRepositoryMismatch(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) item, hypothesis := threadInvestigationPair("inv", "hyp", 1, time.Now().UTC()) - item.Repo = domain.RepoRef{Owner: "other", Repo: "repo"} + item.Repo = domain.MustRepoRef("other", "repo") _, _, _, err := c.StartThreadInvestigation(context.Background(), item, hypothesis) if !errors.Is(err, investigation.ErrInvalidThreadBaseline) { t.Fatalf("repository mismatch error = %v", err) @@ -145,7 +145,7 @@ func TestStartThreadInvestigationRejectsRepositoryMismatch(t *testing.T) { func threadInvestigationPair(investigationID, hypothesisID string, observationID int64, now time.Time) (*investigation.Investigation, *investigation.Hypothesis) { baseline := &investigation.ThreadBaseline{ - Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("o", "r"), Kind: domain.IssueKind, Number: 1, ObservationID: observationID, ObservationSequence: observationID, SourceUpdatedAt: now, ObservedAt: now, Source: domain.SourceRef{Source: "github:rest", URL: "https://api.github.com/repos/o/r/issues/1", ObservedAt: now, AsOf: now}, diff --git a/internal/corpus/jobs.go b/internal/corpus/jobs.go index e29ebe12..6286e81f 100644 --- a/internal/corpus/jobs.go +++ b/internal/corpus/jobs.go @@ -200,10 +200,10 @@ func (c *Corpus) StartJobAs(ctx context.Context, id, ownerID string) error { if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.Status) { - return fmt.Errorf("job is already %s", job.Status) + if isTerminalJobStatus(job.State.Status()) { + return fmt.Errorf("job is already %s", job.State.Status()) } - if job.CancelledAt != nil && !job.CancelledAt.IsZero() { + if job.State.CancellationRequested() { return ErrJobCancelled } return fmt.Errorf("job is not queued") @@ -225,6 +225,7 @@ func (c *Corpus) TransitionJob(ctx context.Context, id, from, to, result, errStr res, dbErr := c.db.ExecContext(ctx, ` UPDATE jobs SET status = ?, result = ?, error = ?, completed_at = ?, updated_at = ?, + cancelled_at = CASE WHEN ? = ? THEN COALESCE(cancelled_at, ?) ELSE cancelled_at END, owner_id = CASE WHEN ? = ? OR ? = ? OR ? = ? THEN NULL @@ -232,6 +233,7 @@ func (c *Corpus) TransitionJob(ctx context.Context, id, from, to, result, errStr END WHERE id = ? AND status = ? AND (COALESCE(cancelled_at, 0) = 0 OR ? = ?) `, to, result, errStr, encodeTime(now), encodeTime(now), + to, JobStatusCancelled, encodeTime(now), to, JobStatusSucceeded, to, JobStatusFailed, to, JobStatusCancelled, id, from, to, JobStatusCancelled) if dbErr != nil { @@ -249,10 +251,10 @@ func (c *Corpus) TransitionJob(ctx context.Context, id, from, to, result, errStr if job == nil { return errors.New("job not found") } - if job.Status != from { - return fmt.Errorf("job status is %s, expected %s", job.Status, from) + if job.State.Status() != from { + return fmt.Errorf("job status is %s, expected %s", job.State.Status(), from) } - if job.CancelledAt != nil && !job.CancelledAt.IsZero() && to != JobStatusCancelled { + if job.State.CancellationRequested() && to != JobStatusCancelled { return ErrJobCancelled } return errors.New("transition not applied") @@ -283,10 +285,10 @@ func (c *Corpus) UpdateJobProgress(ctx context.Context, id, progress, statistics if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.Status) { - return fmt.Errorf("job is already %s", job.Status) + if isTerminalJobStatus(job.State.Status()) { + return fmt.Errorf("job is already %s", job.State.Status()) } - if job.CancelledAt != nil && !job.CancelledAt.IsZero() { + if job.State.CancellationRequested() { return ErrJobCancelled } return errors.New("job is not running") @@ -329,10 +331,10 @@ func (c *Corpus) RequestJobCancellation(ctx context.Context, id string) error { if job == nil { return errors.New("job not found") } - if isTerminalJobStatus(job.Status) { - return fmt.Errorf("job is already %s", job.Status) + if isTerminalJobStatus(job.State.Status()) { + return fmt.Errorf("job is already %s", job.State.Status()) } - return fmt.Errorf("cannot cancel job in status %s", job.Status) + return fmt.Errorf("cannot cancel job in status %s", job.State.Status()) } return nil } @@ -501,7 +503,8 @@ func scanJob(row rowScanner) (*Job, error) { var created, updated int64 var started, completed, cancelled sql.NullInt64 var result, errStr, progress, stats sql.NullString - err := row.Scan(&j.ID, &j.Kind, &j.Status, &j.Request, &result, &errStr, + var status string + err := row.Scan(&j.ID, &j.Kind, &status, &j.Request, &result, &errStr, &progress, &stats, &created, &started, &completed, &updated, &cancelled) if err != nil { return nil, err @@ -512,21 +515,25 @@ func scanJob(row rowScanner) (*Job, error) { j.Statistics = stats.String j.CreatedAt = scanTime(created) j.UpdatedAt = scanTime(updated) - if started.Valid { - t := scanTime(started.Int64) - j.StartedAt = &t - } - if completed.Valid { - t := scanTime(completed.Int64) - j.CompletedAt = &t - } - if cancelled.Valid && cancelled.Int64 != 0 { - t := scanTime(cancelled.Int64) - j.CancelledAt = &t + startedAt := nullableJobTime(started) + completedAt := nullableJobTime(completed) + cancelledAt := nullableJobTime(cancelled) + state, err := parseJobState(status, startedAt, completedAt, cancelledAt) + if err != nil { + return nil, fmt.Errorf("parse stored job state: %w", err) } + j.State = state return &j, nil } +func nullableJobTime(value sql.NullInt64) *time.Time { + if !value.Valid || value.Int64 == 0 { + return nil + } + parsed := scanTime(value.Int64) + return &parsed +} + func isTerminalJobStatus(status string) bool { return status == JobStatusSucceeded || status == JobStatusFailed || status == JobStatusCancelled } diff --git a/internal/corpus/jobs_test.go b/internal/corpus/jobs_test.go index 72081ad2..6baf213d 100644 --- a/internal/corpus/jobs_test.go +++ b/internal/corpus/jobs_test.go @@ -22,8 +22,8 @@ func TestCreateAndGetJob(t *testing.T) { if job.ID == "" { t.Fatal("job id is empty") } - if job.Status != JobStatusQueued { - t.Fatalf("status = %q, want %q", job.Status, JobStatusQueued) + if job.State.Status() != JobStatusQueued { + t.Fatalf("status = %q, want %q", job.State.Status(), JobStatusQueued) } if job.Request != `{"repo":"owner/repo"}` { t.Fatalf("request = %q", job.Request) @@ -38,6 +38,26 @@ func TestCreateAndGetJob(t *testing.T) { } } +func TestJobStateRejectsContradictoryLifecycle(t *testing.T) { + t.Parallel() + now := time.Unix(1, 0).UTC() + for _, test := range []struct { + 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: "invented", completed: &now}, + } { + if _, err := parseJobState(test.status, test.started, test.completed, test.cancelled); err == nil { + t.Fatalf("parseJobState(%q) succeeded", test.status) + } + } +} + func TestGetJobsBatchCanSkipPayloadBlobs(t *testing.T) { t.Parallel() ctx := context.Background() @@ -65,7 +85,7 @@ func TestGetJobsBatchCanSkipPayloadBlobs(t *testing.T) { if len(summary) != 2 || summary["missing"] != nil { t.Fatalf("summary jobs = %+v", summary) } - if summary[first.ID].Request != "" || summary[first.ID].Result != "" || summary[first.ID].Status != JobStatusSucceeded { + 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) @@ -141,7 +161,7 @@ func TestJobStatusTransitions(t *testing.T) { if err != nil { t.Fatal(err) } - if job.Status != JobStatusRunning || job.StartedAt == nil || job.StartedAt.IsZero() { + if _, started := job.State.StartedAt(); job.State.Status() != JobStatusRunning || !started { t.Fatalf("job not running: %+v", job) } @@ -157,7 +177,7 @@ func TestJobStatusTransitions(t *testing.T) { t.Fatalf("complete job: %v", err) } job, _ = c.GetJob(ctx, job.ID) - if job.Status != JobStatusSucceeded || job.Result != `{"done":true}` || job.CompletedAt == nil { + if _, completed := job.State.CompletedAt(); job.State.Status() != JobStatusSucceeded || job.Result != `{"done":true}` || !completed { t.Fatalf("job not succeeded: %+v", job) } @@ -189,7 +209,9 @@ func TestJobCancellation(t *testing.T) { if err != nil { t.Fatal(err) } - if job.Status != JobStatusCancelled || job.CompletedAt == nil || job.CancelledAt == nil { + _, completed := job.State.CompletedAt() + _, cancelled := job.State.CancelledAt() + if job.State.Status() != JobStatusCancelled || !completed || !cancelled { t.Fatalf("queued job not cancelled: %+v", job) } @@ -208,10 +230,10 @@ func TestJobCancellation(t *testing.T) { if err != nil { t.Fatal(err) } - if job.Status != JobStatusRunning { - t.Fatalf("status = %q, want %q", job.Status, JobStatusRunning) + if job.State.Status() != JobStatusRunning { + t.Fatalf("status = %q, want %q", job.State.Status(), JobStatusRunning) } - if job.CancelledAt == nil || job.CancelledAt.IsZero() { + if _, ok := job.State.CancelledAt(); !ok { t.Fatal("cancelled_at not set") } @@ -321,13 +343,13 @@ func TestReconcileInterruptedJobs(t *testing.T) { } plain, _ = c.GetJob(ctx, plain.ID) - if plain.Status != JobStatusFailed || plain.Error != "interrupted by restart" { - t.Fatalf("plain job status = %q, error = %q", plain.Status, plain.Error) + if plain.State.Status() != JobStatusFailed || plain.Error != "interrupted by restart" { + t.Fatalf("plain job status = %q, error = %q", plain.State.Status(), plain.Error) } cancelled, _ = c.GetJob(ctx, cancelled.ID) - if cancelled.Status != JobStatusCancelled { - t.Fatalf("cancelled job status = %q, want %q", cancelled.Status, JobStatusCancelled) + if cancelled.State.Status() != JobStatusCancelled { + t.Fatalf("cancelled job status = %q, want %q", cancelled.State.Status(), JobStatusCancelled) } events, err := c.ListJobEvents(ctx, plain.ID) @@ -578,13 +600,13 @@ func TestReconcileRespectsLiveOwners(t *testing.T) { } liveJob, _ = c.GetJob(ctx, liveJob.ID) - if liveJob.Status != JobStatusRunning { - t.Fatalf("live job reconciled: status=%q", liveJob.Status) + if liveJob.State.Status() != JobStatusRunning { + t.Fatalf("live job reconciled: status=%q", liveJob.State.Status()) } staleJob, _ = c.GetJob(ctx, staleJob.ID) - if staleJob.Status != JobStatusCancelled { - t.Fatalf("stale job status = %q, want cancelled", staleJob.Status) + if staleJob.State.Status() != JobStatusCancelled { + t.Fatalf("stale job status = %q, want cancelled", staleJob.State.Status()) } if staleJob.Error != "interrupted by restart (cancellation requested)" { t.Fatalf("stale job error = %q", staleJob.Error) diff --git a/internal/corpus/lifecycle.go b/internal/corpus/lifecycle.go index d417ed0d..fd0234c0 100644 --- a/internal/corpus/lifecycle.go +++ b/internal/corpus/lifecycle.go @@ -52,7 +52,6 @@ type MigrationStep struct { // SchemaInspection is a read-only migration plan input. type SchemaInspection struct { Path string - Exists bool SizeBytes int64 WALBytes int64 State SchemaState @@ -68,6 +67,17 @@ type SchemaInspection struct { ProjectionRebuildRequired bool } +// Exists is derived from the compatibility state so corpus presence cannot +// disagree with the inspection result. +func (i SchemaInspection) Exists() bool { + switch i.State { + case SchemaCurrent, SchemaMigrationRequired, SchemaNewer, SchemaIncompatible, SchemaDamaged: + return true + default: + return false + } +} + // MigrationProgress reports stable step boundaries. SQL migration internals // remain owned by Goose; data-sized migrations should expose their own bounded // checkpoints rather than pretending statement-level progress is available. @@ -197,7 +207,6 @@ func InspectSchema(ctx context.Context, path string) (result SchemaInspection, r if err != nil { return result, fmt.Errorf("inspect corpus file: %w", err) } - result.Exists = true result.SizeBytes = info.Size() if walInfo, statErr := os.Stat(filePath + "-wal"); statErr == nil { result.WALBytes = walInfo.Size() diff --git a/internal/corpus/lifecycle_test.go b/internal/corpus/lifecycle_test.go index 0bada91d..d5cec2d1 100644 --- a/internal/corpus/lifecycle_test.go +++ b/internal/corpus/lifecycle_test.go @@ -47,7 +47,7 @@ func TestInspectMissingCorpusHasNoFilesystemSideEffects(t *testing.T) { if err != nil { t.Fatal(err) } - if inspection.Exists || inspection.State != SchemaMissing { + if inspection.Exists() || inspection.State != SchemaMissing { t.Fatalf("inspection = %+v", inspection) } for _, candidate := range []string{path, path + ".lock"} { diff --git a/internal/corpus/models.go b/internal/corpus/models.go index 3105fb56..1efea740 100644 --- a/internal/corpus/models.go +++ b/internal/corpus/models.go @@ -1,6 +1,12 @@ package corpus -import "time" +import ( + "errors" + "fmt" + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) // Repository is the current projection of a GitHub repository. type Repository struct { @@ -56,9 +62,7 @@ type Thread struct { Locked bool Milestone string ClosedAt time.Time - MergedAt time.Time - Merged bool - MergedKnown bool + Merge domain.MergeStatus SourceCreatedAt time.Time SourceUpdatedAt time.Time ObservationSequence int64 @@ -144,13 +148,12 @@ type Coverage struct { // Run records a crawl, hydration, indexing, or validation attempt. type Run struct { - ID int64 - Kind string - Status string - StartedAt time.Time - CompletedAt *time.Time - Stats string - Error string + ID int64 + Kind string + State RunState + StartedAt time.Time + Stats string + Error string } // RunEvent is a durable log line emitted during a run. @@ -162,14 +165,48 @@ type RunEvent struct { RecordedAt time.Time } +// RunStatus is a persisted run lifecycle value. +type RunStatus string + // RunStatus values. const ( - RunStatusRunning = "running" - RunStatusCompleted = "completed" - RunStatusPartial = "partial" - RunStatusFailed = "failed" + RunStatusRunning RunStatus = "running" + RunStatusCompleted RunStatus = "completed" + RunStatusPartial RunStatus = "partial" + RunStatusFailed RunStatus = "failed" ) +// RunState binds a run status to the completion time required by terminal +// states. Its zero value is invalid. +type RunState struct { + status RunStatus + completedAt time.Time +} + +func parseRunState(status string, completedAt *time.Time) (RunState, error) { + parsed := RunStatus(status) + switch parsed { + case RunStatusRunning: + if completedAt != nil { + return RunState{}, errors.New("running run cannot have a completion time") + } + return RunState{status: parsed}, nil + case RunStatusCompleted, RunStatusPartial, RunStatusFailed: + if completedAt == nil || completedAt.IsZero() { + return RunState{}, fmt.Errorf("%s run requires a completion time", parsed) + } + return RunState{status: parsed, completedAt: *completedAt}, nil + default: + return RunState{}, fmt.Errorf("unknown run status %q", status) + } +} + +func (s RunState) Status() RunStatus { return s.status } + +func (s RunState) CompletedAt() (time.Time, bool) { + return s.completedAt, !s.completedAt.IsZero() +} + // JobStatus values for the durable job lifecycle. const ( JobStatusQueued = "queued" @@ -181,21 +218,77 @@ const ( // Job is a durable, cancellable unit of work. type Job struct { - ID string - Kind string - Status string - Request string - Result string - Error string - Progress string - Statistics string - CreatedAt time.Time - StartedAt *time.Time - CompletedAt *time.Time - UpdatedAt time.Time - CancelledAt *time.Time + ID string + Kind string + State JobState + Request string + Result string + Error string + Progress string + Statistics string + CreatedAt time.Time + UpdatedAt time.Time } +// 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 + startedAt time.Time + completedAt time.Time + cancelledAt time.Time +} + +func parseJobState(status string, startedAt, completedAt, cancelledAt *time.Time) (JobState, error) { + state := JobState{status: status} + if startedAt != nil { + state.startedAt = *startedAt + } + if completedAt != nil { + state.completedAt = *completedAt + } + if cancelledAt != nil { + state.cancelledAt = *cancelledAt + } + switch status { + case JobStatusQueued: + if startedAt != nil || completedAt != nil || cancelledAt != nil { + return JobState{}, errors.New("queued job cannot have lifecycle timestamps") + } + case JobStatusRunning: + if startedAt == nil || startedAt.IsZero() { + return JobState{}, errors.New("running job requires a start time") + } + if completedAt != nil { + return JobState{}, errors.New("running job cannot have a completion time") + } + case JobStatusSucceeded, JobStatusFailed: + if completedAt == nil || completedAt.IsZero() { + return JobState{}, fmt.Errorf("%s job requires a completion time", status) + } + if cancelledAt != nil { + return JobState{}, fmt.Errorf("%s job cannot have a cancellation time", status) + } + 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) StartedAt() (time.Time, bool) { return s.startedAt, !s.startedAt.IsZero() } + +func (s JobState) CompletedAt() (time.Time, bool) { return s.completedAt, !s.completedAt.IsZero() } + +func (s JobState) CancelledAt() (time.Time, bool) { return s.cancelledAt, !s.cancelledAt.IsZero() } + +func (s JobState) CancellationRequested() bool { return !s.cancelledAt.IsZero() } + // JobEvent is a durable log line emitted during a job. type JobEvent struct { ID int64 diff --git a/internal/corpus/observations.go b/internal/corpus/observations.go index c5457380..b2fe2dbe 100644 --- a/internal/corpus/observations.go +++ b/internal/corpus/observations.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) // ErrThreadObservationRevisionNotFound reports a projection revision whose @@ -185,8 +187,11 @@ func (c *Corpus) ApplyThreadObservation(ctx context.Context, repoID int64, kind // UpsertThread records a thread observation and updates the projection with // all fields when the source ordering is newer. func (c *Corpus) UpsertThread(ctx context.Context, thread Thread, payload string) (*Thread, error) { - if thread.Kind == ThreadKindPullRequest && (thread.Merged || !thread.MergedAt.IsZero()) { - thread.MergedKnown = true + if err := parseThreadProjection(&thread); err != nil { + return nil, err + } + if thread.Kind != ThreadKindPullRequest && thread.Merge.Known() { + return nil, errors.New("only pull requests can have merge status") } tx, err := c.db.BeginTx(ctx, nil) if err != nil { @@ -208,8 +213,8 @@ func (c *Corpus) UpsertThread(ctx context.Context, thread Thread, payload string closed.Valid = true } merged := sql.NullInt64{} - if !thread.MergedAt.IsZero() { - merged.Int64 = encodeTime(thread.MergedAt) + if !thread.Merge.MergedAt().IsZero() { + merged.Int64 = encodeTime(thread.Merge.MergedAt()) merged.Valid = true } assignees := deterministicAssignees(thread.Assignees) @@ -222,7 +227,7 @@ func (c *Corpus) UpsertThread(ctx context.Context, thread Thread, payload string res, err := tx.ExecContext(ctx, ` INSERT INTO threads (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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, thread.RepositoryID, thread.Kind, thread.Number, thread.State, thread.StateReason, thread.Title, thread.Body, thread.Author, thread.AuthorAssociation, joinLabels(thread.Labels), joinLabels(assignees), boolToInt(thread.Draft), boolToInt(thread.Locked), thread.Milestone, sourceCreated, srcSec, seq, now, now, closed, merged, boolToInt(thread.Merged), boolToInt(thread.MergedKnown)) + `, thread.RepositoryID, thread.Kind, thread.Number, thread.State, thread.StateReason, thread.Title, thread.Body, thread.Author, thread.AuthorAssociation, joinLabels(thread.Labels), joinLabels(assignees), boolToInt(thread.Draft), boolToInt(thread.Locked), thread.Milestone, sourceCreated, srcSec, seq, now, now, closed, merged, boolToInt(thread.Merge.IsMerged()), boolToInt(thread.Merge.Known())) if err != nil { return nil, fmt.Errorf("insert thread: %w", err) } @@ -256,7 +261,7 @@ func (c *Corpus) UpsertThread(ctx context.Context, thread Thread, payload string merged_known = CASE WHEN ? = 1 THEN 1 ELSE merged_known END WHERE id = ? AND (source_updated_at < ? OR (source_updated_at = ? AND observation_sequence < ?)) - `, thread.State, thread.StateReason, thread.Title, thread.Body, thread.Author, thread.AuthorAssociation, joinLabels(thread.Labels), joinLabels(assignees), boolToInt(thread.Draft), boolToInt(thread.Locked), thread.Milestone, sourceCreated, srcSec, seq, now, closed, boolToInt(thread.MergedKnown), merged, boolToInt(thread.MergedKnown), boolToInt(thread.Merged), boolToInt(thread.MergedKnown), threadID, srcSec, srcSec, seq); err != nil { + `, thread.State, thread.StateReason, thread.Title, thread.Body, thread.Author, thread.AuthorAssociation, joinLabels(thread.Labels), joinLabels(assignees), boolToInt(thread.Draft), boolToInt(thread.Locked), thread.Milestone, sourceCreated, srcSec, seq, now, closed, boolToInt(thread.Merge.Known()), merged, boolToInt(thread.Merge.Known()), boolToInt(thread.Merge.IsMerged()), boolToInt(thread.Merge.Known()), threadID, srcSec, srcSec, seq); err != nil { return nil, fmt.Errorf("update thread projection: %w", err) } } @@ -598,12 +603,36 @@ func scanThread(row rowScanner) (*Thread, error) { thread.CreatedAt = scanTime(created) thread.UpdatedAt = scanTime(updated) thread.ClosedAt = scanTime(closed.Int64) - thread.MergedAt = scanTime(mergedAt.Int64) - thread.Merged = merged != 0 - thread.MergedKnown = mergedKnown != 0 + if err := parseThreadProjection(&thread); err != nil { + return nil, fmt.Errorf("parse stored thread: %w", err) + } + merge, err := domain.ParseMergeStatus(mergedKnown != 0, merged != 0, scanTime(mergedAt.Int64)) + if err != nil { + return nil, fmt.Errorf("parse stored merge status: %w", err) + } + thread.Merge = merge return &thread, nil } +func parseThreadProjection(thread *Thread) error { + if thread == nil { + return errors.New("thread is required") + } + kind, err := domain.ParseThreadKind(thread.Kind) + if err != nil { + return err + } + state, err := domain.ParseThreadState(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) + return nil +} + func boolToInt(v bool) int { if v { return 1 diff --git a/internal/corpus/observations_test.go b/internal/corpus/observations_test.go index 146d355a..a79c258b 100644 --- a/internal/corpus/observations_test.go +++ b/internal/corpus/observations_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/morluto/gitcontribute/internal/domain" ) func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { @@ -74,6 +75,32 @@ func TestListThreadsFilteredAppliesStateBeforeLimit(t *testing.T) { } } +func TestUpsertThreadRejectsUnknownKindAndStateBeforeWrite(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + repo, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "id", time.Unix(1, 0).UTC(), `{}`) + if err != nil { + t.Fatal(err) + } + 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"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := c.UpsertThread(ctx, thread, `{}`); err == nil { + t.Fatal("invalid thread projection was accepted") + } + }) + } + threads, err := c.ListThreads(ctx, repo.ID, "", 10) + if err != nil { + t.Fatal(err) + } + if len(threads) != 0 { + t.Fatalf("invalid thread projection was written: %+v", threads) + } +} + func TestListThreadsByStateAndMergeIgnoresMergedOutsidePullRequests(t *testing.T) { t.Parallel() ctx := context.Background() @@ -85,8 +112,8 @@ 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", Merged: true, MergedKnown: true, SourceUpdatedAt: time.Unix(2, 0).UTC()}, - {RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "closed", Title: "unmerged", MergedKnown: true, SourceUpdatedAt: time.Unix(1, 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()}, } { if _, err := c.UpsertThread(ctx, thread, `{}`); err != nil { t.Fatal(err) @@ -295,13 +322,12 @@ func TestUpsertThreadUnknownMergeStateDoesNotEraseKnownState(t *testing.T) { mergedAt := at.Add(-time.Hour) known, err := c.UpsertThread(ctx, Thread{ RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, - State: "closed", Title: "details", Merged: true, MergedKnown: true, - MergedAt: mergedAt, SourceUpdatedAt: at, + State: "closed", Title: "details", Merge: domain.MergedStatus(mergedAt), SourceUpdatedAt: at, }, `{"Merged":true}`) if err != nil { t.Fatalf("upsert known details: %v", err) } - if !known.MergedKnown || !known.Merged { + if !known.Merge.Known() || !known.Merge.IsMerged() { t.Fatalf("known projection = %+v", known) } @@ -312,19 +338,19 @@ func TestUpsertThreadUnknownMergeStateDoesNotEraseKnownState(t *testing.T) { if err != nil { t.Fatalf("upsert header-only observation: %v", err) } - if got.Title != "newer header" || !got.MergedKnown || !got.Merged || !got.MergedAt.Equal(mergedAt) { + if got.Title != "newer header" || !got.Merge.Known() || !got.Merge.IsMerged() || !got.Merge.MergedAt().Equal(mergedAt) { t.Fatalf("header sync erased explicit merge state: %+v", got) } got, err = c.UpsertThread(ctx, Thread{ RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, - State: "closed", Title: "observed false", MergedKnown: true, + State: "closed", Title: "observed false", Merge: domain.UnmergedStatus(), SourceUpdatedAt: at.Add(2 * time.Second), }, `{"Merged":false}`) if err != nil { t.Fatalf("upsert observed false details: %v", err) } - if !got.MergedKnown || got.Merged || !got.MergedAt.IsZero() { + if !got.Merge.Known() || got.Merge.IsMerged() || !got.Merge.MergedAt().IsZero() { t.Fatalf("explicit false did not replace projection: %+v", got) } } diff --git a/internal/corpus/portfolio.go b/internal/corpus/portfolio.go index d9f9778e..d711159f 100644 --- a/internal/corpus/portfolio.go +++ b/internal/corpus/portfolio.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "strings" + + "github.com/morluto/gitcontribute/internal/domain" ) // ListPullRequestPortfolio returns pull requests across all stored @@ -122,9 +124,14 @@ func scanPullRequestPortfolioRows(rows *sql.Rows) (_ []PortfolioPullRequest, err item.Thread.CreatedAt = scanTime(created) item.Thread.UpdatedAt = scanTime(updated) item.Thread.ClosedAt = scanTime(closed.Int64) - item.Thread.MergedAt = scanTime(mergedAt.Int64) - item.Thread.Merged = merged != 0 - item.Thread.MergedKnown = mergedKnown != 0 + if err := parseThreadProjection(&item.Thread); err != nil { + return nil, fmt.Errorf("parse stored portfolio thread: %w", err) + } + merge, err := domain.ParseMergeStatus(mergedKnown != 0, merged != 0, scanTime(mergedAt.Int64)) + if err != nil { + return nil, fmt.Errorf("parse stored merge status: %w", err) + } + item.Thread.Merge = merge out = append(out, item) } if err := rows.Err(); err != nil { diff --git a/internal/corpus/portfolio_test.go b/internal/corpus/portfolio_test.go new file mode 100644 index 00000000..fe99b222 --- /dev/null +++ b/internal/corpus/portfolio_test.go @@ -0,0 +1,33 @@ +package corpus + +import ( + "context" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) + +func TestPortfolioReadRejectsInvalidStoredThreadProjection(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + now := time.Unix(1, 0).UTC() + repo, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "rocket", SourceUpdatedAt: now}, `{}`) + if err != nil { + t.Fatal(err) + } + thread, err := c.UpsertThread(ctx, Thread{ + RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 1, State: string(domain.OpenState), + Title: "invalid projection fixture", SourceCreatedAt: now, SourceUpdatedAt: now, + }, `{}`) + if err != nil { + t.Fatal(err) + } + 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 { + t.Fatal("portfolio read accepted an invalid stored thread projection") + } +} diff --git a/internal/corpus/precedent.go b/internal/corpus/precedent.go index 7fd21052..62448171 100644 --- a/internal/corpus/precedent.go +++ b/internal/corpus/precedent.go @@ -53,35 +53,39 @@ func (c *Corpus) LoadPrecedentRepositories(ctx context.Context, refs []precedent if err := ctx.Err(); err != nil { return nil, err } - snapshot := precedent.RepositorySnapshot{Repository: request.ref, Sources: make(map[int]precedent.Thread)} var repositoryID int64 - err = tx.QueryRowContext(ctx, `SELECT id FROM repositories WHERE owner=? AND name=?`, request.ref.Owner, request.ref.Repo).Scan(&repositoryID) + err = tx.QueryRowContext(ctx, `SELECT id FROM repositories WHERE owner=? AND name=?`, request.ref.Owner(), request.ref.Repo()).Scan(&repositoryID) if errors.Is(err, sql.ErrNoRows) { - out = append(out, snapshot) + out = append(out, precedent.MissingRepositorySnapshot(request.ref)) continue } if err != nil { return nil, err } - snapshot.Available = true - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM threads WHERE repository_id=? AND state='closed'`, repositoryID).Scan(&snapshot.ClosedTotal); err != nil { + var closedTotal int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM threads WHERE repository_id=? AND state='closed'`, repositoryID).Scan(&closedTotal); err != nil { return nil, err } sources, err := loadThreadsByNumbersTx(ctx, tx, repositoryID, request.numbers) if err != nil { return nil, err } + sourceByNumber := make(map[int]precedent.Thread, len(sources)) for _, source := range sources { - snapshot.Sources[source.Number] = precedentThread(source) + sourceByNumber[source.Number] = precedentThread(source) } closed, err := loadClosedPrecedentsTx(ctx, tx, repositoryID, closedLimit) if err != nil { return nil, err } + closedThreads := make([]precedent.Thread, 0, len(closed)) for _, candidate := range closed { - snapshot.Closed = append(snapshot.Closed, precedentThread(candidate)) + closedThreads = append(closedThreads, precedentThread(candidate)) + } + snapshot, err := precedent.AvailableRepositorySnapshot(request.ref, sourceByNumber, closedThreads, closedTotal) + if err != nil { + return nil, fmt.Errorf("parse precedent snapshot: %w", err) } - snapshot.ClosedTruncated = len(snapshot.Closed) < snapshot.ClosedTotal out = append(out, snapshot) } if err := tx.Commit(); err != nil { @@ -139,7 +143,6 @@ func precedentThread(thread Thread) precedent.Thread { Body: thread.Body, Labels: append([]string(nil), thread.Labels...), ClosedAt: thread.ClosedAt, - MergedAt: thread.MergedAt, - Merged: thread.Merged, + Merge: thread.Merge, } } diff --git a/internal/corpus/precedent_test.go b/internal/corpus/precedent_test.go index a8683147..8115b6c8 100644 --- a/internal/corpus/precedent_test.go +++ b/internal/corpus/precedent_test.go @@ -28,13 +28,13 @@ func TestLoadPrecedentRepositoriesGroupsSourcesByRepository(t *testing.T) { } snapshots, err := c.LoadPrecedentRepositories(ctx, []precedent.SourceRef{ - {Repository: domain.RepoRef{Owner: "acme", Repo: "rocket"}, Number: 1}, - {Repository: domain.RepoRef{Owner: "acme", Repo: "rocket"}, Number: 2}, + {Repository: domain.MustRepoRef("acme", "rocket"), Number: 1}, + {Repository: domain.MustRepoRef("acme", "rocket"), Number: 2}, }, 2000) if err != nil { t.Fatal(err) } - if len(snapshots) != 1 || !snapshots[0].Available { + if len(snapshots) != 1 || !snapshots[0].Available() { t.Fatalf("snapshots = %+v", snapshots) } if len(snapshots[0].Sources) != 2 || snapshots[0].Sources[1].Title != "source one" || snapshots[0].Sources[2].Title != "source two" { diff --git a/internal/corpus/projections_test.go b/internal/corpus/projections_test.go index 6ad624ac..99ab7109 100644 --- a/internal/corpus/projections_test.go +++ b/internal/corpus/projections_test.go @@ -310,7 +310,7 @@ func TestRebuildCodeSearchProjectionIsAtomicAndSetsState(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") snapshot := codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc", diff --git a/internal/corpus/pull_request_feedback.go b/internal/corpus/pull_request_feedback.go index a650bc18..12bd72ca 100644 --- a/internal/corpus/pull_request_feedback.go +++ b/internal/corpus/pull_request_feedback.go @@ -1,6 +1,10 @@ package corpus -import "time" +import ( + "time" + + "github.com/morluto/gitcontribute/internal/domain" +) const ( feedbackFacetIssueComments = "pr_feedback_issue_comments" @@ -69,8 +73,7 @@ type PullRequestFeedbackProjection struct { PullRequestNumber int PullRequestAuthor string PullRequestState string - PullRequestMergedKnown bool - PullRequestMerged bool + PullRequestMerge domain.MergeStatus Channel string FeedbackID string FeedbackNodeID string @@ -87,8 +90,7 @@ type PullRequestFeedbackProjection struct { ReviewState string CreatedAt time.Time UpdatedAt time.Time - ResolvedKnown bool - Resolved bool + Resolution domain.ObservedBool ResolvedBy string Outdated bool HeadSHA string diff --git a/internal/corpus/pull_request_feedback_search.go b/internal/corpus/pull_request_feedback_search.go index 8d26490c..a1b487dc 100644 --- a/internal/corpus/pull_request_feedback_search.go +++ b/internal/corpus/pull_request_feedback_search.go @@ -8,6 +8,9 @@ import ( "errors" "fmt" "strings" + "time" + + "github.com/morluto/gitcontribute/internal/domain" ) type feedbackSearchCursor struct { @@ -313,10 +316,18 @@ func scanFeedbackProjectionRows(rows *sql.Rows) ([]PullRequestFeedbackProjection return nil, fmt.Errorf("scan feedback search row: %w", err) } item.PullRequestNumber, item.PullRequestAuthor, item.PullRequestState = number, prAuthor, state - item.PullRequestMergedKnown, item.PullRequestMerged = prMergedKnown != 0, prMerged != 0 + merge, err := domain.ParseMergeStatus(prMergedKnown != 0, prMerged != 0, time.Time{}) + if err != nil { + return nil, fmt.Errorf("parse stored pull-request merge status: %w", err) + } + item.PullRequestMerge = merge item.Line, item.StartLine = nullableInt(line), nullableInt(startLine) item.CreatedAt, item.UpdatedAt = scanTime(created), scanTime(updated) - item.ResolvedKnown, item.Resolved, item.Outdated = resolvedKnown != 0, resolved != 0, outdated != 0 + item.Resolution = domain.UnknownBool() + if resolvedKnown != 0 { + item.Resolution = domain.ObservedBoolValue(resolved != 0) + } + item.Outdated = outdated != 0 item.SourceUpdatedAt, item.SourceObservationSequence = scanTime(source), sequence out = append(out, item) } diff --git a/internal/corpus/pull_request_feedback_test.go b/internal/corpus/pull_request_feedback_test.go index ef6cea72..71acea94 100644 --- a/internal/corpus/pull_request_feedback_test.go +++ b/internal/corpus/pull_request_feedback_test.go @@ -6,6 +6,8 @@ import ( "fmt" "testing" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { @@ -17,7 +19,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", Merged: true, MergedKnown: true, SourceUpdatedAt: now}, `{}`) + pr, err := c.UpsertThread(ctx, Thread{RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 7, State: "closed", Author: "submitter", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: now}, `{}`) if err != nil { t.Fatal(err) } @@ -46,7 +48,7 @@ func TestPullRequestFeedbackProjectionRebuildAndSearch(t *testing.T) { if err != nil { t.Fatal(err) } - if len(page.Items) != 1 || page.Items[0].FeedbackID != "11" || page.Items[0].PullRequestNumber != 7 || page.Items[0].PullRequestMerged != true || 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.Status != "complete" { t.Fatalf("feedback page = %+v", page) } exact, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, FeedbackAuthor: "reviewer", Limit: 10}) @@ -234,7 +236,14 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { } for index, value := range cases { at := now.Add(time.Duration(index) * time.Minute) - 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), MergedKnown: value.mergedKnown, Merged: value.merged, SourceUpdatedAt: at}, `{}`) + merge := domain.UnknownMergeStatus() + if value.mergedKnown { + merge = domain.UnmergedStatus() + if value.merged { + 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}, `{}`) if err != nil { t.Fatal(err) } @@ -282,7 +291,11 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { if err != nil { t.Fatal(err) } - if len(filtered.Items) != 1 || filtered.Items[0].Channel != "review_threads" || !filtered.Items[0].ResolvedKnown || !filtered.Items[0].Resolved { + if len(filtered.Items) != 1 { + t.Fatalf("filtered feedback = %+v", filtered) + } + resolved, known := filtered.Items[0].Resolution.Value() + 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}) @@ -292,11 +305,11 @@ func TestPullRequestFeedbackFiltersSortingAndContinuation(t *testing.T) { if len(unknown.Items) != 0 || len(unknown.UnknownMergePullRequests) != 1 || unknown.UnknownMergePullRequests[0] != 1 { t.Fatalf("unknown merge candidates = %+v", unknown) } - resolved, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "resolved", Limit: 10}) + resolvedPage, err := c.SearchPullRequestFeedback(ctx, FeedbackSearchFilter{RepositoryID: repo.ID, Channel: "review_threads", ThreadState: "resolved", Limit: 10}) if err != nil { t.Fatal(err) } - if len(resolved.Items) != 1 || resolved.Items[0].ThreadExternalID != "thread-3" { - t.Fatalf("resolved feedback = %+v", resolved) + if len(resolvedPage.Items) != 1 || resolvedPage.Items[0].ThreadExternalID != "thread-3" { + t.Fatalf("resolved feedback = %+v", resolvedPage) } } diff --git a/internal/corpus/repository_removal.go b/internal/corpus/repository_removal.go index 9805d580..8cfe02db 100644 --- a/internal/corpus/repository_removal.go +++ b/internal/corpus/repository_removal.go @@ -50,8 +50,8 @@ var ErrRepositoryRemovalPlanStale = errors.New("repository removal plan is stale // PlanRepositoryRemoval previews removal without mutating the corpus. func (c *Corpus) PlanRepositoryRemoval(ctx context.Context, ref domain.RepoRef) (plan *RepositoryRemovalPlan, err error) { - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } tx, err := c.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { @@ -74,8 +74,8 @@ func (c *Corpus) ApplyRepositoryRemoval(ctx context.Context, ref domain.RepoRef, if plan == nil { return nil, errors.New("repository removal plan is required") } - if err := ref.Validate(); err != nil { - return nil, err + if !ref.IsValid() { + return nil, errors.New("repository reference is not parsed") } if plan.Ref != ref { return nil, fmt.Errorf("repository removal plan scope %q does not match repository %q", plan.Ref, ref) @@ -113,7 +113,7 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref err := db.QueryRowContext(ctx, ` SELECT id FROM repositories WHERE owner = ? AND name = ? - `, ref.Owner, ref.Repo).Scan(&plan.RepositoryID) + `, ref.Owner(), ref.Repo()).Scan(&plan.RepositoryID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrRepositoryNotFound } @@ -131,18 +131,18 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref {&plan.ThreadObservations, `SELECT COUNT(*) FROM thread_observations WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{plan.RepositoryID}}, {&plan.FacetObservations, `SELECT COUNT(*) FROM facet_observations WHERE repository_id = ?`, []any{plan.RepositoryID}}, {&plan.FacetCoverage, `SELECT COUNT(*) FROM facet_coverage WHERE repository_id = ?`, []any{plan.RepositoryID}}, - {&plan.CodeSnapshots, `SELECT COUNT(*) FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner, ref.Repo}}, - {&plan.CodeDocuments, `SELECT COUNT(*) FROM code_documents WHERE snapshot_id IN (SELECT id FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?)`, []any{ref.Owner, ref.Repo}}, + {&plan.CodeSnapshots, `SELECT COUNT(*) FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, + {&plan.CodeDocuments, `SELECT COUNT(*) FROM code_documents WHERE snapshot_id IN (SELECT id FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?)`, []any{ref.Owner(), ref.Repo()}}, {&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.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}}, {&plan.RemovedSignalSnapshots, `SELECT COUNT(*) FROM portfolio_signal_snapshots WHERE subject_kind = ? AND CAST(subject_ref AS INTEGER) IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{PortfolioSubjectPullRequest, plan.RepositoryID}}, {&plan.DetachedClusterMembers, `SELECT COUNT(*) FROM cluster_members WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{plan.RepositoryID}}, - {&plan.PreservedInvestigations, `SELECT COUNT(*) FROM investigations WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner, ref.Repo}}, + {&plan.PreservedInvestigations, `SELECT COUNT(*) FROM investigations WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, {&plan.PreservedCrossRepoReferences, ` SELECT COUNT(*) FROM portfolio_links link @@ -158,7 +158,7 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref WHERE investigation.repo_owner = ? AND investigation.repo_name = ? ) ) - `, []any{plan.RepositoryID, ref.Owner, ref.Repo, ref.Owner, ref.Repo}}, + `, []any{plan.RepositoryID, ref.Owner(), ref.Repo(), ref.Owner(), ref.Repo()}}, } for _, count := range counts { if err := db.QueryRowContext(ctx, count.query, count.args...).Scan(count.destination); err != nil { @@ -180,24 +180,24 @@ func planRepositoryRemoval(ctx context.Context, db repositoryRemovalQuerier, ref {"thread observations", `SELECT * FROM thread_observations WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY id`, []any{plan.RepositoryID}}, {"facet observations", `SELECT * FROM facet_observations WHERE repository_id = ? ORDER BY id`, []any{plan.RepositoryID}}, {"facet coverage", `SELECT * FROM facet_coverage WHERE repository_id = ? ORDER BY id`, []any{plan.RepositoryID}}, - {"code snapshots", `SELECT * FROM code_snapshots WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner, ref.Repo}}, - {"code documents", `SELECT * FROM code_documents WHERE snapshot_id IN (SELECT id FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?) ORDER BY id`, []any{ref.Owner, ref.Repo}}, + {"code snapshots", `SELECT * FROM code_snapshots WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, + {"code documents", `SELECT * FROM code_documents WHERE snapshot_id IN (SELECT id FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?) ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, {"dossiers", `SELECT * FROM dossiers WHERE repository_id = ? ORDER BY id`, []any{plan.RepositoryID}}, {"dossier sources", `SELECT * FROM dossier_sources WHERE dossier_id IN (SELECT id FROM dossiers WHERE repository_id = ?) ORDER BY id`, []any{plan.RepositoryID}}, - {"cluster runs", `SELECT * FROM cluster_runs WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner, ref.Repo}}, - {"clusters", `SELECT * FROM clusters WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner, ref.Repo}}, - {"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}}, + {"cluster runs", `SELECT * FROM cluster_runs WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, + {"clusters", `SELECT * FROM clusters WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, + {"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}}, + {"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}}, {"signals", `SELECT * FROM portfolio_signals WHERE snapshot_id IN (SELECT id FROM portfolio_signal_snapshots WHERE subject_kind = ? AND CAST(subject_ref AS INTEGER) IN (SELECT id FROM threads WHERE repository_id = ?)) ORDER BY snapshot_id, position`, []any{PortfolioSubjectPullRequest, plan.RepositoryID}}, {"signal projections", `SELECT * FROM portfolio_signal_projections WHERE subject_kind = ? AND CAST(subject_ref AS INTEGER) IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY subject_kind, subject_ref, facet`, []any{PortfolioSubjectPullRequest, plan.RepositoryID}}, {"resolution records", `SELECT * FROM resolution_records WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY id`, []any{plan.RepositoryID}}, {"resolution projections", `SELECT * FROM resolution_projections WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?) ORDER BY thread_id`, []any{plan.RepositoryID}}, - {"investigations", `SELECT * FROM investigations WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner, ref.Repo}}, + {"investigations", `SELECT * FROM investigations WHERE repo_owner = ? AND repo_name = ? ORDER BY id`, []any{ref.Owner(), ref.Repo()}}, } digest := sha256.New() for _, fingerprint := range fingerprintQueries { @@ -266,11 +266,11 @@ func deleteRepositoryScope(ctx context.Context, tx *sql.Tx, plan *RepositoryRemo }{ {"detach cross-repository cluster members", `UPDATE cluster_members SET thread_id = NULL WHERE thread_id IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{repoID}}, {"delete pull-request signal snapshots", `DELETE FROM portfolio_signal_snapshots WHERE subject_kind = ? AND CAST(subject_ref AS INTEGER) IN (SELECT id FROM threads WHERE repository_id = ?)`, []any{PortfolioSubjectPullRequest, repoID}}, - {"delete cluster projection state", `DELETE FROM cluster_projection_state WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner, ref.Repo}}, - {"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 cluster projection state", `DELETE FROM cluster_projection_state WHERE repo_owner = ? AND repo_name = ?`, []any{ref.Owner(), ref.Repo()}}, + {"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 5d3707ca..82b1f351 100644 --- a/internal/corpus/repository_removal_test.go +++ b/internal/corpus/repository_removal_test.go @@ -15,8 +15,8 @@ func TestRepositoryRemovalDryRunAndExactScope(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - target := domain.RepoRef{Owner: "owner", Repo: "target"} - other := domain.RepoRef{Owner: "owner", Repo: "other"} + target := domain.MustRepoRef("owner", "target") + other := domain.MustRepoRef("owner", "other") targetRepo, targetThread := seedRemovalRepository(ctx, t, c, target, 1) _, otherThread := seedRemovalRepository(ctx, t, c, other, 2) @@ -30,12 +30,12 @@ func TestRepositoryRemovalDryRunAndExactScope(t *testing.T) { query string args []any }{ - {`INSERT INTO investigations (id, repo_owner, repo_name, status, payload, created_at, updated_at) VALUES ('target-investigation', ?, ?, 'open', '{}', ?, ?)`, []any{target.Owner, target.Repo, now, now}}, + {`INSERT INTO investigations (id, repo_owner, repo_name, status, payload, created_at, updated_at) VALUES ('target-investigation', ?, ?, 'open', '{}', ?, ?)`, []any{target.Owner(), target.Repo(), now, now}}, {`INSERT INTO hypotheses (id, investigation_id, category, status, payload, created_at, updated_at) VALUES ('target-hypothesis', 'target-investigation', 'bug', 'promoted', '{}', ?, ?)`, []any{now, now}}, {`INSERT INTO opportunities (id, investigation_id, hypothesis_id, category, status, payload, created_at, updated_at) VALUES ('target-opportunity', 'target-investigation', 'target-hypothesis', 'bug', 'validated', '{}', ?, ?)`, []any{now, now}}, {`INSERT INTO portfolio_links (pull_request_thread_id, opportunity_id, created_at) VALUES (?, 'target-opportunity', ?)`, []any{otherThread, now}}, - {`INSERT INTO cluster_runs (repo_owner, repo_name, source_revision, source_window_start, source_window_end, status, started_at, governance_revision, rule_version, statistics_json) VALUES (?, ?, 'rev', 0, 1, 'completed', ?, 0, 'v1', '{}')`, []any{other.Owner, other.Repo, now}}, - {`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 ('other-cluster', ?, ?, 'active', 'issue', ?, ?, 2, 'rev', 0, 1, ?, ?)`, []any{other.Owner, other.Repo, other.Owner, other.Repo, now, now}}, + {`INSERT INTO cluster_runs (repo_owner, repo_name, source_revision, source_window_start, source_window_end, status, started_at, governance_revision, rule_version, statistics_json) VALUES (?, ?, 'rev', 0, 1, 'completed', ?, 0, 'v1', '{}')`, []any{other.Owner(), other.Repo(), now}}, + {`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 ('other-cluster', ?, ?, 'active', 'issue', ?, ?, 2, 'rev', 0, 1, ?, ?)`, []any{other.Owner(), other.Repo(), other.Owner(), other.Repo(), now, now}}, } for _, statement := range statements { _, err := c.db.ExecContext(ctx, statement.query, statement.args...) @@ -43,7 +43,7 @@ func TestRepositoryRemovalDryRunAndExactScope(t *testing.T) { } var clusterID int64 requireRemovalSetup(t, "read seeded cluster", c.db.QueryRowContext(ctx, `SELECT id FROM clusters WHERE stable_id = 'other-cluster'`).Scan(&clusterID)) - _, err = c.db.ExecContext(ctx, `INSERT INTO cluster_members (cluster_id, thread_id, kind, owner, repo, number, title, state, score, reason, created_at, updated_at) VALUES (?, ?, 'issue', ?, ?, 1, 'target', 'open', 0.9, 'shared', ?, ?)`, clusterID, targetThread, target.Owner, target.Repo, now, now) + _, err = c.db.ExecContext(ctx, `INSERT INTO cluster_members (cluster_id, thread_id, kind, owner, repo, number, title, state, score, reason, created_at, updated_at) VALUES (?, ?, 'issue', ?, ?, 1, 'target', 'open', 0.9, 'shared', ?, ?)`, clusterID, targetThread, target.Owner(), target.Repo(), now, now) requireRemovalSetup(t, "seed cluster member", err) plan, err := c.PlanRepositoryRemoval(ctx, target) @@ -52,16 +52,16 @@ func TestRepositoryRemovalDryRunAndExactScope(t *testing.T) { t.Fatalf("plan = %+v", plan) } // Planning is non-mutating. - if inventory, err := c.Inventory(ctx, target.Owner, target.Repo); err != nil || inventory == nil { + if inventory, err := c.Inventory(ctx, target.Owner(), target.Repo()); err != nil || inventory == nil { t.Fatalf("target inventory after plan = (%+v, %v)", inventory, err) } _, err = c.ApplyRepositoryRemoval(ctx, target, plan) requireRemovalSetup(t, "apply repository removal", err) - if inventory, err := c.Inventory(ctx, target.Owner, target.Repo); !errors.Is(err, ErrRepositoryNotFound) || inventory != nil { + if inventory, err := c.Inventory(ctx, target.Owner(), target.Repo()); !errors.Is(err, ErrRepositoryNotFound) || inventory != nil { t.Fatalf("target inventory after removal = (%+v, %v)", inventory, err) } - if inventory, err := c.Inventory(ctx, other.Owner, other.Repo); err != nil || inventory == nil || inventory.Threads != 1 || inventory.CodeSnapshots != 1 { + if inventory, err := c.Inventory(ctx, other.Owner(), other.Repo()); err != nil || inventory == nil || inventory.Threads != 1 || inventory.CodeSnapshots != 1 { t.Fatalf("other inventory after removal = (%+v, %v)", inventory, err) } var preserved int @@ -92,7 +92,7 @@ func TestRepositoryRemovalRejectsStalePlan(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - ref := domain.RepoRef{Owner: "owner", Repo: "target"} + ref := domain.MustRepoRef("owner", "target") repoID, _ := seedRemovalRepository(ctx, t, c, ref, 1) plan, err := c.PlanRepositoryRemoval(ctx, ref) if err != nil { @@ -104,7 +104,7 @@ func TestRepositoryRemovalRejectsStalePlan(t *testing.T) { if _, err := c.ApplyRepositoryRemoval(ctx, ref, plan); !errors.Is(err, ErrRepositoryRemovalPlanStale) { t.Fatalf("ApplyRepositoryRemoval error = %v, want stale plan", err) } - if inventory, err := c.Inventory(ctx, ref.Owner, ref.Repo); err != nil || inventory == nil || inventory.Threads != 2 { + if inventory, err := c.Inventory(ctx, ref.Owner(), ref.Repo()); err != nil || inventory == nil || inventory.Threads != 2 { t.Fatalf("inventory after rejected plan = (%+v, %v)", inventory, err) } } @@ -113,7 +113,7 @@ func TestRepositoryRemovalRejectsSameCountReplacement(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) ctx := context.Background() - ref := domain.RepoRef{Owner: "owner", Repo: "target"} + ref := domain.MustRepoRef("owner", "target") seedRemovalRepository(ctx, t, c, ref, 1) if _, _, err := c.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{RepoPath: "/target", Commit: "old", TotalBytes: 3, CreatedAt: time.Unix(3, 0), Documents: []codeindex.Document{{Path: "old.go", Content: "old", Bytes: 3}}}); err != nil { t.Fatal(err) @@ -122,7 +122,7 @@ func TestRepositoryRemovalRejectsSameCountReplacement(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.db.ExecContext(ctx, `DELETE FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?`, ref.Owner, ref.Repo); err != nil { + if _, err := c.db.ExecContext(ctx, `DELETE FROM code_snapshots WHERE repo_owner = ? AND repo_name = ?`, ref.Owner(), ref.Repo()); err != nil { t.Fatal(err) } if _, _, err := c.StoreCodeSnapshot(ctx, ref, codeindex.Snapshot{RepoPath: "/target", Commit: "new", TotalBytes: 3, CreatedAt: time.Unix(4, 0), Documents: []codeindex.Document{{Path: "new.go", Content: "new", Bytes: 3}}}); err != nil { @@ -132,7 +132,7 @@ func TestRepositoryRemovalRejectsSameCountReplacement(t *testing.T) { if _, err := c.ApplyRepositoryRemoval(ctx, ref, plan); !errors.Is(err, ErrRepositoryRemovalPlanStale) { t.Fatalf("ApplyRepositoryRemoval error = %v, want stale plan", err) } - if inventory, err := c.Inventory(ctx, ref.Owner, ref.Repo); err != nil || inventory == nil || inventory.CodeSnapshots != 1 { + if inventory, err := c.Inventory(ctx, ref.Owner(), ref.Repo()); err != nil || inventory == nil || inventory.CodeSnapshots != 1 { t.Fatalf("inventory after rejected plan = (%+v, %v)", inventory, err) } } @@ -140,7 +140,7 @@ func TestRepositoryRemovalRejectsSameCountReplacement(t *testing.T) { func TestRepositoryRemovalCancellationRollsBack(t *testing.T) { t.Parallel() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "target"} + ref := domain.MustRepoRef("owner", "target") seedRemovalRepository(context.Background(), t, c, ref, 1) plan, err := c.PlanRepositoryRemoval(context.Background(), ref) if err != nil { @@ -151,14 +151,14 @@ func TestRepositoryRemovalCancellationRollsBack(t *testing.T) { if _, err := c.ApplyRepositoryRemoval(ctx, ref, plan); !errors.Is(err, context.Canceled) { t.Fatalf("ApplyRepositoryRemoval error = %v, want context canceled", err) } - if inventory, err := c.Inventory(context.Background(), ref.Owner, ref.Repo); err != nil || inventory == nil || inventory.Threads != 1 { + if inventory, err := c.Inventory(context.Background(), ref.Owner(), ref.Repo()); err != nil || inventory == nil || inventory.Threads != 1 { t.Fatalf("inventory after cancellation = (%+v, %v)", inventory, err) } } func seedRemovalRepository(ctx context.Context, t *testing.T, c *Corpus, ref domain.RepoRef, number int) (int64, int64) { t.Helper() - repo, err := c.ApplyRepositoryObservation(ctx, ref.Owner, ref.Repo, ref.String(), time.Unix(int64(number), 0), `{}`) + repo, err := c.ApplyRepositoryObservation(ctx, ref.Owner(), ref.Repo(), ref.String(), time.Unix(int64(number), 0), `{}`) if err != nil { t.Fatal(err) } diff --git a/internal/corpus/restore.go b/internal/corpus/restore.go index a43e9ffc..ca04b00d 100644 --- a/internal/corpus/restore.go +++ b/internal/corpus/restore.go @@ -188,7 +188,7 @@ func validateRestoreArtifact(ctx context.Context, source string, manifest Backup if err != nil { return fmt.Errorf("inspect restore source: %w", err) } - if !inspection.Exists { + if !inspection.Exists() { return errors.New("restore source does not contain a corpus") } if inspection.Current != manifest.SourceSchema { diff --git a/internal/corpus/revision_test.go b/internal/corpus/revision_test.go index 1bdf3a41..9d496a6f 100644 --- a/internal/corpus/revision_test.go +++ b/internal/corpus/revision_test.go @@ -21,7 +21,7 @@ func TestCorpusRevisionIsMonotonicAndDetectsStaleReads(t *testing.T) { if initial != 0 { t.Fatalf("initial corpus revision = %d, want 0", initial) } - if _, _, err := c.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{ + if _, _, err := c.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{ RepoPath: "/repo", Commit: "first", CreatedAt: time.Unix(1, 0), Documents: []codeindex.Document{{Path: "main.go", Content: "package main", Bytes: 12}}, }); err != nil { diff --git a/internal/corpus/runs.go b/internal/corpus/runs.go index eb6b168f..20b77e66 100644 --- a/internal/corpus/runs.go +++ b/internal/corpus/runs.go @@ -27,29 +27,18 @@ func (c *Corpus) StartRun(ctx context.Context, kind string) (*Run, error) { // GetRun returns a run record by id. func (c *Corpus) GetRun(ctx context.Context, id int64) (*Run, error) { - var r Run - var completed sql.NullInt64 - var started int64 - var stats, errStr sql.NullString - err := c.db.QueryRowContext(ctx, ` + r, err := scanRun(c.db.QueryRowContext(ctx, ` SELECT id, kind, status, started_at, completed_at, stats, error FROM runs WHERE id = ? - `, id).Scan(&r.ID, &r.Kind, &r.Status, &started, &completed, &stats, &errStr) + `, id)) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get run: %w", err) } - r.StartedAt = scanTime(started) - if completed.Valid { - t := scanTime(completed.Int64) - r.CompletedAt = &t - } - r.Stats = stats.String - r.Error = errStr.String - return &r, nil + return r, nil } // FinishRun marks a run as completed with optional statistics. @@ -58,13 +47,13 @@ func (c *Corpus) FinishRun(ctx context.Context, id int64, stats string) error { res, err := c.db.ExecContext(ctx, ` UPDATE runs SET status = ?, completed_at = ?, stats = ? - WHERE id = ? - `, RunStatusCompleted, now, stats, id) + WHERE id = ? AND status = ? AND completed_at IS NULL + `, RunStatusCompleted, now, stats, id, RunStatusRunning) if err != nil { return fmt.Errorf("finish run: %w", err) } if n, _ := res.RowsAffected(); n == 0 { - return fmt.Errorf("run %d not found", id) + return fmt.Errorf("run %d is not running", id) } return nil } @@ -76,13 +65,13 @@ func (c *Corpus) FinishRunPartial(ctx context.Context, id int64, stats, message res, err := c.db.ExecContext(ctx, ` UPDATE runs SET status = ?, completed_at = ?, stats = ?, error = ? - WHERE id = ? - `, RunStatusPartial, now, stats, message, id) + WHERE id = ? AND status = ? AND completed_at IS NULL + `, RunStatusPartial, now, stats, message, id, RunStatusRunning) if err != nil { return fmt.Errorf("finish partial run: %w", err) } if n, _ := res.RowsAffected(); n == 0 { - return fmt.Errorf("run %d not found", id) + return fmt.Errorf("run %d is not running", id) } return nil } @@ -93,13 +82,13 @@ func (c *Corpus) FailRun(ctx context.Context, id int64, message string) error { res, err := c.db.ExecContext(ctx, ` UPDATE runs SET status = ?, completed_at = ?, error = ? - WHERE id = ? - `, RunStatusFailed, now, message, id) + WHERE id = ? AND status = ? AND completed_at IS NULL + `, RunStatusFailed, now, message, id, RunStatusRunning) if err != nil { return fmt.Errorf("fail run: %w", err) } if n, _ := res.RowsAffected(); n == 0 { - return fmt.Errorf("run %d not found", id) + return fmt.Errorf("run %d is not running", id) } return nil } @@ -137,25 +126,40 @@ func (c *Corpus) ListRuns(ctx context.Context, limit int) ([]Run, error) { var out []Run for rows.Next() { - var r Run - var completed sql.NullInt64 - var started int64 - var stats, errStr sql.NullString - if err := rows.Scan(&r.ID, &r.Kind, &r.Status, &started, &completed, &stats, &errStr); err != nil { + r, err := scanRun(rows) + if err != nil { return nil, err } - r.StartedAt = scanTime(started) - if completed.Valid { - t := scanTime(completed.Int64) - r.CompletedAt = &t - } - r.Stats = stats.String - r.Error = errStr.String - out = append(out, r) + out = append(out, *r) } return out, rows.Err() } +func scanRun(row rowScanner) (*Run, error) { + var run Run + var status string + var completed sql.NullInt64 + var started int64 + var stats, errStr sql.NullString + if err := row.Scan(&run.ID, &run.Kind, &status, &started, &completed, &stats, &errStr); err != nil { + return nil, err + } + var completedAt *time.Time + if completed.Valid { + value := scanTime(completed.Int64) + completedAt = &value + } + state, err := parseRunState(status, completedAt) + if err != nil { + return nil, fmt.Errorf("parse stored run state: %w", err) + } + run.State = state + run.StartedAt = scanTime(started) + run.Stats = stats.String + run.Error = errStr.String + return &run, nil +} + // ListRunEvents returns events for a run in chronological order. func (c *Corpus) ListRunEvents(ctx context.Context, runID int64) ([]RunEvent, error) { rows, err := c.db.QueryContext(ctx, ` diff --git a/internal/corpus/runs_test.go b/internal/corpus/runs_test.go index 2e762850..87b3d2a0 100644 --- a/internal/corpus/runs_test.go +++ b/internal/corpus/runs_test.go @@ -32,8 +32,8 @@ func TestListRunsBounded(t *testing.T) { if runs[0].ID <= runs[1].ID { t.Fatalf("expected runs ordered by descending id, got %d then %d", runs[0].ID, runs[1].ID) } - if runs[0].Status != RunStatusCompleted { - t.Fatalf("expected completed run, got %s", runs[0].Status) + if runs[0].State.Status() != RunStatusCompleted { + t.Fatalf("expected completed run, got %s", runs[0].State.Status()) } all, err := c.ListRuns(ctx, 0) @@ -44,3 +44,36 @@ func TestListRunsBounded(t *testing.T) { t.Fatalf("expected 3 runs, got %d", len(all)) } } + +func TestRunStateRejectsContradictoryLifecycle(t *testing.T) { + t.Parallel() + now := time.Unix(1, 0).UTC() + for _, test := range []struct { + status string + completedAt *time.Time + }{ + {status: string(RunStatusRunning), completedAt: &now}, + {status: string(RunStatusCompleted)}, + {status: "invented", completedAt: &now}, + } { + if _, err := parseRunState(test.status, test.completedAt); err == nil { + t.Fatalf("parseRunState(%q, %v) succeeded", test.status, test.completedAt) + } + } +} + +func TestRunCannotTransitionAfterCompletion(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + run, err := c.StartRun(ctx, "test") + if err != nil { + t.Fatal(err) + } + if err := c.FinishRun(ctx, run.ID, `{}`); err != nil { + t.Fatal(err) + } + if err := c.FailRun(ctx, run.ID, "late failure"); err == nil { + t.Fatal("completed run transitioned to failed") + } +} diff --git a/internal/corpus/search.go b/internal/corpus/search.go index 893e2d14..a2c37799 100644 --- a/internal/corpus/search.go +++ b/internal/corpus/search.go @@ -11,6 +11,8 @@ import ( "strconv" "strings" "time" + + "github.com/morluto/gitcontribute/internal/domain" ) // SearchFilter scopes a thread keyword search. @@ -400,6 +402,9 @@ func scanThreadsWithRank(rows *sql.Rows) ([]Thread, error) { if err := rows.Scan(&rank, &t.ID, &t.RepositoryID, &t.Kind, &t.Number, &t.State, &stateReason, &t.Title, &body, &author, &authorAssociation, &labels, &assignees, &draft, &locked, &milestone, &sourceCreated, &src, &t.ObservationSequence, &created, &updated, &closed, &mergedAt, &merged, &mergedKnown, &t.MatchSource, &t.MatchExcerpt, &matchUpdated, &matchTruncated); err != nil { return nil, err } + if err := parseThreadProjection(&t); err != nil { + return nil, fmt.Errorf("parse stored search thread: %w", err) + } t.Body = body.String t.StateReason = stateReason.String t.Author = author.String @@ -416,9 +421,11 @@ func scanThreadsWithRank(rows *sql.Rows) ([]Thread, error) { t.MatchUpdatedAt = scanTime(matchUpdated) t.MatchTruncated = matchTruncated != 0 t.ClosedAt = scanTime(closed.Int64) - t.MergedAt = scanTime(mergedAt.Int64) - t.Merged = merged != 0 - t.MergedKnown = mergedKnown != 0 + merge, err := domain.ParseMergeStatus(mergedKnown != 0, merged != 0, scanTime(mergedAt.Int64)) + if err != nil { + return nil, fmt.Errorf("parse stored merge status: %w", err) + } + t.Merge = merge t.Rank = rank out = append(out, t) } diff --git a/internal/corpus/search_test.go b/internal/corpus/search_test.go index 69b3dde8..4d0e3ada 100644 --- a/internal/corpus/search_test.go +++ b/internal/corpus/search_test.go @@ -217,8 +217,8 @@ func TestSearchThreadsPageDoesNotTreatUnknownMergeStateAsFalse(t *testing.T) { t.Fatal(err) } for _, thread := range []Thread{ - {Kind: ThreadKindPullRequest, Number: 1, State: "closed", Title: "shared term", Merged: true, SourceUpdatedAt: time.Unix(10, 0).UTC()}, - {Kind: ThreadKindPullRequest, Number: 2, State: "closed", Title: "shared term", MergedKnown: true, SourceUpdatedAt: time.Unix(20, 0).UTC()}, + {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()}, } { thread.RepositoryID = repo.ID @@ -238,7 +238,7 @@ func TestSearchThreadsPageDoesNotTreatUnknownMergeStateAsFalse(t *testing.T) { if _, err := c.UpsertThread(ctx, Thread{ RepositoryID: repo.ID, Kind: ThreadKindPullRequest, Number: 3, State: "closed", - Title: "shared term", Merged: true, MergedKnown: true, SourceUpdatedAt: time.Unix(40, 0).UTC(), + Title: "shared term", Merge: domain.MergedStatus(time.Time{}), SourceUpdatedAt: time.Unix(40, 0).UTC(), }, `{"Merged":true}`); err != nil { t.Fatal(err) } @@ -579,7 +579,7 @@ func TestSearchCodePageReturnsNextCursorAndTotal(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") snapshot := codeindex.Snapshot{ RepoPath: "/repo", Commit: "abc", @@ -645,7 +645,7 @@ func TestSearchCodePageMalformedCursorRejected(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") _, err := c.SearchCodeWithOptions(ctx, "term", CodeSearchOptions{Ref: ref, Limit: 10, Cursor: "invalid"}) if err == nil { t.Fatal("expected malformed cursor error") @@ -656,7 +656,7 @@ func TestSearchCodePageHonorsHardMax(t *testing.T) { t.Parallel() ctx := context.Background() c, _ := openTestCorpus(t) - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + 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" { t.Fatalf("unexpected error = %v", err) diff --git a/internal/corpus/tracking.go b/internal/corpus/tracking.go index 21e95400..7a35fdfc 100644 --- a/internal/corpus/tracking.go +++ b/internal/corpus/tracking.go @@ -140,7 +140,7 @@ func resolveTriageLinks(ctx context.Context, c *Corpus, e *tracking.TriageEvent) if e.RepositoryID == nil && e.TargetKind == tracking.TargetRepository { ref, err := parseRepoRef(e.TargetRef) if err == nil { - if repo, err := c.GetRepository(ctx, ref.Owner, ref.Repo); err == nil && repo != nil { + if repo, err := c.GetRepository(ctx, ref.Owner(), ref.Repo()); err == nil && repo != nil { e.RepositoryID = &repo.ID } } @@ -162,7 +162,7 @@ func resolveTriageLinks(ctx context.Context, c *Corpus, e *tracking.TriageEvent) if e.ThreadID == nil && (e.TargetKind == tracking.TargetIssue || e.TargetKind == tracking.TargetPullRequest || e.TargetKind == tracking.TargetThread) { repoRef, number, ok := parseThreadRef(e.TargetRef) if ok { - if repo, err := c.GetRepository(ctx, repoRef.Owner, repoRef.Repo); err == nil && repo != nil { + if repo, err := c.GetRepository(ctx, repoRef.Owner(), repoRef.Repo()); err == nil && repo != nil { e.RepositoryID = &repo.ID kind := "" switch e.TargetKind { @@ -187,15 +187,7 @@ func resolveTriageLinks(ctx context.Context, c *Corpus, e *tracking.TriageEvent) } func parseRepoRef(ref string) (domain.RepoRef, error) { - parts := strings.Split(ref, "/") - if len(parts) != 2 { - return domain.RepoRef{}, fmt.Errorf("invalid repository reference") - } - r := domain.RepoRef{Owner: strings.TrimSpace(parts[0]), Repo: strings.TrimSpace(parts[1])} - if err := r.Validate(); err != nil { - return domain.RepoRef{}, err - } - return r, nil + return domain.ParseRepoRef(ref) } func parseThreadRef(ref string) (domain.RepoRef, int, bool) { diff --git a/internal/corpus/tracking_test.go b/internal/corpus/tracking_test.go index 5a4c2505..fa02432e 100644 --- a/internal/corpus/tracking_test.go +++ b/internal/corpus/tracking_test.go @@ -171,7 +171,7 @@ func TestContributionLifecyclePersists(t *testing.T) { c, _ := openTestCorpus(t) invSvc := investigation.NewService(c, c) - inv, err := invSvc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, err := invSvc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "abc", "") if err != nil { t.Fatalf("start investigation: %v", err) } @@ -251,7 +251,7 @@ func TestExportImportLocalMetadataIsIdempotent(t *testing.T) { c, _ := openTestCorpus(t) invSvc := investigation.NewService(c, c) - inv, _ := invSvc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := invSvc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := invSvc.RecordHypothesis(ctx, inv.ID, "panic", "desc", investigation.CategoryBug, nil) opp, _ := invSvc.PromoteOpportunity(ctx, h.ID, "panic", "parser", "crash", "small", 0.8) @@ -401,7 +401,7 @@ func TestImportLocalMetadataIsAtomicOnReferentialFailure(t *testing.T) { } _ = repo - inv, err := invSvc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, err := invSvc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") if err != nil { t.Fatalf("start investigation: %v", err) } @@ -504,7 +504,7 @@ func TestMalformedContributionMetadataIsPropagated(t *testing.T) { c, _ := openTestCorpus(t) invSvc := investigation.NewService(c, c) - inv, err := invSvc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, err := invSvc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") if err != nil { t.Fatalf("start investigation: %v", err) } diff --git a/internal/corpus/validation_observation_test.go b/internal/corpus/validation_observation_test.go index 7f2b34a2..670cc461 100644 --- a/internal/corpus/validation_observation_test.go +++ b/internal/corpus/validation_observation_test.go @@ -15,19 +15,27 @@ func TestValidationObservationPayloadRoundTrip(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) now := time.Now().UTC() - inv, err := investigation.NewService(c, c).StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc123", "") + inv, err := investigation.NewService(c, c).StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "abc123", "") + if err != nil { + t.Fatal(err) + } + observation, err := evidence.ParseObservationContract(evidence.ObservationContractSpec{ + Intent: "observe artifact", + Base: []evidence.ExpectedObservationSpec{{ + Name: "generated before", Source: evidence.ObservationArtifact, Path: "out.txt", + Matcher: evidence.ObservationExact, Pattern: "broken", Occurrence: evidence.ObservationPresent, + }}, + Candidate: []evidence.ExpectedObservationSpec{{ + Name: "generated", Source: evidence.ObservationArtifact, Path: "out.txt", + Matcher: evidence.ObservationExact, Pattern: "fixed", Occurrence: evidence.ObservationPresent, + }}, + }) if err != nil { t.Fatal(err) } definition := &evidence.ValidationDefinition{ ID: "definition", InvestigationID: inv.ID, Command: []string{"test"}, WorkingDir: "/tmp", CreatedAt: now, - Observation: &evidence.ObservationContract{ - Intent: "observe artifact", - Candidate: []evidence.ExpectedObservation{{ - Name: "generated", Source: evidence.ObservationArtifact, Path: "out.txt", - Matcher: evidence.ObservationExact, Pattern: "fixed", Occurrence: evidence.ObservationPresent, - }}, - }, + Observation: observation, } if err := c.SaveValidationDefinition(ctx, definition); err != nil { t.Fatalf("save definition: %v", err) @@ -37,7 +45,7 @@ func TestValidationObservationPayloadRoundTrip(t *testing.T) { Classification: evidence.RunClassificationPassing, ObservationStatus: evidence.ObservationMatched, Observations: []evidence.ObservationResult{{ - ExpectedObservation: definition.Observation.Candidate[0], + ExpectedObservation: definition.Observation.Candidate()[0], Status: evidence.ObservationMatched, Excerpt: "fixed", }}, StartedAt: now, CompletedAt: now, @@ -50,7 +58,7 @@ func TestValidationObservationPayloadRoundTrip(t *testing.T) { if err != nil { t.Fatalf("get definition: %v", err) } - if gotDefinition.Observation == nil || gotDefinition.Observation.Candidate[0].Path != "out.txt" { + if gotDefinition.Observation == nil || gotDefinition.Observation.Candidate()[0].Spec().Path != "out.txt" { t.Fatalf("definition observation = %#v", gotDefinition.Observation) } gotRun, err := c.GetValidationRun(ctx, run.ID) diff --git a/internal/corpus/workflow.go b/internal/corpus/workflow.go index ab21fa81..09fdacf8 100644 --- a/internal/corpus/workflow.go +++ b/internal/corpus/workflow.go @@ -32,6 +32,11 @@ func unmarshalWorkflow(payload string, value any) error { if err := json.Unmarshal([]byte(payload), value); err != nil { return fmt.Errorf("decode workflow record: %w", err) } + if record, ok := value.(interface{ ParseStored() error }); ok { + if err := record.ParseStored(); err != nil { + return fmt.Errorf("parse workflow record: %w", err) + } + } return nil } @@ -187,7 +192,7 @@ func (c *Corpus) SaveInvestigation(ctx context.Context, item *investigation.Inve VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET repo_owner=excluded.repo_owner, repo_name=excluded.repo_name, status=excluded.status, origin_key=excluded.origin_key, payload=excluded.payload, updated_at=excluded.updated_at - `, item.ID, item.Repo.Owner, item.Repo.Repo, item.Status, investigationOriginKey(item), payload, encodeTime(item.CreatedAt), encodeTime(item.UpdatedAt)) + `, item.ID, item.Repo.Owner(), item.Repo.Repo(), item.Status, investigationOriginKey(item), payload, encodeTime(item.CreatedAt), encodeTime(item.UpdatedAt)) if err != nil { return fmt.Errorf("save investigation: %w", err) } diff --git a/internal/corpus/workflow_test.go b/internal/corpus/workflow_test.go index 3140d1e5..76bf0832 100644 --- a/internal/corpus/workflow_test.go +++ b/internal/corpus/workflow_test.go @@ -22,7 +22,7 @@ func TestContributionManifestPersistsAndSelectsLatest(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) svc := investigation.NewService(c, c) - inv, err := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, err := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") if err != nil { t.Fatal(err) } @@ -69,6 +69,113 @@ func TestContributionManifestPersistsAndSelectsLatest(t *testing.T) { } } +func TestUnmarshalWorkflowRejectsInvalidLifecycleDiscriminators(t *testing.T) { + ref := domain.MustRepoRef("owner", "repo") + tests := []struct { + name string + value any + target func() any + }{ + { + name: "investigation status", + value: &investigation.Investigation{ID: "inv", Repo: ref, Status: "impossible"}, + target: func() any { return &investigation.Investigation{} }, + }, + { + name: "hypothesis category", + value: &investigation.Hypothesis{ID: "hyp", InvestigationID: "inv", Category: "impossible", Status: investigation.HypothesisProposed}, + target: func() any { return &investigation.Hypothesis{} }, + }, + { + name: "opportunity collision", + value: &investigation.Opportunity{ID: "opp", InvestigationID: "inv", HypothesisID: "hyp", Category: investigation.CategoryBug, Status: investigation.OpportunityHypothesis, CollisionStatus: "impossible"}, + target: func() any { return &investigation.Opportunity{} }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + payload, err := marshalWorkflow(testCase.value) + if err != nil { + t.Fatal(err) + } + if err := unmarshalWorkflow(payload, testCase.target()); err == nil { + t.Fatal("invalid durable discriminator was accepted") + } + }) + } +} + +func TestSaveExternalValidationRollsBackDefinitionWhenRunFails(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + definition := &evidence.ValidationDefinition{ID: "external-definition", CreatedAt: time.Now().UTC()} + run := &evidence.ValidationRun{ + ID: "external-run", DefinitionID: "different-definition", Kind: evidence.RunKindCandidate, + Classification: evidence.RunClassificationPassing, ObservationStatus: evidence.ObservationNotEvaluated, + } + if err := c.SaveExternalValidation(ctx, definition, run); err == nil { + t.Fatal("expected invalid run foreign key to fail") + } + if _, err := c.GetValidationDefinition(ctx, definition.ID); !errors.Is(err, evidence.ErrNotFound) { + t.Fatalf("definition survived failed atomic save: %v", err) + } +} + +func TestSaveExternalValidationRejectsMismatchedExistingDefinition(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + paired := &evidence.ValidationDefinition{ID: "paired-definition", CreatedAt: time.Now().UTC()} + other := &evidence.ValidationDefinition{ID: "other-definition", CreatedAt: time.Now().UTC()} + if err := c.SaveValidationDefinition(ctx, other); err != nil { + t.Fatal(err) + } + run := &evidence.ValidationRun{ + ID: "external-run", DefinitionID: other.ID, Kind: evidence.RunKindCandidate, + Classification: evidence.RunClassificationPassing, ObservationStatus: evidence.ObservationNotEvaluated, + } + if err := c.SaveExternalValidation(ctx, paired, run); err == nil { + t.Fatal("external validation accepted a run for a different definition") + } + if _, err := c.GetValidationDefinition(ctx, paired.ID); !errors.Is(err, evidence.ErrNotFound) { + t.Fatalf("paired definition survived rejected save: %v", err) + } +} + +func TestOpportunityStoredParserAcceptsLegacyInitialCollisionAudit(t *testing.T) { + item := &investigation.Opportunity{ + ID: "opp", InvestigationID: "inv", HypothesisID: "hyp", Category: investigation.CategoryBug, + Status: investigation.OpportunityHypothesis, CollisionStatus: investigation.CollisionPossible, + AuditTrail: []investigation.StatusChange{{From: "", To: string(investigation.CollisionPossible)}}, + } + payload, err := marshalWorkflow(item) + if err != nil { + t.Fatal(err) + } + var decoded investigation.Opportunity + if err := unmarshalWorkflow(payload, &decoded); err != nil { + t.Fatal(err) + } +} + +func TestSaveEvidenceBatchRollsBackEarlierItemsOnFailure(t *testing.T) { + ctx := context.Background() + c, _ := openTestCorpus(t) + first := &evidence.Evidence{ + ID: "first", Type: evidence.EvidenceTypeManualObservation, + Relation: evidence.RelationSupporting, Description: "first claim", + } + if err := c.SaveEvidenceBatch(ctx, []*evidence.Evidence{first, nil}); err == nil { + t.Fatal("expected invalid batch item to fail") + } + items, err := c.ListEvidence(ctx, evidence.EvidenceFilter{}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("evidence survived failed batch: %+v", items) + } +} + func TestContributionWorkflowPersistsAcrossReopen(t *testing.T) { t.Parallel() ctx := context.Background() @@ -78,7 +185,7 @@ func TestContributionWorkflowPersistsAcrossReopen(t *testing.T) { t.Fatal(err) } invService := investigation.NewService(c, c) - inv, err := invService.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc123", "go") + inv, err := invService.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "abc123", "go") if err != nil { t.Fatal(err) } @@ -131,7 +238,7 @@ func TestFindRelatedUsesRepositoryAndCategory(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) svc := investigation.NewService(c, c) - inv, _ := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, _ := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") _, err := svc.RecordHypothesis(ctx, inv.ID, "bug", "description", investigation.CategoryBug, []domain.SourceRef{{Source: "issue", URL: "https://github.com/owner/repo/issues/2"}}) if err != nil { t.Fatal(err) @@ -140,7 +247,7 @@ func TestFindRelatedUsesRepositoryAndCategory(t *testing.T) { if err != nil || len(related) != 1 { t.Fatalf("FindRelated = (%+v, %v)", related, err) } - other, err := c.FindRelated(ctx, domain.RepoRef{Owner: "other", Repo: "repo"}, investigation.CategoryBug) + other, err := c.FindRelated(ctx, domain.MustRepoRef("other", "repo"), investigation.CategoryBug) if err != nil || len(other) != 0 { t.Fatalf("other FindRelated = (%+v, %v)", other, err) } @@ -151,7 +258,7 @@ func TestPromoteHypothesisRollsBackOnOpportunityConflict(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) svc := investigation.NewService(c, c) - inv, err := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, err := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") if err != nil { t.Fatal(err) } @@ -193,7 +300,7 @@ func TestPromoteHypothesisRejectsStaleConcurrentPromotion(t *testing.T) { ctx := context.Background() c, _ := openTestCorpus(t) svc := investigation.NewService(c, c) - inv, _ := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + inv, _ := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "repo"), "sha", "") hypothesis, _ := svc.RecordHypothesis(ctx, inv.ID, "race", "description", investigation.CategoryBug, nil) stale := *hypothesis if _, err := svc.PromoteOpportunity(ctx, hypothesis.ID, "first problem", "scope", "impact", "small", 0.8); err != nil { @@ -222,11 +329,11 @@ func TestInvestigationAndOpportunityListQueries(t *testing.T) { c, _ := openTestCorpus(t) svc := investigation.NewService(c, c) - invA, err := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "a"}, "sha-a", "") + invA, err := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "a"), "sha-a", "") if err != nil { t.Fatal(err) } - invB, err := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "b"}, "sha-b", "") + invB, err := svc.StartInvestigation(ctx, domain.MustRepoRef("owner", "b"), "sha-b", "") if err != nil { t.Fatal(err) } diff --git a/internal/corpus/workflow_validation.go b/internal/corpus/workflow_validation.go index eaa5b978..48c0f7a0 100644 --- a/internal/corpus/workflow_validation.go +++ b/internal/corpus/workflow_validation.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "time" "github.com/morluto/gitcontribute/internal/contribution" @@ -65,7 +66,7 @@ func (c *Corpus) FindRelated(ctx context.Context, ref domain.RepoRef, category i rows, err := c.db.QueryContext(ctx, ` SELECT h.payload FROM hypotheses h JOIN investigations i ON i.id=h.investigation_id WHERE i.repo_owner=? AND i.repo_name=? AND (?='' OR h.category=?) ORDER BY h.created_at - `, ref.Owner, ref.Repo, category, category) + `, ref.Owner(), ref.Repo(), category, category) if err != nil { return nil, fmt.Errorf("find related investigations: %w", err) } @@ -87,6 +88,10 @@ func (c *Corpus) FindRelated(ctx context.Context, ref domain.RepoRef, category i // SaveValidationDefinition persists a validation plan without executing it. func (c *Corpus) SaveValidationDefinition(ctx context.Context, item *evidence.ValidationDefinition) error { + return saveValidationDefinition(ctx, c.db, item) +} + +func saveValidationDefinition(ctx context.Context, db dbExecer, item *evidence.ValidationDefinition) error { if item == nil || item.ID == "" { return errors.New("validation definition id is required") } @@ -94,7 +99,7 @@ func (c *Corpus) SaveValidationDefinition(ctx context.Context, item *evidence.Va if err != nil { return err } - _, err = c.db.ExecContext(ctx, ` + _, err = db.ExecContext(ctx, ` INSERT INTO validation_definitions (id, investigation_id, hypothesis_id, opportunity_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET investigation_id=excluded.investigation_id, @@ -134,6 +139,10 @@ func (c *Corpus) ListValidationDefinitions(ctx context.Context, opportunityID st // SaveValidationRun persists the bounded result of an authorized validation execution. func (c *Corpus) SaveValidationRun(ctx context.Context, item *evidence.ValidationRun) error { + return saveValidationRun(ctx, c.db, item) +} + +func saveValidationRun(ctx context.Context, db dbExecer, item *evidence.ValidationRun) error { if item == nil || item.ID == "" { return errors.New("validation run id is required") } @@ -141,7 +150,7 @@ func (c *Corpus) SaveValidationRun(ctx context.Context, item *evidence.Validatio if err != nil { return err } - _, err = c.db.ExecContext(ctx, ` + _, err = db.ExecContext(ctx, ` INSERT INTO validation_runs (id, definition_id, investigation_id, hypothesis_id, opportunity_id, kind, classification, payload, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET definition_id=excluded.definition_id, @@ -155,6 +164,47 @@ func (c *Corpus) SaveValidationRun(ctx context.Context, item *evidence.Validatio return nil } +// SaveExternalValidation atomically stores the synthetic definition and its +// externally produced run so neither record can survive alone. +func (c *Corpus) SaveExternalValidation(ctx context.Context, definition *evidence.ValidationDefinition, run *evidence.ValidationRun) (err error) { + if definition == nil || definition.ID == "" || run == nil || run.ID == "" { + return errors.New("external validation definition and run identities are required") + } + if run.DefinitionID != definition.ID { + return errors.New("external validation run must reference the supplied definition") + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin external validation save: %w", err) + } + defer rollbackSQLOnReturn(tx, &err) + var existingPayload string + err = tx.QueryRowContext(ctx, `SELECT payload FROM validation_definitions WHERE id=?`, definition.ID).Scan(&existingPayload) + switch { + case errors.Is(err, sql.ErrNoRows): + if err := saveValidationDefinition(ctx, tx, definition); err != nil { + return err + } + case err != nil: + return fmt.Errorf("read external validation definition: %w", err) + default: + var existing evidence.ValidationDefinition + if err := unmarshalWorkflow(existingPayload, &existing); err != nil { + return err + } + if !slices.Equal(existing.Command, definition.Command) { + return errors.New("external receipt command differs from the existing validation_id") + } + } + if err := saveValidationRun(ctx, tx, run); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit external validation save: %w", err) + } + return nil +} + // GetValidationRun returns a validation result by ID, or nil when absent. func (c *Corpus) GetValidationRun(ctx context.Context, id string) (*evidence.ValidationRun, error) { var payload string diff --git a/internal/deepwiki/client.go b/internal/deepwiki/client.go index c466fc38..5d6eec08 100644 --- a/internal/deepwiki/client.go +++ b/internal/deepwiki/client.go @@ -24,13 +24,29 @@ type Request struct { Question string } -// Response contains untrusted derived prose. Available is false for a -// provider-level tool error; transport and protocol failures are returned as errors. +type responseState uint8 + +const ( + responseUnavailable responseState = iota + responseAvailable +) + +// Response contains untrusted derived prose. Its private state keeps provider +// unavailability distinct from a successful response; transport and protocol +// failures are returned as errors. type Response struct { - Text string - SourceURL string - Available bool + state responseState + text string + sourceURL string +} + +func UnavailableResponse(text string) Response { return Response{text: text} } +func AvailableResponse(text, sourceURL string) Response { + return Response{state: responseAvailable, text: text, sourceURL: sourceURL} } +func (r Response) Available() bool { return r.state == responseAvailable } +func (r Response) Text() string { return r.text } +func (r Response) SourceURL() string { return r.sourceURL } // Reader performs an external DeepWiki read without writing to the local corpus. type Reader interface { @@ -67,7 +83,7 @@ func (c *Client) Read(ctx context.Context, req Request) (_ Response, err error) return Response{}, fmt.Errorf("call DeepWiki %s: %w", name, err) } if result.IsError { - return Response{Available: false}, nil + return UnavailableResponse(""), nil } var textParts []string for _, item := range result.Content { @@ -76,11 +92,10 @@ func (c *Client) Read(ctx context.Context, req Request) (_ Response, err error) } } text := strings.Join(textParts, "\n") - response := Response{Text: text, SourceURL: sourceURLPattern.FindString(text), Available: true} if isProviderErrorText(text) { - response.Available = false + return UnavailableResponse(text), nil } - return response, nil + return AvailableResponse(text, sourceURLPattern.FindString(text)), nil } func isProviderErrorText(text string) bool { diff --git a/internal/deepwiki/client_test.go b/internal/deepwiki/client_test.go index 2b34f15a..c4987648 100644 --- a/internal/deepwiki/client_test.go +++ b/internal/deepwiki/client_test.go @@ -78,7 +78,7 @@ func TestClientReadMapsResponse(t *testing.T) { if err != nil { t.Fatal(err) } - if !got.Available || got.Text != "first\nhttps://deepwiki.com/owner/repo#topic" || got.SourceURL != "https://deepwiki.com/owner/repo#topic" { + if !got.Available() || got.Text() != "first\nhttps://deepwiki.com/owner/repo#topic" || got.SourceURL() != "https://deepwiki.com/owner/repo#topic" { t.Fatalf("response = %+v", got) } } @@ -89,7 +89,7 @@ func TestClientReadHandlesProviderAndTransportFailures(t *testing.T) { return &mcp.CallToolResult{IsError: true}, nil }) got, err := provider.Read(context.Background(), Request{Action: "structure", Repository: "owner/repo"}) - if err != nil || got.Available { + if err != nil || got.Available() { t.Fatalf("provider error = %+v, %v", got, err) } @@ -131,7 +131,7 @@ func TestClientReadClassifiesProviderErrorTextAsUnavailable(t *testing.T) { Repositories: []string{"indexed/repo", "missing/repo"}, Question: "Compare them.", }) - if err != nil || got.Available || got.Text != tt.text { + if err != nil || got.Available() || got.Text() != tt.text { t.Fatalf("provider error text = %+v, %v", got, err) } }) @@ -149,7 +149,7 @@ func TestClientReadKeepsNormalMultiRepositoryAnswerAvailable(t *testing.T) { Repositories: []string{"indexed/repo", "other/repo"}, Question: "Compare them.", }) - if err != nil || !got.Available || got.Text != answer { + if err != nil || !got.Available() || got.Text() != answer { t.Fatalf("normal answer = %+v, %v", got, err) } } @@ -160,7 +160,7 @@ func TestClientReadAcceptsEmptySuccessfulResponse(t *testing.T) { return &mcp.CallToolResult{}, nil }) got, err := client.Read(context.Background(), Request{Action: "structure", Repository: "owner/repo"}) - if err != nil || !got.Available || got.Text != "" || got.SourceURL != "" { + if err != nil || !got.Available() || got.Text() != "" || got.SourceURL() != "" { t.Fatalf("empty response = %+v, %v", got, err) } } diff --git a/internal/discovery/gharchive.go b/internal/discovery/gharchive.go index 96165f65..bb41136f 100644 --- a/internal/discovery/gharchive.go +++ b/internal/discovery/gharchive.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -462,12 +461,8 @@ func mapState(state string) domain.ThreadState { } func parseRepoRef(name string) (domain.RepoRef, bool) { - parts := strings.Split(name, "/") - if len(parts) != 2 { - return domain.RepoRef{}, false - } - ref := domain.RepoRef{Owner: parts[0], Repo: parts[1]} - if err := ref.Validate(); err != nil { + ref, err := domain.ParseRepoRef(name) + if err != nil { return domain.RepoRef{}, false } return ref, true diff --git a/internal/discovery/repo.go b/internal/discovery/repo.go index 60b0673a..776a5b4a 100644 --- a/internal/discovery/repo.go +++ b/internal/discovery/repo.go @@ -54,8 +54,8 @@ func ParseRepoRef(s string) (domain.RepoRef, error) { return domain.RepoRef{}, fmt.Errorf("invalid repo reference %q: expected owner/repo", s) } - ref := domain.RepoRef{Owner: parts[0], Repo: parts[1]} - if err := ref.Validate(); err != nil { + ref, err := domain.NewRepoRef(parts[0], parts[1]) + if err != nil { return domain.RepoRef{}, err } return ref, nil diff --git a/internal/discovery/repo_test.go b/internal/discovery/repo_test.go index 5375eb35..5b0ac515 100644 --- a/internal/discovery/repo_test.go +++ b/internal/discovery/repo_test.go @@ -17,32 +17,32 @@ func TestParseRepoRef(t *testing.T) { { name: "owner/repo", input: "golang/go", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "https url", input: "https://github.com/golang/go", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "http url with query", input: "http://github.com/golang/go?tab=readme", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "github.com prefix", input: "github.com/golang/go", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "git ssh", input: "git@github.com:golang/go.git", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "ssh url", input: "ssh://git@github.com/golang/go.git", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, { name: "empty", @@ -82,7 +82,7 @@ func TestParseRepoRef(t *testing.T) { { name: "uppercase git suffix", input: "https://github.com/golang/go.GIT", - want: domain.RepoRef{Owner: "golang", Repo: "go"}, + want: domain.MustRepoRef("golang", "go"), }, } diff --git a/internal/domain/merge_status.go b/internal/domain/merge_status.go new file mode 100644 index 00000000..23397027 --- /dev/null +++ b/internal/domain/merge_status.go @@ -0,0 +1,93 @@ +package domain + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "time" +) + +type mergeState uint8 + +const ( + mergeUnknown mergeState = iota + mergeObservedUnmerged + mergeObservedMerged +) + +// MergeStatus is an observed pull-request merge outcome. Its zero value means +// that merge state has not been observed. +type MergeStatus struct { + state mergeState + at time.Time +} + +// UnknownMergeStatus returns a merge status that has not been observed. +func UnknownMergeStatus() MergeStatus { return MergeStatus{} } + +// UnmergedStatus returns an observed, unmerged status. +func UnmergedStatus() MergeStatus { return MergeStatus{state: mergeObservedUnmerged} } + +// MergedStatus returns an observed, merged status. Some providers establish +// the outcome without returning its timestamp, so at may be zero. +func MergedStatus(at time.Time) MergeStatus { + return MergeStatus{state: mergeObservedMerged, at: at} +} + +// ParseMergeStatus reparses persisted scalar columns into a consistent status. +func ParseMergeStatus(known, merged bool, at time.Time) (MergeStatus, error) { + switch { + case !known && (merged || !at.IsZero()): + return MergeStatus{}, errors.New("unknown merge status cannot be merged or have a merge time") + case !known: + return UnknownMergeStatus(), nil + case !merged && !at.IsZero(): + return MergeStatus{}, errors.New("unmerged status cannot have a merge time") + case merged: + return MergedStatus(at), nil + default: + return UnmergedStatus(), nil + } +} + +// Known reports whether the merge outcome was observed. +func (s MergeStatus) Known() bool { return s.state != mergeUnknown } + +// IsMerged reports whether the observed outcome is merged. +func (s MergeStatus) IsMerged() bool { return s.state == mergeObservedMerged } + +// MergedAt returns the observed merge time, if the provider supplied one. +func (s MergeStatus) MergedAt() time.Time { return s.at } + +// Equal compares parsed merge observations without exposing their representation. +func (s MergeStatus) Equal(other MergeStatus) bool { return s == other } + +type mergeStatusJSON struct { + Known bool + Merged bool + MergedAt time.Time +} + +func (s MergeStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(mergeStatusJSON{Known: s.Known(), Merged: s.IsMerged(), MergedAt: s.MergedAt()}) +} + +func (s *MergeStatus) UnmarshalJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var stored mergeStatusJSON + if err := decoder.Decode(&stored); err != nil { + return fmt.Errorf("decode merge status: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("decode merge status: expected one JSON value") + } + parsed, err := ParseMergeStatus(stored.Known, stored.Merged, stored.MergedAt) + if err != nil { + return err + } + *s = parsed + return nil +} diff --git a/internal/domain/merge_status_test.go b/internal/domain/merge_status_test.go new file mode 100644 index 00000000..603555c0 --- /dev/null +++ b/internal/domain/merge_status_test.go @@ -0,0 +1,44 @@ +package domain + +import ( + "encoding/json" + "testing" + "time" +) + +func TestParseMergeStatusRejectsContradictions(t *testing.T) { + t.Parallel() + at := time.Unix(1, 0).UTC() + for _, input := range []struct { + known bool + merged bool + at time.Time + }{ + {merged: true}, + {at: at}, + {known: true, at: at}, + } { + if _, err := ParseMergeStatus(input.known, input.merged, input.at); err == nil { + t.Fatalf("ParseMergeStatus(%v, %v, %v) succeeded", input.known, input.merged, input.at) + } + } +} + +func TestMergeStatusJSONReparses(t *testing.T) { + t.Parallel() + want := MergedStatus(time.Unix(1, 0).UTC()) + payload, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got MergeStatus + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("merge status = %+v, want %+v", got, want) + } + if err := json.Unmarshal([]byte(`{"Known":false,"Merged":true,"MergedAt":"0001-01-01T00:00:00Z"}`), &got); err == nil { + t.Fatal("contradictory merge status decoded") + } +} diff --git a/internal/domain/observed_bool.go b/internal/domain/observed_bool.go new file mode 100644 index 00000000..6ad9cc73 --- /dev/null +++ b/internal/domain/observed_bool.go @@ -0,0 +1,19 @@ +package domain + +// ObservedBool distinguishes an observed false value from an unavailable +// observation without a separate "known" flag. Its zero value is unknown. +type ObservedBool struct { + value bool + known bool +} + +func UnknownBool() ObservedBool { return ObservedBool{} } + +func ObservedBoolValue(value bool) ObservedBool { + return ObservedBool{value: value, known: true} +} + +func (v ObservedBool) Value() (bool, bool) { return v.value, v.known } + +// Equal compares observed booleans without exposing their representation. +func (v ObservedBool) Equal(other ObservedBool) bool { return v == other } diff --git a/internal/domain/types.go b/internal/domain/types.go index 7f4b870e..5310e7fd 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -1,8 +1,11 @@ package domain import ( + "bytes" + "encoding/json" "errors" "fmt" + "io" "regexp" "strings" "time" @@ -10,15 +13,11 @@ import ( // RepoRef identifies a repository by owner and name. type RepoRef struct { - Owner string - Repo string + value string } func (r RepoRef) String() string { - if r.Owner == "" && r.Repo == "" { - return "" - } - return r.Owner + "/" + r.Repo + return r.value } var ( @@ -28,23 +27,101 @@ var ( repoRegex = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) ) -// Validate checks that the owner and repo are non-empty and syntactically valid. -func (r RepoRef) Validate() error { - if strings.TrimSpace(r.Owner) == "" { - return errOwnerEmpty +// NewRepoRef parses a repository owner and name into a canonical identity. +func NewRepoRef(owner, repo string) (RepoRef, error) { + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + if owner == "" { + return RepoRef{}, errOwnerEmpty } - if strings.TrimSpace(r.Repo) == "" { - return errRepoEmpty + if repo == "" { + return RepoRef{}, errRepoEmpty } - if !ownerRegex.MatchString(r.Owner) { - return fmt.Errorf("invalid owner %q", r.Owner) + if !ownerRegex.MatchString(owner) { + return RepoRef{}, fmt.Errorf("invalid owner %q", owner) } - if !repoRegex.MatchString(r.Repo) { - return fmt.Errorf("invalid repo %q", r.Repo) + if !repoRegex.MatchString(repo) { + return RepoRef{}, fmt.Errorf("invalid repo %q", repo) } - if r.Repo == "." || r.Repo == ".." || strings.Contains(r.Repo, "..") { - return fmt.Errorf("invalid repo %q", r.Repo) + if repo == "." || repo == ".." || strings.Contains(repo, "..") { + return RepoRef{}, fmt.Errorf("invalid repo %q", repo) + } + return RepoRef{value: owner + "/" + repo}, nil +} + +// ParseRepoRef parses the canonical owner/repository form. +func ParseRepoRef(value string) (RepoRef, error) { + value = strings.TrimSpace(value) + owner, repo, ok := strings.Cut(value, "/") + if !ok || strings.Contains(repo, "/") { + return RepoRef{}, fmt.Errorf("invalid repository reference %q", value) } + return NewRepoRef(owner, repo) +} + +// MustRepoRef returns a parsed repository identity or panics. It is intended +// for fixed program constants and test fixtures, not boundary input. +func MustRepoRef(owner, repo string) RepoRef { + ref, err := NewRepoRef(owner, repo) + if err != nil { + panic(err) + } + return ref +} + +// IsValid reports whether r is a parsed repository identity. The zero value is +// invalid and can be used where repository identity is optional. +func (r RepoRef) IsValid() bool { return r.value != "" } + +// Equal compares canonical repository identities without exposing their representation. +func (r RepoRef) Equal(other RepoRef) bool { return r == other } + +// Owner returns the repository owner. +func (r RepoRef) Owner() string { + owner, _, _ := strings.Cut(r.value, "/") + return owner +} + +// Repo returns the repository name. +func (r RepoRef) Repo() string { + _, repo, _ := strings.Cut(r.value, "/") + return repo +} + +type repoRefJSON struct { + Owner string + Repo string +} + +// MarshalJSON preserves the object representation used by workflow records. +func (r RepoRef) MarshalJSON() ([]byte, error) { + return json.Marshal(repoRefJSON{Owner: r.Owner(), Repo: r.Repo()}) +} + +// UnmarshalJSON reparses workflow data before it enters the domain model. +func (r *RepoRef) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + *r = RepoRef{} + return nil + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var stored repoRefJSON + if err := decoder.Decode(&stored); err != nil { + return fmt.Errorf("decode repository reference: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("decode repository reference: expected one JSON value") + } + if stored.Owner == "" && stored.Repo == "" { + *r = RepoRef{} + return nil + } + parsed, err := NewRepoRef(stored.Owner, stored.Repo) + if err != nil { + return err + } + *r = parsed return nil } @@ -64,12 +141,37 @@ const ( ClosedState ThreadState = "closed" ) +// ParseThreadKind parses the only supported thread variants. +func ParseThreadKind(value string) (ThreadKind, error) { + switch ThreadKind(strings.TrimSpace(value)) { + case IssueKind: + return IssueKind, nil + case PullRequestKind: + return PullRequestKind, nil + default: + return "", fmt.Errorf("unsupported thread kind %q", value) + } +} + +// ParseThreadState parses the closed thread lifecycle used by GitHub issues +// and pull requests. +func ParseThreadState(value string) (ThreadState, error) { + switch ThreadState(strings.TrimSpace(value)) { + case OpenState: + return OpenState, nil + case ClosedState: + return ClosedState, nil + default: + return "", fmt.Errorf("unsupported thread state %q", value) + } +} + // Thread is a product-owned model for an issue or pull request. // It carries no vendor-specific API types. type Thread struct { ID int64 Repo RepoRef - Kind ThreadKind + Type ThreadType Number int Title string Body string @@ -81,9 +183,36 @@ type Thread struct { CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time +} + +// ThreadType is a parsed issue-or-pull-request variant. Its fields are private +// so pull-request details cannot be attached to an issue. +type ThreadType struct { + kind ThreadKind + pullRequestDetails PullRequestDetails +} + +// IssueThread returns the issue variant. +func IssueThread() ThreadType { return ThreadType{kind: IssueKind} } - // PullRequest is present when Kind is PullRequestKind. - PullRequest *PullRequestDetails +// PullRequestThread returns a pull-request variant with its observed details. +func PullRequestThread(details PullRequestDetails) ThreadType { + return ThreadType{kind: PullRequestKind, pullRequestDetails: details} +} + +// Kind returns the kind derived from the sealed thread variant. +func (t Thread) Kind() ThreadKind { + return t.Type.kind +} + +// PullRequest returns PR details only for the pull-request variant. +func (t Thread) PullRequest() (PullRequestDetails, bool) { + return t.Type.pullRequestDetails, t.Type.kind == PullRequestKind +} + +// Equal compares parsed thread variants without exposing their representation. +func (t ThreadType) Equal(other ThreadType) bool { + return t.kind == other.kind && t.pullRequestDetails == other.pullRequestDetails } // Comment is a product-owned model for a thread comment. @@ -96,15 +225,11 @@ type Comment struct { // PullRequestDetails contains PR-specific facets. type PullRequestDetails struct { - HeadRef string - BaseRef string - HeadSHA string - BaseSHA string - Merged bool - // MergedKnown distinguishes an observed false value from an unavailable - // merge state, such as a pull request stored from header-only sync. - MergedKnown bool - MergedAt time.Time + HeadRef string + BaseRef string + HeadSHA string + BaseSHA string + Merge MergeStatus MergeCommitSHA string Additions int Deletions int @@ -114,7 +239,7 @@ type PullRequestDetails struct { // Repository is a product-owned snapshot of repository metadata and counts. type Repository struct { - RepoRef + Ref RepoRef ID int64 Description string Topics []string @@ -137,30 +262,121 @@ type Repository struct { UpdatedAt time.Time } -// FreshnessStatus describes how current a facet is. -type FreshnessStatus string +// repositoryJSON preserves the original flattened repository identity used by +// persisted dossier snapshots. RepoRef is private in memory, but Owner and Repo +// remain top-level durable fields. +type repositoryJSON struct { + Owner string + Repo string + ID int64 + Description string + Topics []string + Languages []string + License string + DefaultBranch string + CommitSHA string + Archived bool + Fork bool + Stars int + Watchers int + Forks int + OpenIssueCount int + ClosedIssueCount int + OpenPullRequestCount int + MergedPullRequestCount int + ClosedUnmergedPullRequestCount int + ClosedPullRequestUnknownCount int + CreatedAt time.Time + UpdatedAt time.Time +} -const ( - Fresh FreshnessStatus = "fresh" - Stale FreshnessStatus = "stale" - Missing FreshnessStatus = "missing" -) +func (r Repository) MarshalJSON() ([]byte, error) { + return json.Marshal(repositoryJSON{ + Owner: r.Ref.Owner(), Repo: r.Ref.Repo(), ID: r.ID, Description: r.Description, + Topics: append([]string(nil), r.Topics...), Languages: append([]string(nil), r.Languages...), + License: r.License, DefaultBranch: r.DefaultBranch, CommitSHA: r.CommitSHA, + Archived: r.Archived, Fork: r.Fork, Stars: r.Stars, Watchers: r.Watchers, Forks: r.Forks, + OpenIssueCount: r.OpenIssueCount, ClosedIssueCount: r.ClosedIssueCount, + OpenPullRequestCount: r.OpenPullRequestCount, MergedPullRequestCount: r.MergedPullRequestCount, + ClosedUnmergedPullRequestCount: r.ClosedUnmergedPullRequestCount, + ClosedPullRequestUnknownCount: r.ClosedPullRequestUnknownCount, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) +} -// Freshness records the observed time and status of a facet. -type Freshness struct { - Status FreshnessStatus - AsOf time.Time +func (r *Repository) UnmarshalJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var stored repositoryJSON + if err := decoder.Decode(&stored); err != nil { + return fmt.Errorf("decode repository: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("decode repository: expected one JSON value") + } + var ref RepoRef + if stored.Owner != "" || stored.Repo != "" { + parsed, err := NewRepoRef(stored.Owner, stored.Repo) + if err != nil { + return err + } + ref = parsed + } + *r = Repository{ + Ref: ref, ID: stored.ID, Description: stored.Description, + Topics: append([]string(nil), stored.Topics...), Languages: append([]string(nil), stored.Languages...), + License: stored.License, DefaultBranch: stored.DefaultBranch, CommitSHA: stored.CommitSHA, + Archived: stored.Archived, Fork: stored.Fork, Stars: stored.Stars, Watchers: stored.Watchers, Forks: stored.Forks, + OpenIssueCount: stored.OpenIssueCount, ClosedIssueCount: stored.ClosedIssueCount, + OpenPullRequestCount: stored.OpenPullRequestCount, MergedPullRequestCount: stored.MergedPullRequestCount, + ClosedUnmergedPullRequestCount: stored.ClosedUnmergedPullRequestCount, + ClosedPullRequestUnknownCount: stored.ClosedPullRequestUnknownCount, + CreatedAt: stored.CreatedAt, UpdatedAt: stored.UpdatedAt, + } + return nil } -// FacetCoverage describes the presence, completeness, and freshness of one facet. +// FacetCoverage describes one present repository facet. Missing facets are +// absent from Coverage.Facets, so presence cannot contradict the observation. type FacetCoverage struct { - Facet string - Present bool - Complete bool - Freshness Freshness - Count int + facet string + complete bool + asOf time.Time + count int } +// NewFacetCoverage constructs a present facet observation. +func NewFacetCoverage(facet string, complete bool, asOf time.Time, count int) (FacetCoverage, error) { + facet = strings.TrimSpace(facet) + if facet == "" { + return FacetCoverage{}, errors.New("coverage facet is required") + } + if asOf.IsZero() { + return FacetCoverage{}, errors.New("coverage as-of time is required") + } + if count < 0 { + return FacetCoverage{}, errors.New("coverage count cannot be negative") + } + 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 } +func (c FacetCoverage) Count() int { return c.count } + +// Equal compares parsed facet observations without exposing their representation. +func (c FacetCoverage) Equal(other FacetCoverage) bool { return c == other } + // Coverage is a product-owned model for corpus facet coverage and freshness. type Coverage struct { AsOf time.Time diff --git a/internal/domain/types_test.go b/internal/domain/types_test.go index 64f31c80..9112104f 100644 --- a/internal/domain/types_test.go +++ b/internal/domain/types_test.go @@ -1,66 +1,78 @@ package domain import ( + "encoding/json" "errors" + "strings" "testing" ) -func TestRepoRefValidate(t *testing.T) { +func TestNewRepoRef(t *testing.T) { t.Parallel() cases := []struct { name string - ref RepoRef + owner string + repo string wantErr error }{ { name: "valid owner and repo", - ref: RepoRef{Owner: "golang", Repo: "go"}, + owner: "golang", + repo: "go", wantErr: nil, }, { name: "owner with hyphen", - ref: RepoRef{Owner: "some-owner", Repo: "repo-name"}, + owner: "some-owner", + repo: "repo-name", wantErr: nil, }, { name: "repo with dot", - ref: RepoRef{Owner: "owner", Repo: "repo.go"}, + owner: "owner", + repo: "repo.go", wantErr: nil, }, { name: "empty owner", - ref: RepoRef{Owner: "", Repo: "go"}, + repo: "go", wantErr: errOwnerEmpty, }, { name: "empty repo", - ref: RepoRef{Owner: "golang", Repo: ""}, + owner: "golang", wantErr: errRepoEmpty, }, { name: "owner starts with hyphen", - ref: RepoRef{Owner: "-bad", Repo: "go"}, + owner: "-bad", + repo: "go", wantErr: errors.New("invalid owner \"-bad\""), }, { name: "repo is path traversal", - ref: RepoRef{Owner: "golang", Repo: "../go"}, + owner: "golang", + repo: "../go", wantErr: errors.New("invalid repo \"../go\""), }, { name: "repo is dot", - ref: RepoRef{Owner: "golang", Repo: "."}, + owner: "golang", + repo: ".", wantErr: errors.New("invalid repo \".\""), }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := tc.ref.Validate() + ref, err := NewRepoRef(tc.owner, tc.repo) if tc.wantErr == nil { if err != nil { t.Fatalf("expected no error, got %v", err) } + if ref.Owner() != tc.owner || ref.Repo() != tc.repo || ref.String() != tc.owner+"/"+tc.repo { + t.Fatalf("repository reference = %#v", ref) + } return } if err == nil || err.Error() != tc.wantErr.Error() { @@ -69,3 +81,78 @@ func TestRepoRefValidate(t *testing.T) { }) } } + +func TestParseRepoRefAndJSONRejectInvalidRepresentations(t *testing.T) { + t.Parallel() + ref, err := ParseRepoRef(" golang/go ") + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(ref) + if err != nil { + t.Fatal(err) + } + var decoded RepoRef + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if decoded != ref { + t.Fatalf("decoded = %v, want %v", decoded, ref) + } + for _, input := range []string{"", "owner", "owner/repo/extra"} { + if _, err := ParseRepoRef(input); err == nil { + t.Errorf("ParseRepoRef(%q) succeeded", input) + } + } + for _, payload := range []string{ + `{"Owner":"owner","Repo":""}`, + `{"Owner":"owner","Repo":"repo","Unexpected":true}`, + } { + if err := json.Unmarshal([]byte(payload), &decoded); err == nil { + t.Errorf("json.Unmarshal(%s) succeeded", payload) + } + } +} + +func TestRepositoryJSONKeepsIdentityAndSnapshotFields(t *testing.T) { + t.Parallel() + want := Repository{Ref: MustRepoRef("golang", "go"), Description: "Go", Stars: 42} + payload, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if encoded := string(payload); !strings.Contains(encoded, `"Owner":"golang"`) || !strings.Contains(encoded, `"Repo":"go"`) || strings.Contains(encoded, `"Ref"`) { + t.Fatalf("repository JSON changed its durable identity shape: %s", encoded) + } + var got Repository + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + if got.Ref != want.Ref || got.Description != want.Description || got.Stars != want.Stars { + t.Fatalf("repository round trip = %+v, want %+v", got, want) + } + + var legacy Repository + if err := json.Unmarshal([]byte(`{"Owner":"golang","Repo":"go","Description":"stored dossier","Stars":7}`), &legacy); err != nil { + t.Fatal(err) + } + if legacy.Ref != MustRepoRef("golang", "go") || legacy.Description != "stored dossier" || legacy.Stars != 7 { + t.Fatalf("legacy repository JSON = %+v", legacy) + } +} + +func TestParseThreadKindAndStateRejectUnknownVariants(t *testing.T) { + t.Parallel() + if kind, err := ParseThreadKind(" pull_request "); err != nil || kind != PullRequestKind { + t.Fatalf("parse thread kind = %q, %v", kind, err) + } + if state, err := ParseThreadState(" open "); err != nil || state != OpenState { + t.Fatalf("parse thread state = %q, %v", state, err) + } + if _, err := ParseThreadKind("discussion"); err == nil { + t.Fatal("unknown thread kind was accepted") + } + if _, err := ParseThreadState("draft"); err == nil { + t.Fatal("unknown thread state was accepted") + } +} diff --git a/internal/dossier/builder.go b/internal/dossier/builder.go index 2a8b4948..17cd7110 100644 --- a/internal/dossier/builder.go +++ b/internal/dossier/builder.go @@ -27,13 +27,13 @@ func NewBuilder(reader Reader, recentLimit int) *Builder { return &Builder{reader: reader, recentLimit: recentLimit} } -// Build constructs a Dossier for ref. It validates the repo reference, reads +// Build constructs a Dossier for a parsed ref, reads // repository metadata, contribution guidance, coverage, and threads, then // deterministically selects and orders recent items. No LLM summarization is // performed. func (b *Builder) Build(ctx context.Context, ref domain.RepoRef) (*domain.Dossier, error) { - if err := ref.Validate(); err != nil { - return nil, fmt.Errorf("invalid repo reference: %w", err) + if !ref.IsValid() { + return nil, fmt.Errorf("invalid repo reference: repository reference is not parsed") } repo, repoRefs, err := b.reader.ReadRepository(ctx, ref) @@ -124,9 +124,10 @@ func (b *Builder) readIssues(ctx context.Context, ref domain.RepoRef) ([]domain. func partitionClosedPullRequests(threads []domain.Thread) (merged, unmerged, unknown []domain.Thread) { for _, thread := range threads { - if thread.PullRequest == nil || !thread.PullRequest.MergedKnown { + pullRequest, ok := thread.PullRequest() + if !ok || !pullRequest.Merge.Known() { unknown = append(unknown, thread) - } else if thread.PullRequest.Merged { + } else if pullRequest.Merge.IsMerged() { merged = append(merged, thread) } else { unmerged = append(unmerged, thread) @@ -156,8 +157,8 @@ func toDossierThreads(threads []domain.Thread, limit int) []domain.DossierThread ClosedAt: t.ClosedAt, Labels: append([]string(nil), t.Labels...), } - if t.PullRequest != nil { - dt.MergedAt = t.PullRequest.MergedAt + if pullRequest, ok := t.PullRequest(); ok { + dt.MergedAt = pullRequest.Merge.MergedAt() } out[i] = dt } diff --git a/internal/dossier/builder_test.go b/internal/dossier/builder_test.go index 4a856f70..1009bf09 100644 --- a/internal/dossier/builder_test.go +++ b/internal/dossier/builder_test.go @@ -28,15 +28,15 @@ type threadKey struct { } func (f *fakeReader) ReadRepository(_ context.Context, ref domain.RepoRef) (domain.Repository, []domain.SourceRef, error) { - if err := ref.Validate(); err != nil { - return domain.Repository{}, nil, err + if !ref.IsValid() { + return domain.Repository{}, nil, errors.New("repository reference is not parsed") } return f.repo, f.repoRefs, nil } func (f *fakeReader) ReadThreads(_ context.Context, ref domain.RepoRef, q ThreadQuery) ([]domain.Thread, []domain.SourceRef, error) { - if err := ref.Validate(); err != nil { - return nil, nil, err + if !ref.IsValid() { + return nil, nil, errors.New("repository reference is not parsed") } merged := "*" if q.Merged != nil { @@ -56,15 +56,15 @@ func (f *fakeReader) ReadThreads(_ context.Context, ref domain.RepoRef, q Thread } func (f *fakeReader) ReadCoverage(_ context.Context, ref domain.RepoRef) (domain.Coverage, error) { - if err := ref.Validate(); err != nil { - return domain.Coverage{}, err + if !ref.IsValid() { + return domain.Coverage{}, errors.New("repository reference is not parsed") } return f.coverage, nil } func (f *fakeReader) ReadContributionGuidance(_ context.Context, ref domain.RepoRef) (string, []domain.SourceRef, error) { - if err := ref.Validate(); err != nil { - return "", nil, err + if !ref.IsValid() { + return "", nil, errors.New("repository reference is not parsed") } return f.guidance, f.guidanceRefs, nil } @@ -73,16 +73,16 @@ var now = time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) func TestBuilderValidation(t *testing.T) { b := NewBuilder(&fakeReader{}, 5) - _, err := b.Build(context.Background(), domain.RepoRef{Owner: "", Repo: "go"}) + _, err := b.Build(context.Background(), domain.RepoRef{}) if err == nil { t.Fatal("expected validation error for empty owner") } } func TestBuilderBuild(t *testing.T) { - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") repo := domain.Repository{ - RepoRef: ref, + Ref: ref, CommitSHA: "abc123", Description: "A test repository", OpenIssueCount: 3, @@ -95,8 +95,8 @@ func TestBuilderBuild(t *testing.T) { coverage := domain.Coverage{ AsOf: now, Facets: []domain.FacetCoverage{ - {Facet: "metadata", Present: true, Complete: true, Freshness: domain.Freshness{Status: domain.Fresh, AsOf: now}}, - {Facet: "threads", Present: true, Complete: false, Freshness: domain.Freshness{Status: domain.Stale, AsOf: now.Add(-time.Hour)}}, + domain.MustFacetCoverage("metadata", true, now, 0), + domain.MustFacetCoverage("threads", false, now.Add(-time.Hour), 0), }, } guidance := "Please open an issue first." @@ -106,22 +106,22 @@ func TestBuilderBuild(t *testing.T) { // Return threads unsorted to exercise stable ordering in the builder. mergedPRs := []domain.Thread{ - {Repo: ref, Kind: domain.PullRequestKind, Number: 9, Title: "Second merged", State: domain.ClosedState, UpdatedAt: now.Add(-2 * time.Hour), CreatedAt: now.Add(-10 * time.Hour), PullRequest: &domain.PullRequestDetails{Merged: true, MergedKnown: true, MergedAt: now.Add(-3 * time.Hour)}}, - {Repo: ref, Kind: domain.PullRequestKind, Number: 5, Title: "First merged", State: domain.ClosedState, UpdatedAt: now.Add(-time.Hour), CreatedAt: now.Add(-12 * time.Hour), PullRequest: &domain.PullRequestDetails{Merged: true, MergedKnown: true, MergedAt: now.Add(-2 * time.Hour)}}, + {Repo: ref, Type: domain.PullRequestThread(domain.PullRequestDetails{Merge: domain.MergedStatus(now.Add(-3 * time.Hour))}), Number: 9, Title: "Second merged", State: domain.ClosedState, UpdatedAt: now.Add(-2 * time.Hour), CreatedAt: now.Add(-10 * time.Hour)}, + {Repo: ref, Type: domain.PullRequestThread(domain.PullRequestDetails{Merge: domain.MergedStatus(now.Add(-2 * time.Hour))}), Number: 5, Title: "First merged", State: domain.ClosedState, UpdatedAt: now.Add(-time.Hour), CreatedAt: now.Add(-12 * time.Hour)}, } openPRs := []domain.Thread{ - {Repo: ref, Kind: domain.PullRequestKind, Number: 11, Title: "Open PR", State: domain.OpenState, UpdatedAt: now, CreatedAt: now.Add(-time.Hour)}, + {Repo: ref, Type: domain.PullRequestThread(domain.PullRequestDetails{}), Number: 11, Title: "Open PR", State: domain.OpenState, UpdatedAt: now, CreatedAt: now.Add(-time.Hour)}, } closedUnmergedPRs := []domain.Thread{ - {Repo: ref, Kind: domain.PullRequestKind, Number: 3, Title: "Closed unmerged", State: domain.ClosedState, UpdatedAt: now.Add(-3 * time.Hour), CreatedAt: now.Add(-20 * time.Hour), PullRequest: &domain.PullRequestDetails{MergedKnown: true}}, + {Repo: ref, Type: domain.PullRequestThread(domain.PullRequestDetails{Merge: domain.UnmergedStatus()}), Number: 3, Title: "Closed unmerged", State: domain.ClosedState, UpdatedAt: now.Add(-3 * time.Hour), CreatedAt: now.Add(-20 * time.Hour)}, } unknownMergePRs := []domain.Thread{ - {Repo: ref, Kind: domain.PullRequestKind, Number: 4, Title: "Closed with unknown merge state", State: domain.ClosedState, UpdatedAt: now.Add(-4 * time.Hour), CreatedAt: now.Add(-21 * time.Hour), PullRequest: &domain.PullRequestDetails{}}, + {Repo: ref, Type: domain.PullRequestThread(domain.PullRequestDetails{}), Number: 4, Title: "Closed with unknown merge state", State: domain.ClosedState, UpdatedAt: now.Add(-4 * time.Hour), CreatedAt: now.Add(-21 * time.Hour)}, } closedPRs := append(append(append([]domain.Thread{}, mergedPRs...), closedUnmergedPRs...), unknownMergePRs...) issues := []domain.Thread{ - {Repo: ref, Kind: domain.IssueKind, Number: 42, Title: "Recent issue", State: domain.OpenState, UpdatedAt: now.Add(-30 * time.Minute), CreatedAt: now.Add(-2 * time.Hour)}, - {Repo: ref, Kind: domain.IssueKind, Number: 7, Title: "Old issue", State: domain.ClosedState, UpdatedAt: now.Add(-4 * time.Hour), CreatedAt: now.Add(-24 * time.Hour)}, + {Repo: ref, Type: domain.IssueThread(), Number: 42, Title: "Recent issue", State: domain.OpenState, UpdatedAt: now.Add(-30 * time.Minute), CreatedAt: now.Add(-2 * time.Hour)}, + {Repo: ref, Type: domain.IssueThread(), Number: 7, Title: "Old issue", State: domain.ClosedState, UpdatedAt: now.Add(-4 * time.Hour), CreatedAt: now.Add(-24 * time.Hour)}, } fr := &fakeReader{ @@ -217,15 +217,15 @@ func TestBuilderBuild(t *testing.T) { } func TestBuilderDeterministicSorting(t *testing.T) { - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} - repo := domain.Repository{RepoRef: ref} + ref := domain.MustRepoRef("owner", "repo") + repo := domain.Repository{Ref: ref} base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Threads with identical UpdatedAt must be ordered by CreatedAt, number, title. threads := []domain.Thread{ - {Repo: ref, Kind: domain.IssueKind, Number: 3, Title: "C", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, - {Repo: ref, Kind: domain.IssueKind, Number: 1, Title: "A", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, - {Repo: ref, Kind: domain.IssueKind, Number: 2, Title: "B", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, + {Repo: ref, Type: domain.IssueThread(), Number: 3, Title: "C", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, + {Repo: ref, Type: domain.IssueThread(), Number: 1, Title: "A", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, + {Repo: ref, Type: domain.IssueThread(), Number: 2, Title: "B", State: domain.OpenState, UpdatedAt: base, CreatedAt: base}, } fr := &fakeReader{ @@ -253,9 +253,9 @@ func TestBuilderDeterministicSorting(t *testing.T) { } func TestBuilderReaderError(t *testing.T) { - ref := domain.RepoRef{Owner: "owner", Repo: "repo"} + ref := domain.MustRepoRef("owner", "repo") fr := &fakeReader{ - repo: domain.Repository{RepoRef: ref}, + repo: domain.Repository{Ref: ref}, } fr.threads = nil // not relevant diff --git a/internal/evidence/external_manifest.go b/internal/evidence/external_manifest.go index eb41a7b1..7d9ac212 100644 --- a/internal/evidence/external_manifest.go +++ b/internal/evidence/external_manifest.go @@ -107,6 +107,7 @@ func (s *Service) ImportExternalEvidenceManifest(ctx context.Context, item Exter return nil, fmt.Errorf("external evidence manifest digest mismatch: got %q want %q", item.ManifestSHA256, digest) } firstEvidenceID := "" + items := make([]*Evidence, 0, len(item.Claims)) for _, claim := range item.Claims { claimDigest, err := DigestExternalEvidenceClaim(digest, claim) if err != nil { @@ -122,9 +123,10 @@ func (s *Service) ImportExternalEvidenceManifest(ctx context.Context, item Exter if firstEvidenceID == "" { firstEvidenceID = e.ID } - if err := s.repo.SaveEvidence(ctx, e); err != nil { - return nil, fmt.Errorf("save imported external evidence %q: %w", claim.ID, err) - } + items = append(items, e) + } + 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 } @@ -189,10 +191,16 @@ func validateExternalEvidenceManifest(item ExternalEvidenceManifest) error { if err != nil || len(payload) > maxExternalEvidenceManifestBytes { return fmt.Errorf("external evidence manifest exceeds %d bytes", maxExternalEvidenceManifestBytes) } + claimIDs := make(map[string]struct{}, len(item.Claims)) for _, claim := range item.Claims { if strings.TrimSpace(claim.ID) == "" || strings.TrimSpace(claim.Description) == "" { return errors.New("external evidence claims require id and description") } + claimID := strings.TrimSpace(claim.ID) + if _, duplicate := claimIDs[claimID]; duplicate { + return fmt.Errorf("duplicate external evidence claim id %q", claimID) + } + claimIDs[claimID] = struct{}{} switch claim.Type { case EvidenceTypeBaseFailingRegression, EvidenceTypeCandidatePassingRegression, EvidenceTypeMinimalReproduction, EvidenceTypeBenchmark, EvidenceTypeProfiler, EvidenceTypeInvariantViolation, EvidenceTypeCompatibilityMatrix, EvidenceTypeStaticAnalysis, EvidenceTypeManualObservation, EvidenceTypeGitHubSource: default: diff --git a/internal/evidence/external_manifest_test.go b/internal/evidence/external_manifest_test.go index 5d471816..d414074e 100644 --- a/internal/evidence/external_manifest_test.go +++ b/internal/evidence/external_manifest_test.go @@ -53,3 +53,18 @@ func TestImportExternalEvidenceManifestRejectsDigestAndInvalidIntegrity(t *testi t.Fatal("unsigned manifest accepted") } } + +func TestExternalEvidenceManifestRejectsDuplicateClaimIdentity(t *testing.T) { + item := ExternalEvidenceManifest{ + SchemaVersion: ExternalEvidenceManifestSchemaV1, Producer: "tool", InvestigationID: "inv", + Repository: "octo/project", Revision: "abc", ObservedAt: time.Now().UTC(), + Completeness: "complete", Integrity: "verified", + Claims: []ExternalEvidenceClaim{ + {ID: "same", Type: EvidenceTypeStaticAnalysis, Relation: RelationSupporting, Description: "first"}, + {ID: "same", Type: EvidenceTypeManualObservation, Relation: RelationInconclusive, Description: "second"}, + }, + } + if err := validateExternalEvidenceManifest(item); err == nil { + t.Fatal("duplicate claim identity was accepted") + } +} diff --git a/internal/evidence/external_receipt.go b/internal/evidence/external_receipt.go index 354c3359..2ad4ded2 100644 --- a/internal/evidence/external_receipt.go +++ b/internal/evidence/external_receipt.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "slices" "strings" "time" ) @@ -110,17 +109,6 @@ func (s *Service) AttachExternalReceipt(ctx context.Context, receipt ExternalRec Name: receipt.Producer + " external validation", Kind: "external", Command: append([]string(nil), receipt.Command...), WorkingDir: receipt.WorkingDir, CreatedAt: receipt.StartedAt.UTC(), } - existing, err := s.repo.GetValidationDefinition(ctx, definitionID) - if err != nil && !errors.Is(err, ErrNotFound) { - return nil, fmt.Errorf("read external validation definition: %w", err) - } - if existing != nil { - if !slices.Equal(existing.Command, definition.Command) { - return nil, errors.New("external receipt command differs from the existing validation_id") - } - } else if err := s.repo.SaveValidationDefinition(ctx, definition); err != nil { - return nil, fmt.Errorf("save external validation definition: %w", err) - } run := &ValidationRun{ ID: "external-run-" + digest, DefinitionID: definitionID, InvestigationID: receipt.InvestigationID, OpportunityID: receipt.OpportunityID, Kind: receipt.Kind, StartedAt: receipt.StartedAt.UTC(), @@ -135,8 +123,8 @@ func (s *Service) AttachExternalReceipt(ctx context.Context, receipt ExternalRec Limitations: append([]string(nil), receipt.Limitations...), Incomplete: receipt.Incomplete, }, } - if err := s.repo.SaveValidationRun(ctx, run); err != nil { - return nil, fmt.Errorf("save external validation run: %w", err) + if err := s.repo.SaveExternalValidation(ctx, definition, run); err != nil { + return nil, fmt.Errorf("save external validation: %w", err) } return run, nil } diff --git a/internal/evidence/freshness.go b/internal/evidence/freshness.go index 3339c5b3..fe6c5cca 100644 --- a/internal/evidence/freshness.go +++ b/internal/evidence/freshness.go @@ -187,7 +187,7 @@ func (r SourceRevision) Validate() error { // Validate checks the shape required by each subject kind. func (s SourceSubject) Validate() error { - if err := (domain.RepoRef{Owner: s.Owner, Repo: s.Repo}).Validate(); err != nil { + if _, err := domain.NewRepoRef(s.Owner, s.Repo); err != nil { return fmt.Errorf("invalid source repository: %w", err) } threadScoped := s.ThreadKind != "" || s.Number != 0 diff --git a/internal/evidence/metric_test.go b/internal/evidence/metric_test.go new file mode 100644 index 00000000..bbf24997 --- /dev/null +++ b/internal/evidence/metric_test.go @@ -0,0 +1,54 @@ +package evidence + +import ( + "encoding/json" + "testing" +) + +func TestMetricJSONRoundTripPreservesAvailableAndUnavailableVariants(t *testing.T) { + tests := []struct { + name string + metric Int64Metric + value int64 + known bool + reason string + }{ + {name: "available zero", metric: AvailableInt64Metric(0), value: 0, known: true}, + {name: "unavailable", metric: UnavailableInt64Metric("not supported"), reason: "not supported"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + encoded, err := json.Marshal(testCase.metric) + if err != nil { + t.Fatal(err) + } + var decoded Int64Metric + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + value, known := decoded.Value() + if value != testCase.value || known != testCase.known || decoded.UnavailableReason() != testCase.reason { + t.Fatalf("decoded metric = value %d known %t reason %q", value, known, decoded.UnavailableReason()) + } + }) + } +} + +func TestMetricJSONRejectsValueUnavailableContradiction(t *testing.T) { + for name, payload := range map[string]string{ + "signed": `{"Value":1,"UnavailableReason":"not supported"}`, + "unsigned": `{"Value":1,"UnavailableReason":"not supported"}`, + } { + t.Run(name, func(t *testing.T) { + var err error + if name == "signed" { + err = json.Unmarshal([]byte(payload), &Int64Metric{}) + } else { + err = json.Unmarshal([]byte(payload), &Uint64Metric{}) + } + if err == nil { + t.Fatal("contradictory metric was accepted") + } + }) + } +} diff --git a/internal/evidence/models.go b/internal/evidence/models.go index 3525a9df..b835ee2d 100644 --- a/internal/evidence/models.go +++ b/internal/evidence/models.go @@ -2,6 +2,10 @@ package evidence import ( "context" + "encoding/json" + "errors" + "fmt" + "regexp" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -99,18 +103,107 @@ type RunPhases struct { ShutdownCheckedAt time.Time } -// Int64Metric represents a sampled value. Nil means unavailable, never zero. +// Int64Metric is either a sampled value or an unavailable reason. Its private +// representation prevents both claims from being populated simultaneously. type Int64Metric struct { + value *int64 + unavailableReason string +} + +// Uint64Metric is either a sampled unsigned value or an unavailable reason. +type Uint64Metric struct { + value *uint64 + unavailableReason string +} + +func AvailableInt64Metric(value int64) Int64Metric { return Int64Metric{value: &value} } +func UnavailableInt64Metric(reason string) Int64Metric { + return Int64Metric{unavailableReason: reason} +} +func (m Int64Metric) Value() (int64, bool) { + if m.value == nil { + return 0, false + } + return *m.value, true +} +func (m Int64Metric) ValuePointer() *int64 { + if m.value == nil { + return nil + } + value := *m.value + return &value +} +func (m Int64Metric) UnavailableReason() string { return m.unavailableReason } + +func AvailableUint64Metric(value uint64) Uint64Metric { return Uint64Metric{value: &value} } +func UnavailableUint64Metric(reason string) Uint64Metric { + return Uint64Metric{unavailableReason: reason} +} +func (m Uint64Metric) Value() (uint64, bool) { + if m.value == nil { + return 0, false + } + return *m.value, true +} +func (m Uint64Metric) ValuePointer() *uint64 { + if m.value == nil { + return nil + } + value := *m.value + return &value +} +func (m Uint64Metric) UnavailableReason() string { return m.unavailableReason } + +type int64MetricJSON struct { Value *int64 UnavailableReason string } -// Uint64Metric represents a sampled unsigned value. -type Uint64Metric struct { +func (m Int64Metric) MarshalJSON() ([]byte, error) { + return json.Marshal(int64MetricJSON{Value: m.ValuePointer(), UnavailableReason: m.unavailableReason}) +} + +func (m *Int64Metric) UnmarshalJSON(data []byte) error { + var stored int64MetricJSON + if err := json.Unmarshal(data, &stored); err != nil { + return err + } + if stored.Value != nil && stored.UnavailableReason != "" { + return errors.New("int64 metric cannot be both available and unavailable") + } + if stored.Value != nil { + *m = AvailableInt64Metric(*stored.Value) + } else { + *m = UnavailableInt64Metric(stored.UnavailableReason) + } + return nil +} + +type uint64MetricJSON struct { Value *uint64 UnavailableReason string } +func (m Uint64Metric) MarshalJSON() ([]byte, error) { + return json.Marshal(uint64MetricJSON{Value: m.ValuePointer(), UnavailableReason: m.unavailableReason}) +} + +func (m *Uint64Metric) UnmarshalJSON(data []byte) error { + var stored uint64MetricJSON + if err := json.Unmarshal(data, &stored); err != nil { + return err + } + if stored.Value != nil && stored.UnavailableReason != "" { + return errors.New("uint64 metric cannot be both available and unavailable") + } + if stored.Value != nil { + *m = AvailableUint64Metric(*stored.Value) + } else { + *m = UnavailableUint64Metric(stored.UnavailableReason) + } + return nil +} + // ResourceTelemetry contains bounded process-tree high-water marks. type ResourceTelemetry struct { Provider string @@ -169,8 +262,9 @@ const ( ObservationAbsent ObservationOccurrence = "absent" ) -// ExpectedObservation is one bounded assertion over captured output. -type ExpectedObservation struct { +// ExpectedObservationSpec is the untrusted representation parsed at command, +// protocol, and storage boundaries. +type ExpectedObservationSpec struct { Name string Source ObservationSource Matcher ObservationMatcher @@ -179,11 +273,33 @@ type ExpectedObservation struct { Path string } -// ObservationContract ties validation output to the intended proof. -type ObservationContract struct { +// ExpectedObservation is one parsed, bounded assertion over captured output. +// Its representation is private so execution never receives an unknown source, +// an invalid matcher, or an artifact observation without a safe relative path. +type ExpectedObservation struct { + name string + source ObservationSource + matcher ObservationMatcher + pattern string + occurrence ObservationOccurrence + path string + compiled *regexp.Regexp +} + +// ObservationContractSpec is the untrusted representation of a proof contract. +type ObservationContractSpec struct { Intent string - Base []ExpectedObservation - Candidate []ExpectedObservation + Base []ExpectedObservationSpec + Candidate []ExpectedObservationSpec +} + +// ObservationContract ties validation output to the intended proof. Contracts +// can only be populated by ParseObservationContract or JSON decoding, both of +// which establish the same invariants. +type ObservationContract struct { + intent string + base []ExpectedObservation + candidate []ExpectedObservation } // ObservationStatus is the aggregate outcome of a run's output assertions. @@ -432,3 +548,140 @@ 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) + } + 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) { + return fmt.Errorf("validation attempt %d has an unsupported discriminator", i) + } + } + for i, aggregate := range g.Aggregates { + if !validRunKind(aggregate.Kind) || !validRunGroupClassification(aggregate.Classification) { + 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 e.External.Completeness != "complete" && e.External.Completeness != "incomplete" && e.External.Completeness != "unknown" { + return fmt.Errorf("unsupported external evidence completeness %q", e.External.Completeness) + } + if e.External.Integrity != "verified" && e.External.Integrity != "unverified" { + 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 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/observation.go b/internal/evidence/observation.go index cc4dc120..bcfc6371 100644 --- a/internal/evidence/observation.go +++ b/internal/evidence/observation.go @@ -1,7 +1,9 @@ package evidence import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -19,65 +21,223 @@ const ( maxObservationExcerptBytes = 1024 ) -func validateObservationContract(contract *ObservationContract) error { - if contract == nil { - return nil +// ParseExpectedObservation converts an untrusted observation specification into +// the representation used by execution and persistence. +func ParseExpectedObservation(spec ExpectedObservationSpec) (ExpectedObservation, error) { + observation, err := parseExpectedObservation(spec) + if err != nil { + return ExpectedObservation{}, fmt.Errorf("%w: %w", ErrInvalidObservation, err) + } + return observation, nil +} + +func parseExpectedObservation(spec ExpectedObservationSpec) (ExpectedObservation, error) { + name := strings.TrimSpace(spec.Name) + if name == "" || len(name) > maxObservationNameBytes { + return ExpectedObservation{}, fmt.Errorf("name is required and must be at most %d bytes", maxObservationNameBytes) + } + if spec.Source != ObservationStdout && spec.Source != ObservationStderr && spec.Source != ObservationArtifact { + return ExpectedObservation{}, errors.New("source must be stdout, stderr, or artifact") + } + path := spec.Path + if spec.Source == ObservationArtifact { + if err := validateArtifactPath(path); err != nil { + return ExpectedObservation{}, fmt.Errorf("artifact path: %w", err) + } + path = filepath.Clean(path) + } else if path != "" { + return ExpectedObservation{}, errors.New("path is only valid for artifact observations") } - if strings.TrimSpace(contract.Intent) == "" || len(contract.Intent) > maxObservationIntentBytes { - return fmt.Errorf("%w: intent is required and must be at most %d bytes", ErrInvalidObservation, maxObservationIntentBytes) + if spec.Matcher != ObservationExact && spec.Matcher != ObservationRegexp { + return ExpectedObservation{}, errors.New("matcher must be exact or regexp") } - if len(contract.Base) == 0 || len(contract.Candidate) == 0 { - return fmt.Errorf("%w: at least one base and candidate observation is required", ErrInvalidObservation) + if spec.Pattern == "" || len(spec.Pattern) > maxObservationPatternBytes { + return ExpectedObservation{}, fmt.Errorf("pattern is required and must be at most %d bytes", maxObservationPatternBytes) } - if err := validateExpectedObservations("base", contract.Base); err != nil { - return err + occurrence := spec.Occurrence + if occurrence == "" { + occurrence = ObservationPresent + } + if occurrence != ObservationPresent && occurrence != ObservationAbsent { + return ExpectedObservation{}, errors.New("occurrence must be present or absent") + } + var compiled *regexp.Regexp + if spec.Matcher == ObservationRegexp { + var err error + compiled, err = regexp.Compile(spec.Pattern) + if err != nil { + return ExpectedObservation{}, fmt.Errorf("regexp: %w", err) + } + } + return ExpectedObservation{ + name: name, source: spec.Source, matcher: spec.Matcher, pattern: spec.Pattern, + occurrence: occurrence, path: path, compiled: compiled, + }, nil +} + +// ParseObservationContract establishes the complete proof-contract invariant. +func ParseObservationContract(spec ObservationContractSpec) (*ObservationContract, error) { + intent := strings.TrimSpace(spec.Intent) + if intent == "" || len(intent) > maxObservationIntentBytes { + return nil, fmt.Errorf("%w: intent is required and must be at most %d bytes", ErrInvalidObservation, maxObservationIntentBytes) + } + if len(spec.Base) == 0 || len(spec.Candidate) == 0 { + return nil, fmt.Errorf("%w: at least one base and candidate observation is required", ErrInvalidObservation) + } + base, err := parseExpectedObservations("base", spec.Base) + if err != nil { + return nil, err + } + candidate, err := parseExpectedObservations("candidate", spec.Candidate) + if err != nil { + return nil, err } - return validateExpectedObservations("candidate", contract.Candidate) + return &ObservationContract{intent: intent, base: base, candidate: candidate}, nil } -func validateExpectedObservations(kind string, observations []ExpectedObservation) error { - if len(observations) > maxObservationsPerRun { - return fmt.Errorf("%w: %s has more than %d observations", ErrInvalidObservation, kind, maxObservationsPerRun) +func parseExpectedObservations(kind string, specs []ExpectedObservationSpec) ([]ExpectedObservation, error) { + if len(specs) > maxObservationsPerRun { + return nil, fmt.Errorf("%w: %s has more than %d observations", ErrInvalidObservation, kind, maxObservationsPerRun) } - for i := range observations { - observation := &observations[i] - if err := validateExpectedObservation(observation); err != nil { - return fmt.Errorf("%w: %s[%d]: %w", ErrInvalidObservation, kind, i, err) + observations := make([]ExpectedObservation, len(specs)) + for i, spec := range specs { + observation, err := ParseExpectedObservation(spec) + if err != nil { + return nil, fmt.Errorf("%w: %s[%d]: %w", ErrInvalidObservation, kind, i, err) } + observations[i] = observation } - return nil + return observations, nil } -func validateExpectedObservation(observation *ExpectedObservation) error { - if strings.TrimSpace(observation.Name) == "" || len(observation.Name) > maxObservationNameBytes { - return fmt.Errorf("name is required and must be at most %d bytes", maxObservationNameBytes) +// Spec returns the canonical boundary representation of an observation. +func (o ExpectedObservation) Spec() ExpectedObservationSpec { + return ExpectedObservationSpec{ + Name: o.name, Source: o.source, Matcher: o.matcher, Pattern: o.pattern, + Occurrence: o.occurrence, Path: o.path, } - if observation.Source != ObservationStdout && observation.Source != ObservationStderr && observation.Source != ObservationArtifact { - return errors.New("source must be stdout, stderr, or artifact") +} + +// Name identifies the assertion in human-facing evidence. +func (o ExpectedObservation) Name() string { return o.name } + +// Intent describes the proof established by the contract. +func (c *ObservationContract) Intent() string { return c.intent } + +// Base returns the parsed observations expected from the base revision. +func (c *ObservationContract) Base() []ExpectedObservation { + return append([]ExpectedObservation(nil), c.base...) +} + +// Candidate returns the parsed observations expected from the candidate revision. +func (c *ObservationContract) Candidate() []ExpectedObservation { + return append([]ExpectedObservation(nil), c.candidate...) +} + +// Spec returns the canonical boundary representation of a contract. +func (c *ObservationContract) Spec() ObservationContractSpec { + return ObservationContractSpec{Intent: c.intent, Base: observationSpecs(c.base), Candidate: observationSpecs(c.candidate)} +} + +func observationSpecs(observations []ExpectedObservation) []ExpectedObservationSpec { + specs := make([]ExpectedObservationSpec, len(observations)) + for i, observation := range observations { + specs[i] = observation.Spec() } - if observation.Source == ObservationArtifact { - if err := validateArtifactPath(observation.Path); err != nil { - return fmt.Errorf("artifact path: %w", err) - } - } else if observation.Path != "" { - return errors.New("path is only valid for artifact observations") + return specs +} + +// MarshalJSON persists only the canonical boundary representation. +func (o ExpectedObservation) MarshalJSON() ([]byte, error) { + if o.name == "" { + return nil, fmt.Errorf("%w: unparsed expected observation", ErrInvalidObservation) } - if observation.Matcher != ObservationExact && observation.Matcher != ObservationRegexp { - return errors.New("matcher must be exact or regexp") + return json.Marshal(o.Spec()) +} + +// UnmarshalJSON reparses persisted input instead of trusting stored fields. +func (o *ExpectedObservation) UnmarshalJSON(data []byte) error { + var spec ExpectedObservationSpec + if err := decodeObservationJSON(data, &spec); err != nil { + return err } - if observation.Pattern == "" || len(observation.Pattern) > maxObservationPatternBytes { - return fmt.Errorf("pattern is required and must be at most %d bytes", maxObservationPatternBytes) + parsed, err := ParseExpectedObservation(spec) + if err != nil { + return err + } + *o = parsed + return nil +} + +// MarshalJSON persists only the canonical boundary representation. +func (c ObservationContract) MarshalJSON() ([]byte, error) { + if c.intent == "" { + return nil, fmt.Errorf("%w: unparsed observation contract", ErrInvalidObservation) + } + return json.Marshal(c.Spec()) +} + +// UnmarshalJSON reparses persisted input instead of trusting stored fields. +func (c *ObservationContract) UnmarshalJSON(data []byte) error { + var spec ObservationContractSpec + if err := decodeObservationJSON(data, &spec); err != nil { + return err + } + parsed, err := ParseObservationContract(spec) + if err != nil { + return err + } + *c = *parsed + return nil +} + +type observationResultJSON struct { + ExpectedObservationSpec + Status ObservationStatus + Excerpt string + Error string +} + +// MarshalJSON keeps the historical flattened result shape while preventing +// the embedded observation's marshaler from swallowing result fields. +func (r ObservationResult) MarshalJSON() ([]byte, error) { + if r.name == "" { + return nil, fmt.Errorf("%w: result contains an unparsed observation", ErrInvalidObservation) + } + return json.Marshal(observationResultJSON{ + ExpectedObservationSpec: r.Spec(), + Status: r.Status, Excerpt: r.Excerpt, Error: r.Error, + }) +} + +// UnmarshalJSON reparses the embedded observation before exposing a stored result. +func (r *ObservationResult) UnmarshalJSON(data []byte) error { + var stored observationResultJSON + if err := decodeObservationJSON(data, &stored); err != nil { + return err } - if observation.Occurrence == "" { - observation.Occurrence = ObservationPresent + observation, err := ParseExpectedObservation(stored.ExpectedObservationSpec) + if err != nil { + return fmt.Errorf("stored result: %w", err) } - if observation.Occurrence != ObservationPresent && observation.Occurrence != ObservationAbsent { - return errors.New("occurrence must be present or absent") + *r = ObservationResult{ + ExpectedObservation: observation, + Status: stored.Status, Excerpt: stored.Excerpt, Error: stored.Error, } - if observation.Matcher == ObservationRegexp { - if _, err := regexp.Compile(observation.Pattern); err != nil { - return fmt.Errorf("regexp: %w", err) + return nil +} + +func decodeObservationJSON(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("decode observation: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("decode observation: multiple JSON values") } + return fmt.Errorf("decode observation: %w", err) } return nil } @@ -86,9 +246,9 @@ func evaluateObservations(ctx context.Context, contract *ObservationContract, ki if contract == nil { return ObservationNotEvaluated, nil } - expected := contract.Candidate + expected := contract.candidate if kind == RunKindBase { - expected = contract.Base + expected = contract.base } if len(expected) == 0 { return ObservationNotEvaluated, nil @@ -105,16 +265,8 @@ func evaluateObservations(ctx context.Context, contract *ObservationContract, ki }) continue } - matched, excerpt, matchErr := matchObservation(output, observation) - if matchErr != nil { - status = ObservationMismatched - results = append(results, ObservationResult{ - ExpectedObservation: observation, - Status: ObservationMismatched, Error: matchErr.Error(), - }) - continue - } - if observation.Occurrence == ObservationAbsent { + matched, excerpt := matchObservation(output, observation) + if observation.occurrence == ObservationAbsent { matched = !matched } resultStatus := ObservationMatched @@ -149,22 +301,19 @@ func observationOutput(ctx context.Context, observation ExpectedObservation, wor if err := ctx.Err(); err != nil { return "", err } - switch observation.Source { + switch observation.source { case ObservationStdout: return stdout, nil case ObservationStderr: return stderr, nil case ObservationArtifact: - return readObservationArtifact(workingDir, observation.Path, maxBytes) + return readObservationArtifact(workingDir, observation.path, maxBytes) default: - return "", fmt.Errorf("unsupported observation source %q", observation.Source) + return "", fmt.Errorf("unparsed observation source %q", observation.source) } } func readObservationArtifact(workingDir, path string, maxBytes int64) (string, error) { - if err := validateArtifactPath(path); err != nil { - return "", err - } root, err := filepath.EvalSymlinks(workingDir) if err != nil { return "", fmt.Errorf("resolve workspace: %w", err) @@ -198,27 +347,23 @@ func readObservationArtifact(workingDir, path string, maxBytes int64) (string, e return string(data), nil } -func matchObservation(output string, observation ExpectedObservation) (bool, string, error) { +func matchObservation(output string, observation ExpectedObservation) (bool, string) { start, end := -1, -1 - if observation.Matcher == ObservationExact { - start = strings.Index(output, observation.Pattern) + if observation.matcher == ObservationExact { + start = strings.Index(output, observation.pattern) if start >= 0 { - end = start + len(observation.Pattern) + end = start + len(observation.pattern) } } else { - matcher, err := regexp.Compile(observation.Pattern) - if err != nil { - return false, "", fmt.Errorf("compile observation regexp: %w", err) - } - location := matcher.FindStringIndex(output) + location := observation.compiled.FindStringIndex(output) if location != nil { start, end = location[0], location[1] } } if start < 0 { - return false, "", nil + return false, "" } - return true, boundedObservationExcerpt(output, start, end), nil + return true, boundedObservationExcerpt(output, start, end) } func boundedObservationExcerpt(output string, start, end int) string { diff --git a/internal/evidence/observation_test.go b/internal/evidence/observation_test.go index 336caa03..22b81102 100644 --- a/internal/evidence/observation_test.go +++ b/internal/evidence/observation_test.go @@ -2,6 +2,7 @@ package evidence import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -23,17 +24,17 @@ func TestRunValidationEvaluatesObservationContract(t *testing.T) { ID: "def", Command: []string{"lit", "pipeline.mlir"}, WorkingDir: "/tmp", - Observation: &ObservationContract{ + Observation: mustObservationContract(t, ObservationContractSpec{ Intent: "the base pipeline buffer has three slots", - Base: []ExpectedObservation{{ + Base: []ExpectedObservationSpec{{ Name: "undersized buffer", Source: ObservationStdout, Matcher: ObservationRegexp, Pattern: `!buffer<3>`, Occurrence: ObservationPresent, }}, - Candidate: []ExpectedObservation{{ + Candidate: []ExpectedObservationSpec{{ Name: "corrected buffer", Source: ObservationStdout, Matcher: ObservationRegexp, Pattern: `!buffer<4>`, Occurrence: ObservationPresent, }}, - }, + }), } if err := svc.DefineValidation(context.Background(), def); err != nil { t.Fatalf("define: %v", err) @@ -52,13 +53,17 @@ func TestRunValidationEvaluatesObservationContract(t *testing.T) { func TestObservationContractSupportsExpectedAbsence(t *testing.T) { t.Parallel() - contract := &ObservationContract{ + contract := mustObservationContract(t, ObservationContractSpec{ Intent: "candidate removes the undersized buffer", - Candidate: []ExpectedObservation{{ + Base: []ExpectedObservationSpec{{ + Name: "undersized buffer present", Source: ObservationStdout, + Matcher: ObservationExact, Pattern: "!buffer<3>", Occurrence: ObservationPresent, + }}, + Candidate: []ExpectedObservationSpec{{ Name: "undersized buffer absent", Source: ObservationStdout, Matcher: ObservationExact, Pattern: "!buffer<3>", Occurrence: ObservationAbsent, }}, - } + }) status, results := evaluateObservations(context.Background(), contract, RunKindCandidate, "", "generated !buffer<4>\n", "", 1024) if status != ObservationMatched || len(results) != 1 || results[0].Status != ObservationMatched { t.Fatalf("status=%q results=%#v, want matched absence", status, results) @@ -71,13 +76,17 @@ func TestObservationContractMatchesBoundedArtifact(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "pipeline.mlir"), []byte("!buffer<4>\n"), 0o600); err != nil { t.Fatal(err) } - contract := &ObservationContract{ + contract := mustObservationContract(t, ObservationContractSpec{ Intent: "candidate generates a four-slot buffer", - Candidate: []ExpectedObservation{{ + Base: []ExpectedObservationSpec{{ + Name: "generated buffer", Source: ObservationArtifact, Path: "pipeline.mlir", + Matcher: ObservationExact, Pattern: "!buffer<3>", Occurrence: ObservationPresent, + }}, + Candidate: []ExpectedObservationSpec{{ Name: "generated buffer", Source: ObservationArtifact, Path: "pipeline.mlir", Matcher: ObservationExact, Pattern: "!buffer<4>", Occurrence: ObservationPresent, }}, - } + }) status, results := evaluateObservations(context.Background(), contract, RunKindCandidate, dir, "", "", 1024) if status != ObservationMatched || len(results) != 1 || results[0].Excerpt == "" { t.Fatalf("status=%q results=%#v, want matched artifact", status, results) @@ -94,74 +103,126 @@ func TestObservationArtifactRejectsWorkspaceEscape(t *testing.T) { if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { t.Fatal(err) } - contract := &ObservationContract{ + contract := mustObservationContract(t, ObservationContractSpec{ Intent: "artifact remains inside workspace", - Base: []ExpectedObservation{{ + Base: []ExpectedObservationSpec{{ Name: "escaped", Source: ObservationArtifact, Path: "escape/secret", Matcher: ObservationExact, Pattern: "secret", Occurrence: ObservationPresent, }}, - } + Candidate: []ExpectedObservationSpec{{ + Name: "escaped", Source: ObservationArtifact, Path: "escape/secret", + Matcher: ObservationExact, Pattern: "secret", Occurrence: ObservationPresent, + }}, + }) status, results := evaluateObservations(context.Background(), contract, RunKindBase, root, "", "", 1024) if status != ObservationMismatched || len(results) != 1 || !strings.Contains(results[0].Error, "escapes") { t.Fatalf("status=%q results=%#v, want escape rejection", status, results) } } -func TestDefineValidationRejectsInvalidObservationRegexp(t *testing.T) { +func TestParseObservationContractRejectsInvalidRegexp(t *testing.T) { t.Parallel() - svc := NewService(newFakeRepo(), &fakeRunner{}) - err := svc.DefineValidation(context.Background(), &ValidationDefinition{ - Command: []string{"test"}, WorkingDir: "/tmp", - Observation: &ObservationContract{ - Intent: "observe output", - Base: []ExpectedObservation{{ - Name: "invalid", Source: ObservationStderr, - Matcher: ObservationRegexp, Pattern: "[", Occurrence: ObservationPresent, - }}, - Candidate: []ExpectedObservation{{ - Name: "valid", Source: ObservationStdout, - Matcher: ObservationExact, Pattern: "ok", Occurrence: ObservationPresent, - }}, - }, + _, err := ParseObservationContract(ObservationContractSpec{ + Intent: "observe output", + Base: []ExpectedObservationSpec{{ + Name: "invalid", Source: ObservationStderr, + Matcher: ObservationRegexp, Pattern: "[", Occurrence: ObservationPresent, + }}, + Candidate: []ExpectedObservationSpec{{ + Name: "valid", Source: ObservationStdout, + Matcher: ObservationExact, Pattern: "ok", Occurrence: ObservationPresent, + }}, }) if !errors.Is(err, ErrInvalidObservation) { t.Fatalf("error = %v, want ErrInvalidObservation", err) } } -func TestEvaluateObservationsReportsCorruptPersistedRegexp(t *testing.T) { +func TestParseExpectedObservationEstablishesCanonicalInvariants(t *testing.T) { t.Parallel() - contract := &ObservationContract{ - Intent: "persisted contract may be corrupt", - Base: []ExpectedObservation{{ - Name: "invalid", Source: ObservationStdout, - Matcher: ObservationRegexp, Pattern: "[", Occurrence: ObservationPresent, - }}, + parsed, err := ParseExpectedObservation(ExpectedObservationSpec{ + Name: " generated artifact ", Source: ObservationArtifact, Path: "build/../out.txt", + Matcher: ObservationExact, Pattern: "fixed", + }) + if err != nil { + t.Fatal(err) + } + want := ExpectedObservationSpec{ + Name: "generated artifact", Source: ObservationArtifact, Path: "out.txt", + Matcher: ObservationExact, Pattern: "fixed", Occurrence: ObservationPresent, } - status, results := evaluateObservations(context.Background(), contract, RunKindBase, "", "output", "", 1024) - if status != ObservationMismatched || len(results) != 1 || !strings.Contains(results[0].Error, "compile observation regexp") { - t.Fatalf("status=%q results=%#v, want persisted-regexp error", status, results) + if got := parsed.Spec(); got != want { + t.Fatalf("spec = %#v, want %#v", got, want) + } + + invalid := []ExpectedObservationSpec{ + {Name: "unknown source", Source: "network", Matcher: ObservationExact, Pattern: "x"}, + {Name: "stdout path", Source: ObservationStdout, Path: "out.txt", Matcher: ObservationExact, Pattern: "x"}, + {Name: "escape", Source: ObservationArtifact, Path: "../out.txt", Matcher: ObservationExact, Pattern: "x"}, + {Name: "unknown matcher", Source: ObservationStdout, Matcher: "glob", Pattern: "x"}, + {Name: "unknown occurrence", Source: ObservationStdout, Matcher: ObservationExact, Pattern: "x", Occurrence: "sometimes"}, + {Name: "empty pattern", Source: ObservationStdout, Matcher: ObservationExact}, + } + for _, spec := range invalid { + if _, err := ParseExpectedObservation(spec); !errors.Is(err, ErrInvalidObservation) { + t.Errorf("ParseExpectedObservation(%q) error = %v, want ErrInvalidObservation", spec.Name, err) + } } } -func TestDefineValidationRequiresBaseAndCandidateObservations(t *testing.T) { +func TestObservationContractJSONRejectsCorruptPersistedRegexp(t *testing.T) { + t.Parallel() + data := []byte(`{"Intent":"persisted contract may be corrupt","Base":[{"Name":"invalid","Source":"stdout","Matcher":"regexp","Pattern":"[","Occurrence":"present","Path":""}],"Candidate":[{"Name":"valid","Source":"stdout","Matcher":"exact","Pattern":"ok","Occurrence":"present","Path":""}]}`) + var contract ObservationContract + err := json.Unmarshal(data, &contract) + if !errors.Is(err, ErrInvalidObservation) { + t.Fatalf("error = %v, want ErrInvalidObservation", err) + } +} + +func TestObservationContractJSONRejectsUnknownFields(t *testing.T) { + t.Parallel() + data := []byte(`{"Intent":"proof","Base":[],"Candidate":[],"Unexpected":true}`) + var contract ObservationContract + if err := json.Unmarshal(data, &contract); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("error = %v, want unknown-field rejection", err) + } +} + +func TestParseObservationContractRequiresBaseAndCandidateObservations(t *testing.T) { + t.Parallel() + _, err := ParseObservationContract(ObservationContractSpec{ + Intent: "observe the intended behavior on both runs", + Base: []ExpectedObservationSpec{{ + Name: "base symptom", Source: ObservationStderr, + Matcher: ObservationExact, Pattern: "failure", Occurrence: ObservationPresent, + }}, + }) + if !errors.Is(err, ErrInvalidObservation) { + t.Fatalf("error = %v, want ErrInvalidObservation", err) + } +} + +func TestDefineValidationRejectsUnparsedObservationContract(t *testing.T) { t.Parallel() svc := NewService(newFakeRepo(), &fakeRunner{}) err := svc.DefineValidation(context.Background(), &ValidationDefinition{ - Command: []string{"test"}, WorkingDir: "/tmp", - Observation: &ObservationContract{ - Intent: "observe the intended behavior on both runs", - Base: []ExpectedObservation{{ - Name: "base symptom", Source: ObservationStderr, - Matcher: ObservationExact, Pattern: "failure", Occurrence: ObservationPresent, - }}, - }, + Command: []string{"test"}, WorkingDir: "/tmp", Observation: &ObservationContract{}, }) if !errors.Is(err, ErrInvalidObservation) { t.Fatalf("error = %v, want ErrInvalidObservation", err) } } +func mustObservationContract(t *testing.T, spec ObservationContractSpec) *ObservationContract { + t.Helper() + contract, err := ParseObservationContract(spec) + if err != nil { + t.Fatalf("parse observation contract: %v", err) + } + return contract +} + func TestCompareValidationObservationMismatchIsInconclusive(t *testing.T) { t.Parallel() base := &ValidationRun{ diff --git a/internal/evidence/repeat.go b/internal/evidence/repeat.go index 5cd4d69e..66ec1291 100644 --- a/internal/evidence/repeat.go +++ b/internal/evidence/repeat.go @@ -302,7 +302,10 @@ func compareValidationAggregates(aggregates []ValidationAggregate) *ValidationGr } func resourcesUnavailable(resources ResourceTelemetry) bool { - return resources.CPUTimeMillis.Value == nil || resources.PeakRSSBytes.Value == nil || resources.PeakChildCount.Value == nil + _, cpuAvailable := resources.CPUTimeMillis.Value() + _, memoryAvailable := resources.PeakRSSBytes.Value() + _, childrenAvailable := resources.PeakChildCount.Value() + return !cpuAvailable || !memoryAvailable || !childrenAvailable } func validationConfigurationDigest(def *ValidationDefinition) (string, error) { diff --git a/internal/evidence/repeat_test.go b/internal/evidence/repeat_test.go index d6636ab3..edb5f92a 100644 --- a/internal/evidence/repeat_test.go +++ b/internal/evidence/repeat_test.go @@ -58,10 +58,9 @@ func runResult(classification RunClassification) *RunResult { if classification == RunClassificationFailing { exitCode = 1 } - zero64, zeroU64 := int64(0), uint64(0) return &RunResult{ ExitCode: exitCode, Classification: classification, StartedAt: now, CompletedAt: now, - Resources: ResourceTelemetry{CPUTimeMillis: Int64Metric{Value: &zero64}, PeakRSSBytes: Uint64Metric{Value: &zeroU64}, PeakChildCount: Int64Metric{Value: &zero64}}, + Resources: ResourceTelemetry{CPUTimeMillis: AvailableInt64Metric(0), PeakRSSBytes: AvailableUint64Metric(0), PeakChildCount: AvailableInt64Metric(0)}, Cleanup: CleanupResult{Status: "clean"}, } } @@ -115,11 +114,11 @@ func TestRunValidationGroupCancellationReturnsPartialResults(t *testing.T) { func TestRunValidationGroupComparisonRejectsUnrelatedCandidateError(t *testing.T) { t.Parallel() - contract := &ObservationContract{ + contract := mustObservationContract(t, ObservationContractSpec{ Intent: "candidate removes expected symptom", - Base: []ExpectedObservation{{Name: "symptom", Source: ObservationStderr, Matcher: ObservationExact, Pattern: "expected symptom", Occurrence: ObservationPresent}}, - Candidate: []ExpectedObservation{{Name: "symptom absent", Source: ObservationStderr, Matcher: ObservationExact, Pattern: "expected symptom", Occurrence: ObservationAbsent}}, - } + Base: []ExpectedObservationSpec{{Name: "symptom", Source: ObservationStderr, Matcher: ObservationExact, Pattern: "expected symptom", Occurrence: ObservationPresent}}, + Candidate: []ExpectedObservationSpec{{Name: "symptom absent", Source: ObservationStderr, Matcher: ObservationExact, Pattern: "expected symptom", Occurrence: ObservationAbsent}}, + }) base := runResult(RunClassificationFailing) base.Stderr = "expected symptom" candidate := runResult(RunClassificationError) diff --git a/internal/evidence/repository.go b/internal/evidence/repository.go index 9cd0d76e..4fc5ef33 100644 --- a/internal/evidence/repository.go +++ b/internal/evidence/repository.go @@ -9,10 +9,12 @@ type Repository interface { SaveValidationDefinition(ctx context.Context, d *ValidationDefinition) error GetValidationDefinition(ctx context.Context, id string) (*ValidationDefinition, error) SaveValidationRun(ctx context.Context, r *ValidationRun) error + SaveExternalValidation(ctx context.Context, d *ValidationDefinition, r *ValidationRun) error GetValidationRun(ctx context.Context, id string) (*ValidationRun, error) SaveValidationRunGroup(ctx context.Context, group *ValidationRunGroup) error GetValidationRunGroup(ctx context.Context, id string) (*ValidationRunGroup, error) SaveEvidence(ctx context.Context, e *Evidence) error + SaveEvidenceBatch(ctx context.Context, evidence []*Evidence) error ListEvidence(ctx context.Context, filter EvidenceFilter) ([]*Evidence, error) } diff --git a/internal/evidence/runner_test.go b/internal/evidence/runner_test.go index 894c11a2..d79ad840 100644 --- a/internal/evidence/runner_test.go +++ b/internal/evidence/runner_test.go @@ -161,7 +161,8 @@ func TestExecRunnerCapturesProcessTreeTelemetryAndCleanup(t *testing.T) { if res.Process.PID <= 0 || res.Process.CreateTimeUnixMilli <= 0 { t.Fatalf("process identity = %+v", res.Process) } - if res.Resources.Provider != "gopsutil/v4" || res.Resources.SampleCount == 0 || res.Resources.PeakChildCount.Value == nil || *res.Resources.PeakChildCount.Value < 1 { + peakChildren, peakChildrenAvailable := res.Resources.PeakChildCount.Value() + if res.Resources.Provider != "gopsutil/v4" || res.Resources.SampleCount == 0 || !peakChildrenAvailable || peakChildren < 1 { t.Fatalf("telemetry = %+v", res.Resources) } if (res.Cleanup.Status != "clean" && res.Cleanup.Status != "unavailable") || len(res.Cleanup.Survivors) != 0 { @@ -175,7 +176,7 @@ func TestExecRunnerCapturesProcessTreeTelemetryAndCleanup(t *testing.T) { func TestUnavailableMetricIsNotEncodedAsZero(t *testing.T) { t.Parallel() metric := metricInt64(0, errors.New("unsupported platform metric")) - if metric.Value != nil || metric.UnavailableReason == "" { + if _, available := metric.Value(); available || metric.UnavailableReason() == "" { t.Fatalf("metric = %+v", metric) } } diff --git a/internal/evidence/service.go b/internal/evidence/service.go index b834ebe4..5338d8fb 100644 --- a/internal/evidence/service.go +++ b/internal/evidence/service.go @@ -71,8 +71,8 @@ func (s *Service) DefineValidation(ctx context.Context, d *ValidationDefinition) return err } d.Env = env - if err := validateObservationContract(d.Observation); err != nil { - return err + if d.Observation != nil && d.Observation.intent == "" { + return fmt.Errorf("%w: contract was not parsed", ErrInvalidObservation) } if d.ID == "" { d.ID = uuid.NewString() diff --git a/internal/evidence/service_test.go b/internal/evidence/service_test.go index 33a40e6c..e9e7ce97 100644 --- a/internal/evidence/service_test.go +++ b/internal/evidence/service_test.go @@ -3,6 +3,7 @@ package evidence import ( "context" "errors" + "slices" "sync" "testing" "time" @@ -52,6 +53,29 @@ func (r *fakeRepo) SaveValidationRun(_ context.Context, run *ValidationRun) erro return nil } +func (r *fakeRepo) SaveEvidenceBatch(_ context.Context, items []*Evidence) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, item := range items { + r.evidence[item.ID] = item + } + return nil +} + +func (r *fakeRepo) SaveExternalValidation(_ context.Context, definition *ValidationDefinition, run *ValidationRun) error { + r.mu.Lock() + defer r.mu.Unlock() + if run.DefinitionID != definition.ID { + return errors.New("external validation run must reference the supplied definition") + } + if existing, ok := r.defs[definition.ID]; ok && !slices.Equal(existing.Command, definition.Command) { + return errors.New("external receipt command differs from the existing validation_id") + } + r.defs[definition.ID] = definition + r.runs[run.ID] = run + return nil +} + func (r *fakeRepo) GetValidationRun(_ context.Context, id string) (*ValidationRun, error) { r.mu.Lock() defer r.mu.Unlock() diff --git a/internal/evidence/telemetry.go b/internal/evidence/telemetry.go index 06239467..f6a61f60 100644 --- a/internal/evidence/telemetry.go +++ b/internal/evidence/telemetry.go @@ -264,14 +264,14 @@ func (s *samplerState) survivors(ctx context.Context) []ProcessIdentity { func metricInt64(value int64, err error) Int64Metric { if err != nil { - return Int64Metric{UnavailableReason: err.Error()} + return UnavailableInt64Metric(err.Error()) } - return Int64Metric{Value: &value} + return AvailableInt64Metric(value) } func metricUint64(value uint64, err error) Uint64Metric { if err != nil { - return Uint64Metric{UnavailableReason: err.Error()} + return UnavailableUint64Metric(err.Error()) } - return Uint64Metric{Value: &value} + return AvailableUint64Metric(value) } diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 5fe11d23..94cb11d0 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -97,7 +97,7 @@ func orderDossier(d *domain.Dossier) { return a.CommitSHA < b.CommitSHA }) sort.SliceStable(d.Coverage.Facets, func(i, j int) bool { - return d.Coverage.Facets[i].Facet < d.Coverage.Facets[j].Facet + return d.Coverage.Facets[i].Facet() < d.Coverage.Facets[j].Facet() }) sortThreads(d.RecentMergedPullRequests) sortThreads(d.RecentOpenPullRequests) @@ -184,9 +184,13 @@ func writeDossierMarkdown(w io.Writer, d *domain.Dossier) error { fmt.Fprintln(&b, "_No coverage recorded._") } else { for _, f := range d.Coverage.Facets { - fmt.Fprintf(&b, "- **%s:** present=%v, complete=%v, freshness=%s, as_of=%s", f.Facet, f.Present, f.Complete, f.Freshness.Status, formatTime(f.Freshness.AsOf)) - if f.Count > 0 { - fmt.Fprintf(&b, ", count=%d", f.Count) + freshness := "stale" + if f.Complete() { + freshness = "fresh" + } + fmt.Fprintf(&b, "- **%s:** present=true, complete=%v, freshness=%s, as_of=%s", f.Facet(), f.Complete(), freshness, formatTime(f.AsOf())) + if f.Count() > 0 { + fmt.Fprintf(&b, ", count=%d", f.Count()) } fmt.Fprintln(&b) } diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index a73c9a40..086ffca8 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -12,12 +12,17 @@ import ( var now = time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) +var ( + syntheticGitHubTokenA = "ghp_" + strings.Repeat("1", 36) + syntheticGitHubTokenB = "ghp_" + strings.Repeat("0", 36) +) + func sampleDossier() *domain.Dossier { return &domain.Dossier{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, + Repo: domain.MustRepoRef("owner", "repo"), Repository: domain.Repository{ - RepoRef: domain.RepoRef{Owner: "owner", Repo: "repo"}, - Description: "token=ghp_123456789012345678901234567890123456 secret=keep-quiet A test repository with Authorization: Bearer ghp_000000000000000000000000000000000000", + Ref: domain.MustRepoRef("owner", "repo"), + Description: "token=" + syntheticGitHubTokenA + " secret=keep-quiet A test repository with Authorization: Bearer " + syntheticGitHubTokenB, Languages: []string{"Go"}, DefaultBranch: "main", License: "MIT", @@ -40,8 +45,8 @@ func sampleDossier() *domain.Dossier { Coverage: domain.Coverage{ AsOf: now, Facets: []domain.FacetCoverage{ - {Facet: "threads", Present: true, Complete: false, Freshness: domain.Freshness{Status: domain.Stale, AsOf: now.Add(-time.Hour)}}, - {Facet: "metadata", Present: true, Complete: true, Freshness: domain.Freshness{Status: domain.Fresh, AsOf: now}}, + domain.MustFacetCoverage("threads", false, now.Add(-time.Hour), 0), + domain.MustFacetCoverage("metadata", true, now, 0), }, }, RecentMergedPullRequests: []domain.DossierThread{ @@ -67,7 +72,7 @@ func sampleEvidence() *contracts.EvidenceResult { Evidence: []contracts.EvidenceItem{ { ID: "ev-2", Type: "manual_observation", Relation: "supporting", - Description: " observed with Authorization: token ghp_000000000000000000000000000000000000", + Description: " observed with Authorization: token " + syntheticGitHubTokenB, ValidationRunID: "run-1", OpportunityID: "opp-1", Freshness: "not_applicable", FreshnessReason: "local evidence has no corpus source revision", CreatedAt: now.Format(time.RFC3339), }, @@ -177,7 +182,7 @@ func TestDossierRedaction(t *testing.T) { t.Fatalf("export: %v", err) } out := buf.String() - if strings.Contains(out, "ghp_123456789012345678901234567890123456") { + if strings.Contains(out, syntheticGitHubTokenA) { t.Fatalf("GitHub token was not redacted in JSON output") } if strings.Contains(out, "hunter2") { @@ -186,7 +191,7 @@ func TestDossierRedaction(t *testing.T) { if !strings.Contains(out, "[REDACTED]") { t.Fatalf("expected [REDACTED] placeholder in JSON output") } - if strings.Contains(out, "ghp_000000000000000000000000000000000000") { + if strings.Contains(out, syntheticGitHubTokenB) { t.Fatalf("second GitHub token was not redacted in JSON output") } @@ -211,7 +216,7 @@ func TestEvidenceRedaction(t *testing.T) { t.Fatalf("export: %v", err) } out := buf.String() - if strings.Contains(out, "ghp_000000000000000000000000000000000000") { + if strings.Contains(out, syntheticGitHubTokenB) { t.Fatalf("GitHub token in evidence description was not redacted") } if strings.Contains(out, "supersecret") { diff --git a/internal/github/models.go b/internal/github/models.go index 32a62bb6..83a7a03b 100644 --- a/internal/github/models.go +++ b/internal/github/models.go @@ -1,6 +1,11 @@ package github -import "time" +import ( + "encoding/json" + "fmt" + "strings" + "time" +) // Actor is a domain-neutral GitHub account profile. Nullable fields preserve // provider omission and visibility instead of manufacturing zero values. @@ -356,8 +361,54 @@ type PullRequestStatus struct { // null and UNKNOWN mergeability explicitly unknown rather than negative. type PullRequestMergeState struct { MergeStateStatus string - Mergeable string - MergeableKnown bool + mergeable string +} + +// NewPullRequestMergeState parses GitHub's nullable mergeability scalar. A +// missing, blank, or UNKNOWN value is represented by absence, not a second flag +// that can contradict the scalar. +func NewPullRequestMergeState(status string, mergeable *string) PullRequestMergeState { + state := PullRequestMergeState{MergeStateStatus: status} + if mergeable != nil { + state.mergeable = *mergeable + } + return state +} + +// Mergeability returns GitHub's mergeability enum when it has been computed. +func (s PullRequestMergeState) Mergeability() (string, bool) { + return s.mergeable, s.mergeable != "" && !strings.EqualFold(s.mergeable, "UNKNOWN") +} + +// MarshalJSON preserves the historical observation shape while deriving the +// compatibility flag from the single canonical value. +func (s PullRequestMergeState) MarshalJSON() ([]byte, error) { + _, known := s.Mergeability() + return json.Marshal(struct { + MergeStateStatus string + Mergeable string + MergeableKnown bool + }{s.MergeStateStatus, s.mergeable, known}) +} + +// UnmarshalJSON rejects persisted observations whose redundant compatibility +// flag disagrees with the mergeability scalar. +func (s *PullRequestMergeState) UnmarshalJSON(data []byte) error { + var wire struct { + MergeStateStatus string + Mergeable string + MergeableKnown bool + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + parsed := NewPullRequestMergeState(wire.MergeStateStatus, &wire.Mergeable) + _, known := parsed.Mergeability() + if known != wire.MergeableKnown { + return fmt.Errorf("mergeable_known contradicts mergeable %q", wire.Mergeable) + } + *s = parsed + return nil } // PullRequestMergeQueueEntry describes the PR's current queue entry. diff --git a/internal/github/models_test.go b/internal/github/models_test.go new file mode 100644 index 00000000..13280e5f --- /dev/null +++ b/internal/github/models_test.go @@ -0,0 +1,37 @@ +package github + +import ( + "encoding/json" + "testing" +) + +func TestPullRequestMergeStateJSONRejectsContradictoryKnownFlag(t *testing.T) { + t.Parallel() + for _, data := range []string{ + `{"MergeStateStatus":"CLEAN","Mergeable":"MERGEABLE","MergeableKnown":false}`, + `{"MergeStateStatus":"UNKNOWN","Mergeable":"UNKNOWN","MergeableKnown":true}`, + } { + var state PullRequestMergeState + if err := json.Unmarshal([]byte(data), &state); err == nil { + t.Fatalf("Unmarshal accepted contradictory mergeability: %s", data) + } + } +} + +func TestPullRequestMergeStateJSONRoundTrip(t *testing.T) { + t.Parallel() + mergeable := "MERGEABLE" + want := NewPullRequestMergeState("CLEAN", &mergeable) + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got PullRequestMergeState + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + value, known := got.Mergeability() + if got.MergeStateStatus != "CLEAN" || !known || value != mergeable { + t.Fatalf("round trip = %+v, mergeability = %q, known = %v", got, value, known) + } +} diff --git a/internal/github/pull_request_status.go b/internal/github/pull_request_status.go index d323b05b..d99442fc 100644 --- a/internal/github/pull_request_status.go +++ b/internal/github/pull_request_status.go @@ -1,7 +1,6 @@ package github import ( - "strings" "time" ) @@ -125,15 +124,9 @@ type pullRequestFileDTO struct { } func convertPullRequestStatus(dto pullRequestStatusDTO) PullRequestStatus { - mergeable := "" - known := false - if dto.Mergeable != nil { - mergeable = *dto.Mergeable - known = mergeable != "" && !strings.EqualFold(mergeable, "UNKNOWN") - } result := PullRequestStatus{ NodeID: dto.ID, HeadSHA: dto.HeadRefOID, SourceUpdatedAt: dto.UpdatedAt, - MergeState: PullRequestMergeState{MergeStateStatus: dto.MergeStateStatus, Mergeable: mergeable, MergeableKnown: known}, + MergeState: NewPullRequestMergeState(dto.MergeStateStatus, dto.Mergeable), MergeStateCoverage: scalarCoverage(), MergeQueueCoverage: scalarCoverage(), } if dto.MergeQueueEntry != nil { diff --git a/internal/github/pull_request_status_test.go b/internal/github/pull_request_status_test.go index f986b6b7..bf03cce1 100644 --- a/internal/github/pull_request_status_test.go +++ b/internal/github/pull_request_status_test.go @@ -51,7 +51,8 @@ func TestGetPullRequestStatusConvertsHealthAndReportsPartialCoverage(t *testing. if request.Variables["first"] != float64(maxPullRequestStatusPageSize) { t.Errorf("first = %#v, want %d", request.Variables["first"], maxPullRequestStatusPageSize) } - if result.HeadSHA != "abc123" || result.MergeState.MergeStateStatus != "BLOCKED" || !result.MergeState.MergeableKnown { + _, mergeabilityKnown := result.MergeState.Mergeability() + if result.HeadSHA != "abc123" || result.MergeState.MergeStateStatus != "BLOCKED" || !mergeabilityKnown { t.Errorf("status identity/merge = %+v", result) } if result.MergeQueue == nil || result.MergeQueue.Position != 3 || result.MergeQueue.EstimatedTimeToMergeSeconds == nil || *result.MergeQueue.EstimatedTimeToMergeSeconds != 90000 { @@ -164,7 +165,7 @@ func TestGetPullRequestStatusKeepsNullAndUnknownMergeabilityUnknown(t *testing.T if err != nil { t.Fatalf("GetPullRequestStatus: %v", err) } - if result.MergeState.MergeableKnown { + if _, known := result.MergeState.Mergeability(); known { t.Errorf("mergeability = %+v, want unknown", result.MergeState) } if !result.MergeStateCoverage.Complete { diff --git a/internal/health/compute.go b/internal/health/compute.go index 87486873..57dff9b0 100644 --- a/internal/health/compute.go +++ b/internal/health/compute.go @@ -35,6 +35,10 @@ func Compute(ctx context.Context, c *corpus.Corpus, repoID int64, opts Options) if repo == nil { return nil, fmt.Errorf("repository not found") } + repoRef, err := domain.NewRepoRef(repo.Owner, repo.Name) + if err != nil { + return nil, fmt.Errorf("decode repository identity: %w", err) + } threads, err := c.ListThreads(ctx, repoID, "", threadListLimit) if err != nil { @@ -68,7 +72,7 @@ func Compute(ctx context.Context, c *corpus.Corpus, repoID int64, opts Options) } report := &Report{ - Repo: domain.RepoRef{Owner: repo.Owner, Repo: repo.Name}, + Repo: repoRef, GeneratedAt: now, Window: window, Repository: RepositoryMetrics{ @@ -169,10 +173,10 @@ func countThreads(threads []corpus.Thread, window Window, incomplete bool) (Issu prMetrics.SampleSize++ if t.State == "open" { prMetrics.Open++ - } else if !t.MergedKnown { + } else if !t.Merge.Known() { prMetrics.ClosedUnknownMerge++ missingMergeState = true - } else if t.Merged { + } else if t.Merge.IsMerged() { prMetrics.Merged++ } else { prMetrics.ClosedUnmerged++ @@ -239,9 +243,9 @@ func computeExternalMetrics(threads []corpus.Thread, start, end time.Time) Exter switch { case t.State == "open": out.Open++ - case !t.MergedKnown: + case !t.Merge.Known(): out.ClosedUnknownMerge++ - case t.Merged: + case t.Merge.IsMerged(): out.Merged++ default: out.ClosedUnmerged++ diff --git a/internal/health/health_test.go b/internal/health/health_test.go index 3d9ccaa2..a6d87da7 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/github" ) @@ -136,8 +137,7 @@ func TestComputeHealthMetrics(t *testing.T) { Author: "owner1", SourceCreatedAt: now.Add(-12 * 24 * time.Hour), SourceUpdatedAt: now.Add(-3 * 24 * time.Hour), - MergedAt: now.Add(-3 * 24 * time.Hour), - Merged: true, + Merge: domain.MergedStatus(now.Add(-3 * 24 * time.Hour)), }, "OWNER") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ @@ -149,7 +149,7 @@ func TestComputeHealthMetrics(t *testing.T) { SourceCreatedAt: now.Add(-8 * 24 * time.Hour), SourceUpdatedAt: now.Add(-2 * 24 * time.Hour), ClosedAt: now.Add(-2 * 24 * time.Hour), - MergedKnown: true, + Merge: domain.UnmergedStatus(), }, "NONE") _ = upsertThread(t, ctx, c, repo.ID, corpus.Thread{ @@ -267,7 +267,7 @@ func TestExternalMergeRateRequiresObservedOutcome(t *testing.T) { { name: "known zero is preserved", threads: []corpus.Thread{{ - Kind: corpus.ThreadKindPullRequest, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, MergedKnown: true, + Kind: corpus.ThreadKindPullRequest, 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, MergedKnown: true, Merged: true, + Kind: corpus.ThreadKindPullRequest, State: "closed", AuthorAssociation: "NONE", SourceCreatedAt: now, Merge: domain.MergedStatus(time.Time{}), }}, wantRate: float64Pointer(1), wantCoverage: "complete", diff --git a/internal/investigation/models.go b/internal/investigation/models.go index d2b4e367..d733bd04 100644 --- a/internal/investigation/models.go +++ b/internal/investigation/models.go @@ -1,6 +1,8 @@ package investigation import ( + "errors" + "fmt" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -140,6 +142,115 @@ type Opportunity struct { UpdatedAt time.Time } +// ParseStored parses an investigation at the durable JSON boundary. +func (i *Investigation) ParseStored() error { + if i == nil || i.ID == "" || !i.Repo.IsValid() { + return errors.New("investigation identity and repository are required") + } + if i.Status != InvestigationOpen && i.Status != InvestigationClosed { + return fmt.Errorf("unsupported investigation status %q", i.Status) + } + for index, change := range i.AuditTrail { + fromValid := validInvestigationStatus(InvestigationStatus(change.From)) || (index == 0 && change.From == "") + if !fromValid || !validInvestigationStatus(InvestigationStatus(change.To)) { + return fmt.Errorf("investigation audit entry %d has an unsupported status", index) + } + } + return nil +} + +// ParseStored parses a hypothesis at the durable JSON boundary. +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) { + return fmt.Errorf("unsupported hypothesis category %q", h.Category) + } + switch h.Status { + case HypothesisProposed, HypothesisPromoted, HypothesisRejected, HypothesisDeferred, HypothesisSuperseded: + default: + return fmt.Errorf("unsupported hypothesis status %q", h.Status) + } + for index, change := range h.AuditTrail { + fromValid := validHypothesisStatus(HypothesisStatus(change.From)) || (index == 0 && change.From == "") + if !fromValid || !validHypothesisStatus(HypothesisStatus(change.To)) { + return fmt.Errorf("hypothesis audit entry %d has an unsupported status", index) + } + } + return nil +} + +// ParseStored parses an opportunity at the durable JSON boundary, including +// the legacy empty representation of unknown collision state. +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) { + return fmt.Errorf("unsupported opportunity category %q", o.Category) + } + // Empty is the legacy JSON representation of the initial unknown state. + if o.CollisionStatus == "" { + o.CollisionStatus = CollisionUnknown + } + switch o.Status { + case OpportunityHypothesis, OpportunityReproduced, OpportunityValidated, OpportunityMaintainerAligned, + OpportunityImplemented, OpportunitySubmitted, OpportunityMerged, OpportunityRejected, + OpportunityDeferred, OpportunitySuperseded: + default: + return fmt.Errorf("unsupported opportunity status %q", o.Status) + } + switch o.CollisionStatus { + case CollisionUnknown, CollisionNone, CollisionPossible, CollisionConfirmed, CollisionBlocked: + default: + return fmt.Errorf("unsupported collision status %q", o.CollisionStatus) + } + for index, change := range o.AuditTrail { + initialChange := index == 0 && change.From == "" && validOpportunityStatus(OpportunityStatus(change.To)) + initialCollisionChange := index == 0 && change.From == "" && validCollisionStatus(CollisionStatus(change.To)) + lifecycleChange := validOpportunityStatus(OpportunityStatus(change.From)) && validOpportunityStatus(OpportunityStatus(change.To)) + collisionChange := validCollisionStatus(CollisionStatus(change.From)) && validCollisionStatus(CollisionStatus(change.To)) + if !initialChange && !initialCollisionChange && !lifecycleChange && !collisionChange { + return fmt.Errorf("opportunity audit entry %d has an unsupported status", index) + } + } + return nil +} + +func validInvestigationStatus(status InvestigationStatus) bool { + return status == InvestigationOpen || status == InvestigationClosed +} + +func validHypothesisStatus(status HypothesisStatus) bool { + switch status { + case HypothesisProposed, HypothesisPromoted, HypothesisRejected, HypothesisDeferred, HypothesisSuperseded: + return true + default: + return false + } +} + +func validOpportunityStatus(status OpportunityStatus) bool { + switch status { + case OpportunityHypothesis, OpportunityReproduced, OpportunityValidated, OpportunityMaintainerAligned, + OpportunityImplemented, OpportunitySubmitted, OpportunityMerged, OpportunityRejected, + OpportunityDeferred, OpportunitySuperseded: + return true + default: + return false + } +} + +func validCollisionStatus(status CollisionStatus) bool { + switch status { + case CollisionUnknown, CollisionNone, CollisionPossible, CollisionConfirmed, CollisionBlocked: + return true + default: + return false + } +} + // SupportingEvidence returns evidence items marked as supporting. func (o *Opportunity) SupportingEvidence(all []*evidence.Evidence) []*evidence.Evidence { return filterEvidence(all, evidence.RelationSupporting) diff --git a/internal/investigation/service.go b/internal/investigation/service.go index a9e6f619..63ebeb16 100644 --- a/internal/investigation/service.go +++ b/internal/investigation/service.go @@ -26,8 +26,8 @@ func NewService(repo Repository, evidence EvidenceStore) *Service { // StartInvestigation creates a new investigation for a repository and commit. func (s *Service) StartInvestigation(ctx context.Context, repo domain.RepoRef, commitSHA, lens string) (*Investigation, error) { - if err := repo.Validate(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrInvalidRepo, err) + if !repo.IsValid() { + return nil, fmt.Errorf("%w: repository reference is not parsed", ErrInvalidRepo) } now := time.Now().UTC() inv := &Investigation{ diff --git a/internal/investigation/service_test.go b/internal/investigation/service_test.go index 418f3972..216cc6fe 100644 --- a/internal/investigation/service_test.go +++ b/internal/investigation/service_test.go @@ -176,7 +176,7 @@ func (s *fakeEvidenceStore) ListEvidence(_ context.Context, filter evidence.Evid func TestStartInvestigation(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, err := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, err := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") if err != nil { t.Fatalf("start: %v", err) } @@ -190,7 +190,7 @@ func TestStartInvestigation(t *testing.T) { func TestRecordHypothesis(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, err := svc.RecordHypothesis(context.Background(), inv.ID, "race in foo", "data race under load", CategoryBug, []domain.SourceRef{ {Source: "github", URL: "https://github.com/owner/repo/issues/1", ObservedAt: time.Now().UTC()}, }) @@ -207,7 +207,7 @@ func TestRecordHypothesis(t *testing.T) { func TestPromoteOpportunity(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, err := svc.PromoteOpportunity(context.Background(), h.ID, "data race under load", "pkg/foo", "crashes under contention", "small", 0.8) @@ -225,7 +225,7 @@ func TestPromoteOpportunity(t *testing.T) { func TestPromoteOpportunityFailureDoesNotMutateStoredHypothesis(t *testing.T) { repo := &failingPromotionRepo{fakeRepo: newFakeRepo()} svc := NewService(repo, &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) if _, err := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.8); err == nil { t.Fatal("expected promotion failure") @@ -251,7 +251,7 @@ func TestPromoteOpportunityRejectsInvalidInputs(t *testing.T) { func TestInvalidOpportunityTransition(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, _ := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.5) @@ -274,7 +274,7 @@ func TestInvalidOpportunityTransition(t *testing.T) { func TestOpportunityTransitionAuditTrail(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, _ := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.5) @@ -290,7 +290,7 @@ func TestOpportunityTransitionAuditTrail(t *testing.T) { func TestUpdateCollisionStatusRecordsPreviousValue(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, _ := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.5) updated, err := svc.UpdateCollisionStatus(context.Background(), o.ID, CollisionPossible, "similar open PR") @@ -308,7 +308,7 @@ func TestContradictingEvidenceBlocksValidation(t *testing.T) { store := &fakeEvidenceStore{} svc := NewService(repo, store) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, _ := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.5) @@ -363,7 +363,7 @@ func TestSummarizeEvidence(t *testing.T) { store := &fakeEvidenceStore{} svc := NewService(repo, store) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.RecordHypothesis(context.Background(), inv.ID, "race", "race desc", CategoryBug, nil) o, _ := svc.PromoteOpportunity(context.Background(), h.ID, "problem", "scope", "impact", "small", 0.5) @@ -391,7 +391,7 @@ func TestSummarizeEvidence(t *testing.T) { func TestCreateHypothesisWithStructuredFields(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, err := svc.CreateHypothesis(context.Background(), inv.ID, CreateHypothesisInput{ Title: "race in parser", Description: "data race under load", @@ -422,7 +422,7 @@ func TestCreateHypothesisWithStructuredFields(t *testing.T) { func TestUpdateHypothesisRecordsRationale(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.CreateHypothesis(context.Background(), inv.ID, CreateHypothesisInput{ Title: "race", Description: "desc", @@ -447,7 +447,7 @@ func TestUpdateHypothesisRecordsRationale(t *testing.T) { func TestTransitionHypothesisWithRationale(t *testing.T) { svc := NewService(newFakeRepo(), &fakeEvidenceStore{}) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.CreateHypothesis(context.Background(), inv.ID, CreateHypothesisInput{Title: "race", Description: "desc", Category: CategoryBug}) updated, err := svc.TransitionHypothesis(context.Background(), h.ID, HypothesisRejected, "not reproducible") if err != nil { @@ -465,7 +465,7 @@ func TestPromoteOpportunityWithInput(t *testing.T) { repo := newFakeRepo() store := &fakeEvidenceStore{} svc := NewService(repo, store) - inv, _ := svc.StartInvestigation(context.Background(), domain.RepoRef{Owner: "owner", Repo: "repo"}, "abc", "") + inv, _ := svc.StartInvestigation(context.Background(), domain.MustRepoRef("owner", "repo"), "abc", "") h, _ := svc.CreateHypothesis(context.Background(), inv.ID, CreateHypothesisInput{Title: "race", Description: "desc", Category: CategoryBug}) o, err := svc.PromoteOpportunityWithInput(context.Background(), h.ID, PromoteOpportunityInput{ ProblemStatement: "parser panics", diff --git a/internal/investigation/thread_start.go b/internal/investigation/thread_start.go index c89b67b7..730864e4 100644 --- a/internal/investigation/thread_start.go +++ b/internal/investigation/thread_start.go @@ -38,8 +38,8 @@ func (b ThreadBaseline) OriginKey() string { // Validate ensures the baseline can identify both a source thread and one // immutable local observation. func (b ThreadBaseline) Validate() error { - if err := b.Repo.Validate(); err != nil { - return fmt.Errorf("%w: %w", ErrInvalidThreadBaseline, err) + if !b.Repo.IsValid() { + return fmt.Errorf("%w: repository reference is not parsed", ErrInvalidThreadBaseline) } if b.Kind != domain.IssueKind && b.Kind != domain.PullRequestKind { return fmt.Errorf("%w: unsupported thread kind %q", ErrInvalidThreadBaseline, b.Kind) diff --git a/internal/investigation/thread_start_test.go b/internal/investigation/thread_start_test.go index 6b59ec46..cb3a24a0 100644 --- a/internal/investigation/thread_start_test.go +++ b/internal/investigation/thread_start_test.go @@ -15,7 +15,7 @@ func TestStartFromThreadCreatesAuditedIdempotentPair(t *testing.T) { service := NewService(repo, &fakeEvidenceStore{}) now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) baseline := ThreadBaseline{ - Repo: domain.RepoRef{Owner: "Owner", Repo: "Repo"}, Kind: domain.IssueKind, Number: 42, + Repo: domain.MustRepoRef("Owner", "Repo"), Kind: domain.IssueKind, Number: 42, ObservationID: 7, ObservationSequence: 3, SourceUpdatedAt: now.Add(-time.Hour), ObservedAt: now, Source: domain.SourceRef{Source: "github:rest", URL: "https://api.github.com/repos/Owner/Repo/issues/42", ObservedAt: now, AsOf: now.Add(-time.Hour)}, } @@ -57,7 +57,7 @@ func TestStartFromThreadRejectsInvalidBaselineAndCancellation(t *testing.T) { cancel() _, err = service.StartFromThread(ctx, StartFromThreadInput{ Baseline: ThreadBaseline{ - Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Kind: domain.IssueKind, Number: 1, + Repo: domain.MustRepoRef("o", "r"), Kind: domain.IssueKind, Number: 1, ObservationID: 1, ObservationSequence: 1, Source: domain.SourceRef{Source: "github:rest", URL: "https://api.github.com/repos/o/r/issues/1"}, }, diff --git a/internal/mcpcontract/follow_up_action_test.go b/internal/mcpcontract/follow_up_action_test.go new file mode 100644 index 00000000..9c465f0b --- /dev/null +++ b/internal/mcpcontract/follow_up_action_test.go @@ -0,0 +1,41 @@ +package mcpcontract + +import ( + "encoding/json" + "testing" +) + +func TestFollowUpActionRoundTripsTypedVariant(t *testing.T) { + t.Parallel() + want := FollowUpActionFor(ResourceReadAction{URI: "gitcontribute://artifact/1"}) + payload, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got FollowUpAction + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + input, ok := RecoveryInput[ResourceReadAction](got) + if !ok || got.Type() != "read_resource" || input.URI != "gitcontribute://artifact/1" { + t.Fatalf("follow-up action = type %q input %+v", got.Type(), input) + } +} + +func TestFollowUpActionRejectsMismatchedOrEmptyVariant(t *testing.T) { + t.Parallel() + for _, payload := range []string{ + `{"type":"read_resource","poll_job":{"ids":["job-1"]}}`, + `{"type":"read_resource","read_resource":{"uri":"x"},"poll_job":{"ids":["job-1"]}}`, + `{"type":"sync_threads","sync_threads":{"selection":"repositories"}}`, + `{"type":"read_resource","read_resource":null}`, + } { + var action FollowUpAction + if err := json.Unmarshal([]byte(payload), &action); err == nil { + t.Fatalf("decoded invalid follow-up %s", payload) + } + } + if _, err := json.Marshal(FollowUpAction{}); err == nil { + t.Fatal("empty follow-up action encoded") + } +} diff --git a/internal/mcpcontract/operation_contracts.go b/internal/mcpcontract/operation_contracts.go index 272a9545..e1e65868 100644 --- a/internal/mcpcontract/operation_contracts.go +++ b/internal/mcpcontract/operation_contracts.go @@ -1,6 +1,10 @@ package mcpcontract -import "time" +import ( + "errors" + "fmt" + "time" +) // SearchRepositoriesInput describes an offline repository search page. type SearchRepositoriesInput struct { @@ -103,20 +107,43 @@ type SnapshotReadAction struct { SnapshotToken string `json:"snapshot_token"` } -// FollowUpAction is a discriminated union of valid post-job transitions. Each -// variant owns its arguments; callers never reconstruct a generic argument bag -// from prose. -type FollowUpAction struct { - Type string `json:"type"` - PollJob *GetJobsInput `json:"poll_job,omitempty"` - ReadResource *ResourceReadAction `json:"read_resource,omitempty"` - ReadSnapshot *SnapshotReadAction `json:"read_snapshot,omitempty"` - InspectCommitChanges *InspectCommitChangesInput `json:"inspect_commit_changes,omitempty"` - GetRepositories *GetRepositoriesInput `json:"get_repositories,omitempty"` - GetThreads *GetThreadsInput `json:"get_threads,omitempty"` - GetThreadFacets *GetThreadFacetsInput `json:"get_thread_facets,omitempty"` - ListPortfolio *ListPullRequestPortfolioInput `json:"list_pull_request_portfolio,omitempty"` - SearchFeedback *SearchPullRequestFeedbackInput `json:"search_pull_request_feedback,omitempty"` +// FollowUpAction is the sealed tool-call union used for post-job transitions. +// Its concrete wrapper gives encoding/json a parse boundary for interface data. +type FollowUpAction struct{ call ToolCall } + +func (FollowUpAction) isToolCall() {} + +func (a FollowUpAction) Type() string { + if a.call == nil { + return "" + } + return a.call.Type() +} + +func (a FollowUpAction) Input() any { + if a.call == nil { + return nil + } + return a.call.Input() +} + +func (a FollowUpAction) MarshalJSON() ([]byte, error) { + if a.call == nil { + return nil, errors.New("follow-up action is not parsed") + } + return a.call.MarshalJSON() +} + +func (a *FollowUpAction) UnmarshalJSON(data []byte) error { + call, err := parseRecoveryAction(data) + if err != nil { + return fmt.Errorf("decode follow-up action: %w", err) + } + if !isFollowUpAction(call.Input()) { + return fmt.Errorf("decode follow-up action: %s is not a post-job read or poll action", call.Type()) + } + a.call = call + return nil } // JobFollowUp points to the typed read plane for a job's durable result. diff --git a/internal/mcpcontract/recovery_action_test.go b/internal/mcpcontract/recovery_action_test.go index c2a8af06..226f0e53 100644 --- a/internal/mcpcontract/recovery_action_test.go +++ b/internal/mcpcontract/recovery_action_test.go @@ -14,14 +14,82 @@ func TestRecoveryActionOwnsArgumentsForItsDiscriminator(t *testing.T) { t.Fatal(err) } encoded := string(payload) - if action.Type != "hydrate_threads" || action.HydrateThreads == nil || !strings.Contains(encoded, `"hydrate_threads"`) || strings.Contains(encoded, `"arguments"`) || strings.Contains(encoded, `"tool"`) { + input, ok := RecoveryInput[HydrateThreadsInput](action) + if action.Type() != "hydrate_threads" || !ok || len(input.Threads) != 1 || !strings.Contains(encoded, `"hydrate_threads"`) || strings.Contains(encoded, `"arguments"`) || strings.Contains(encoded, `"tool"`) { t.Fatalf("recovery action = %s (%+v)", encoded, action) } } func TestRecoveryActionSupportsManifestReplay(t *testing.T) { action := RecoveryAction(ExportManifestInput{OpportunityID: "opp-1", WorkspaceID: "ws-1"}) - if action.Type != "export_manifest" || action.ExportManifest == nil || action.ExportManifest.OpportunityID != "opp-1" { + input, ok := RecoveryInput[ExportManifestInput](action) + if action.Type() != "export_manifest" || !ok || input.OpportunityID != "opp-1" { t.Fatalf("manifest recovery action = %+v", action) } } + +func TestRecoveryActionCatalogRoundTripsEveryParserVariant(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}) + for _, action := range RecoveryActionPrototypes() { + if _, duplicate := seen[action.Type()]; duplicate { + t.Fatalf("duplicate recovery action type %q", action.Type()) + } + seen[action.Type()] = struct{}{} + data, err := json.Marshal(action) + if err != nil { + t.Fatalf("marshal %s: %v", action.Type(), err) + } + parsed, err := parseRecoveryAction(data) + if err != nil { + t.Fatalf("parse %s: %v", action.Type(), err) + } + if parsed.Type() != action.Type() { + t.Fatalf("parsed type = %q, want %q", parsed.Type(), action.Type()) + } + } +} + +func TestRecoveryPlanParsesTypedActions(t *testing.T) { + plan := RecoveryPlan{Version: RecoveryPlanVersion, Reason: "partial", Then: []ToolCall{ + RecoveryAction(SyncThreadsInput{Selection: "repositories", LimitPerRepository: 20}), + }} + encoded, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + var decoded RecoveryPlan + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Then) != 1 || decoded.Then[0].Type() != "sync_threads" { + t.Fatalf("decoded recovery plan = %+v", decoded) + } + input, ok := RecoveryInput[SyncThreadsInput](decoded.Then[0]) + if !ok || input.Selection != "repositories" || input.LimitPerRepository != 20 { + t.Fatalf("decoded recovery input = %+v", decoded.Then[0].Input()) + } +} + +func TestRecoveryPlanRejectsImpossibleActions(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + json string + }{ + {name: "unsupported plan version", json: `{"version":"invented","then":[{"type":"sync_threads","sync_threads":{}}]}`}, + {name: "mismatched input", json: `{"version":"gitcontribute.recovery.v1","then":[{"type":"sync_threads","hydrate_threads":{}}]}`}, + {name: "multiple inputs", json: `{"version":"gitcontribute.recovery.v1","then":[{"type":"sync_threads","sync_threads":{},"hydrate_threads":{}}]}`}, + {name: "unknown type", json: `{"version":"gitcontribute.recovery.v1","then":[{"type":"invented","invented":{}}]}`}, + {name: "unknown input field", json: `{"version":"gitcontribute.recovery.v1","then":[{"type":"list_concerns","list_concerns":{"invented":true}}]}`}, + {name: "null input", json: `{"version":"gitcontribute.recovery.v1","then":[{"type":"sync_threads","sync_threads":null}]}`}, + {name: "unknown plan field", json: `{"version":"gitcontribute.recovery.v1","invented":true}`}, + } { + t.Run(test.name, func(t *testing.T) { + var plan RecoveryPlan + if err := json.Unmarshal([]byte(test.json), &plan); err == nil { + t.Fatalf("decoded impossible recovery action: %+v", plan) + } + }) + } +} diff --git a/internal/mcpcontract/recovery_contracts.go b/internal/mcpcontract/recovery_contracts.go new file mode 100644 index 00000000..60e535b8 --- /dev/null +++ b/internal/mcpcontract/recovery_contracts.go @@ -0,0 +1,374 @@ +package mcpcontract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +// RecoveryPlan is the only model-visible recovery shape. Versioning keeps the +// contract explicit while Then preserves the order in which calls are made. +type RecoveryPlan struct { + Version string `json:"version"` + Reason string `json:"reason"` + Message string `json:"message"` + Then []ToolCall `json:"then,omitempty"` +} + +func (p *RecoveryPlan) UnmarshalJSON(data []byte) error { + var wire struct { + Version string `json:"version"` + Reason string `json:"reason"` + Message string `json:"message"` + Then []json.RawMessage `json:"then"` + } + if err := decodeStrictJSON(data, &wire); err != nil { + return fmt.Errorf("decode recovery plan: %w", err) + } + if wire.Version != RecoveryPlanVersion { + return fmt.Errorf("decode recovery plan: unsupported version %q", wire.Version) + } + var actions []ToolCall + if wire.Then != nil { + actions = make([]ToolCall, len(wire.Then)) + } + for i, raw := range wire.Then { + action, err := parseRecoveryAction(raw) + if err != nil { + return fmt.Errorf("decode recovery plan action %d: %w", i, err) + } + actions[i] = action + } + *p = RecoveryPlan{Version: wire.Version, Reason: wire.Reason, Message: wire.Message, Then: actions} + return nil +} + +// ToolCall is one discriminated, replayable MCP action in a recovery plan. +// The unexported method seals the variant set to this package. Callers can +// inspect the discriminator and concrete input, but cannot construct a non-nil +// empty, mismatched, or multi-payload action. +type ToolCall interface { + json.Marshaler + Type() string + Input() any + isToolCall() +} + +// RecoveryActionPrototypes is the canonical recovery-action catalog used by +// schema generation and round-trip tests. Values carry zero inputs only; use +// RecoveryAction to construct an executable call. +func RecoveryActionPrototypes() []ToolCall { + return []ToolCall{ + RecoveryAction(GetJobsInput{}), + RecoveryAction(ResourceReadAction{}), + RecoveryAction(SnapshotReadAction{}), + RecoveryAction(GetThreadsInput{}), + RecoveryAction(GetThreadFacetsInput{}), + RecoveryAction(SearchPullRequestFeedbackInput{}), + RecoveryAction(GetRepositoriesInput{}), + RecoveryAction(EnsureCoverageInput{}), + RecoveryAction(SyncRepositoryContextInput{}), + RecoveryAction(SyncThreadsInput{}), + RecoveryAction(HydrateThreadsInput{}), + RecoveryAction(SyncPortfolioInput{}), + RecoveryAction(SyncPullRequestFeedbackInput{}), + RecoveryAction(IndexPullRequestFeedbackInput{}), + RecoveryAction(SyncCIFailuresInput{}), + RecoveryAction(DeepWikiInput{}), + RecoveryAction(IndexRepositoriesInput{}), + RecoveryAction(FindClustersInput{}), + RecoveryAction(FindNeighborsInput{}), + RecoveryAction(RankOpportunitiesInput{}), + RecoveryAction(MineRepositoryFixPatternsInput{}), + RecoveryAction(PreviewRepositoryFixPatternsInput{}), + RecoveryAction(SearchGitHubRepositoriesInput{}), + RecoveryAction(SearchGitHubThreadsInput{}), + RecoveryAction(SearchCodeInput{}), + RecoveryAction(ReadSourceFilesInput{}), + RecoveryAction(InspectCommitChangesInput{}), + RecoveryAction(CheckMergeConflictsInput{}), + RecoveryAction(FindRelatedWorkInput{}), + RecoveryAction(ListConcernsInput{}), + RecoveryAction(ListPullRequestPortfolioInput{}), + RecoveryAction(ExportManifestInput{}), + } +} + +type recoveryActionInput interface { + GetJobsInput | GetRepositoriesInput | EnsureCoverageInput | SyncRepositoryContextInput | SyncThreadsInput | HydrateThreadsInput | SyncPortfolioInput | SyncPullRequestFeedbackInput | IndexPullRequestFeedbackInput | SyncCIFailuresInput | DeepWikiInput | IndexRepositoriesInput | FindClustersInput | FindNeighborsInput | RankOpportunitiesInput | MineRepositoryFixPatternsInput | PreviewRepositoryFixPatternsInput | SearchGitHubRepositoriesInput | SearchGitHubThreadsInput | SearchCodeInput | ReadSourceFilesInput | InspectCommitChangesInput | CheckMergeConflictsInput | FindRelatedWorkInput | ListConcernsInput | ListPullRequestPortfolioInput | ExportManifestInput | ResourceReadAction | SnapshotReadAction | GetThreadsInput | GetThreadFacetsInput | SearchPullRequestFeedbackInput +} + +type followUpActionInput interface { + GetJobsInput | ResourceReadAction | SnapshotReadAction | InspectCommitChangesInput | GetRepositoriesInput | GetThreadsInput | GetThreadFacetsInput | ListPullRequestPortfolioInput | SearchPullRequestFeedbackInput +} + +type recoveryToolCall[T recoveryActionInput] struct { + input T +} + +func (recoveryToolCall[T]) isToolCall() {} + +func (a recoveryToolCall[T]) Type() string { + name, ok := recoveryActionName(any(a.input)) + if !ok { + panic("unreachable recovery action input") + } + return name +} + +func (a recoveryToolCall[T]) Input() any { return a.input } + +func (a recoveryToolCall[T]) MarshalJSON() ([]byte, error) { + name := a.Type() + payload, err := json.Marshal(a.input) + if err != nil { + return nil, fmt.Errorf("marshal recovery action %s: %w", name, err) + } + typeJSON, err := json.Marshal(name) + if err != nil { + return nil, fmt.Errorf("marshal recovery action type: %w", err) + } + var out bytes.Buffer + out.WriteString(`{"type":`) + out.Write(typeJSON) + out.WriteString(`,"`) + out.WriteString(name) + out.WriteString(`":`) + out.Write(payload) + out.WriteByte('}') + return out.Bytes(), nil +} + +// RecoveryAction derives the action discriminator from a concrete input type, +// making incompatible tool/argument combinations unrepresentable. +func RecoveryAction[T recoveryActionInput](input T) ToolCall { + return recoveryToolCall[T]{input: input} +} + +// FollowUpActionFor constructs a typed post-job action from its concrete +// input. The discriminator is derived from the input type. +func FollowUpActionFor[T followUpActionInput](input T) FollowUpAction { + return FollowUpAction{call: recoveryToolCall[T]{input: input}} +} + +// FollowUpActionPrototypes is the closed catalog of read and poll actions that +// may follow a durable job. Recovery plans have a broader catalog because they +// may also prescribe explicit write operations. +func FollowUpActionPrototypes() []ToolCall { + return []ToolCall{ + FollowUpActionFor(GetJobsInput{}), + FollowUpActionFor(ResourceReadAction{}), + FollowUpActionFor(SnapshotReadAction{}), + FollowUpActionFor(InspectCommitChangesInput{}), + FollowUpActionFor(GetRepositoriesInput{}), + FollowUpActionFor(GetThreadsInput{}), + FollowUpActionFor(GetThreadFacetsInput{}), + FollowUpActionFor(ListPullRequestPortfolioInput{}), + FollowUpActionFor(SearchPullRequestFeedbackInput{}), + } +} + +func isFollowUpAction(input any) bool { + switch input.(type) { + case GetJobsInput, ResourceReadAction, SnapshotReadAction, InspectCommitChangesInput, GetRepositoriesInput, GetThreadsInput, GetThreadFacetsInput, ListPullRequestPortfolioInput, SearchPullRequestFeedbackInput: + return true + default: + return false + } +} + +// RecoveryInput narrows an action back to its concrete input type for callers +// that need to replay or inspect it. The boolean is false for a different +// variant, so the discriminator and payload cannot disagree. +func RecoveryInput[T recoveryActionInput](action ToolCall) (T, bool) { + var zero T + if action == nil { + return zero, false + } + input, ok := action.Input().(T) + return input, ok +} + +func recoveryActionName(input any) (string, bool) { + switch input.(type) { + case GetJobsInput: + return "poll_job", true + case ResourceReadAction: + return "read_resource", true + case SnapshotReadAction: + return "read_snapshot", true + case GetThreadsInput: + return "get_threads", true + case GetThreadFacetsInput: + return "get_thread_facets", true + case SearchPullRequestFeedbackInput: + return "search_pull_request_feedback", true + case GetRepositoriesInput: + return "get_repositories", true + case EnsureCoverageInput: + return "ensure_coverage", true + case SyncRepositoryContextInput: + return "sync_repository_context", true + case SyncThreadsInput: + return "sync_threads", true + case HydrateThreadsInput: + return "hydrate_threads", true + case SyncPortfolioInput: + return "sync_portfolio", true + case SyncPullRequestFeedbackInput: + return "sync_pull_request_feedback", true + case IndexPullRequestFeedbackInput: + return "index_pull_request_feedback", true + case SyncCIFailuresInput: + return "sync_ci_failures", true + case DeepWikiInput: + return "query_deepwiki", true + case IndexRepositoriesInput: + return "index_repositories", true + case FindClustersInput: + return "find_clusters", true + case FindNeighborsInput: + return "find_neighbors", true + case RankOpportunitiesInput: + return "rank_opportunities", true + case MineRepositoryFixPatternsInput: + return "mine_repository_fix_patterns", true + case PreviewRepositoryFixPatternsInput: + return "preview_fix_patterns", true + case SearchGitHubRepositoriesInput: + return "search_github_repositories", true + case SearchGitHubThreadsInput: + return "search_github_threads", true + case SearchCodeInput: + return "search_code", true + case ReadSourceFilesInput: + return "read_source_files", true + case InspectCommitChangesInput: + return "inspect_commit_changes", true + case CheckMergeConflictsInput: + return "check_merge_conflicts", true + case FindRelatedWorkInput: + return "find_related_work", true + case ListConcernsInput: + return "list_concerns", true + case ListPullRequestPortfolioInput: + return "list_pull_request_portfolio", true + case ExportManifestInput: + return "export_manifest", true + default: + return "", false + } +} + +func parseRecoveryAction(data []byte) (ToolCall, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + if len(object) != 2 { + return nil, fmt.Errorf("recovery action must contain exactly type and one typed input") + } + var actionType string + if err := json.Unmarshal(object["type"], &actionType); err != nil || actionType == "" { + return nil, fmt.Errorf("recovery action type is required") + } + input, ok := object[actionType] + if !ok { + return nil, fmt.Errorf("recovery action %q is missing its typed input", actionType) + } + switch actionType { + case "poll_job": + return decodeRecoveryAction[GetJobsInput](input) + case "read_resource": + return decodeRecoveryAction[ResourceReadAction](input) + case "read_snapshot": + return decodeRecoveryAction[SnapshotReadAction](input) + case "get_threads": + return decodeRecoveryAction[GetThreadsInput](input) + case "get_thread_facets": + return decodeRecoveryAction[GetThreadFacetsInput](input) + case "search_pull_request_feedback": + return decodeRecoveryAction[SearchPullRequestFeedbackInput](input) + case "get_repositories": + return decodeRecoveryAction[GetRepositoriesInput](input) + case "ensure_coverage": + return decodeRecoveryAction[EnsureCoverageInput](input) + case "sync_repository_context": + return decodeRecoveryAction[SyncRepositoryContextInput](input) + case "sync_threads": + return decodeRecoveryAction[SyncThreadsInput](input) + case "hydrate_threads": + return decodeRecoveryAction[HydrateThreadsInput](input) + case "sync_portfolio": + return decodeRecoveryAction[SyncPortfolioInput](input) + case "sync_pull_request_feedback": + return decodeRecoveryAction[SyncPullRequestFeedbackInput](input) + case "index_pull_request_feedback": + return decodeRecoveryAction[IndexPullRequestFeedbackInput](input) + case "sync_ci_failures": + return decodeRecoveryAction[SyncCIFailuresInput](input) + case "query_deepwiki": + return decodeRecoveryAction[DeepWikiInput](input) + case "index_repositories": + return decodeRecoveryAction[IndexRepositoriesInput](input) + case "find_clusters": + return decodeRecoveryAction[FindClustersInput](input) + case "find_neighbors": + return decodeRecoveryAction[FindNeighborsInput](input) + case "rank_opportunities": + return decodeRecoveryAction[RankOpportunitiesInput](input) + case "mine_repository_fix_patterns": + return decodeRecoveryAction[MineRepositoryFixPatternsInput](input) + case "preview_fix_patterns": + return decodeRecoveryAction[PreviewRepositoryFixPatternsInput](input) + case "search_github_repositories": + return decodeRecoveryAction[SearchGitHubRepositoriesInput](input) + case "search_github_threads": + return decodeRecoveryAction[SearchGitHubThreadsInput](input) + case "search_code": + return decodeRecoveryAction[SearchCodeInput](input) + case "read_source_files": + return decodeRecoveryAction[ReadSourceFilesInput](input) + case "inspect_commit_changes": + return decodeRecoveryAction[InspectCommitChangesInput](input) + case "check_merge_conflicts": + return decodeRecoveryAction[CheckMergeConflictsInput](input) + case "find_related_work": + return decodeRecoveryAction[FindRelatedWorkInput](input) + case "list_concerns": + return decodeRecoveryAction[ListConcernsInput](input) + case "list_pull_request_portfolio": + return decodeRecoveryAction[ListPullRequestPortfolioInput](input) + case "export_manifest": + return decodeRecoveryAction[ExportManifestInput](input) + default: + return nil, fmt.Errorf("unknown recovery action type %q", actionType) + } +} + +func decodeRecoveryAction[T recoveryActionInput](data []byte) (ToolCall, error) { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return nil, errors.New("recovery action input must be an object") + } + var input T + if err := decodeStrictJSON(data, &input); err != nil { + return nil, err + } + return RecoveryAction(input), nil +} + +func decodeStrictJSON(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return errors.New("input contains trailing JSON") + } + return nil +} + +const RecoveryPlanVersion = "gitcontribute.recovery.v1" diff --git a/internal/mcpcontract/scalable_contracts.go b/internal/mcpcontract/scalable_contracts.go index e15f1264..3ae5fd6d 100644 --- a/internal/mcpcontract/scalable_contracts.go +++ b/internal/mcpcontract/scalable_contracts.go @@ -2,7 +2,6 @@ package mcpcontract import ( "github.com/morluto/gitcontribute/internal/failure" - "github.com/morluto/gitcontribute/internal/similarity" ) @@ -28,116 +27,6 @@ type SearchGitHubRepositoriesInput struct { ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise or detailed"` } -// RecoveryPlan is the only model-visible recovery shape. Versioning keeps the -// contract explicit while Then preserves the order in which calls are made. -type RecoveryPlan struct { - Version string `json:"version"` - Reason string `json:"reason"` - Message string `json:"message"` - Then []ToolCall `json:"then,omitempty"` -} - -// ToolCall is one discriminated, replayable MCP action in a recovery plan. -type ToolCall struct { - Type string `json:"type"` - PollJob *GetJobsInput `json:"poll_job,omitempty"` - GetRepositories *GetRepositoriesInput `json:"get_repositories,omitempty"` - EnsureCoverage *EnsureCoverageInput `json:"ensure_coverage,omitempty"` - SyncRepositoryContext *SyncRepositoryContextInput `json:"sync_repository_context,omitempty"` - SyncThreads *SyncThreadsInput `json:"sync_threads,omitempty"` - HydrateThreads *HydrateThreadsInput `json:"hydrate_threads,omitempty"` - SyncPortfolio *SyncPortfolioInput `json:"sync_portfolio,omitempty"` - SyncFeedback *SyncPullRequestFeedbackInput `json:"sync_pull_request_feedback,omitempty"` - IndexFeedback *IndexPullRequestFeedbackInput `json:"index_pull_request_feedback,omitempty"` - SyncCI *SyncCIFailuresInput `json:"sync_ci_failures,omitempty"` - QueryDeepWiki *DeepWikiInput `json:"query_deepwiki,omitempty"` - IndexRepositories *IndexRepositoriesInput `json:"index_repositories,omitempty"` - FindClusters *FindClustersInput `json:"find_clusters,omitempty"` - FindNeighbors *FindNeighborsInput `json:"find_neighbors,omitempty"` - RankOpportunities *RankOpportunitiesInput `json:"rank_opportunities,omitempty"` - MineFixPatterns *MineRepositoryFixPatternsInput `json:"mine_repository_fix_patterns,omitempty"` - PreviewFixPatterns *PreviewRepositoryFixPatternsInput `json:"preview_fix_patterns,omitempty"` - SearchGitHubRepos *SearchGitHubRepositoriesInput `json:"search_github_repositories,omitempty"` - SearchGitHubThreads *SearchGitHubThreadsInput `json:"search_github_threads,omitempty"` - SearchCode *SearchCodeInput `json:"search_code,omitempty"` - ReadSourceFiles *ReadSourceFilesInput `json:"read_source_files,omitempty"` - InspectCommitChanges *InspectCommitChangesInput `json:"inspect_commit_changes,omitempty"` - CheckMergeConflicts *CheckMergeConflictsInput `json:"check_merge_conflicts,omitempty"` - FindRelatedWork *FindRelatedWorkInput `json:"find_related_work,omitempty"` - ListConcerns *ListConcernsInput `json:"list_concerns,omitempty"` - ListPortfolio *ListPullRequestPortfolioInput `json:"list_pull_request_portfolio,omitempty"` - ExportManifest *ExportManifestInput `json:"export_manifest,omitempty"` -} - -type recoveryActionInput interface { - GetJobsInput | GetRepositoriesInput | EnsureCoverageInput | SyncRepositoryContextInput | SyncThreadsInput | HydrateThreadsInput | SyncPortfolioInput | SyncPullRequestFeedbackInput | IndexPullRequestFeedbackInput | SyncCIFailuresInput | DeepWikiInput | IndexRepositoriesInput | FindClustersInput | FindNeighborsInput | RankOpportunitiesInput | MineRepositoryFixPatternsInput | PreviewRepositoryFixPatternsInput | SearchGitHubRepositoriesInput | SearchGitHubThreadsInput | SearchCodeInput | ReadSourceFilesInput | InspectCommitChangesInput | CheckMergeConflictsInput | FindRelatedWorkInput | ListConcernsInput | ListPullRequestPortfolioInput | ExportManifestInput -} - -// RecoveryAction derives the action discriminator from a concrete input type, -// making incompatible tool/argument combinations unrepresentable. -func RecoveryAction[T recoveryActionInput](input T) ToolCall { - switch value := any(input).(type) { - case GetJobsInput: - return ToolCall{Type: "poll_job", PollJob: &value} - case GetRepositoriesInput: - return ToolCall{Type: "get_repositories", GetRepositories: &value} - case EnsureCoverageInput: - return ToolCall{Type: "ensure_coverage", EnsureCoverage: &value} - case SyncRepositoryContextInput: - return ToolCall{Type: "sync_repository_context", SyncRepositoryContext: &value} - case SyncThreadsInput: - return ToolCall{Type: "sync_threads", SyncThreads: &value} - case HydrateThreadsInput: - return ToolCall{Type: "hydrate_threads", HydrateThreads: &value} - case SyncPortfolioInput: - return ToolCall{Type: "sync_portfolio", SyncPortfolio: &value} - case SyncPullRequestFeedbackInput: - return ToolCall{Type: "sync_pull_request_feedback", SyncFeedback: &value} - case IndexPullRequestFeedbackInput: - return ToolCall{Type: "index_pull_request_feedback", IndexFeedback: &value} - case SyncCIFailuresInput: - return ToolCall{Type: "sync_ci_failures", SyncCI: &value} - case DeepWikiInput: - return ToolCall{Type: "query_deepwiki", QueryDeepWiki: &value} - case IndexRepositoriesInput: - return ToolCall{Type: "index_repositories", IndexRepositories: &value} - case FindClustersInput: - return ToolCall{Type: "find_clusters", FindClusters: &value} - case FindNeighborsInput: - return ToolCall{Type: "find_neighbors", FindNeighbors: &value} - case RankOpportunitiesInput: - return ToolCall{Type: "rank_opportunities", RankOpportunities: &value} - case MineRepositoryFixPatternsInput: - return ToolCall{Type: "mine_repository_fix_patterns", MineFixPatterns: &value} - case PreviewRepositoryFixPatternsInput: - return ToolCall{Type: "preview_fix_patterns", PreviewFixPatterns: &value} - case SearchGitHubRepositoriesInput: - return ToolCall{Type: "search_github_repositories", SearchGitHubRepos: &value} - case SearchGitHubThreadsInput: - return ToolCall{Type: "search_github_threads", SearchGitHubThreads: &value} - case SearchCodeInput: - return ToolCall{Type: "search_code", SearchCode: &value} - case ReadSourceFilesInput: - return ToolCall{Type: "read_source_files", ReadSourceFiles: &value} - case InspectCommitChangesInput: - return ToolCall{Type: "inspect_commit_changes", InspectCommitChanges: &value} - case CheckMergeConflictsInput: - return ToolCall{Type: "check_merge_conflicts", CheckMergeConflicts: &value} - case FindRelatedWorkInput: - return ToolCall{Type: "find_related_work", FindRelatedWork: &value} - case ListConcernsInput: - return ToolCall{Type: "list_concerns", ListConcerns: &value} - case ListPullRequestPortfolioInput: - return ToolCall{Type: "list_pull_request_portfolio", ListPortfolio: &value} - case ExportManifestInput: - return ToolCall{Type: "export_manifest", ExportManifest: &value} - default: - panic("unreachable recovery action input") - } -} - -const RecoveryPlanVersion = "gitcontribute.recovery.v1" - // SearchWarning explains a request-specific limitation and how to improve it. type SearchWarning struct { Code string `json:"code"` diff --git a/internal/mcpserver/agent_eval_heldout_test.go b/internal/mcpserver/agent_eval_heldout_test.go index 89034e5a..d90fefc4 100644 --- a/internal/mcpserver/agent_eval_heldout_test.go +++ b/internal/mcpserver/agent_eval_heldout_test.go @@ -260,36 +260,32 @@ func replayHeldOutRecovery(t *testing.T, run *heldOutRun, action mcpcontract.Too func heldOutRecoveryCall(action mcpcontract.ToolCall) (string, map[string]any, bool) { var name string - var value any - switch action.Type { + switch action.Type() { case "poll_job": - name, value = mcpcontract.ToolGetJob, action.PollJob + name = mcpcontract.ToolGetJob case "get_repositories": - name, value = mcpcontract.ToolGetRepositories, action.GetRepositories + name = mcpcontract.ToolGetRepositories case "ensure_coverage": - name, value = mcpcontract.ToolEnsureCoverage, action.EnsureCoverage + name = mcpcontract.ToolEnsureCoverage case "sync_repository_context": - name, value = mcpcontract.ToolSyncRepositoryContext, action.SyncRepositoryContext + name = mcpcontract.ToolSyncRepositoryContext case "sync_threads": - name, value = mcpcontract.ToolSyncThreads, action.SyncThreads + name = mcpcontract.ToolSyncThreads case "hydrate_threads": - name, value = mcpcontract.ToolHydrateThreads, action.HydrateThreads + name = mcpcontract.ToolHydrateThreads case "sync_portfolio": - name, value = mcpcontract.ToolSyncPortfolio, action.SyncPortfolio + name = mcpcontract.ToolSyncPortfolio case "sync_pull_request_feedback": - name, value = mcpcontract.ToolSyncPullRequestFeedback, action.SyncFeedback + name = mcpcontract.ToolSyncPullRequestFeedback case "sync_ci_failures": - name, value = mcpcontract.ToolSyncCIFailures, action.SyncCI + name = mcpcontract.ToolSyncCIFailures case "query_deepwiki": - name, value = mcpcontract.ToolQueryDeepWiki, action.QueryDeepWiki + name = mcpcontract.ToolQueryDeepWiki default: return "", nil, false } - if value == nil { - return "", nil, false - } var input map[string]any - if !decodeHeldOut(value, &input) { + if !decodeHeldOut(action.Input(), &input) { return "", nil, false } return name, input, true diff --git a/internal/mcpserver/recovery_schema.go b/internal/mcpserver/recovery_schema.go new file mode 100644 index 00000000..2ad3bcf7 --- /dev/null +++ b/internal/mcpserver/recovery_schema.go @@ -0,0 +1,45 @@ +package mcpserver + +import ( + "fmt" + "reflect" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +var recoveryToolCallSchema = sync.OnceValues(buildRecoveryToolCallSchema) + +func buildRecoveryToolCallSchema() (*jsonschema.Schema, error) { + return buildToolCallSchema(mcpcontract.RecoveryActionPrototypes(), "One replayable recovery action whose discriminator owns exactly one typed input.") +} + +var followUpToolCallSchema = sync.OnceValues(func() (*jsonschema.Schema, error) { + return buildToolCallSchema(mcpcontract.FollowUpActionPrototypes(), "One read or poll action that follows a durable job.") +}) + +func buildToolCallSchema(actions []mcpcontract.ToolCall, description string) (*jsonschema.Schema, error) { + root := &jsonschema.Schema{ + Description: description, + OneOf: make([]*jsonschema.Schema, 0, len(actions)), + } + for _, action := range actions { + input, err := jsonschema.ForType(reflect.TypeOf(action.Input()), nil) + if err != nil { + return nil, fmt.Errorf("%s input: %w", action.Type(), err) + } + input.Schema = "" + discriminator := any(action.Type()) + root.OneOf = append(root.OneOf, &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "type": {Type: "string", Const: &discriminator}, + action.Type(): input, + }, + Required: []string{"type", action.Type()}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }) + } + return root, nil +} diff --git a/internal/mcpserver/recovery_schema_test.go b/internal/mcpserver/recovery_schema_test.go new file mode 100644 index 00000000..4752035f --- /dev/null +++ b/internal/mcpserver/recovery_schema_test.go @@ -0,0 +1,49 @@ +package mcpserver + +import "testing" + +func TestToolCallSchemaUsesExclusiveVariants(t *testing.T) { + t.Parallel() + schema, err := recoveryToolCallSchema() + if err != nil { + t.Fatal(err) + } + if len(schema.OneOf) != 32 { + t.Fatalf("tool-call variants = %d, want 32", len(schema.OneOf)) + } + seen := make(map[string]bool, len(schema.OneOf)) + for _, variant := range schema.OneOf { + if variant.Type != "object" || len(variant.Required) != 2 || variant.AdditionalProperties == nil || variant.AdditionalProperties.Not == nil { + t.Fatalf("non-exclusive recovery action schema: %+v", variant) + } + discriminator := variant.Properties["type"] + if discriminator == nil || discriminator.Const == nil { + t.Fatalf("recovery action has no constant discriminator: %+v", variant) + } + name, ok := (*discriminator.Const).(string) + if !ok || name == "" || variant.Properties[name] == nil { + t.Fatalf("recovery action discriminator does not own input: %+v", variant) + } + if seen[name] { + t.Fatalf("duplicate recovery action schema %q", name) + } + seen[name] = true + } +} + +func TestFollowUpSchemaExcludesMutatingRecoveryActions(t *testing.T) { + t.Parallel() + schema, err := followUpToolCallSchema() + if err != nil { + t.Fatal(err) + } + if len(schema.OneOf) != 9 { + t.Fatalf("follow-up variants = %d, want 9", len(schema.OneOf)) + } + for _, variant := range schema.OneOf { + name, _ := (*variant.Properties["type"].Const).(string) + if name == "sync_threads" || name == "export_manifest" { + t.Fatalf("mutating recovery action %q appears in follow-up schema", name) + } + } +} diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index bc80d34d..a3e7e3d3 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -3,9 +3,7 @@ package mcpserver import ( "encoding/json" "fmt" - "maps" "reflect" - "sync" "github.com/google/jsonschema-go/jsonschema" "github.com/morluto/gitcontribute/internal/mcpcontract" @@ -16,121 +14,109 @@ type schemaDefinition struct { err error } -type schemaCacheEntry struct { - once sync.Once - definition schemaDefinition -} - type schemaBuilder struct { schema *jsonschema.Schema err *error } -// schemaCache caches the reflection-based schema for each Go type so -// repeated tool registration does not re-run jsonschema.For on every call. -// The Once also makes concurrent construction single-flight and retains -// reflection errors, rather than allowing every caller to repeat the failed -// computation. Callers that customize a schema must clone before mutating it. -var schemaCache sync.Map // map[reflect.Type]*schemaCacheEntry - func inferredSchema[T any]() schemaDefinition { - key := reflect.TypeFor[T]() - entry, _ := schemaCache.LoadOrStore(key, &schemaCacheEntry{}) - cached, ok := entry.(*schemaCacheEntry) - if !ok { - panic("MCP schema cache contains an invalid entry") - } - cached.once.Do(func() { - schema, err := jsonschema.For[T](&jsonschema.ForOptions{ - TypeSchemas: map[reflect.Type]*jsonschema.Schema{ - 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"}, - }, + toolCallSchema, err := recoveryToolCallSchema() + if err != nil { + return schemaDefinition{err: fmt.Errorf("infer recovery tool-call schema: %w", err)} + } + followUpSchema, err := followUpToolCallSchema() + 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), }, - }) - if err != nil { - cached.definition.err = fmt.Errorf("infer MCP schema: %w", err) - return - } - cached.definition.schema = schema + 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"}, + }, + }, }) - return cached.definition + if err != nil { + return schemaDefinition{err: fmt.Errorf("infer MCP schema: %w", err)} + } + return schemaDefinition{schema: schema} } func inputSchema[T any](customize func(*schemaBuilder)) schemaDefinition { @@ -138,16 +124,12 @@ func inputSchema[T any](customize func(*schemaBuilder)) schemaDefinition { if definition.err != nil { return definition } - // Clone the cached schema before mutating so the cached instance - // is not corrupted for other callers. The customize function - // mutates properties on the schema (ranges, defaults, enums). - clone := cloneSchemaTree(definition.schema) var buildErr error - builder := &schemaBuilder{schema: clone, err: &buildErr} + builder := &schemaBuilder{schema: definition.schema, err: &buildErr} if customize != nil { customize(builder) } - return schemaDefinition{schema: clone, err: buildErr} + return schemaDefinition{schema: definition.schema, err: buildErr} } func outputSchema[T any](description string) schemaDefinition { @@ -155,87 +137,8 @@ func outputSchema[T any](description string) schemaDefinition { if definition.err != nil { return definition } - // Clone the cached schema before mutating Description so the - // cached instance is not corrupted for other callers. - clone := cloneSchemaTree(definition.schema) - clone.Description = description - return schemaDefinition{schema: clone} -} - -// cloneSchemaTree copies the complete mutable schema tree. jsonschema's -// CloneSchemas copies nested schemas, but intentionally leaves non-schema -// maps and slices shallow. Customizers update maps such as -// DependentRequired, so those values must not be shared between registrations. -func cloneSchemaTree(schema *jsonschema.Schema) *jsonschema.Schema { - clone := schema.CloneSchemas() - seen := make(map[*jsonschema.Schema]bool) - var copyMutable func(*jsonschema.Schema) - copyMutable = func(current *jsonschema.Schema) { - if current == nil || seen[current] { - return - } - seen[current] = true - current.Types = append([]string(nil), current.Types...) - current.Enum = append([]any(nil), current.Enum...) - current.Examples = append([]any(nil), current.Examples...) - current.Default = append([]byte(nil), current.Default...) - current.Required = append([]string(nil), current.Required...) - current.PropertyOrder = append([]string(nil), current.PropertyOrder...) - if current.Vocabulary != nil { - current.Vocabulary = maps.Clone(current.Vocabulary) - } - if current.DependencyStrings != nil { - current.DependencyStrings = cloneStringSlices(current.DependencyStrings) - } - if current.DependentRequired != nil { - current.DependentRequired = cloneStringSlices(current.DependentRequired) - } - for _, child := range schemaChildren(current) { - copyMutable(child) - } - } - copyMutable(clone) - return clone -} - -func cloneStringSlices(values map[string][]string) map[string][]string { - clone := make(map[string][]string, len(values)) - for key, value := range values { - clone[key] = append([]string(nil), value...) - } - return clone -} - -func schemaChildren(schema *jsonschema.Schema) []*jsonschema.Schema { - children := make([]*jsonschema.Schema, 0) - for _, child := range schema.Defs { - children = append(children, child) - } - for _, child := range schema.Definitions { - children = append(children, child) - } - for _, child := range schema.DependencySchemas { - children = append(children, child) - } - children = append(children, schema.PrefixItems...) - children = append(children, schema.Items, schema.AdditionalItems, schema.Contains, schema.UnevaluatedItems) - for _, child := range schema.Properties { - children = append(children, child) - } - for _, child := range schema.PatternProperties { - children = append(children, child) - } - children = append(children, schema.AdditionalProperties, schema.PropertyNames, schema.UnevaluatedProperties) - children = append(children, schema.AllOf...) - children = append(children, schema.AnyOf...) - children = append(children, schema.OneOf...) - children = append(children, schema.Not, schema.If, schema.Then, schema.Else) - for _, child := range schema.DependentSchemas { - children = append(children, child) - } - children = append(children, schema.ContentSchema) - children = append(children, schema.ItemsArray...) - return children + definition.schema.Description = description + return definition } func property(builder *schemaBuilder, name string) *jsonschema.Schema { diff --git a/internal/mcpserver/schemas_test.go b/internal/mcpserver/schemas_test.go index cceb20b3..db5ac8b6 100644 --- a/internal/mcpserver/schemas_test.go +++ b/internal/mcpserver/schemas_test.go @@ -1,17 +1,16 @@ package mcpserver import ( - "sync" "testing" "github.com/morluto/gitcontribute/internal/mcpcontract" ) -func TestInferredSchemaIsSharedByType(t *testing.T) { +func TestInferredSchemaIsFreshByType(t *testing.T) { first := inferredSchema[mcpcontract.RepoInput]() second := inferredSchema[mcpcontract.RepoInput]() - if first.err != nil || second.err != nil || first.schema != second.schema { - t.Fatalf("cached schema definitions differ: first=%p/%v second=%p/%v", first.schema, first.err, second.schema, second.err) + if first.err != nil || second.err != nil || first.schema == second.schema { + t.Fatalf("schema definitions share mutable state: first=%p/%v second=%p/%v", first.schema, first.err, second.schema, second.err) } } @@ -58,36 +57,16 @@ func TestNestedDefinitionsAndArrayItemsRemainCustomizable(t *testing.T) { } } -func TestConcurrentServerConstructionProducesIdenticalCatalogs(t *testing.T) { - const count = 12 - fingerprints := make(chan string, count) - errs := make(chan error, count) - var group sync.WaitGroup - for range count { - group.Go(func() { - server, err := New(&fakeReader{}, "test") - if err != nil { - errs <- err - return - } - fingerprints <- server.catalogFingerprint() - }) - } - group.Wait() - close(fingerprints) - close(errs) - for err := range errs { +func TestServerConstructionProducesStableCatalog(t *testing.T) { + first, err := New(&fakeReader{}, "test") + if err != nil { t.Fatal(err) } - var want string - for fingerprint := range fingerprints { - if want == "" { - want = fingerprint - } else if fingerprint != want { - t.Fatalf("catalog fingerprint = %q, want %q", fingerprint, want) - } + second, err := New(&fakeReader{}, "test") + if err != nil { + t.Fatal(err) } - if want == "" { - t.Fatal("no catalog fingerprints recorded") + if got, want := second.catalogFingerprint(), first.catalogFingerprint(); got == "" || got != want { + t.Fatalf("catalog fingerprint = %q, want %q", got, want) } } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 272a2422..1b3e56ab 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -342,7 +342,12 @@ func newServer(reader mcpcontract.Reader, version string, readOnly bool) (*Serve server: mcp.NewServer(&mcp.Implementation{ Name: "gitcontribute", Version: version, - }, &mcp.ServerOptions{Instructions: serverInstructions}), + }, &mcp.ServerOptions{ + Instructions: serverInstructions, + // Do not advertise the SDK's historical default logging capability. + // Tools, prompts, and resources are inferred from their registrations. + Capabilities: &mcp.ServerCapabilities{}, + }), } s.register() if s.registrationErr != nil { diff --git a/internal/mcpserver/server_contract_test.go b/internal/mcpserver/server_contract_test.go index 514e2f85..7df9af00 100644 --- a/internal/mcpserver/server_contract_test.go +++ b/internal/mcpserver/server_contract_test.go @@ -343,6 +343,17 @@ func TestServerNegotiates20260728AndReturnsCompleteToolResults(t *testing.T) { if got, want := init.ProtocolVersion, "2026-07-28"; got != want { t.Fatalf("protocol version = %q, want %q", got, want) } + capabilitiesJSON, err := json.Marshal(init.Capabilities) + if err != nil { + t.Fatalf("marshal negotiated capabilities: %v", err) + } + var capabilities map[string]json.RawMessage + if err := json.Unmarshal(capabilitiesJSON, &capabilities); err != nil { + t.Fatalf("decode negotiated capabilities: %v", err) + } + if _, advertised := capabilities["logging"]; advertised { + t.Fatalf("logging capability advertised in %s", capabilitiesJSON) + } result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ Name: mcpcontract.ToolSearchThreads, Arguments: map[string]any{"query": "stall"}, diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 5805b0a4..05a4d97c 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -295,7 +295,7 @@ func (*fakeReader) BuildRepositoryDossier(_ context.Context, in mcpcontract.Buil id := "job-dossier-" + in.Owner + "-" + in.Repo return mcpcontract.JobReference{ ID: id, Ref: "job:" + id, Kind: "build_repository_dossier", Status: "queued", PollAfterMS: 1000, - FollowUp: &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpAction{Type: "poll_job", PollJob: &mcpcontract.GetJobsInput{IDs: []string{"job-1"}}}, Reason: "Poll this durable job ID."}, + FollowUp: &mcpcontract.JobFollowUp{Action: mcpcontract.FollowUpActionFor(mcpcontract.GetJobsInput{IDs: []string{"job-1"}}), Reason: "Poll this durable job ID."}, }, nil } diff --git a/internal/precedent/models.go b/internal/precedent/models.go index a877aa5b..07fb9b58 100644 --- a/internal/precedent/models.go +++ b/internal/precedent/models.go @@ -4,6 +4,7 @@ package precedent import ( + "errors" "strings" "time" @@ -27,22 +28,53 @@ type Thread struct { Body string Labels []string ClosedAt time.Time - MergedAt time.Time - Merged bool + Merge domain.MergeStatus } // RepositorySnapshot contains all source threads and bounded closed history // needed to score every input for one repository. type RepositorySnapshot struct { Repository domain.RepoRef - Available bool + available bool Sources map[int]Thread Closed []Thread ClosedTotal int ClosedTruncated bool } +// MissingRepositorySnapshot records that the requested repository is absent +// from the local corpus. +func MissingRepositorySnapshot(repository domain.RepoRef) RepositorySnapshot { + return RepositorySnapshot{Repository: repository, Sources: map[int]Thread{}} +} + +// AvailableRepositorySnapshot records one bounded, locally stored history. +func AvailableRepositorySnapshot(repository domain.RepoRef, sources map[int]Thread, closed []Thread, closedTotal int) (RepositorySnapshot, error) { + if !repository.IsValid() || closedTotal < len(closed) { + return RepositorySnapshot{}, errors.New("available precedent snapshot requires a repository and a complete population bound") + } + sourceCopy := make(map[int]Thread, len(sources)) + for number, thread := range sources { + sourceCopy[number] = cloneThread(thread) + } + closedCopy := make([]Thread, len(closed)) + for index, thread := range closed { + closedCopy[index] = cloneThread(thread) + } + return RepositorySnapshot{ + Repository: repository, available: true, Sources: sourceCopy, Closed: closedCopy, + ClosedTotal: closedTotal, ClosedTruncated: len(closedCopy) < closedTotal, + }, nil +} + +func (s RepositorySnapshot) Available() bool { return s.available } + +func cloneThread(thread Thread) Thread { + thread.Labels = append([]string{}, thread.Labels...) + return thread +} + // RepositoryKey provides a stable case-insensitive grouping key. func RepositoryKey(ref domain.RepoRef) string { - return strings.ToLower(ref.Owner) + "/" + strings.ToLower(ref.Repo) + return strings.ToLower(ref.Owner()) + "/" + strings.ToLower(ref.Repo()) } diff --git a/internal/precedent/models_test.go b/internal/precedent/models_test.go index f22adc38..cdf03187 100644 --- a/internal/precedent/models_test.go +++ b/internal/precedent/models_test.go @@ -7,7 +7,7 @@ import ( ) func TestRepositoryKeyNormalizesOwnerAndRepository(t *testing.T) { - got := RepositoryKey(domain.RepoRef{Owner: "Morluto", Repo: "GitContribute"}) + got := RepositoryKey(domain.MustRepoRef("Morluto", "GitContribute")) if got != "morluto/gitcontribute" { t.Fatalf("repository key = %q", got) } diff --git a/internal/radar/eligibility_test.go b/internal/radar/eligibility_test.go index 135a368f..859c840a 100644 --- a/internal/radar/eligibility_test.go +++ b/internal/radar/eligibility_test.go @@ -199,7 +199,7 @@ func TestAIPolicyIsAnExplicitGate(t *testing.T) { func completeEligibilityRepo(now time.Time) RepositorySnapshot { return RepositorySnapshot{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, SourceUpdated: now, + Repo: domain.MustRepoRef("owner", "repo"), SourceUpdated: now, GuidanceStatus: "available", Guidance: []GuidanceDocument{{ Path: "CONTRIBUTING.md", Content: "Contributions are welcome.", URL: "https://github.com/owner/repo/blob/main/CONTRIBUTING.md", }}, diff --git a/internal/radar/radar.go b/internal/radar/radar.go index 2ac50a96..caf22ada 100644 --- a/internal/radar/radar.go +++ b/internal/radar/radar.go @@ -191,8 +191,8 @@ type Report struct { // Rank scores a bounded set of local issue snapshots. Missing coverage is // represented as unknown and never silently converted into a penalty. func Rank(repo RepositorySnapshot, issues []IssueSnapshot, opts Options) (*Report, error) { - if err := repo.Repo.Validate(); err != nil { - return nil, err + if !repo.Repo.IsValid() { + return nil, errors.New("repository reference is not parsed") } if opts.Limit == 0 { opts.Limit = DefaultLimit diff --git a/internal/radar/radar_test.go b/internal/radar/radar_test.go index 36603efc..e19a820d 100644 --- a/internal/radar/radar_test.go +++ b/internal/radar/radar_test.go @@ -14,7 +14,7 @@ import ( func TestRankOrdersEligibilityAndExplainsScore(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) repo := RepositorySnapshot{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, + Repo: domain.MustRepoRef("owner", "repo"), SourceUpdated: now.Add(-time.Hour), GuidanceStatus: "available", Coverage: []Coverage{ @@ -77,7 +77,7 @@ func TestMissingCoverageIsUnknownNotPenalty(t *testing.T) { Number: 1, State: "open", Title: "Issue", Body: "Description", SourceUpdated: now.Add(-24 * time.Hour), URL: "https://github.com/owner/repo/issues/1", } - repo := RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available"} + repo := RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available"} missing, err := Rank(repo, []IssueSnapshot{base}, Options{Now: now}) if err != nil { t.Fatal(err) @@ -131,7 +131,7 @@ func TestMissingCoverageIsUnknownNotPenalty(t *testing.T) { func TestClosingPullRequestBlocksCandidate(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}}, + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo")}, []IssueSnapshot{{ Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, URL: "https://github.com/owner/repo/issues/7", @@ -151,7 +151,7 @@ func TestClosingPullRequestBlocksCandidate(t *testing.T) { func TestOpenDependencyRequiresCoordinationWithoutBecomingBlocker(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available", Coverage: []Coverage{ + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, }}, []IssueSnapshot{{ @@ -177,7 +177,7 @@ func TestOpenDependencyRequiresCoordinationWithoutBecomingBlocker(t *testing.T) func TestCappedRelatedWorkPreventsReadyToCodeClaim(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available", Coverage: []Coverage{ + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, @@ -196,7 +196,7 @@ func TestCappedRelatedWorkPreventsReadyToCodeClaim(t *testing.T) { func TestUnknownRelatedPullRequestStatePreventsReadyToCodeClaim(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available", Coverage: []Coverage{ + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, @@ -220,7 +220,7 @@ func TestUnknownRelatedPullRequestStatePreventsReadyToCodeClaim(t *testing.T) { func TestClosedRelatedPullRequestIsBackgroundOnly(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available", Coverage: []Coverage{ + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available", Coverage: []Coverage{ {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, }}, []IssueSnapshot{{Number: 7, State: "open", Title: "Bug", Body: "Description", SourceUpdated: now, @@ -241,7 +241,7 @@ func TestClosedRelatedPullRequestIsBackgroundOnly(t *testing.T) { func TestRankUsesStableFinalTieBreakAndLimit(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) - repo := RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}} + repo := RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo")} issues := []IssueSnapshot{ {Number: 8, State: "open", Title: "Same", Body: "Same", SourceUpdated: now}, {Number: 2, State: "open", Title: "Same", Body: "Same", SourceUpdated: now}, @@ -260,7 +260,7 @@ func TestRankUsesStableFinalTieBreakAndLimit(t *testing.T) { func TestRankRejectsUnsafeLimit(t *testing.T) { _, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}}, + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo")}, nil, Options{Limit: MaxLimit + 1, Now: time.Now()}, ) @@ -278,7 +278,7 @@ func TestRankReturnsMaximumBoundedPopulation(t *testing.T) { } } report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}}, + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo")}, issues, Options{Limit: MaxLimit, Now: now, TotalOpenIssues: MaxLimit}, ) @@ -293,7 +293,7 @@ func TestRankReturnsMaximumBoundedPopulation(t *testing.T) { func TestObjectiveStateUsesBlockersNotScore(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Archived: true}, + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), Archived: true}, []IssueSnapshot{{Number: 1, State: "closed", Title: "Finished", SourceUpdated: now}}, Options{Now: now}, ) @@ -312,7 +312,7 @@ func TestObjectiveStateUsesBlockersNotScore(t *testing.T) { func TestRankReportsBoundedEvidenceScans(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( - RepositorySnapshot{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, GuidanceStatus: "available"}, + RepositorySnapshot{Repo: domain.MustRepoRef("owner", "repo"), GuidanceStatus: "available"}, nil, Options{Now: now, PopulationCapped: true, LinkedPullRequestScanCapped: true, DuplicateClusterScanCapped: true}, ) @@ -337,7 +337,7 @@ func TestCappedCollisionEvidenceCannotClaimEligibility(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) report, err := Rank( RepositorySnapshot{ - Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, + Repo: domain.MustRepoRef("owner", "repo"), Coverage: []Coverage{ {Facet: "metadata", Present: true, Complete: true}, {Facet: "threads", Present: true, Complete: true}, diff --git a/internal/relatedwork/references_test.go b/internal/relatedwork/references_test.go index 8037042b..13c3799a 100644 --- a/internal/relatedwork/references_test.go +++ b/internal/relatedwork/references_test.go @@ -7,7 +7,7 @@ import ( ) func TestExtractClassifiesRelationshipsAndExcludesQuotedCode(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") refs := Extract("Fixes #1. Based on owner/repo#2. Blocks https://github.com/owner/repo/issues/3.\n> Fixes #4\n``Fixes #5``\n```\nFixes #6\n```", repo) if len(refs) != 3 { t.Fatalf("references = %+v", refs) @@ -21,7 +21,7 @@ func TestExtractClassifiesRelationshipsAndExcludesQuotedCode(t *testing.T) { } func TestExtractUsesStrongestRelationAndPreservesExplicitKind(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") refs := Extract("See https://github.com/owner/repo/pull/7; this depends on https://github.com/owner/repo/pull/7 and fixes https://github.com/owner/repo/pull/7.", repo) if len(refs) != 1 { t.Fatalf("references = %+v", refs) @@ -37,7 +37,7 @@ func TestExtractUsesStrongestRelationAndPreservesExplicitKind(t *testing.T) { } func TestExtractRequiresMatchingFenceAndCodeSpanDelimiters(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") text := "```go\nFixes #1\n~~~\nFixes #2\n```\n" + "`Fixes\n#3`\n" + "`` Fixes #4 ` still code ``\n" + @@ -49,7 +49,7 @@ func TestExtractRequiresMatchingFenceAndCodeSpanDelimiters(t *testing.T) { } func TestExtractHandlesTildeFenceAndUnmatchedBacktickAsProse(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") refs := Extract("~~~text\nFixes #1\n~~~~\nAn unmatched ` does not hide Fixes #2", repo) if len(refs) != 1 || refs[0].Number != 2 || refs[0].Relation != RelationClaimsToClose { t.Fatalf("references = %+v", refs) @@ -57,7 +57,7 @@ func TestExtractHandlesTildeFenceAndUnmatchedBacktickAsProse(t *testing.T) { } func TestExtractClassifiesReplacementDirectionAndEvidence(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") refs := Extract("Superseded by: #8. Replaces owner/repo#3.", repo) if len(refs) != 2 { t.Fatalf("references = %+v", refs) diff --git a/internal/research/builder.go b/internal/research/builder.go index 0a40dd14..710c21a5 100644 --- a/internal/research/builder.go +++ b/internal/research/builder.go @@ -160,9 +160,9 @@ func assembleBrief(in assemblyInput) *Brief { allSources = append(allSources, in.repoSources...) allSources = append(allSources, in.guidanceSources...) allSources = append(allSources, in.relations.Sources...) - allSources = append(allSources, in.health.Sources...) - if in.code.Present { - allSources = append(allSources, in.code.Source) + allSources = append(allSources, in.health.Sources()...) + if in.code.Present() { + allSources = append(allSources, in.code.Source()) } brief.SourceAsOf = latestSourceTime(allSources) return brief @@ -170,14 +170,15 @@ func assembleBrief(in assemblyInput) *Brief { func buildCurrentState(t ThreadSnapshot) CurrentStateSection { var merged *bool - if t.MergedKnown { - merged = &t.Merged + if t.Merge.Known() { + value := t.Merge.IsMerged() + merged = &value } return CurrentStateSection{ SectionMeta: sourceMeta([]SourceRef{t.Source}, ""), State: t.State, StateReason: t.StateReason, Draft: t.Draft, Locked: t.Locked, Merged: merged, Labels: cleanSorted(t.Labels), Milestone: t.Milestone, - CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, ClosedAt: t.ClosedAt, MergedAt: t.MergedAt, + CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, ClosedAt: t.ClosedAt, MergedAt: t.Merge.MergedAt(), } } @@ -289,7 +290,7 @@ func buildTimeline(e ThreadEvidence, gap string) TimelineSection { add(t.CreatedAt, "created", t.Author, "thread created", t.Source) add(t.UpdatedAt, "updated", "", "thread source updated", t.Source) add(t.ClosedAt, "closed", "", "thread closed", t.Source) - add(t.MergedAt, "merged", "", "pull request merged", t.Source) + add(t.Merge.MergedAt(), "merged", "", "pull request merged", t.Source) discussion := append([]DiscussionItem{}, e.Discussion...) sort.SliceStable(discussion, func(i, j int) bool { left, right := eventTime(discussion[i]), eventTime(discussion[j]) @@ -356,19 +357,19 @@ func buildPullRequests(e RelationshipEvidence) PullRequestSection { } func buildCode(e CodeEvidence) CodeSection { - if !e.Present { + if !e.Present() { return CodeSection{ SectionMeta: sourceMeta(nil, "repository has no local code snapshot"), - Queries: cleanSorted(e.Queries), Hits: []CodeHit{}, + Queries: cleanSorted(e.Queries()), Hits: []CodeHit{}, } } unknown := "" - if e.Truncated { + if e.Truncated() { unknown = "code matches reached the per-brief bound" } return CodeSection{ - SectionMeta: sourceMeta([]SourceRef{e.Source}, unknown), CommitSHA: e.CommitSHA, - Queries: cleanSorted(e.Queries), Hits: append([]CodeHit{}, e.Hits...), Truncated: e.Truncated, + SectionMeta: sourceMeta([]SourceRef{e.Source()}, unknown), CommitSHA: e.CommitSHA(), + Queries: cleanSorted(e.Queries()), Hits: e.Hits(), Truncated: e.Truncated(), } } @@ -380,24 +381,24 @@ func buildGuidance(text string, sources []SourceRef) GuidanceSection { } func buildHealth(e HealthEvidence) HealthSection { + metrics, available := e.Metrics() + if !available { + return HealthSection{SectionMeta: sourceMeta(nil, nonEmpty(e.UnknownReason(), "repository health is unavailable"))} + } section := HealthSection{ - Archived: e.Archived, OpenIssues: e.OpenIssues, OpenPullRequests: e.OpenPullRequests, - ExternalPRMergeRate: e.ExternalPRMergeRate, ExternalPRSampleSize: e.ExternalPRSampleSize, - IssueResponseMedianHours: e.IssueResponseMedianHours, - PullRequestResponseMedianHours: e.PullRequestResponseMedianHours, - IssueResponseSampleSize: e.IssueResponseSampleSize, - PullRequestResponseSampleSize: e.PullRequestResponseSampleSize, - ThreadSampleSize: e.ThreadSampleSize, ThreadsTruncated: e.ThreadsTruncated, - } - if !e.Available { - section.SectionMeta = sourceMeta(e.Sources, nonEmpty(e.UnknownReason, "repository health is unavailable")) - return section - } - unknown := e.UnknownReason - if e.ThreadsTruncated { + Archived: metrics.Archived, OpenIssues: metrics.OpenIssues, OpenPullRequests: metrics.OpenPullRequests, + ExternalPRMergeRate: metrics.ExternalPRMergeRate, ExternalPRSampleSize: metrics.ExternalPRSampleSize, + IssueResponseMedianHours: metrics.IssueResponseMedianHours, + PullRequestResponseMedianHours: metrics.PullRequestResponseMedianHours, + IssueResponseSampleSize: metrics.IssueResponseSampleSize, + PullRequestResponseSampleSize: metrics.PullRequestResponseSampleSize, + ThreadSampleSize: metrics.ThreadSampleSize, ThreadsTruncated: metrics.ThreadsTruncated, + } + unknown := e.UnknownReason() + if metrics.ThreadsTruncated { unknown = joinReasons(unknown, "health metrics use a bounded thread population") } - section.SectionMeta = sourceMeta(e.Sources, unknown) + section.SectionMeta = sourceMeta(e.Sources(), unknown) return section } @@ -406,13 +407,13 @@ func buildCoverage(in assemblyInput, discussionGap string) CoverageSection { gaps := []string{} repoFacets := map[string]domain.FacetCoverage{} for _, facet := range in.repoCoverage.Facets { - repoFacets[facet.Facet] = facet + repoFacets[facet.Facet()] = facet facts = append(facts, CoverageFact{ - Scope: "repository", Facet: facet.Facet, Present: facet.Present, Complete: facet.Complete, - AsOf: facet.Freshness.AsOf, Count: facet.Count, + Scope: "repository", Facet: facet.Facet(), Present: true, Complete: facet.Complete(), + AsOf: facet.AsOf(), Count: facet.Count(), }) - if !facet.Present || !facet.Complete { - gaps = append(gaps, "repository:"+facet.Facet) + if !facet.Complete() { + gaps = append(gaps, "repository:"+facet.Facet()) } } for _, required := range []string{"metadata", "threads"} { @@ -423,18 +424,19 @@ func buildCoverage(in assemblyInput, discussionGap string) CoverageSection { } 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, + Scope: "thread", Facet: facet.Facet(), Present: facet.Present(), Complete: facet.Complete(), + Truncated: facet.Truncated(), AsOf: facet.AsOf(), Count: facet.Count(), }) - if !facet.Present || !facet.Complete || facet.Truncated { - gaps = append(gaps, "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: in.code.Present, Complete: in.code.Present, - AsOf: in.code.Source.AsOf, Count: len(in.code.Hits), + Scope: "repository", Facet: "code_index", Present: codePresent, Complete: codePresent, + AsOf: in.code.Source().AsOf, Count: len(in.code.Hits()), }) - if !in.code.Present { + if !codePresent { gaps = append(gaps, "repository:code_index") } guidancePresent := strings.TrimSpace(in.guidance) != "" && len(in.guidanceSources) > 0 @@ -451,8 +453,8 @@ func buildCoverage(in assemblyInput, discussionGap string) CoverageSection { gaps = cleanSorted(gaps) sources := append([]SourceRef{}, in.repoSources...) sources = append(sources, discussionSources(in.thread)...) - if in.code.Present { - sources = append(sources, in.code.Source) + if codePresent { + sources = append(sources, in.code.Source()) } sources = append(sources, in.guidanceSources...) unknown := discussionGap @@ -467,8 +469,8 @@ func buildNext(in assemblyInput, discussionGap string) NextSection { commands := []NextCommand{} missingFacets := []string{} for _, facet := range in.thread.Coverage { - if !facet.Present || !facet.Complete { - missingFacets = append(missingFacets, facet.Facet) + if !facet.Complete() { + missingFacets = append(missingFacets, facet.Facet()) } } if len(missingFacets) > 0 { @@ -480,7 +482,7 @@ func buildNext(in assemblyInput, discussionGap string) NextSection { for _, required := range []string{"metadata", "threads"} { covered := false for _, facet := range in.repoCoverage.Facets { - if facet.Facet == required && facet.Present && facet.Complete { + if facet.Facet() == required && facet.Complete() { covered = true break } @@ -493,7 +495,7 @@ func buildNext(in assemblyInput, discussionGap string) NextSection { break } } - if !in.code.Present { + if !in.code.Present() { commands = append(commands, NextCommand{Reason: "index a clean local checkout for code hits", Command: "gitcontribute index " + ref.Repo.String() + " ."}) } if in.relations.PullRequestCapped || in.relations.DuplicateCapped { @@ -526,10 +528,10 @@ func extractReferences(e ThreadEvidence) []Reference { out := []Reference{} for _, input := range inputs { for _, ref := range relatedwork.Extract(input.text, e.Thread.Ref.Repo) { - if strings.EqualFold(ref.Repo.Owner, e.Thread.Ref.Repo.Owner) && strings.EqualFold(ref.Repo.Repo, e.Thread.Ref.Repo.Repo) && ref.Number == e.Thread.Ref.Number { + if strings.EqualFold(ref.Repo.Owner(), e.Thread.Ref.Repo.Owner()) && strings.EqualFold(ref.Repo.Repo(), e.Thread.Ref.Repo.Repo()) && ref.Number == e.Thread.Ref.Number { continue } - key := strings.ToLower(fmt.Sprintf("%s/%s:%s#%d", ref.Repo.Owner, ref.Repo.Repo, ref.Kind, ref.Number)) + key := strings.ToLower(fmt.Sprintf("%s/%s:%s#%d", ref.Repo.Owner(), ref.Repo.Repo(), ref.Kind, ref.Number)) if _, ok := seen[key]; ok { continue } @@ -585,14 +587,14 @@ func discussionCoverageGap(kind domain.ThreadKind, coverage []FacetCoverage) str required := facets.DefaultFor(string(kind)) byFacet := map[string]FacetCoverage{} for _, item := range coverage { - byFacet[item.Facet] = item + byFacet[item.Facet()] = item } gaps := []string{} for _, facet := range required { item, ok := byFacet[facet] - if !ok || !item.Present { + if !ok || !item.Present() { gaps = append(gaps, facet+" not hydrated") - } else if !item.Complete { + } else if !item.Complete() { gaps = append(gaps, facet+" incomplete") } } @@ -602,8 +604,8 @@ func discussionCoverageGap(kind domain.ThreadKind, coverage []FacetCoverage) str func discussionSources(e ThreadEvidence) []SourceRef { out := []SourceRef{e.Thread.Source} for _, facet := range e.Coverage { - if facet.Present { - out = append(out, facet.Source) + if facet.Present() { + out = append(out, facet.Source()) } } for _, item := range e.Discussion { diff --git a/internal/research/builder_test.go b/internal/research/builder_test.go index 574fec59..4059cb28 100644 --- a/internal/research/builder_test.go +++ b/internal/research/builder_test.go @@ -24,6 +24,22 @@ type fakeResearchReader struct { health HealthEvidence } +func mustObservedHealthEvidence(metrics HealthMetrics, sources []SourceRef) HealthEvidence { + evidence, err := ObservedHealthEvidence(metrics, sources, "") + if err != nil { + panic(err) + } + return evidence +} + +func mustObservedFacetCoverage(facet string, complete bool, asOf time.Time, count int, source SourceRef) FacetCoverage { + coverage, err := ObservedFacetCoverage(facet, complete, asOf, count, source) + if err != nil { + panic(err) + } + return coverage +} + func (f *fakeResearchReader) ReadRepository(context.Context, domain.RepoRef) (domain.Repository, []domain.SourceRef, error) { return f.repo, f.repoSources, nil } @@ -54,18 +70,18 @@ func (f *fakeResearchReader) ReadResearchHealth(context.Context, domain.RepoRef) func TestBuilderMakesCoverageAndUnknownsExplicit(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) - ref := ThreadRef{Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 42} + ref := ThreadRef{Repo: domain.MustRepoRef("owner", "repo"), Kind: domain.IssueKind, Number: 42} threadSource := SourceRef{Source: "github:rest", URL: "https://api.github.com/repos/owner/repo/issues/42", ObservedAt: now.Add(-time.Hour), AsOf: now.Add(-2 * time.Hour)} commentSource := SourceRef{Source: "github:rest", URL: "https://github.com/owner/repo/issues/42#issuecomment-1", ObservedAt: now.Add(-30 * time.Minute), AsOf: now.Add(-time.Hour)} localSource := SourceRef{Source: "local:relationships", URL: "local://relationships/owner/repo/42", AsOf: now.Add(-time.Hour)} reader := &fakeResearchReader{ - repo: domain.Repository{RepoRef: ref.Repo}, + repo: domain.Repository{Ref: ref.Repo}, repoSources: []domain.SourceRef{{ 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{ - {Facet: "metadata", Present: true, Complete: true, Freshness: domain.Freshness{AsOf: now.Add(-4 * time.Hour)}}, - {Facet: "threads", Present: true, Complete: true, Freshness: domain.Freshness{AsOf: now.Add(-3 * time.Hour)}}, + domain.MustFacetCoverage("metadata", true, now.Add(-4*time.Hour), 0), + domain.MustFacetCoverage("threads", true, now.Add(-3*time.Hour), 0), }}, thread: ThreadEvidence{ Thread: ThreadSnapshot{ @@ -78,9 +94,9 @@ func TestBuilderMakesCoverageAndUnknownsExplicit(t *testing.T) { ID: 1, Kind: "issue_comment", Body: "Please add a cancellation test; this must stay bounded.", Author: "maintainer", AuthorAssociation: "MEMBER", CreatedAt: now.Add(-time.Hour), Source: commentSource, }}, - Coverage: []FacetCoverage{{ - Facet: "issue_comments", Present: true, Complete: false, AsOf: now.Add(-time.Hour), Count: 1, Source: commentSource, - }}, + Coverage: []FacetCoverage{ + mustObservedFacetCoverage("issue_comments", false, now.Add(-time.Hour), 1, commentSource), + }, }, relations: RelationshipEvidence{ DuplicateThreads: []RelatedThread{{ @@ -93,11 +109,10 @@ func TestBuilderMakesCoverageAndUnknownsExplicit(t *testing.T) { }}, Sources: []SourceRef{localSource}, }, - code: CodeEvidence{Present: false}, - health: HealthEvidence{ - Available: true, OpenIssues: 4, OpenPullRequests: 1, ThreadSampleSize: 5, - Sources: []SourceRef{{Source: "local:health", URL: "local://health/owner/repo", AsOf: now.Add(-3 * time.Hour)}}, - }, + code: MissingCodeEvidence(nil), + health: mustObservedHealthEvidence(HealthMetrics{ + OpenIssues: 4, OpenPullRequests: 1, ThreadSampleSize: 5, + }, []SourceRef{{Source: "local:health", URL: "local://health/owner/repo", AsOf: now.Add(-3 * time.Hour)}}), } brief, err := NewBuilder(reader, func() time.Time { return now }).Build(context.Background(), ThreadRef{Repo: ref.Repo, Number: ref.Number}) @@ -183,7 +198,7 @@ func TestParseThreadRef(t *testing.T) { func TestBuilderHonorsCancellationAndProvenanceValidation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := NewBuilder(&fakeResearchReader{}, time.Now).Build(ctx, ThreadRef{Repo: domain.RepoRef{Owner: "o", Repo: "r"}, Number: 1}) + _, err := NewBuilder(&fakeResearchReader{}, time.Now).Build(ctx, ThreadRef{Repo: domain.MustRepoRef("o", "r"), Number: 1}) if !errors.Is(err, context.Canceled) { t.Fatalf("cancelled build error = %v", err) } diff --git a/internal/research/model.go b/internal/research/model.go index 2a4c78a4..b66b2d29 100644 --- a/internal/research/model.go +++ b/internal/research/model.go @@ -63,12 +63,8 @@ func ParseThreadRef(raw string) (ThreadRef, error) { if idx <= 0 || idx == len(raw)-1 { return ThreadRef{}, fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", raw) } - parts := strings.Split(raw[:idx], "/") - if len(parts) != 2 { - return ThreadRef{}, fmt.Errorf("invalid thread reference %q: expected OWNER/REPO#NUMBER", raw) - } - repo := domain.RepoRef{Owner: parts[0], Repo: parts[1]} - if err := repo.Validate(); err != nil { + repo, err := domain.ParseRepoRef(raw[:idx]) + if err != nil { return ThreadRef{}, fmt.Errorf("invalid thread reference %q: %w", raw, err) } number, err := strconv.Atoi(raw[idx+1:]) @@ -89,8 +85,8 @@ func (r ThreadRef) String() string { // Validate checks a programmatically constructed thread reference. func (r ThreadRef) Validate() error { - if err := r.Repo.Validate(); err != nil { - return err + if !r.Repo.IsValid() { + return errors.New("repository reference is not parsed") } if r.Number <= 0 { return errors.New("thread number must be positive") diff --git a/internal/research/reader.go b/internal/research/reader.go index 77216a57..a612d540 100644 --- a/internal/research/reader.go +++ b/internal/research/reader.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/morluto/gitcontribute/internal/domain" @@ -24,15 +25,71 @@ func KindMismatchError(requested, stored domain.ThreadKind) error { return fmt.Errorf("%w: requested %s, stored %s", ErrThreadKindMismatch, requested, stored) } -// FacetCoverage describes one thread child-facet snapshot. +type facetCoverageState uint8 + +const ( + facetCoverageMissing facetCoverageState = iota + facetCoverageObserved +) + +// FacetCoverage describes one expected thread child facet. Its private state +// prevents missing facets from carrying counts, timestamps, or source claims. type FacetCoverage struct { - Facet string - Present bool - Complete bool - Truncated bool - AsOf time.Time - Count int - Source SourceRef + facet string + state facetCoverageState + complete bool + truncated bool + asOf time.Time + count int + source SourceRef +} + +// MissingFacetCoverage records an expected facet with no local observation. +func MissingFacetCoverage(facet string) (FacetCoverage, error) { + facet = strings.TrimSpace(facet) + if facet == "" { + return FacetCoverage{}, errors.New("coverage facet is required") + } + return FacetCoverage{facet: facet}, nil +} + +// ObservedFacetCoverage records a present facet observation. +func ObservedFacetCoverage(facet string, complete bool, asOf time.Time, count int, source SourceRef) (FacetCoverage, error) { + facet = strings.TrimSpace(facet) + if facet == "" { + return FacetCoverage{}, errors.New("coverage facet is required") + } + if asOf.IsZero() { + return FacetCoverage{}, errors.New("coverage as-of time is required") + } + if count < 0 { + return FacetCoverage{}, errors.New("coverage count cannot be negative") + } + if strings.TrimSpace(source.Source) == "" || !source.AsOf.Equal(asOf) { + return FacetCoverage{}, errors.New("coverage source and matching as-of time are required") + } + return FacetCoverage{facet: facet, state: facetCoverageObserved, complete: complete, asOf: asOf, count: count, source: source}, nil +} + +func (c FacetCoverage) Facet() string { return c.facet } +func (c FacetCoverage) Present() bool { return c.state == facetCoverageObserved } +func (c FacetCoverage) Complete() bool { return c.Present() && c.complete && !c.truncated } +func (c FacetCoverage) Truncated() bool { return c.Present() && c.truncated } +func (c FacetCoverage) AsOf() time.Time { return c.asOf } +func (c FacetCoverage) Count() int { return c.count } +func (c FacetCoverage) Source() SourceRef { return c.source } +func (c FacetCoverage) Equal(other FacetCoverage) bool { return c == other } + +// WithTruncated records a read bound only for an observed facet. Missing +// facets cannot be truncated because no collection was read. +func (c FacetCoverage) WithTruncated(truncated bool) FacetCoverage { + if c.Present() { + c.truncated = truncated + if truncated { + c.complete = false + } + } + return c } // ThreadSnapshot is a product-owned issue/PR projection. @@ -49,12 +106,10 @@ type ThreadSnapshot struct { Draft bool Locked bool Milestone string - Merged bool - MergedKnown bool + Merge domain.MergeStatus CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time - MergedAt time.Time Source SourceRef } @@ -101,19 +156,73 @@ type RelationshipEvidence struct { PullRequestCapped bool } -// CodeEvidence reports the latest immutable snapshot and bounded matches. +type codeEvidenceState uint8 + +const ( + codeEvidenceMissing codeEvidenceState = iota + codeEvidenceObserved +) + +// CodeEvidence is either a query against a missing local snapshot or evidence +// bound to one immutable commit. Its private representation prevents callers +// from attaching hits or provenance to a missing snapshot. type CodeEvidence struct { - Present bool - CommitSHA string - Queries []string - Hits []CodeHit - Source SourceRef - Truncated bool + state codeEvidenceState + commitSHA string + queries []string + hits []CodeHit + source SourceRef + truncated bool } -// HealthEvidence is a compact adapter view over offline health metrics. -type HealthEvidence struct { - Available bool +// MissingCodeEvidence records the queries that could not be run because no +// local code snapshot exists. +func MissingCodeEvidence(queries []string) CodeEvidence { + return CodeEvidence{queries: append([]string{}, queries...)} +} + +// ObservedCodeEvidence binds all returned matches to one immutable snapshot. +func ObservedCodeEvidence(commitSHA string, queries []string, hits []CodeHit, source SourceRef, truncated bool) (CodeEvidence, error) { + commitSHA = strings.TrimSpace(commitSHA) + if commitSHA == "" { + return CodeEvidence{}, errors.New("code evidence commit SHA is required") + } + if strings.TrimSpace(source.Source) == "" { + return CodeEvidence{}, errors.New("code evidence source is required") + } + if source.CommitSHA != "" && source.CommitSHA != commitSHA { + return CodeEvidence{}, errors.New("code evidence source commit disagrees with snapshot") + } + for i, hit := range hits { + if strings.TrimSpace(hit.Path) == "" || strings.TrimSpace(hit.MatchedTerm) == "" { + return CodeEvidence{}, fmt.Errorf("code hit %d requires a path and matched term", i) + } + if hit.CommitSHA != commitSHA { + return CodeEvidence{}, fmt.Errorf("code hit %d commit disagrees with snapshot", i) + } + if strings.TrimSpace(hit.Source.Source) == "" { + return CodeEvidence{}, fmt.Errorf("code hit %d source is required", i) + } + if hit.Source.CommitSHA != "" && hit.Source.CommitSHA != commitSHA { + return CodeEvidence{}, fmt.Errorf("code hit %d source commit disagrees with snapshot", i) + } + } + return CodeEvidence{ + state: codeEvidenceObserved, commitSHA: commitSHA, + queries: append([]string{}, queries...), hits: append([]CodeHit{}, hits...), + source: source, truncated: truncated, + }, nil +} + +func (e CodeEvidence) Present() bool { return e.state == codeEvidenceObserved } +func (e CodeEvidence) CommitSHA() string { return e.commitSHA } +func (e CodeEvidence) Queries() []string { return append([]string{}, e.queries...) } +func (e CodeEvidence) Hits() []CodeHit { return append([]CodeHit{}, e.hits...) } +func (e CodeEvidence) Source() SourceRef { return e.source } +func (e CodeEvidence) Truncated() bool { return e.Present() && e.truncated } + +// HealthMetrics is the measured portion of one offline health report. +type HealthMetrics struct { Archived bool OpenIssues int OpenPullRequests int @@ -125,10 +234,62 @@ type HealthEvidence struct { PullRequestResponseSampleSize int ThreadSampleSize int ThreadsTruncated bool - Sources []SourceRef - UnknownReason string } +// HealthEvidence is either unavailable with a reason or an observed metrics +// snapshot with provenance. The private representation prevents unavailable +// sections from carrying apparently authoritative metric values. +type HealthEvidence struct { + metrics *HealthMetrics + sources []SourceRef + unknownReason string +} + +func MissingHealthEvidence(reason string) HealthEvidence { + return HealthEvidence{unknownReason: strings.TrimSpace(reason)} +} + +func ObservedHealthEvidence(metrics HealthMetrics, sources []SourceRef, unknownReason string) (HealthEvidence, error) { + if len(sources) == 0 { + return HealthEvidence{}, errors.New("health evidence requires provenance") + } + for i, source := range sources { + if strings.TrimSpace(source.Source) == "" { + return HealthEvidence{}, fmt.Errorf("health source %d is required", i) + } + } + if metrics.OpenIssues < 0 || metrics.OpenPullRequests < 0 || metrics.ExternalPRSampleSize < 0 || + metrics.IssueResponseSampleSize < 0 || metrics.PullRequestResponseSampleSize < 0 || metrics.ThreadSampleSize < 0 { + return HealthEvidence{}, errors.New("health counts cannot be negative") + } + if metrics.ExternalPRMergeRate != nil && (*metrics.ExternalPRMergeRate < 0 || *metrics.ExternalPRMergeRate > 1) { + return HealthEvidence{}, errors.New("external pull-request merge rate must be between zero and one") + } + copyMetrics := metrics + if metrics.ExternalPRMergeRate != nil { + value := *metrics.ExternalPRMergeRate + copyMetrics.ExternalPRMergeRate = &value + } + return HealthEvidence{ + metrics: ©Metrics, sources: append([]SourceRef{}, sources...), + unknownReason: strings.TrimSpace(unknownReason), + }, nil +} + +func (e HealthEvidence) Metrics() (HealthMetrics, bool) { + if e.metrics == nil { + return HealthMetrics{}, false + } + metrics := *e.metrics + if metrics.ExternalPRMergeRate != nil { + value := *metrics.ExternalPRMergeRate + metrics.ExternalPRMergeRate = &value + } + return metrics, true +} +func (e HealthEvidence) Sources() []SourceRef { return append([]SourceRef{}, e.sources...) } +func (e HealthEvidence) UnknownReason() string { return e.unknownReason } + // ThreadReader reads one thread and its already stored child facets. type ThreadReader interface { ReadResearchThread(ctx context.Context, ref ThreadRef) (ThreadEvidence, error) diff --git a/internal/research/reader_test.go b/internal/research/reader_test.go new file mode 100644 index 00000000..3eeab620 --- /dev/null +++ b/internal/research/reader_test.go @@ -0,0 +1,110 @@ +package research + +import ( + "testing" + "time" +) + +func TestObservedFacetCoverageRejectsInvalidObservations(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + for name, testCase := range map[string]struct { + facet string + asOf time.Time + count int + }{ + "missing facet": {asOf: now}, + "missing as-of": {facet: "issue_comments"}, + "negative count": {facet: "issue_comments", asOf: now, count: -1}, + } { + t.Run(name, func(t *testing.T) { + if _, err := ObservedFacetCoverage(testCase.facet, true, testCase.asOf, testCase.count, SourceRef{Source: "github:rest"}); err == nil { + t.Fatal("expected invalid facet observation to be rejected") + } + }) + } + if _, err := ObservedFacetCoverage("issue_comments", true, now, 1, SourceRef{Source: "github:rest", AsOf: now.Add(-time.Second)}); err == nil { + t.Fatal("coverage accepted provenance for a different observation time") + } +} + +func TestHealthEvidenceSeparatesUnavailableAndObservedMetrics(t *testing.T) { + missing := MissingHealthEvidence("not computed") + if _, available := missing.Metrics(); available || missing.UnknownReason() != "not computed" || len(missing.Sources()) != 0 { + t.Fatalf("unexpected missing health evidence: %+v", missing) + } + + rate := 0.5 + sources := []SourceRef{{Source: "local:health"}} + observed, err := ObservedHealthEvidence(HealthMetrics{ExternalPRMergeRate: &rate, ExternalPRSampleSize: 2}, sources, "bounded") + if err != nil { + t.Fatal(err) + } + rate = 1 + sources[0].Source = "mutated" + metrics, available := observed.Metrics() + if !available || *metrics.ExternalPRMergeRate != 0.5 || observed.Sources()[0].Source != "local:health" { + t.Fatalf("observed health evidence did not preserve ownership: metrics=%+v sources=%+v", metrics, observed.Sources()) + } + if _, err := ObservedHealthEvidence(HealthMetrics{}, nil, ""); err == nil { + t.Fatal("observed health evidence without provenance was accepted") + } +} + +func TestMissingFacetCoverageCannotBeTruncated(t *testing.T) { + missing, err := MissingFacetCoverage("issue_comments") + if err != nil { + t.Fatalf("construct missing coverage: %v", err) + } + if missing.WithTruncated(true).Truncated() { + t.Fatal("missing facet reported truncation") + } +} + +func TestTruncatedFacetCoverageCannotRemainComplete(t *testing.T) { + now := time.Unix(1, 0).UTC() + coverage, err := ObservedFacetCoverage("issue_comments", true, now, 10, SourceRef{Source: "github:rest", AsOf: now}) + if err != nil { + t.Fatal(err) + } + coverage = coverage.WithTruncated(true) + if !coverage.Present() || !coverage.Truncated() || coverage.Complete() { + t.Fatalf("truncated coverage = present:%t complete:%t truncated:%t", coverage.Present(), coverage.Complete(), coverage.Truncated()) + } +} + +func TestCodeEvidenceSeparatesMissingAndObservedSnapshots(t *testing.T) { + queries := []string{"parser"} + missing := MissingCodeEvidence(queries) + queries[0] = "mutated" + if missing.Present() || missing.CommitSHA() != "" || len(missing.Hits()) != 0 || missing.Queries()[0] != "parser" { + t.Fatalf("unexpected missing evidence: present=%t commit=%q queries=%v hits=%v", missing.Present(), missing.CommitSHA(), missing.Queries(), missing.Hits()) + } + + source := SourceRef{Source: "local:code-index", CommitSHA: "abc123"} + hits := []CodeHit{{ + Path: "parser.go", CommitSHA: "abc123", MatchedTerm: "parser", + Source: SourceRef{Source: "local:code-index", CommitSHA: "abc123"}, + }} + observed, err := ObservedCodeEvidence("abc123", []string{"parser"}, hits, source, true) + if err != nil { + t.Fatalf("construct observed evidence: %v", err) + } + hits[0].Path = "mutated.go" + if !observed.Present() || observed.CommitSHA() != "abc123" || observed.Hits()[0].Path != "parser.go" || !observed.Truncated() { + t.Fatalf("unexpected observed evidence: present=%t commit=%q hits=%v truncated=%t", observed.Present(), observed.CommitSHA(), observed.Hits(), observed.Truncated()) + } +} + +func TestObservedCodeEvidenceRejectsContradictorySnapshotIdentity(t *testing.T) { + source := SourceRef{Source: "local:code-index", CommitSHA: "other"} + if _, err := ObservedCodeEvidence("abc123", nil, nil, source, false); err == nil { + t.Fatal("expected contradictory source commit to be rejected") + } + hit := CodeHit{ + Path: "parser.go", CommitSHA: "other", MatchedTerm: "parser", + Source: SourceRef{Source: "local:code-index", CommitSHA: "other"}, + } + if _, err := ObservedCodeEvidence("abc123", nil, []CodeHit{hit}, SourceRef{Source: "local:code-index", CommitSHA: "abc123"}, false); err == nil { + t.Fatal("expected contradictory hit commit to be rejected") + } +} diff --git a/internal/similarity/candidates.go b/internal/similarity/candidates.go index c0687517..998a2562 100644 --- a/internal/similarity/candidates.go +++ b/internal/similarity/candidates.go @@ -138,8 +138,8 @@ type threadRefKey struct { func duplicateThreadRefKey(ref ThreadRef) threadRefKey { return threadRefKey{ - owner: strings.ToLower(ref.Repo.Owner), - repo: strings.ToLower(ref.Repo.Repo), + owner: strings.ToLower(ref.Repo.Owner()), + repo: strings.ToLower(ref.Repo.Repo()), kind: ref.Kind, number: ref.Number, } } diff --git a/internal/similarity/candidates_test.go b/internal/similarity/candidates_test.go index 5118efa0..85802ca7 100644 --- a/internal/similarity/candidates_test.go +++ b/internal/similarity/candidates_test.go @@ -11,7 +11,7 @@ import ( func TestDuplicateCandidatesKeepEveryQualifyingPair(t *testing.T) { rule := similarity.DefaultDuplicateRule() - repo := domain.RepoRef{Owner: "Owner", Repo: "Repo"} + repo := domain.MustRepoRef("Owner", "Repo") threads := []similarity.ThreadText{ {Ref: similarity.ThreadRef{Repo: repo, Kind: domain.IssueKind, Number: 1}, Title: "login crash"}, {Ref: similarity.ThreadRef{Repo: repo, Kind: domain.IssueKind, Number: 2}, Title: "login failure"}, @@ -62,7 +62,7 @@ func FuzzDuplicateCandidatesKeepQualifyingPairs(f *testing.F) { titleA, titleB = boundedText(titleA), boundedText(titleB) bodyA, bodyB = boundedText(bodyA), boundedText(bodyB) rule := similarity.DefaultDuplicateRule() - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") prepared := []similarity.PreparedDuplicate{ rule.Prepare(similarity.ThreadText{ Ref: similarity.ThreadRef{Repo: repo, Kind: domain.IssueKind, Number: 1}, diff --git a/internal/similarity/duplicate.go b/internal/similarity/duplicate.go index ed31e257..9f276c52 100644 --- a/internal/similarity/duplicate.go +++ b/internal/similarity/duplicate.go @@ -30,11 +30,11 @@ type ThreadRef struct { // Less reports the canonical ordering of thread references. func (r ThreadRef) Less(other ThreadRef) bool { - leftOwner, rightOwner := strings.ToLower(r.Repo.Owner), strings.ToLower(other.Repo.Owner) + leftOwner, rightOwner := strings.ToLower(r.Repo.Owner()), strings.ToLower(other.Repo.Owner()) if leftOwner != rightOwner { return leftOwner < rightOwner } - leftRepo, rightRepo := strings.ToLower(r.Repo.Repo), strings.ToLower(other.Repo.Repo) + leftRepo, rightRepo := strings.ToLower(r.Repo.Repo()), strings.ToLower(other.Repo.Repo()) if leftRepo != rightRepo { return leftRepo < rightRepo } @@ -173,7 +173,7 @@ func (DuplicateRule) signals(a, b PreparedDuplicate) DuplicateSignals { func references(refs []ThreadRef, candidate ThreadRef) bool { for _, ref := range refs { - if ref.Number == candidate.Number && strings.EqualFold(ref.Repo.Owner, candidate.Repo.Owner) && strings.EqualFold(ref.Repo.Repo, candidate.Repo.Repo) && (ref.Kind == "" || ref.Kind == candidate.Kind) { + if ref.Number == candidate.Number && strings.EqualFold(ref.Repo.Owner(), candidate.Repo.Owner()) && strings.EqualFold(ref.Repo.Repo(), candidate.Repo.Repo()) && (ref.Kind == "" || ref.Kind == candidate.Kind) { return true } } diff --git a/internal/similarity/duplicate_test.go b/internal/similarity/duplicate_test.go index 3a813e00..abe15187 100644 --- a/internal/similarity/duplicate_test.go +++ b/internal/similarity/duplicate_test.go @@ -9,7 +9,7 @@ import ( func TestDuplicateV1ExplainsExplicitReference(t *testing.T) { rule := similarity.DefaultDuplicateRule() - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") a := rule.Prepare(similarity.ThreadText{ Ref: similarity.ThreadRef{Repo: repo, Kind: domain.IssueKind, Number: 1}, Title: "bug", diff --git a/internal/similarity/text.go b/internal/similarity/text.go index 91b76bdc..712dd32c 100644 --- a/internal/similarity/text.go +++ b/internal/similarity/text.go @@ -81,7 +81,11 @@ func ExtractRefs(text string, defaultRepo domain.RepoRef) []ThreadRef { if owner == "" || repo == "" || number == 0 { return } - seen[ThreadRef{Repo: domain.RepoRef{Owner: owner, Repo: repo}, Kind: kind, Number: number}] = struct{}{} + ref, err := domain.NewRepoRef(owner, repo) + if err != nil { + return + } + seen[ThreadRef{Repo: ref, Kind: kind, Number: number}] = struct{}{} } for _, match := range repoIssueRefPattern.FindAllStringSubmatch(text, -1) { number, err := strconv.Atoi(match[3]) @@ -106,7 +110,7 @@ func ExtractRefs(text string, defaultRepo domain.RepoRef) []ThreadRef { if err != nil { continue } - add(strings.ToLower(defaultRepo.Owner), strings.ToLower(defaultRepo.Repo), "", number) + add(strings.ToLower(defaultRepo.Owner()), strings.ToLower(defaultRepo.Repo()), "", number) } out := make([]ThreadRef, 0, len(seen)) for ref := range seen { diff --git a/internal/similarity/text_test.go b/internal/similarity/text_test.go index 2f68ca1a..a1115fd0 100644 --- a/internal/similarity/text_test.go +++ b/internal/similarity/text_test.go @@ -22,7 +22,7 @@ func TestTokensNormalizeAndFilterStopWords(t *testing.T) { } func TestExtractRefsRecognizesGitHubThreadSyntax(t *testing.T) { - repo := domain.RepoRef{Owner: "owner", Repo: "repo"} + repo := domain.MustRepoRef("owner", "repo") for _, tc := range []struct { input string want int diff --git a/internal/tracking/service.go b/internal/tracking/service.go index 5c12da53..50deb49d 100644 --- a/internal/tracking/service.go +++ b/internal/tracking/service.go @@ -1,9 +1,12 @@ package tracking import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "strings" "time" @@ -150,6 +153,32 @@ func (s *Service) ExportLocalMetadata(ctx context.Context, opts ExportOptions) ( // ImportLocalMetadata imports a bounded bundle idempotently. func (s *Service) ImportLocalMetadata(ctx context.Context, bundle *Bundle) error { + if err := ValidateBundle(bundle); err != nil { + return err + } + return s.repo.ImportLocalMetadata(ctx, bundle) +} + +// ParseBundle parses and validates the complete import before the caller opens +// or mutates a corpus. +func ParseBundle(data []byte) (*Bundle, error) { + var bundle Bundle + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&bundle); err != nil { + return nil, fmt.Errorf("parse local metadata: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, errors.New("parse local metadata: expected one JSON value") + } + if err := ValidateBundle(&bundle); err != nil { + return nil, err + } + return &bundle, nil +} + +// ValidateBundle validates every record before an import transaction begins. +func ValidateBundle(bundle *Bundle) error { if bundle == nil { return errors.New("bundle is required") } @@ -203,7 +232,7 @@ func (s *Service) ImportLocalMetadata(ctx context.Context, bundle *Bundle) error return fmt.Errorf("evidence %q: %w", item.ID, err) } } - return s.repo.ImportLocalMetadata(ctx, bundle) + return nil } // ResolveBundleVersion rejects any bundle that does not declare the current diff --git a/internal/tracking/service_test.go b/internal/tracking/service_test.go new file mode 100644 index 00000000..eb9e030b --- /dev/null +++ b/internal/tracking/service_test.go @@ -0,0 +1,21 @@ +package tracking + +import ( + "fmt" + "testing" +) + +func TestParseBundleRejectsUnknownFieldsAndTrailingValues(t *testing.T) { + valid := fmt.Sprintf(`{"schema_version":%d}`, CurrentBundleSchemaVersion) + if _, err := ParseBundle([]byte(valid)); err != nil { + t.Fatalf("parse minimal bundle: %v", err) + } + for _, payload := range []string{ + fmt.Sprintf(`{"schema_version":%d,"unexpected":true}`, CurrentBundleSchemaVersion), + valid + `{}`, + } { + if _, err := ParseBundle([]byte(payload)); err == nil { + t.Fatalf("invalid bundle was accepted: %s", payload) + } + } +}