From b8441f3ee4d04b9dfd455137489661123a5a6a08 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:29:50 +0800 Subject: [PATCH 1/8] feat(corpus): add first-class actor projections --- internal/app/corpus_lifecycle_test.go | 2 +- internal/corpus/actor_facets.go | 386 +++++++++++ internal/corpus/actors.go | 621 ++++++++++++++++++ internal/corpus/actors_test.go | 199 ++++++ internal/corpus/migration_test.go | 39 ++ .../corpus/migrations/014_actor_corpus.sql | 242 +++++++ internal/corpus/projections_test.go | 6 +- 7 files changed, 1491 insertions(+), 4 deletions(-) create mode 100644 internal/corpus/actor_facets.go create mode 100644 internal/corpus/actors.go create mode 100644 internal/corpus/actors_test.go create mode 100644 internal/corpus/migrations/014_actor_corpus.sql diff --git a/internal/app/corpus_lifecycle_test.go b/internal/app/corpus_lifecycle_test.go index e348c0b..7f65188 100644 --- a/internal/app/corpus_lifecycle_test.go +++ b/internal/app/corpus_lifecycle_test.go @@ -224,7 +224,7 @@ func TestListCorpusInventoryCombinesSchemaRepositoriesAndProjections(t *testing. if result.Schema == nil || result.Schema.State != "current" || len(result.Repositories) != 1 || result.Repositories[0].Repo != "owner/repo" { t.Fatalf("inventory = %+v", result) } - if len(result.Projections) != 5 || result.DatabaseBytes == 0 || result.SizeAttribution == "" { + if len(result.Projections) != 6 || result.DatabaseBytes == 0 || result.SizeAttribution == "" { t.Fatalf("inventory metadata = %+v", result) } } diff --git a/internal/corpus/actor_facets.go b/internal/corpus/actor_facets.go new file mode 100644 index 0000000..f5c66f4 --- /dev/null +++ b/internal/corpus/actor_facets.go @@ -0,0 +1,386 @@ +package corpus + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" +) + +type ActorSocialAccount struct{ Provider, URL, DisplayName string } +type ActorOrganization struct{ NodeID, Login string } +type ActorPinnedItem struct { + Rank int + Kind, NodeID, Name, RepositoryOwner, ShowcaseKind string +} +type ActorRepositoryAffiliation struct { + RepositoryID int64 + Relationship string +} +type ActorContributionDay struct { + Date string + Count int + Level string +} +type ActorContributionItem struct { + Kind string + OccurredAt time.Time + RepositoryID *int64 + TargetNodeID, TargetURL string + Restricted bool + Count int +} +type ActorRepositoryContributionTotal struct { + RepositoryID int64 + Kind string + Count int +} +type ActorContributionPeriodInput struct { + ActorID int64 + From, To time.Time + OrganizationNodeID, AuthorizationScope string + TotalCommits, TotalIssues, TotalPullRequests, TotalPullRequestReviews, TotalRepositories, RestrictedContributions *int + Complete bool + ObservedAt, SourceUpdatedAt time.Time + Days []ActorContributionDay + Items []ActorContributionItem + RepositoryTotals []ActorRepositoryContributionTotal + RawPayload json.RawMessage +} + +// GetActorContributionCoverage returns a complete contribution period in the +// exact organization scope that contains the requested bounded range. +func (c *Corpus) GetActorContributionCoverage(ctx context.Context, actorID int64, organizationNodeID string, from, to time.Time) (*ActorFacetCoverage, error) { + if from.IsZero() || to.IsZero() || !to.After(from) { + return nil, nil + } + var coverage ActorFacetCoverage + var complete bool + var sourceUpdated, observedAt int64 + err := c.db.QueryRowContext(ctx, ` + SELECT complete, source_updated_at, observation_sequence, observed_at, authorization_scope + FROM actor_contribution_periods + WHERE actor_id=? AND organization_node_id=? AND period_start<=? AND period_end>=? + ORDER BY (period_end-period_start) ASC, source_updated_at DESC, observation_sequence DESC LIMIT 1 + `, actorID, organizationNodeID, encodeTime(from), encodeTime(to)).Scan(&complete, &sourceUpdated, &coverage.ObservationSequence, &observedAt, &coverage.AuthorizationScope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get actor contribution coverage: %w", err) + } + if !complete { + return nil, nil + } + coverage.Facet = "contributions" + coverage.Complete = true + coverage.SourceUpdatedAt, coverage.ObservedAt = scanTime(sourceUpdated), scanTime(observedAt) + return &coverage, nil +} + +func (c *Corpus) ReplaceActorSocialAccounts(ctx context.Context, actorID int64, items []ActorSocialAccount, complete bool, observedAt, sourceUpdatedAt time.Time, authScope string, raw json.RawMessage) error { + return c.applyActorFacetSet(ctx, actorID, "social_accounts", complete, observedAt, sourceUpdatedAt, authScope, raw, func(tx *sql.Tx, sequence int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM actor_social_accounts WHERE actor_id=?`, actorID); err != nil { + return err + } + for _, item := range items { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_social_accounts(actor_id,provider_name,url,display_name,source_updated_at,observation_sequence,observed_at) VALUES(?,?,?,?,?,?,?)`, actorID, item.Provider, item.URL, item.DisplayName, encodeTime(sourceUpdatedAt), sequence, encodeTime(observedAt)); err != nil { + return err + } + } + return nil + }) +} + +func (c *Corpus) ReplaceActorOrganizations(ctx context.Context, actorID int64, items []ActorOrganization, complete bool, observedAt, sourceUpdatedAt time.Time, authScope string, raw json.RawMessage) error { + return c.applyActorFacetSet(ctx, actorID, "organizations", complete, observedAt, sourceUpdatedAt, authScope, raw, func(tx *sql.Tx, sequence int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM actor_organization_memberships WHERE actor_id=?`, actorID); err != nil { + return err + } + for _, item := range items { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_organization_memberships(actor_id,organization_node_id,organization_login,source_updated_at,observation_sequence,observed_at) VALUES(?,?,?,?,?,?)`, actorID, item.NodeID, item.Login, encodeTime(sourceUpdatedAt), sequence, encodeTime(observedAt)); err != nil { + return err + } + } + return nil + }) +} + +func (c *Corpus) ReplaceActorPinnedItems(ctx context.Context, actorID int64, items []ActorPinnedItem, complete bool, observedAt, sourceUpdatedAt time.Time, authScope string, raw json.RawMessage) error { + return c.applyActorFacetSet(ctx, actorID, "pinned_items", complete, observedAt, sourceUpdatedAt, authScope, raw, func(tx *sql.Tx, sequence int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM actor_pinned_items WHERE actor_id=?`, actorID); err != nil { + return err + } + for _, item := range items { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_pinned_items(actor_id,rank,item_kind,target_node_id,target_name,repository_owner,showcase_kind,source_updated_at,observation_sequence,observed_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, actorID, item.Rank, item.Kind, item.NodeID, item.Name, item.RepositoryOwner, item.ShowcaseKind, encodeTime(sourceUpdatedAt), sequence, encodeTime(observedAt)); err != nil { + return err + } + } + return nil + }) +} + +func (c *Corpus) ReplaceActorRepositoryAffiliations(ctx context.Context, actorID int64, relationship string, items []ActorRepositoryAffiliation, complete bool, observedAt, sourceUpdatedAt time.Time, authScope string, raw json.RawMessage) error { + facet := "repositories:" + relationship + return c.applyActorFacetSet(ctx, actorID, facet, complete, observedAt, sourceUpdatedAt, authScope, raw, func(tx *sql.Tx, sequence int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM actor_repository_affiliations WHERE actor_id=? AND relationship=?`, actorID, relationship); err != nil { + return err + } + for _, item := range items { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_repository_affiliations(actor_id,repository_id,relationship,source_updated_at,observation_sequence,observed_at) VALUES(?,?,?,?,?,?)`, actorID, item.RepositoryID, relationship, encodeTime(sourceUpdatedAt), sequence, encodeTime(observedAt)); err != nil { + return err + } + } + return nil + }) +} + +func (c *Corpus) ApplyActorContributionPeriod(ctx context.Context, input ActorContributionPeriodInput) error { + if input.AuthorizationScope == "" { + input.AuthorizationScope = "public" + } + if input.ObservedAt.IsZero() { + input.ObservedAt = time.Now().UTC() + } + payload := input.RawPayload + if len(payload) == 0 { + payload = []byte(`{}`) + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + sequence, err := c.nextSequence(ctx, tx) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_observations(actor_id,facet,source_updated_at,observation_sequence,observed_at,complete,authorization_scope,payload) VALUES(?, 'contributions', ?, ?, ?, ?, ?, ?)`, input.ActorID, encodeTime(input.SourceUpdatedAt), sequence, encodeTime(input.ObservedAt), boolToInt(input.Complete), input.AuthorizationScope, string(payload)); err != nil { + return err + } + var existingSource, existingSequence int64 + err = tx.QueryRowContext(ctx, `SELECT source_updated_at,observation_sequence FROM actor_contribution_periods WHERE actor_id=? AND period_start=? AND period_end=? AND organization_node_id=? AND authorization_scope=?`, input.ActorID, encodeTime(input.From), encodeTime(input.To), input.OrganizationNodeID, input.AuthorizationScope).Scan(&existingSource, &existingSequence) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil && !orderingNewer(encodeTime(input.SourceUpdatedAt), sequence, existingSource, existingSequence) { + return tx.Commit() + } + var periodID int64 + err = tx.QueryRowContext(ctx, `INSERT INTO actor_contribution_periods(actor_id,period_start,period_end,organization_node_id,authorization_scope,total_commits,total_issues,total_pull_requests,total_pull_request_reviews,total_repositories,restricted_contributions,complete,source_updated_at,observation_sequence,observed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(actor_id,period_start,period_end,organization_node_id,authorization_scope) DO UPDATE SET total_commits=excluded.total_commits,total_issues=excluded.total_issues,total_pull_requests=excluded.total_pull_requests,total_pull_request_reviews=excluded.total_pull_request_reviews,total_repositories=excluded.total_repositories,restricted_contributions=excluded.restricted_contributions,complete=excluded.complete,source_updated_at=excluded.source_updated_at,observation_sequence=excluded.observation_sequence,observed_at=excluded.observed_at RETURNING id`, input.ActorID, encodeTime(input.From), encodeTime(input.To), input.OrganizationNodeID, input.AuthorizationScope, input.TotalCommits, input.TotalIssues, input.TotalPullRequests, input.TotalPullRequestReviews, input.TotalRepositories, input.RestrictedContributions, boolToInt(input.Complete), encodeTime(input.SourceUpdatedAt), sequence, encodeTime(input.ObservedAt)).Scan(&periodID) + if err != nil { + return fmt.Errorf("upsert actor contribution period: %w", err) + } + if !input.Complete { + return tx.Commit() + } + for _, statement := range []string{ + `DELETE FROM actor_contribution_days WHERE period_id=?`, + `DELETE FROM actor_contribution_items WHERE period_id=?`, + `DELETE FROM actor_repository_contribution_totals WHERE period_id=?`, + } { + if _, err := tx.ExecContext(ctx, statement, periodID); err != nil { + return err + } + } + for _, day := range input.Days { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_contribution_days(period_id,contribution_date,contribution_count,contribution_level) VALUES(?,?,?,?)`, periodID, day.Date, day.Count, day.Level); err != nil { + return err + } + } + for _, item := range input.Items { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_contribution_items(period_id,contribution_kind,occurred_at,repository_id,target_node_id,target_url,restricted,count) VALUES(?,?,?,?,?,?,?,?)`, periodID, item.Kind, encodeTime(item.OccurredAt), item.RepositoryID, item.TargetNodeID, item.TargetURL, boolToInt(item.Restricted), item.Count); err != nil { + return err + } + } + for _, total := range input.RepositoryTotals { + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_repository_contribution_totals(period_id,repository_id,contribution_kind,contribution_count) VALUES(?,?,?,?)`, periodID, total.RepositoryID, total.Kind, total.Count); err != nil { + return err + } + } + return tx.Commit() +} + +func (c *Corpus) applyActorFacetSet(ctx context.Context, actorID int64, facet string, complete bool, observedAt, sourceUpdatedAt time.Time, authScope string, raw json.RawMessage, replace func(*sql.Tx, int64) error) error { + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + if authScope == "" { + authScope = "public" + } + if len(raw) == 0 { + raw = []byte(`{}`) + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + sequence, err := c.nextSequence(ctx, tx) + if err != nil { + return err + } + var existingSource, existingSequence int64 + err = tx.QueryRowContext(ctx, `SELECT source_updated_at,observation_sequence FROM actor_observations WHERE actor_id=? AND facet=? AND complete=1 ORDER BY source_updated_at DESC,observation_sequence DESC LIMIT 1`, actorID, facet).Scan(&existingSource, &existingSequence) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO actor_observations(actor_id,facet,source_updated_at,observation_sequence,observed_at,complete,authorization_scope,payload) VALUES(?,?,?,?,?,?,?,?)`, actorID, facet, encodeTime(sourceUpdatedAt), sequence, encodeTime(observedAt), boolToInt(complete), authScope, string(raw)); err != nil { + return err + } + if !complete || (err == nil && !orderingNewer(encodeTime(sourceUpdatedAt), sequence, existingSource, existingSequence)) { + return tx.Commit() + } + if err := replace(tx, sequence); err != nil { + return fmt.Errorf("replace actor %s facet: %w", facet, err) + } + return tx.Commit() +} + +func orderingNewer(source, sequence, currentSource, currentSequence int64) bool { + return source > currentSource || (source == currentSource && sequence > currentSequence) +} + +type ContributionSearchOptions struct { + ActorRefs []string + RepositoryRefs []string + Kinds []string + OrganizationNodeID string + From, To time.Time + Sort, Order string + Limit int + Cursor string +} +type ContributionSearchItem struct { + ActorKey, Login, Kind string + OccurredAt time.Time + RepositoryRef, TargetNodeID, TargetURL string + Restricted bool + Count int +} +type ContributionSearchPage struct { + Items []ContributionSearchItem + Total int + NextCursor string +} + +func (c *Corpus) SearchActorContributions(ctx context.Context, opts ContributionSearchOptions) (ContributionSearchPage, error) { + if opts.Limit == 0 { + opts.Limit = 20 + } + if opts.Limit < 1 || opts.Limit > 100 { + return ContributionSearchPage{}, errors.New("contribution search limit must be 1 to 100") + } + if opts.Sort == "" { + opts.Sort = "occurred_at" + } + if opts.Order == "" { + opts.Order = "desc" + } + if opts.Sort != "occurred_at" && opts.Sort != "repository" && opts.Sort != "type" { + return ContributionSearchPage{}, errors.New("unsupported contribution sort") + } + if opts.Order != "asc" && opts.Order != "desc" { + return ContributionSearchPage{}, errors.New("contribution order must be asc or desc") + } + offset := 0 + if opts.Cursor != "" { + cursor, err := decodeCursor(opts.Cursor) + if err != nil || cursor.Scope != "actor_contributions" || cursor.Filter != contributionFilterKey(opts) { + return ContributionSearchPage{}, errors.New("invalid contribution cursor") + } + offset = int(cursor.ID) + } + where := " WHERE p.organization_node_id=?" + args := []any{opts.OrganizationNodeID} + if len(opts.ActorRefs) > 0 { + placeholders := make([]string, len(opts.ActorRefs)) + for i := range opts.ActorRefs { + placeholders[i] = "?" + } + where += ` AND (a.actor_key IN (` + strings.Join(placeholders, ",") + `) OR a.node_id IN (` + strings.Join(placeholders, ",") + `) OR a.id IN (SELECT actor_id FROM actor_aliases WHERE normalized_login IN (` + strings.Join(placeholders, ",") + `)))` + for _, ref := range opts.ActorRefs { + args = append(args, strings.TrimSpace(ref)) + } + for _, ref := range opts.ActorRefs { + args = append(args, strings.TrimSpace(ref)) + } + for _, ref := range opts.ActorRefs { + args = append(args, normalizeLogin(ref)) + } + } + if len(opts.Kinds) > 0 { + p := make([]string, len(opts.Kinds)) + for i, kind := range opts.Kinds { + p[i] = "?" + args = append(args, kind) + } + where += ` AND i.contribution_kind IN (` + strings.Join(p, ",") + `)` + } + if len(opts.RepositoryRefs) > 0 { + p := make([]string, len(opts.RepositoryRefs)) + for i, ref := range opts.RepositoryRefs { + p[i] = "?" + args = append(args, strings.ToLower(strings.TrimSpace(ref))) + } + where += ` AND lower(COALESCE(r.owner||'/'||r.name,'')) IN (` + strings.Join(p, ",") + `)` + } + if !opts.From.IsZero() { + where += ` AND i.occurred_at>=?` + args = append(args, encodeTime(opts.From)) + } + if !opts.To.IsZero() { + where += ` AND i.occurred_at opts.Limit { + page.Items = page.Items[:opts.Limit] + page.NextCursor = encodeCursor(searchCursor{Scope: "actor_contributions", Filter: contributionFilterKey(opts), ID: int64(offset + opts.Limit)}) + } + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM (`+projection+`)`, args...).Scan(&page.Total); err != nil { + return page, err + } + return page, nil +} + +func contributionFilterKey(opts ContributionSearchOptions) string { + actors := append([]string(nil), opts.ActorRefs...) + repositories := append([]string(nil), opts.RepositoryRefs...) + kinds := append([]string(nil), opts.Kinds...) + for i := range actors { + actors[i] = normalizeLogin(actors[i]) + } + for i := range repositories { + repositories[i] = strings.ToLower(strings.TrimSpace(repositories[i])) + } + slices.Sort(actors) + slices.Sort(repositories) + slices.Sort(kinds) + return strings.Join([]string{opts.Sort, opts.Order, opts.OrganizationNodeID, strings.Join(actors, ","), strings.Join(repositories, ","), strings.Join(kinds, ","), strconv.FormatInt(encodeTime(opts.From), 10), strconv.FormatInt(encodeTime(opts.To), 10)}, "|") +} diff --git a/internal/corpus/actors.go b/internal/corpus/actors.go new file mode 100644 index 0000000..2fd97bc --- /dev/null +++ b/internal/corpus/actors.go @@ -0,0 +1,621 @@ +package corpus + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" +) + +const ProjectionNameActorsFTS = "actors_fts" +const ProjectionVersionActorsFTS = "actors-fts-v1" + +// Actor is the current normalized projection of one GitHub actor. +type Actor struct { + ID int64 + Key string + Provider string + NodeID string + DatabaseID *int64 + Kind string + Login string + SourceUpdatedAt time.Time + ObservationSequence int64 + CreatedAt time.Time + UpdatedAt time.Time + Profile *ActorProfile + Rank float64 +} + +// ActorProfile contains nullable public profile facts. Pointer scalars retain +// the distinction between a known zero/false value and an unavailable field. +type ActorProfile struct { + Name *string + AvatarURL *string + Bio *string + Company *string + Location *string + WebsiteURL *string + PublicEmail *string + TwitterUsername *string + Hireable *bool + Followers *int + Following *int + PublicRepositories *int + PublicGists *int + ProviderCreatedAt time.Time + SourceUpdatedAt time.Time + ObservationSequence int64 + ObservedAt time.Time + AuthorizationScope string +} + +// ActorProfileObservation is one source-backed profile header observation. +type ActorProfileObservation struct { + Provider string + NodeID string + DatabaseID *int64 + Kind string + Login string + Profile ActorProfile + SourceUpdatedAt time.Time + ObservedAt time.Time + AuthorizationScope string + RawPayload json.RawMessage +} + +// ActorSearchOptions scopes one local actor search page. +type ActorSearchOptions struct { + Query string + Kinds []string + Sort string + Limit int + Cursor string +} + +// ActorSearchPage is one bounded local result page. +type ActorSearchPage struct { + Actors []Actor + NextCursor string + Total int +} + +type ActorFacetCoverage struct { + Facet string + Complete bool + SourceUpdatedAt time.Time + ObservationSequence int64 + ObservedAt time.Time + AuthorizationScope string +} + +type ActorFacetObservation struct { + ActorFacetCoverage + Payload json.RawMessage +} + +func (c *Corpus) GetActorFacetObservation(ctx context.Context, actorID int64, facet string) (*ActorFacetObservation, error) { + var observation ActorFacetObservation + var sourceUpdated, observedAt int64 + var payload string + err := c.db.QueryRowContext(ctx, ` + SELECT facet,complete,source_updated_at,observation_sequence,observed_at,authorization_scope,payload + FROM actor_observations WHERE actor_id=? AND facet=? ORDER BY observation_sequence DESC LIMIT 1 + `, actorID, facet).Scan(&observation.Facet, &observation.Complete, &sourceUpdated, &observation.ObservationSequence, &observedAt, &observation.AuthorizationScope, &payload) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get actor facet observation: %w", err) + } + observation.SourceUpdatedAt, observation.ObservedAt = scanTime(sourceUpdated), scanTime(observedAt) + observation.Payload = json.RawMessage(payload) + return &observation, nil +} + +// ListActorFacetCoverage returns the newest observation for each requested +// facet without inferring completeness from child row counts. +func (c *Corpus) ListActorFacetCoverage(ctx context.Context, actorID int64, facets []string) (map[string]ActorFacetCoverage, error) { + if len(facets) == 0 { + return map[string]ActorFacetCoverage{}, nil + } + placeholders := make([]string, len(facets)) + args := []any{actorID} + for index, facet := range facets { + placeholders[index] = "?" + args = append(args, facet) + } + rows, err := c.db.QueryContext(ctx, ` + SELECT ao.facet, ao.complete, ao.source_updated_at, ao.observation_sequence, ao.observed_at, ao.authorization_scope + FROM actor_observations ao + JOIN ( + SELECT facet, MAX(observation_sequence) AS sequence + FROM actor_observations WHERE actor_id=? AND facet IN (`+strings.Join(placeholders, ",")+`) + GROUP BY facet + ) latest ON latest.facet=ao.facet AND latest.sequence=ao.observation_sequence + WHERE ao.actor_id=? + `, append(args, actorID)...) + if err != nil { + return nil, fmt.Errorf("list actor facet coverage: %w", err) + } + defer func() { _ = rows.Close() }() + out := make(map[string]ActorFacetCoverage, len(facets)) + for rows.Next() { + var coverage ActorFacetCoverage + var sourceUpdated, observedAt int64 + if err := rows.Scan(&coverage.Facet, &coverage.Complete, &sourceUpdated, &coverage.ObservationSequence, &observedAt, &coverage.AuthorizationScope); err != nil { + return nil, err + } + coverage.SourceUpdatedAt, coverage.ObservedAt = scanTime(sourceUpdated), scanTime(observedAt) + out[coverage.Facet] = coverage + } + return out, rows.Err() +} + +// ApplyActorIdentityObservation records a search or relationship identity stub +// without claiming complete profile coverage. +func (c *Corpus) ApplyActorIdentityObservation(ctx context.Context, provider, login, nodeID string, databaseID *int64, kind, authorizationScope string, observedAt time.Time, raw json.RawMessage) (_ Actor, returnErr error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + login = strings.TrimSpace(login) + if provider == "" || login == "" { + return Actor{}, errors.New("actor provider and login are required") + } + if kind == "" { + kind = "unknown" + } + if authorizationScope == "" { + authorizationScope = "public" + } + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + if len(raw) == 0 { + raw = json.RawMessage(`{}`) + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return Actor{}, fmt.Errorf("begin actor identity observation: %w", err) + } + defer func() { _ = tx.Rollback() }() + input := ActorProfileObservation{Provider: provider, Login: login, NodeID: nodeID, DatabaseID: databaseID, Kind: kind, ObservedAt: observedAt} + actorID, err := resolveActorID(ctx, tx, input, encodeTime(observedAt)) + if err != nil { + return Actor{}, err + } + sequence, err := c.nextSequence(ctx, tx) + if err != nil { + return Actor{}, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_observations + (actor_id, facet, source_updated_at, observation_sequence, observed_at, complete, authorization_scope, payload) + VALUES (?, 'identity_search', 0, ?, ?, 0, ?, ?) + `, actorID, sequence, encodeTime(observedAt), authorizationScope, string(raw)); err != nil { + return Actor{}, fmt.Errorf("insert actor identity observation: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + UPDATE actors SET actor_key=?, node_id=NULLIF(?,''), database_id=?, kind=?, current_login=?, + observation_sequence=?, updated_at=? WHERE id=? + `, actorKey(provider, nodeID, login), nodeID, databaseID, kind, login, sequence, encodeTime(observedAt), actorID); err != nil { + return Actor{}, fmt.Errorf("advance actor identity: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) + VALUES (?, ?, ?, 1, ?, ?) + ON CONFLICT(actor_id, normalized_login) DO UPDATE SET login=excluded.login, active=1, last_observed_at=excluded.last_observed_at + `, actorID, login, normalizeLogin(login), encodeTime(observedAt), encodeTime(observedAt)); err != nil { + return Actor{}, fmt.Errorf("upsert actor identity alias: %w", err) + } + if err := refreshActorFTS(ctx, tx, actorID); err != nil { + return Actor{}, fmt.Errorf("refresh actor search row: %w", err) + } + if err := tx.Commit(); err != nil { + return Actor{}, fmt.Errorf("commit actor identity: %w", err) + } + actor, err := c.GetActorByID(ctx, actorID) + if err != nil { + return Actor{}, err + } + if actor == nil { + return Actor{}, errors.New("actor missing after identity observation") + } + return *actor, nil +} + +// ApplyActorProfileObservation appends the provider observation and advances +// current actor/profile projections only when the source ordering wins. +func (c *Corpus) ApplyActorProfileObservation(ctx context.Context, input ActorProfileObservation) (Actor, error) { + input.Provider = strings.ToLower(strings.TrimSpace(input.Provider)) + input.Login = strings.TrimSpace(input.Login) + input.NodeID = strings.TrimSpace(input.NodeID) + if input.Provider == "" || input.Login == "" { + return Actor{}, errors.New("actor provider and login are required") + } + if input.Kind == "" { + input.Kind = "unknown" + } + if input.ObservedAt.IsZero() { + input.ObservedAt = time.Now().UTC() + } + if input.AuthorizationScope == "" { + input.AuthorizationScope = "public" + } + payload := input.RawPayload + if len(payload) == 0 { + encoded, err := json.Marshal(input) + if err != nil { + return Actor{}, fmt.Errorf("encode actor observation: %w", err) + } + payload = encoded + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return Actor{}, fmt.Errorf("begin actor observation: %w", err) + } + defer func() { _ = tx.Rollback() }() + actorID, err := resolveActorID(ctx, tx, input, encodeTime(input.ObservedAt)) + if err != nil { + return Actor{}, err + } + sequence, err := c.nextSequence(ctx, tx) + if err != nil { + return Actor{}, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_observations + (actor_id, facet, source_updated_at, observation_sequence, observed_at, complete, authorization_scope, payload) + VALUES (?, 'profile', ?, ?, ?, 1, ?, ?) + `, actorID, encodeTime(input.SourceUpdatedAt), sequence, encodeTime(input.ObservedAt), input.AuthorizationScope, string(payload)); err != nil { + return Actor{}, fmt.Errorf("insert actor observation: %w", err) + } + actorKey := actorKey(input.Provider, input.NodeID, input.Login) + if _, err := tx.ExecContext(ctx, ` + UPDATE actors SET + actor_key = ?, node_id = NULLIF(?, ''), database_id = ?, kind = ?, current_login = ?, + source_updated_at = ?, observation_sequence = ?, updated_at = ? + WHERE id = ? AND + (source_updated_at < ? OR (source_updated_at = ? AND observation_sequence < ?)) + `, actorKey, input.NodeID, input.DatabaseID, input.Kind, input.Login, + encodeTime(input.SourceUpdatedAt), sequence, encodeTime(input.ObservedAt), actorID, + encodeTime(input.SourceUpdatedAt), encodeTime(input.SourceUpdatedAt), sequence); err != nil { + return Actor{}, fmt.Errorf("advance actor projection: %w", err) + } + profile := input.Profile + profile.SourceUpdatedAt = input.SourceUpdatedAt + profile.ObservationSequence = sequence + profile.ObservedAt = input.ObservedAt + profile.AuthorizationScope = input.AuthorizationScope + if _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_profiles + (actor_id, name, avatar_url, bio, company, location, website_url, public_email, twitter_username, + hireable, followers, following, public_repositories, public_gists, provider_created_at, + source_updated_at, observation_sequence, observed_at, authorization_scope) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(actor_id) DO UPDATE SET + name=excluded.name, avatar_url=excluded.avatar_url, bio=excluded.bio, company=excluded.company, + location=excluded.location, website_url=excluded.website_url, public_email=excluded.public_email, + twitter_username=excluded.twitter_username, hireable=excluded.hireable, followers=excluded.followers, + following=excluded.following, public_repositories=excluded.public_repositories, public_gists=excluded.public_gists, + provider_created_at=excluded.provider_created_at, source_updated_at=excluded.source_updated_at, + observation_sequence=excluded.observation_sequence, observed_at=excluded.observed_at, + authorization_scope=excluded.authorization_scope + WHERE actor_profiles.source_updated_at < excluded.source_updated_at OR + (actor_profiles.source_updated_at = excluded.source_updated_at AND + actor_profiles.observation_sequence < excluded.observation_sequence) + `, actorID, profile.Name, profile.AvatarURL, profile.Bio, profile.Company, profile.Location, + profile.WebsiteURL, profile.PublicEmail, profile.TwitterUsername, nullableBool(profile.Hireable), + profile.Followers, profile.Following, profile.PublicRepositories, profile.PublicGists, + encodeTime(profile.ProviderCreatedAt), encodeTime(profile.SourceUpdatedAt), profile.ObservationSequence, + encodeTime(profile.ObservedAt), profile.AuthorizationScope); err != nil { + return Actor{}, fmt.Errorf("advance actor profile: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) + VALUES (?, ?, ?, 1, ?, ?) + ON CONFLICT(actor_id, normalized_login) DO UPDATE SET + login=excluded.login, active=1, last_observed_at=excluded.last_observed_at + `, actorID, input.Login, normalizeLogin(input.Login), encodeTime(input.ObservedAt), encodeTime(input.ObservedAt)); err != nil { + return Actor{}, fmt.Errorf("upsert actor alias: %w", err) + } + if err := refreshActorFTS(ctx, tx, actorID); err != nil { + return Actor{}, fmt.Errorf("refresh actor search row: %w", err) + } + if err := tx.Commit(); err != nil { + return Actor{}, fmt.Errorf("commit actor observation: %w", err) + } + actor, err := c.GetActorByID(ctx, actorID) + if err != nil { + return Actor{}, err + } + if actor == nil { + return Actor{}, errors.New("actor projection missing after commit") + } + return *actor, nil +} + +func resolveActorID(ctx context.Context, tx *sql.Tx, input ActorProfileObservation, observedAt int64) (int64, error) { + var id int64 + if input.NodeID != "" { + err := tx.QueryRowContext(ctx, `SELECT id FROM actors WHERE provider=? AND node_id=?`, input.Provider, input.NodeID).Scan(&id) + if err == nil { + return id, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("resolve actor node id: %w", err) + } + } + err := tx.QueryRowContext(ctx, ` + SELECT a.id FROM actor_aliases aa JOIN actors a ON a.id=aa.actor_id + WHERE a.provider=? AND aa.normalized_login=? AND aa.active=1 + ORDER BY aa.last_observed_at DESC, a.id DESC LIMIT 1 + `, input.Provider, normalizeLogin(input.Login)).Scan(&id) + if err == nil { + return id, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("resolve actor alias: %w", err) + } + result, err := tx.ExecContext(ctx, ` + INSERT INTO actors + (actor_key, provider, node_id, database_id, kind, current_login, created_at, updated_at) + VALUES (?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?) + `, actorKey(input.Provider, input.NodeID, input.Login), input.Provider, input.NodeID, + input.DatabaseID, input.Kind, input.Login, observedAt, observedAt) + if err != nil { + return 0, fmt.Errorf("insert actor: %w", err) + } + id, err = result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("read actor id: %w", err) + } + return id, nil +} + +func actorKey(provider, nodeID, login string) string { + if nodeID != "" { + return provider + ":node:" + nodeID + } + return provider + ":login:" + normalizeLogin(login) +} + +func normalizeLogin(login string) string { return strings.ToLower(strings.TrimSpace(login)) } + +func refreshActorFTS(ctx context.Context, tx *sql.Tx, actorID int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM actors_fts WHERE actor_id=?`, actorID); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO actors_fts (actor_id, login, name, bio, company, location) + SELECT a.id, a.current_login, COALESCE(p.name,''), COALESCE(p.bio,''), COALESCE(p.company,''), COALESCE(p.location,'') + FROM actors a LEFT JOIN actor_profiles p ON p.actor_id=a.id WHERE a.id=? + `, actorID) + return err +} + +func nullableBool(value *bool) any { + if value == nil { + return nil + } + if *value { + return 1 + } + return 0 +} + +// GetActorByID reads one current actor projection by local row identity. +func (c *Corpus) GetActorByID(ctx context.Context, id int64) (*Actor, error) { + row := c.db.QueryRowContext(ctx, actorSelect+` WHERE a.id=?`, id) + actor, err := scanActor(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get actor: %w", err) + } + return &actor, nil +} + +// GetActor resolves a product actor key, stable node id, or observed login. +func (c *Corpus) GetActor(ctx context.Context, ref string) (*Actor, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, errors.New("actor reference is required") + } + row := c.db.QueryRowContext(ctx, actorSelect+` + LEFT JOIN actor_aliases lookup_alias ON lookup_alias.actor_id=a.id + WHERE a.actor_key=? OR a.node_id=? OR lookup_alias.normalized_login=? + ORDER BY lookup_alias.active DESC, lookup_alias.last_observed_at DESC, a.id DESC LIMIT 1 + `, ref, ref, normalizeLogin(ref)) + actor, err := scanActor(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("resolve actor: %w", err) + } + return &actor, nil +} + +const actorSelect = ` + SELECT a.id, a.actor_key, a.provider, COALESCE(a.node_id,''), a.database_id, a.kind, a.current_login, + a.source_updated_at, a.observation_sequence, a.created_at, a.updated_at, + p.name, p.avatar_url, p.bio, p.company, p.location, p.website_url, p.public_email, + p.twitter_username, p.hireable, p.followers, p.following, p.public_repositories, + p.public_gists, p.provider_created_at, p.source_updated_at, p.observation_sequence, + p.observed_at, p.authorization_scope + FROM actors a LEFT JOIN actor_profiles p ON p.actor_id=a.id` + +type actorRowScanner interface{ Scan(...any) error } + +func scanActor(row actorRowScanner) (Actor, error) { + var actor Actor + var databaseID sql.NullInt64 + var sourceUpdated, createdAt, updatedAt int64 + var name, avatar, bio, company, location, website, email, twitter sql.NullString + var hireable sql.NullBool + var followers, following, repos, gists sql.NullInt64 + var providerCreated, profileSourceUpdated, profileObserved sql.NullInt64 + var profileSequence sql.NullInt64 + var authScope sql.NullString + err := row.Scan(&actor.ID, &actor.Key, &actor.Provider, &actor.NodeID, &databaseID, &actor.Kind, &actor.Login, + &sourceUpdated, &actor.ObservationSequence, &createdAt, &updatedAt, + &name, &avatar, &bio, &company, &location, &website, &email, &twitter, &hireable, + &followers, &following, &repos, &gists, &providerCreated, &profileSourceUpdated, + &profileSequence, &profileObserved, &authScope) + if err != nil { + return Actor{}, err + } + if databaseID.Valid { + actor.DatabaseID = &databaseID.Int64 + } + actor.SourceUpdatedAt, actor.CreatedAt, actor.UpdatedAt = scanTime(sourceUpdated), scanTime(createdAt), scanTime(updatedAt) + if profileObserved.Valid { + actor.Profile = &ActorProfile{ + Name: stringPtr(name), AvatarURL: stringPtr(avatar), Bio: stringPtr(bio), Company: stringPtr(company), + Location: stringPtr(location), WebsiteURL: stringPtr(website), PublicEmail: stringPtr(email), + TwitterUsername: stringPtr(twitter), Hireable: boolPtrSQL(hireable), Followers: intPtrSQL(followers), + Following: intPtrSQL(following), PublicRepositories: intPtrSQL(repos), PublicGists: intPtrSQL(gists), + ProviderCreatedAt: scanTime(providerCreated.Int64), SourceUpdatedAt: scanTime(profileSourceUpdated.Int64), + ObservationSequence: profileSequence.Int64, ObservedAt: scanTime(profileObserved.Int64), AuthorizationScope: authScope.String, + } + } + return actor, nil +} + +func stringPtr(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} +func boolPtrSQL(value sql.NullBool) *bool { + if !value.Valid { + return nil + } + return &value.Bool +} +func intPtrSQL(value sql.NullInt64) *int { + if !value.Valid { + return nil + } + v := int(value.Int64) + return &v +} + +// SearchActors runs one bounded offline actor query. +func (c *Corpus) SearchActors(ctx context.Context, options ActorSearchOptions) (ActorSearchPage, error) { + if options.Limit == 0 { + options.Limit = 20 + } + if options.Limit < 1 || options.Limit > 100 { + return ActorSearchPage{}, errors.New("actor search limit must be between 1 and 100") + } + if options.Sort == "" { + options.Sort = "relevance" + } + validSort := map[string]bool{"relevance": true, "login": true, "followers": true, "public_repositories": true, "profile_updated_at": true, "observed_at": true} + if !validSort[options.Sort] { + return ActorSearchPage{}, errors.New("unsupported actor sort") + } + offset := 0 + filterKey := actorSearchFilterKey(options) + if options.Cursor != "" { + cursor, err := decodeCursor(options.Cursor) + if err != nil || cursor.Scope != "actors" || cursor.Query != options.Query || cursor.Filter != filterKey { + return ActorSearchPage{}, errors.New("invalid actor search cursor") + } + offset = int(cursor.ID) + } + ftsQuery := literalFTSQuery(options.Query) + from := `actors a LEFT JOIN actor_profiles p ON p.actor_id=a.id` + where, args := ` WHERE 1=1`, []any{} + rank := `0.0` + if ftsQuery != "" { + from = `actors_fts f JOIN actors a ON a.id=CAST(f.actor_id AS INTEGER) LEFT JOIN actor_profiles p ON p.actor_id=a.id` + // FTS5 MATCH and bm25 require the virtual table name, even when its + // row source has an alias in the FROM clause. + where += ` AND actors_fts MATCH ?` + args = append(args, ftsQuery) + rank = `bm25(actors_fts, 0.0, 10.0, 5.0, 2.0, 2.0, 1.0)` + } + if len(options.Kinds) > 0 { + placeholders := make([]string, len(options.Kinds)) + for i, kind := range options.Kinds { + placeholders[i] = "?" + args = append(args, kind) + } + where += ` AND a.kind IN (` + strings.Join(placeholders, ",") + `)` + } + order := map[string]string{ + "relevance": rank + `, a.source_updated_at DESC, a.id`, + "login": `a.current_login COLLATE NOCASE, a.id`, + "followers": `COALESCE(p.followers,-1) DESC, a.id`, + "public_repositories": `COALESCE(p.public_repositories,-1) DESC, a.id`, + "profile_updated_at": `COALESCE(p.source_updated_at,0) DESC, a.id`, + "observed_at": `COALESCE(p.observed_at,0) DESC, a.id`, + }[options.Sort] + // actorSelect includes its own FROM clause, so build the projection directly. + query := `SELECT ` + rank + `, a.id, a.actor_key, a.provider, COALESCE(a.node_id,''), a.database_id, a.kind, a.current_login, + a.source_updated_at, a.observation_sequence, a.created_at, a.updated_at, + p.name, p.avatar_url, p.bio, p.company, p.location, p.website_url, p.public_email, p.twitter_username, + p.hireable, p.followers, p.following, p.public_repositories, p.public_gists, p.provider_created_at, + p.source_updated_at, p.observation_sequence, p.observed_at, p.authorization_scope + FROM ` + from + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?` + args = append(args, options.Limit+1, offset) + rows, err := c.db.QueryContext(ctx, query, args...) + if err != nil { + return ActorSearchPage{}, fmt.Errorf("search actors: %w", err) + } + defer func() { _ = rows.Close() }() + actors := make([]Actor, 0, options.Limit+1) + for rows.Next() { + var rankValue float64 + var actor Actor + var databaseID sql.NullInt64 + var sourceUpdated, createdAt, updatedAt int64 + var name, avatar, bio, company, location, website, email, twitter sql.NullString + var hireable sql.NullBool + var followers, following, repos, gists sql.NullInt64 + var providerCreated, profileSourceUpdated, profileSequence, profileObserved sql.NullInt64 + var authScope sql.NullString + if err := rows.Scan(&rankValue, &actor.ID, &actor.Key, &actor.Provider, &actor.NodeID, &databaseID, &actor.Kind, &actor.Login, + &sourceUpdated, &actor.ObservationSequence, &createdAt, &updatedAt, &name, &avatar, &bio, &company, &location, + &website, &email, &twitter, &hireable, &followers, &following, &repos, &gists, &providerCreated, + &profileSourceUpdated, &profileSequence, &profileObserved, &authScope); err != nil { + return ActorSearchPage{}, err + } + actor.Rank = rankValue + if databaseID.Valid { + actor.DatabaseID = &databaseID.Int64 + } + actor.SourceUpdatedAt, actor.CreatedAt, actor.UpdatedAt = scanTime(sourceUpdated), scanTime(createdAt), scanTime(updatedAt) + if profileObserved.Valid { + actor.Profile = &ActorProfile{Name: stringPtr(name), AvatarURL: stringPtr(avatar), Bio: stringPtr(bio), Company: stringPtr(company), Location: stringPtr(location), WebsiteURL: stringPtr(website), PublicEmail: stringPtr(email), TwitterUsername: stringPtr(twitter), Hireable: boolPtrSQL(hireable), Followers: intPtrSQL(followers), Following: intPtrSQL(following), PublicRepositories: intPtrSQL(repos), PublicGists: intPtrSQL(gists), ProviderCreatedAt: scanTime(providerCreated.Int64), SourceUpdatedAt: scanTime(profileSourceUpdated.Int64), ObservationSequence: profileSequence.Int64, ObservedAt: scanTime(profileObserved.Int64), AuthorizationScope: authScope.String} + } + actors = append(actors, actor) + } + if err := rows.Err(); err != nil { + return ActorSearchPage{}, err + } + page := ActorSearchPage{Actors: actors} + if len(page.Actors) > options.Limit { + page.Actors = page.Actors[:options.Limit] + page.NextCursor = encodeCursor(searchCursor{Scope: "actors", Query: options.Query, Filter: filterKey, ID: int64(offset + options.Limit)}) + } + countQuery := `SELECT COUNT(*) FROM ` + from + where + countArgs := args[:len(args)-2] + if err := c.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&page.Total); err != nil { + return ActorSearchPage{}, fmt.Errorf("count actors: %w", err) + } + return page, nil +} + +func actorSearchFilterKey(options ActorSearchOptions) string { + kinds := append([]string(nil), options.Kinds...) + slices.Sort(kinds) + return options.Sort + "|" + strings.Join(kinds, ",") +} diff --git a/internal/corpus/actors_test.go b/internal/corpus/actors_test.go new file mode 100644 index 0000000..3008b1c --- /dev/null +++ b/internal/corpus/actors_test.go @@ -0,0 +1,199 @@ +package corpus + +import ( + "context" + "testing" + "time" +) + +func TestActorProfileObservationReconcilesLoginToNodeIDAndPreservesNewerProjection(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + one := int64(1) + newer := time.Unix(20, 0).UTC() + name := "Mona" + bio := "machine learning" + followers := 10 + first, err := c.ApplyActorProfileObservation(ctx, ActorProfileObservation{ + Provider: "github", Login: "Mona", NodeID: "U_1", DatabaseID: &one, Kind: "user", + SourceUpdatedAt: newer, ObservedAt: time.Unix(21, 0).UTC(), + Profile: ActorProfile{Name: &name, Bio: &bio, Followers: &followers}, + }) + if err != nil { + t.Fatal(err) + } + if first.NodeID != "U_1" || first.Key != "github:node:U_1" || first.Profile == nil || *first.Profile.Followers != 10 { + t.Fatalf("first actor = %+v", first) + } + olderFollowers := 2 + olderBio := "gardening" + if _, err := c.ApplyActorProfileObservation(ctx, ActorProfileObservation{ + Provider: "github", Login: "mona", NodeID: "U_1", Kind: "user", + SourceUpdatedAt: time.Unix(10, 0).UTC(), ObservedAt: time.Unix(22, 0).UTC(), + Profile: ActorProfile{Bio: &olderBio, Followers: &olderFollowers}, + }); err != nil { + t.Fatal(err) + } + stored, err := c.GetActor(ctx, "MONA") + if err != nil { + t.Fatal(err) + } + if stored == nil || stored.ID != first.ID || stored.Profile == nil || *stored.Profile.Followers != 10 { + t.Fatalf("stored actor = %+v", stored) + } + if _, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_1", &one, "user", "public", time.Unix(23, 0).UTC(), nil); err != nil { + t.Fatal(err) + } + machine, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Limit: 10}) + if err != nil { + t.Fatal(err) + } + gardening, err := c.SearchActors(ctx, ActorSearchOptions{Query: "gardening", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(machine.Actors) != 1 || machine.Actors[0].ID != first.ID || len(gardening.Actors) != 0 { + t.Fatalf("search projection after stale profile and identity observations: machine=%+v gardening=%+v", machine, gardening) + } +} + +func TestSearchActorsReturnsNullableProfilesAndBoundedCursor(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + for index, login := range []string{"alice", "alicia"} { + bio := "machine learning" + followers := 20 - index + if _, err := c.ApplyActorProfileObservation(ctx, ActorProfileObservation{ + Provider: "github", Login: login, NodeID: "U_" + login, Kind: "user", + SourceUpdatedAt: time.Unix(int64(index+1), 0).UTC(), ObservedAt: time.Unix(10, 0).UTC(), + Profile: ActorProfile{Bio: &bio, Followers: &followers}, + }); err != nil { + t.Fatal(err) + } + } + page, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Sort: "followers", Limit: 1}) + if err != nil { + t.Fatal(err) + } + if len(page.Actors) != 1 || page.Total != 2 || page.NextCursor == "" || page.Actors[0].Login != "alice" { + t.Fatalf("first page = %+v", page) + } + next, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Sort: "followers", Limit: 1, Cursor: page.NextCursor}) + if err != nil { + t.Fatal(err) + } + if len(next.Actors) != 1 || next.Actors[0].Login != "alicia" { + t.Fatalf("next page = %+v", next) + } + if _, err := c.SearchActors(ctx, ActorSearchOptions{Query: "machine", Kinds: []string{"bot"}, Sort: "followers", Limit: 1, Cursor: page.NextCursor}); err == nil { + t.Fatal("actor cursor was accepted with a different kind filter") + } +} + +func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + repository, err := c.UpsertRepository(ctx, Repository{Owner: "acme", Name: "ml"}, `{}`) + if err != nil { + t.Fatal(err) + } + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + items := []ActorContributionItem{ + {Kind: "commit", OccurredAt: from.Add(time.Hour), RepositoryID: &repository.ID, Count: 1}, + {Kind: "issue", OccurredAt: from.Add(2 * time.Hour), RepositoryID: &repository.ID, Count: 1}, + } + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), Complete: true, ObservedAt: from.Add(25 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour), Items: items}); err != nil { + t.Fatal(err) + } + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), OrganizationNodeID: "O_acme", Complete: true, ObservedAt: from.Add(26 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour), Items: items}); err != nil { + t.Fatal(err) + } + page, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"acme/ml"}, Sort: "occurred_at", Limit: 1}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Total != 2 || page.NextCursor == "" || page.Items[0].Kind != "issue" { + t.Fatalf("page = %+v", page) + } + organizationPage, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, OrganizationNodeID: "O_acme", Sort: "occurred_at", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if organizationPage.Total != 2 { + t.Fatalf("organization-scoped page = %+v", organizationPage) + } + for _, ref := range []string{actor.Key, actor.NodeID} { + exact, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{ref}, Sort: "occurred_at", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if exact.Total != 2 { + t.Fatalf("actor reference %q returned %+v", ref, exact) + } + } + if _, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, RepositoryRefs: []string{"other/repo"}, Sort: "occurred_at", Limit: 1, Cursor: page.NextCursor}); err == nil { + t.Fatal("cursor was accepted with different repository filters") + } + covered, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(time.Hour), from.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + uncovered, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(-time.Hour), from.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + if covered == nil || uncovered != nil { + t.Fatalf("period coverage: covered=%+v uncovered=%+v", covered, uncovered) + } + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), Complete: false, ObservedAt: from.Add(27 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour)}); err != nil { + t.Fatal(err) + } + partial, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(time.Hour), from.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + if partial != nil { + t.Fatalf("partial refresh retained complete coverage: %+v", partial) + } + organizationCovered, err := c.GetActorContributionCoverage(ctx, actor.ID, "O_acme", from.Add(time.Hour), from.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + if organizationCovered == nil { + t.Fatal("organization-scoped period was not recognized as covered") + } +} + +func TestActorContributionSearchDeduplicatesOverlappingPeriods(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + item := ActorContributionItem{Kind: "issue", OccurredAt: from.Add(2 * time.Hour), TargetNodeID: "I_1", TargetURL: "https://example/issues/1", Count: 1} + for _, period := range []struct{ from, to time.Time }{ + {from: from, to: from.Add(24 * time.Hour)}, + {from: from.Add(time.Hour), to: from.Add(12 * time.Hour)}, + } { + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: period.from, To: period.to, Complete: true, ObservedAt: period.to.Add(time.Hour), SourceUpdatedAt: period.to, Items: []ActorContributionItem{item}}); err != nil { + t.Fatal(err) + } + } + page, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{actor.Key}, From: from.Add(time.Hour), To: from.Add(12 * time.Hour), Limit: 10}) + if err != nil { + t.Fatal(err) + } + if page.Total != 1 || len(page.Items) != 1 || page.Items[0].TargetNodeID != "I_1" { + t.Fatalf("overlapping contribution periods = %+v", page) + } +} diff --git a/internal/corpus/migration_test.go b/internal/corpus/migration_test.go index 33bada2..35168dd 100644 --- a/internal/corpus/migration_test.go +++ b/internal/corpus/migration_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "path/filepath" "testing" + "time" ) func TestBaselineMigrationCreatesCurrentSchema(t *testing.T) { @@ -27,6 +28,10 @@ func TestBaselineMigrationCreatesCurrentSchema(t *testing.T) { "validation_run_groups", "code_index_artifacts", "corpus_snapshot_tokens", "corpus_read_artifacts", "pull_request_feedback_discovery", "pull_request_feedback_projection", "pull_request_feedback_fts", + "actors", "actor_aliases", "actor_observations", "actor_profiles", "actor_social_accounts", + "actor_organization_memberships", "actor_pinned_items", "actor_repository_affiliations", + "actor_contribution_periods", "actor_contribution_days", "actor_contribution_items", + "actor_repository_contribution_totals", "actors_fts", } { if !migrationTableExists(ctx, t, c.db, table) { t.Fatalf("table %s missing after baseline migration", table) @@ -47,6 +52,40 @@ func TestBaselineMigrationCreatesCurrentSchema(t *testing.T) { } } +func TestActorMigrationDeduplicatesExistingLoginsCaseInsensitively(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + provider, logger, err := c.migrationProvider() + if err != nil { + t.Fatal(err) + } + if _, err := provider.Down(ctx); err != nil { + t.Fatal(err) + } + if err := logger.Err(); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"Mona", "mona"} { + if _, err := c.ApplyRepositoryObservation(ctx, owner, "repo-"+owner, "", time.Unix(1, 0).UTC(), `{}`); err != nil { + t.Fatal(err) + } + } + if _, err := provider.UpTo(ctx, 14); err != nil { + t.Fatal(err) + } + if err := logger.Err(); err != nil { + t.Fatal(err) + } + var count int + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM actors WHERE lower(current_login)='mona'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("case-insensitive actor count = %d, want 1", count) + } +} + func migrationTableExists(ctx context.Context, t *testing.T, db *sql.DB, table string) bool { t.Helper() var found int diff --git a/internal/corpus/migrations/014_actor_corpus.sql b/internal/corpus/migrations/014_actor_corpus.sql new file mode 100644 index 0000000..68c6a8b --- /dev/null +++ b/internal/corpus/migrations/014_actor_corpus.sql @@ -0,0 +1,242 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE actors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_key TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + node_id TEXT, + database_id INTEGER, + kind TEXT NOT NULL DEFAULT 'unknown', + current_login TEXT NOT NULL DEFAULT '', + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE UNIQUE INDEX idx_actors_provider_node + ON actors (provider, node_id) WHERE node_id IS NOT NULL AND node_id <> ''; +CREATE INDEX idx_actors_login ON actors (provider, current_login COLLATE NOCASE); + +CREATE TABLE actor_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL, + login TEXT NOT NULL, + normalized_login TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + first_observed_at INTEGER NOT NULL, + last_observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + UNIQUE (actor_id, normalized_login) +); +CREATE INDEX idx_actor_aliases_lookup + ON actor_aliases (normalized_login, active, last_observed_at DESC); + +CREATE TABLE actor_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL, + facet TEXT NOT NULL, + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL, + observed_at INTEGER NOT NULL, + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + authorization_scope TEXT NOT NULL DEFAULT 'public', + payload TEXT NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE +); +CREATE INDEX idx_actor_observations_lookup + ON actor_observations (actor_id, facet, source_updated_at DESC, observation_sequence DESC); + +CREATE TABLE actor_profiles ( + actor_id INTEGER PRIMARY KEY, + name TEXT, + avatar_url TEXT, + bio TEXT, + company TEXT, + location TEXT, + website_url TEXT, + public_email TEXT, + twitter_username TEXT, + hireable INTEGER, + followers INTEGER, + following INTEGER, + public_repositories INTEGER, + public_gists INTEGER, + provider_created_at INTEGER NOT NULL DEFAULT 0, + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + authorization_scope TEXT NOT NULL DEFAULT 'public', + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE +); + +CREATE TABLE actor_social_accounts ( + actor_id INTEGER NOT NULL, + provider_name TEXT NOT NULL, + url TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + PRIMARY KEY (actor_id, provider_name, url) +); + +CREATE TABLE actor_organization_memberships ( + actor_id INTEGER NOT NULL, + organization_node_id TEXT NOT NULL, + organization_login TEXT NOT NULL, + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + PRIMARY KEY (actor_id, organization_node_id) +); + +CREATE TABLE actor_pinned_items ( + actor_id INTEGER NOT NULL, + rank INTEGER NOT NULL, + item_kind TEXT NOT NULL, + target_node_id TEXT NOT NULL, + target_name TEXT NOT NULL DEFAULT '', + repository_owner TEXT NOT NULL DEFAULT '', + showcase_kind TEXT NOT NULL DEFAULT 'pinned', + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + PRIMARY KEY (actor_id, rank) +); + +CREATE TABLE actor_repository_affiliations ( + actor_id INTEGER NOT NULL, + repository_id INTEGER NOT NULL, + relationship TEXT NOT NULL, + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE CASCADE, + PRIMARY KEY (actor_id, repository_id, relationship) +); +CREATE INDEX idx_actor_repo_affiliations_repository + ON actor_repository_affiliations (repository_id, relationship, actor_id); + +CREATE TABLE actor_contribution_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL, + period_start INTEGER NOT NULL, + period_end INTEGER NOT NULL, + organization_node_id TEXT NOT NULL DEFAULT '', + authorization_scope TEXT NOT NULL DEFAULT 'public', + total_commits INTEGER, + total_issues INTEGER, + total_pull_requests INTEGER, + total_pull_request_reviews INTEGER, + total_repositories INTEGER, + restricted_contributions INTEGER, + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + source_updated_at INTEGER NOT NULL DEFAULT 0, + observation_sequence INTEGER NOT NULL DEFAULT 0, + observed_at INTEGER NOT NULL, + FOREIGN KEY (actor_id) REFERENCES actors (id) ON DELETE CASCADE, + UNIQUE (actor_id, period_start, period_end, organization_node_id, authorization_scope) +); + +CREATE TABLE actor_contribution_days ( + period_id INTEGER NOT NULL, + contribution_date TEXT NOT NULL, + contribution_count INTEGER NOT NULL, + contribution_level TEXT NOT NULL, + FOREIGN KEY (period_id) REFERENCES actor_contribution_periods (id) ON DELETE CASCADE, + PRIMARY KEY (period_id, contribution_date) +); + +CREATE TABLE actor_contribution_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_id INTEGER NOT NULL, + contribution_kind TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + repository_id INTEGER, + target_node_id TEXT NOT NULL DEFAULT '', + target_url TEXT NOT NULL DEFAULT '', + restricted INTEGER NOT NULL DEFAULT 0 CHECK (restricted IN (0, 1)), + count INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (period_id) REFERENCES actor_contribution_periods (id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL +); +CREATE INDEX idx_actor_contribution_items_actor_time + ON actor_contribution_items (period_id, occurred_at DESC, id DESC); +CREATE INDEX idx_actor_contribution_items_repository + ON actor_contribution_items (repository_id, contribution_kind, occurred_at DESC); + +CREATE TABLE actor_repository_contribution_totals ( + period_id INTEGER NOT NULL, + repository_id INTEGER NOT NULL, + contribution_kind TEXT NOT NULL, + contribution_count INTEGER NOT NULL, + FOREIGN KEY (period_id) REFERENCES actor_contribution_periods (id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE CASCADE, + PRIMARY KEY (period_id, repository_id, contribution_kind) +); + +CREATE VIRTUAL TABLE actors_fts USING fts5( + actor_id UNINDEXED, + login, + name, + bio, + company, + location +); + +INSERT INTO actors (actor_key, provider, kind, current_login, created_at, updated_at) +SELECT 'github:login:' || lower(login), 'github', 'unknown', login, + (strftime('%s','now') * 1000000000), (strftime('%s','now') * 1000000000) +FROM ( + SELECT MIN(login) AS login FROM ( + SELECT owner AS login FROM repositories WHERE trim(owner) <> '' + UNION ALL + SELECT author AS login FROM threads WHERE trim(author) <> '' + UNION ALL + SELECT author AS login FROM pull_request_feedback_projection WHERE trim(author) <> '' + ) observed_logins + GROUP BY lower(login) +) +ORDER BY lower(login); + +INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) +SELECT id, current_login, lower(current_login), 1, created_at, updated_at +FROM actors WHERE current_login <> ''; + +INSERT INTO actors_fts (actor_id, login, name, bio, company, location) +SELECT id, current_login, '', '', '', '' FROM actors; + +INSERT INTO projection_states (name, version, status, refreshed_at, row_count, source_revision, content_hash) +SELECT 'actors_fts', 'actors-fts-v1', 'current', + (strftime('%s','now') * 1000000000), COUNT(*), '', '' +FROM actors; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM projection_states WHERE name = 'actors_fts'; +DROP TABLE IF EXISTS actors_fts; +DROP TABLE IF EXISTS actor_repository_contribution_totals; +DROP INDEX IF EXISTS idx_actor_contribution_items_repository; +DROP INDEX IF EXISTS idx_actor_contribution_items_actor_time; +DROP TABLE IF EXISTS actor_contribution_items; +DROP TABLE IF EXISTS actor_contribution_days; +DROP TABLE IF EXISTS actor_contribution_periods; +DROP INDEX IF EXISTS idx_actor_repo_affiliations_repository; +DROP TABLE IF EXISTS actor_repository_affiliations; +DROP TABLE IF EXISTS actor_pinned_items; +DROP TABLE IF EXISTS actor_organization_memberships; +DROP TABLE IF EXISTS actor_social_accounts; +DROP TABLE IF EXISTS actor_profiles; +DROP INDEX IF EXISTS idx_actor_observations_lookup; +DROP TABLE IF EXISTS actor_observations; +DROP INDEX IF EXISTS idx_actor_aliases_lookup; +DROP TABLE IF EXISTS actor_aliases; +DROP INDEX IF EXISTS idx_actors_login; +DROP INDEX IF EXISTS idx_actors_provider_node; +DROP TABLE IF EXISTS actors; +-- +goose StatementEnd diff --git a/internal/corpus/projections_test.go b/internal/corpus/projections_test.go index 2fe2a4d..6ad624a 100644 --- a/internal/corpus/projections_test.go +++ b/internal/corpus/projections_test.go @@ -35,10 +35,10 @@ func TestProjectionStatesSeededByOpen(t *testing.T) { if err != nil { t.Fatalf("list projection states: %v", err) } - if len(states) != 5 { - t.Fatalf("projection states = %d, want 5", len(states)) + if len(states) != 6 { + t.Fatalf("projection states = %d, want 6", len(states)) } - if states[0].Name != ProjectionNameCodeDocumentsFTS || states[1].Name != ProjectionNameFacetObservationsFTS || states[2].Name != ProjectionNamePullRequestFeedbackFTS || states[3].Name != ProjectionNameRepositoriesFTS || states[4].Name != ProjectionNameThreadsFTS { + if states[0].Name != ProjectionNameActorsFTS || states[1].Name != ProjectionNameCodeDocumentsFTS || states[2].Name != ProjectionNameFacetObservationsFTS || states[3].Name != ProjectionNamePullRequestFeedbackFTS || states[4].Name != ProjectionNameRepositoriesFTS || states[5].Name != ProjectionNameThreadsFTS { t.Fatalf("projection states order = %v", states) } } From e3ab59557a14e99de42ea72a8f5f2bf96b89a68a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:29:55 +0800 Subject: [PATCH 2/8] feat(github): add bounded actor acquisition adapters --- internal/github/client.go | 147 ++++++++++- internal/github/models.go | 128 +++++++++ internal/github/user_graphql.go | 370 +++++++++++++++++++++++++++ internal/github/user_graphql_test.go | 175 +++++++++++++ 4 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 internal/github/user_graphql.go create mode 100644 internal/github/user_graphql_test.go diff --git a/internal/github/client.go b/internal/github/client.go index bf43922..8ae22a0 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -3,7 +3,9 @@ package github import ( "context" "errors" + "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -47,6 +49,40 @@ type RepositorySearcher interface { SearchRepositories(ctx context.Context, opts RepositorySearchOptions) (RepositorySearchResult, error) } +// UserSearcher discovers GitHub account identities without hydrating every +// result profile. +type UserSearcher interface { + SearchUsers(context.Context, UserSearchOptions) (UserSearchResult, error) +} + +// UserProfileReader reads one exact public or viewer-visible profile header. +type UserProfileReader interface { + GetUser(context.Context, string) (Actor, RateInfo, error) +} + +// UserSocialAccountReader reads one bounded page of public social accounts. +type UserSocialAccountReader interface { + ListUserSocialAccounts(context.Context, string, PageOptions) (ListResult[SocialAccount], error) +} + +// UserRepositoryReader reads one bounded page of repositories related to a +// user. Contributed relationships require the GraphQL capability below. +type UserRepositoryReader interface { + ListUserRepositories(context.Context, string, UserRepositoryOptions) (ListResult[Repository], error) +} + +type UserOrganizationReader interface { + ListUserOrganizations(context.Context, string, CursorPageOptions) (ListResult[OrganizationIdentity], error) +} + +type UserPinnedItemReader interface { + GetUserPinnedItems(context.Context, string, int) (PinnedItemsResult, error) +} + +type UserContributionReader interface { + GetUserContributions(context.Context, string, UserContributionOptions) (UserContributionCollection, error) +} + // IdentityReader resolves the authenticated GitHub account without granting // any mutation capability. type IdentityReader interface { @@ -74,6 +110,7 @@ type PullRequestStatusReader interface { type Client struct { gh *gh.Client downloadClient *http.Client + graphQLURL string } // Config controls how the GitHub client is constructed. @@ -147,7 +184,25 @@ func NewClient(cfg Config) (*Client, error) { if err != nil { return nil, err } - return &Client{gh: ghc, downloadClient: &downloadClient}, nil + graphQLURL, err := githubGraphQLURL(cfg.BaseURL) + if err != nil { + return nil, err + } + return &Client{gh: ghc, downloadClient: &downloadClient, graphQLURL: graphQLURL}, nil +} + +func githubGraphQLURL(base string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("parse GitHub base URL for GraphQL: %w", err) + } + if strings.EqualFold(u.Hostname(), "api.github.com") { + u.Path = "/graphql" + } else { + u.Path = strings.TrimSuffix(strings.TrimSuffix(u.Path, "/"), "/api/v3") + "/api/graphql" + } + u.RawQuery, u.Fragment = "", "" + return u.String(), nil } // GetRepository reads repository metadata and the response rate-limit state. @@ -244,6 +299,96 @@ func (c *Client) SearchRepositories(ctx context.Context, opts RepositorySearchOp }, nil } +// SearchUsers reads one page from GitHub's user Search API. Search results are +// identity stubs and intentionally do not trigger per-result profile reads. +func (c *Client) SearchUsers(ctx context.Context, opts UserSearchOptions) (UserSearchResult, error) { + result, resp, err := c.gh.Search.Users(ctx, opts.Query, &gh.SearchOptions{ + Sort: opts.Sort, Order: opts.Order, + ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, + }) + if err != nil { + return UserSearchResult{}, classifyError(err) + } + items := make([]Actor, 0, len(result.Users)) + for _, user := range result.Users { + items = append(items, convertActor(user)) + } + return UserSearchResult{Total: result.GetTotal(), Incomplete: result.GetIncompleteResults(), Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +// GetUser reads one exact profile header. +func (c *Client) GetUser(ctx context.Context, login string) (Actor, RateInfo, error) { + user, resp, err := c.gh.Users.Get(ctx, login) + if err != nil { + return Actor{}, RateInfo{}, classifyError(err) + } + return convertActor(user), rateInfo(resp.Rate), nil +} + +// ListUserSocialAccounts reads one bounded social-account page. +func (c *Client) ListUserSocialAccounts(ctx context.Context, login string, opts PageOptions) (ListResult[SocialAccount], error) { + accounts, resp, err := c.gh.Users.ListUserSocialAccounts(ctx, login, &gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}) + if err != nil { + return ListResult[SocialAccount]{}, classifyError(err) + } + items := make([]SocialAccount, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + items = append(items, SocialAccount{Provider: account.GetProvider(), URL: account.GetURL()}) + } + return ListResult[SocialAccount]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +// ListUserRepositories reads one bounded owned or affiliated repository page. +func (c *Client) ListUserRepositories(ctx context.Context, login string, opts UserRepositoryOptions) (ListResult[Repository], error) { + if opts.Relationship == "contributed" { + return c.listUserContributedRepositories(ctx, login, opts) + } + relation := opts.Relationship + switch relation { + case "owned": + relation = "owner" + case "affiliated": + relation = "member" + } + repositories, resp, err := c.gh.Repositories.ListByUser(ctx, login, &gh.RepositoryListByUserOptions{ + Type: relation, Sort: opts.Sort, Direction: opts.Direction, + ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, + }) + if err != nil { + return ListResult[Repository]{}, classifyError(err) + } + items := make([]Repository, 0, len(repositories)) + for _, repository := range repositories { + items = append(items, convertRepository(repository)) + } + return ListResult[Repository]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +func convertActor(user *gh.User) Actor { + if user == nil { + return Actor{} + } + actor := Actor{ + Login: user.GetLogin(), ID: user.GetID(), NodeID: user.GetNodeID(), Kind: strings.ToLower(user.GetType()), + AvatarURL: user.AvatarURL, Name: user.Name, Bio: user.Bio, Company: user.Company, Location: user.Location, + WebsiteURL: user.Blog, PublicEmail: user.Email, TwitterUsername: user.TwitterUsername, Hireable: user.Hireable, + Followers: user.Followers, Following: user.Following, PublicRepositories: user.PublicRepos, PublicGists: user.PublicGists, + } + if user.CreatedAt != nil { + actor.CreatedAt = user.CreatedAt.Time + } + if user.UpdatedAt != nil { + actor.UpdatedAt = user.UpdatedAt.Time + } + if actor.Kind == "" { + actor.Kind = "unknown" + } + return actor +} + // ListIssues reads one page of issues and pull-request markers for a repository. func (c *Client) ListIssues(ctx context.Context, owner, name string, opts ListIssueOptions) (ListResult[Issue], error) { gopts := &gh.IssueListByRepoOptions{ diff --git a/internal/github/models.go b/internal/github/models.go index d1ef673..f542f09 100644 --- a/internal/github/models.go +++ b/internal/github/models.go @@ -2,6 +2,133 @@ package github import "time" +// Actor is a domain-neutral GitHub account profile. Nullable fields preserve +// provider omission and visibility instead of manufacturing zero values. +type Actor struct { + Login string + ID int64 + NodeID string + Kind string + AvatarURL *string + Name *string + Bio *string + Company *string + Location *string + WebsiteURL *string + PublicEmail *string + TwitterUsername *string + Hireable *bool + Followers *int + Following *int + PublicRepositories *int + PublicGists *int + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserSearchOptions struct { + Query string + Sort string + Order string + Page int + PerPage int +} + +type UserSearchResult struct { + Total int + Incomplete bool + Items []Actor + Page PageInfo + Rate RateInfo +} + +type UserRepositoryOptions struct { + Relationship string + Sort string + Direction string + After string + PageOptions +} + +type CursorPageOptions struct { + First int + After string +} + +type SocialAccount struct { + Provider string + URL string + DisplayName string +} + +type OrganizationIdentity struct { + NodeID string + Login string + AvatarURL string +} + +type PinnedItem struct { + Kind string + NodeID string + Name string + RepositoryOwner string + Rank int +} + +type PinnedItemsResult struct { + Items []PinnedItem + ShowcaseKind string + Coverage FacetCoverage + Rate RateInfo +} + +type UserContributionOptions struct { + From time.Time + To time.Time + OrganizationNodeID string + MaxRepositories int +} + +type ContributionDay struct { + Date string + Count int + Level string +} + +type UserContribution struct { + Kind string + OccurredAt time.Time + RepositoryNodeID string + RepositoryNameOwner string + TargetNodeID string + TargetURL string + Restricted bool + Count int +} + +type RepositoryContributionTotal struct { + RepositoryNodeID string + RepositoryNameOwner string + Kind string + Count int +} + +type UserContributionCollection struct { + StartedAt time.Time + EndedAt time.Time + TotalCommits int + TotalIssues int + TotalPullRequests int + TotalPullRequestReviews int + TotalRepositories int + RestrictedContributions int + Days []ContributionDay + Items []UserContribution + RepositoryTotals []RepositoryContributionTotal + Complete bool + Rate RateInfo +} + // ThreadKind classifies an issue-list entry. type ThreadKind string @@ -308,6 +435,7 @@ type PageInfo struct { HasPrev bool HasFirst bool HasLast bool + EndCursor string } // RateInfo carries rate-limit metadata from the response headers. diff --git a/internal/github/user_graphql.go b/internal/github/user_graphql.go new file mode 100644 index 0000000..b3f17f8 --- /dev/null +++ b/internal/github/user_graphql.go @@ -0,0 +1,370 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + gh "github.com/google/go-github/v89/github" +) + +const userOrganizationsQuery = `query UserOrganizations($login:String!,$first:Int!,$after:String){user(login:$login){organizations(first:$first,after:$after){totalCount nodes{id login avatarUrl} pageInfo{hasNextPage endCursor}}}}` +const userPinnedItemsQuery = `query UserPinnedItems($login:String!,$first:Int!){user(login:$login){itemShowcase{hasPinnedItems items(first:$first){totalCount nodes{__typename ... on Repository{id name owner{login}} ... on Gist{id name}} pageInfo{hasNextPage endCursor}}}}}` +const userContributionsQuery = `query UserContributions($login:String!,$from:DateTime!,$to:DateTime!,$organizationID:ID,$maxRepositories:Int!){user(login:$login){contributionsCollection(from:$from,to:$to,organizationID:$organizationID){startedAt endedAt restrictedContributionsCount totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions totalRepositoryContributions contributionCalendar{weeks{contributionDays{date contributionCount contributionLevel}}} commitContributionsByRepository(maxRepositories:$maxRepositories){repository{id nameWithOwner} contributions(first:100){nodes{occurredAt commitCount isRestricted} pageInfo{hasNextPage}}} issueContributions(first:100){nodes{occurredAt isRestricted issue{id url repository{id nameWithOwner}}} pageInfo{hasNextPage}} pullRequestContributions(first:100){nodes{occurredAt isRestricted pullRequest{id url repository{id nameWithOwner}}} pageInfo{hasNextPage}} pullRequestReviewContributions(first:100){nodes{occurredAt isRestricted pullRequest{id url repository{id nameWithOwner}} pullRequestReview{id url}} pageInfo{hasNextPage}} repositoryContributions(first:100){nodes{occurredAt isRestricted repository{id url nameWithOwner}} pageInfo{hasNextPage}}}}}` +const userContributedRepositoriesQuery = `query UserContributedRepositories($login:String!,$first:Int!,$after:String){user(login:$login){repositoriesContributedTo(first:$first,after:$after,includeUserRepositories:true,contributionTypes:[COMMIT,ISSUE,PULL_REQUEST,REPOSITORY]){nodes{id name nameWithOwner description defaultBranchRef{name} isFork isArchived isPrivate stargazerCount forkCount issues(states:OPEN){totalCount} primaryLanguage{name} licenseInfo{spdxId} repositoryTopics(first:20){nodes{topic{name}}} owner{login} createdAt updatedAt pushedAt} pageInfo{hasNextPage endCursor}}}}` + +type graphQLErrorDTO struct { + Message string `json:"message"` + Type string `json:"type"` +} + +func (c *Client) ListUserOrganizations(ctx context.Context, login string, opts CursorPageOptions) (ListResult[OrganizationIdentity], error) { + first := opts.First + if first <= 0 { + first = 100 + } + if first > 100 { + first = 100 + } + var envelope struct { + Data struct { + User *struct { + Organizations graphQLConnection[struct { + ID string `json:"id"` + Login string `json:"login"` + AvatarURL string `json:"avatarUrl"` + }] `json:"organizations"` + } `json:"user"` + } `json:"data"` + Errors []graphQLErrorDTO `json:"errors"` + } + resp, err := c.graphQLRead(ctx, userOrganizationsQuery, map[string]any{"login": login, "first": first, "after": optionalGraphQLCursor(opts.After)}, &envelope) + if err != nil { + return ListResult[OrganizationIdentity]{}, err + } + if len(envelope.Errors) > 0 { + return ListResult[OrganizationIdentity]{}, graphQLErrors(envelope.Errors) + } + if envelope.Data.User == nil { + return ListResult[OrganizationIdentity]{}, &NotFoundError{Resource: "user " + login} + } + items := make([]OrganizationIdentity, 0, len(envelope.Data.User.Organizations.Nodes)) + for _, node := range envelope.Data.User.Organizations.Nodes { + items = append(items, OrganizationIdentity{NodeID: node.ID, Login: node.Login, AvatarURL: node.AvatarURL}) + } + page := PageInfo{HasNext: envelope.Data.User.Organizations.PageInfo.HasNextPage, EndCursor: envelope.Data.User.Organizations.PageInfo.EndCursor} + return ListResult[OrganizationIdentity]{Items: items, Page: page, Rate: rateInfo(resp.Rate)}, nil +} + +func (c *Client) GetUserPinnedItems(ctx context.Context, login string, limit int) (PinnedItemsResult, error) { + if limit <= 0 { + limit = 6 + } + if limit > 6 { + limit = 6 + } + type nodeDTO struct { + TypeName string `json:"__typename"` + ID string `json:"id"` + Name string `json:"name"` + Owner *struct { + Login string `json:"login"` + } `json:"owner"` + } + var envelope struct { + Data struct { + User *struct { + ItemShowcase struct { + HasPinnedItems bool `json:"hasPinnedItems"` + Items graphQLConnection[nodeDTO] `json:"items"` + } `json:"itemShowcase"` + } `json:"user"` + } `json:"data"` + Errors []graphQLErrorDTO `json:"errors"` + } + resp, err := c.graphQLRead(ctx, userPinnedItemsQuery, map[string]any{"login": login, "first": limit}, &envelope) + if err != nil { + return PinnedItemsResult{}, err + } + if len(envelope.Errors) > 0 { + return PinnedItemsResult{}, graphQLErrors(envelope.Errors) + } + if envelope.Data.User == nil { + return PinnedItemsResult{}, &NotFoundError{Resource: "user " + login} + } + items := make([]PinnedItem, 0, len(envelope.Data.User.ItemShowcase.Items.Nodes)) + for index, node := range envelope.Data.User.ItemShowcase.Items.Nodes { + owner := "" + if node.Owner != nil { + owner = node.Owner.Login + } + items = append(items, PinnedItem{Kind: strings.ToLower(node.TypeName), NodeID: node.ID, Name: node.Name, RepositoryOwner: owner, Rank: index + 1}) + } + showcase := "popular" + if envelope.Data.User.ItemShowcase.HasPinnedItems { + showcase = "pinned" + } + return PinnedItemsResult{Items: items, ShowcaseKind: showcase, Coverage: coverage(envelope.Data.User.ItemShowcase.Items), Rate: rateInfo(resp.Rate)}, nil +} + +func (c *Client) listUserContributedRepositories(ctx context.Context, login string, opts UserRepositoryOptions) (ListResult[Repository], error) { + first := opts.PerPage + if first <= 0 { + first = 100 + } + if first > 100 { + first = 100 + } + type repoDTO struct { + ID string `json:"id"` + Name string `json:"name"` + NameWithOwner string `json:"nameWithOwner"` + Description string `json:"description"` + DefaultBranchRef *struct { + Name string `json:"name"` + } `json:"defaultBranchRef"` + IsFork bool `json:"isFork"` + IsArchived bool `json:"isArchived"` + IsPrivate bool `json:"isPrivate"` + Stars int `json:"stargazerCount"` + Forks int `json:"forkCount"` + Issues struct { + Total int `json:"totalCount"` + } `json:"issues"` + PrimaryLanguage *struct { + Name string `json:"name"` + } `json:"primaryLanguage"` + LicenseInfo *struct { + SPDXID string `json:"spdxId"` + } `json:"licenseInfo"` + Topics struct { + Nodes []struct { + Topic struct { + Name string `json:"name"` + } `json:"topic"` + } `json:"nodes"` + } `json:"repositoryTopics"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + PushedAt *time.Time `json:"pushedAt"` + } + var envelope struct { + Data struct { + User *struct { + Repositories graphQLConnection[repoDTO] `json:"repositoriesContributedTo"` + } `json:"user"` + } `json:"data"` + Errors []graphQLErrorDTO `json:"errors"` + } + resp, err := c.graphQLRead(ctx, userContributedRepositoriesQuery, map[string]any{"login": login, "first": first, "after": optionalGraphQLCursor(opts.After)}, &envelope) + if err != nil { + return ListResult[Repository]{}, err + } + if len(envelope.Errors) > 0 { + return ListResult[Repository]{}, graphQLErrors(envelope.Errors) + } + if envelope.Data.User == nil { + return ListResult[Repository]{}, &NotFoundError{Resource: "user " + login} + } + items := make([]Repository, 0, len(envelope.Data.User.Repositories.Nodes)) + for _, node := range envelope.Data.User.Repositories.Nodes { + repository := Repository{NodeID: node.ID, Owner: node.Owner.Login, Name: node.Name, FullName: node.NameWithOwner, Description: node.Description, Fork: node.IsFork, Archived: node.IsArchived, Private: node.IsPrivate, Stars: node.Stars, Forks: node.Forks, OpenIssues: node.Issues.Total, CreatedAt: node.CreatedAt, UpdatedAt: node.UpdatedAt, PushedAt: node.PushedAt} + if node.DefaultBranchRef != nil { + repository.DefaultBranch = node.DefaultBranchRef.Name + } + if node.PrimaryLanguage != nil { + repository.Language = node.PrimaryLanguage.Name + } + if node.LicenseInfo != nil { + repository.License = node.LicenseInfo.SPDXID + } + for _, topic := range node.Topics.Nodes { + repository.Topics = append(repository.Topics, topic.Topic.Name) + } + items = append(items, repository) + } + page := PageInfo{HasNext: envelope.Data.User.Repositories.PageInfo.HasNextPage, EndCursor: envelope.Data.User.Repositories.PageInfo.EndCursor} + return ListResult[Repository]{Items: items, Page: page, Rate: rateInfo(resp.Rate)}, nil +} + +func (c *Client) GetUserContributions(ctx context.Context, login string, opts UserContributionOptions) (UserContributionCollection, error) { + if opts.MaxRepositories <= 0 { + opts.MaxRepositories = 25 + } + if opts.MaxRepositories > 100 { + opts.MaxRepositories = 100 + } + type contributionNode struct { + OccurredAt time.Time `json:"occurredAt"` + Restricted bool `json:"isRestricted"` + Issue *struct { + ID string `json:"id"` + URL string `json:"url"` + Repository struct { + ID string `json:"id"` + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` + } `json:"issue"` + PullRequest *struct { + ID string `json:"id"` + URL string `json:"url"` + Repository struct { + ID string `json:"id"` + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` + } `json:"pullRequest"` + PullRequestReview *struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"pullRequestReview"` + Repository *struct { + ID string `json:"id"` + URL string `json:"url"` + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` + } + type contributionConnection struct { + Nodes []contributionNode `json:"nodes"` + PageInfo graphQLPageInfo `json:"pageInfo"` + } + type commitGroup struct { + Repository struct { + ID string `json:"id"` + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` + Contributions struct { + Nodes []struct { + OccurredAt time.Time `json:"occurredAt"` + CommitCount int `json:"commitCount"` + Restricted bool `json:"isRestricted"` + } `json:"nodes"` + PageInfo graphQLPageInfo `json:"pageInfo"` + } `json:"contributions"` + } + var envelope struct { + Data struct { + User *struct { + Contributions struct { + StartedAt time.Time `json:"startedAt"` + EndedAt time.Time `json:"endedAt"` + Restricted int `json:"restrictedContributionsCount"` + TotalCommits int `json:"totalCommitContributions"` + TotalIssues int `json:"totalIssueContributions"` + TotalPRs int `json:"totalPullRequestContributions"` + TotalReviews int `json:"totalPullRequestReviewContributions"` + TotalRepos int `json:"totalRepositoryContributions"` + Calendar struct { + Weeks []struct { + Days []struct { + Date string `json:"date"` + Count int `json:"contributionCount"` + Level string `json:"contributionLevel"` + } `json:"contributionDays"` + } `json:"weeks"` + } `json:"contributionCalendar"` + Commits []commitGroup `json:"commitContributionsByRepository"` + Issues contributionConnection `json:"issueContributions"` + PRs contributionConnection `json:"pullRequestContributions"` + Reviews contributionConnection `json:"pullRequestReviewContributions"` + Repositories contributionConnection `json:"repositoryContributions"` + } `json:"contributionsCollection"` + } `json:"user"` + } `json:"data"` + Errors []graphQLErrorDTO `json:"errors"` + } + resp, err := c.graphQLRead(ctx, userContributionsQuery, map[string]any{"login": login, "from": opts.From.UTC().Format(time.RFC3339), "to": opts.To.UTC().Format(time.RFC3339), "organizationID": emptyToNil(opts.OrganizationNodeID), "maxRepositories": opts.MaxRepositories}, &envelope) + if err != nil { + return UserContributionCollection{}, err + } + if len(envelope.Errors) > 0 { + return UserContributionCollection{}, graphQLErrors(envelope.Errors) + } + if envelope.Data.User == nil { + return UserContributionCollection{}, &NotFoundError{Resource: "user " + login} + } + d := envelope.Data.User.Contributions + out := UserContributionCollection{StartedAt: d.StartedAt, EndedAt: d.EndedAt, TotalCommits: d.TotalCommits, TotalIssues: d.TotalIssues, TotalPullRequests: d.TotalPRs, TotalPullRequestReviews: d.TotalReviews, TotalRepositories: d.TotalRepos, RestrictedContributions: d.Restricted, Complete: true, Rate: rateInfo(resp.Rate)} + if len(d.Commits) >= opts.MaxRepositories { + out.Complete = false + } + for _, week := range d.Calendar.Weeks { + for _, day := range week.Days { + out.Days = append(out.Days, ContributionDay{Date: day.Date, Count: day.Count, Level: day.Level}) + } + } + for _, group := range d.Commits { + total := 0 + for _, node := range group.Contributions.Nodes { + total += node.CommitCount + out.Items = append(out.Items, UserContribution{Kind: "commit", OccurredAt: node.OccurredAt, RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Restricted: node.Restricted, Count: node.CommitCount}) + } + out.RepositoryTotals = append(out.RepositoryTotals, RepositoryContributionTotal{RepositoryNodeID: group.Repository.ID, RepositoryNameOwner: group.Repository.NameWithOwner, Kind: "commit", Count: total}) + out.Complete = out.Complete && !group.Contributions.PageInfo.HasNextPage + } + appendConnection := func(kind string, connection contributionConnection) { + for _, node := range connection.Nodes { + item := UserContribution{Kind: kind, OccurredAt: node.OccurredAt, Restricted: node.Restricted, Count: 1} + switch { + case node.Issue != nil: + item.RepositoryNodeID = node.Issue.Repository.ID + item.RepositoryNameOwner = node.Issue.Repository.NameWithOwner + item.TargetNodeID = node.Issue.ID + item.TargetURL = node.Issue.URL + case node.PullRequest != nil: + item.RepositoryNodeID = node.PullRequest.Repository.ID + item.RepositoryNameOwner = node.PullRequest.Repository.NameWithOwner + item.TargetNodeID = node.PullRequest.ID + item.TargetURL = node.PullRequest.URL + if node.PullRequestReview != nil { + item.TargetNodeID = node.PullRequestReview.ID + item.TargetURL = node.PullRequestReview.URL + } + case node.Repository != nil: + item.RepositoryNodeID = node.Repository.ID + item.RepositoryNameOwner = node.Repository.NameWithOwner + item.TargetNodeID = node.Repository.ID + item.TargetURL = node.Repository.URL + } + out.Items = append(out.Items, item) + } + if connection.PageInfo.HasNextPage { + out.Complete = false + } + } + appendConnection("issue", d.Issues) + appendConnection("pull_request", d.PRs) + appendConnection("pull_request_review", d.Reviews) + appendConnection("repository", d.Repositories) + return out, nil +} + +func (c *Client) graphQLRead(ctx context.Context, query string, variables map[string]any, out any) (*gh.Response, error) { + req, err := c.gh.NewRequest(ctx, http.MethodPost, c.graphQLURL, graphQLRequest{Query: query, Variables: variables}) + if err != nil { + return nil, err + } + req = markReplayableRead(req) + resp, err := c.gh.Do(req, out) + if err != nil { + return resp, classifyError(err) + } + return resp, nil +} +func graphQLErrors(items []graphQLErrorDTO) error { + messages := make([]string, len(items)) + for i, item := range items { + messages[i] = item.Message + } + return fmt.Errorf("github graphql: %s", strings.Join(messages, "; ")) +} +func emptyToNil(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/internal/github/user_graphql_test.go b/internal/github/user_graphql_test.go new file mode 100644 index 0000000..e186b7a --- /dev/null +++ b/internal/github/user_graphql_test.go @@ -0,0 +1,175 @@ +package github + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSearchUsersReturnsIdentityOnlyPage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/search/users" || r.URL.Query().Get("q") != "language:go" { + http.NotFound(w, r) + return + } + writeJSON(w, map[string]any{"total_count": 1, "incomplete_results": false, "items": []any{map[string]any{"login": "octocat", "id": 1, "node_id": "U_1", "type": "User", "avatar_url": "https://example/avatar"}}}) + })) + defer srv.Close() + + result, err := newTestClient(t, srv, StaticTokenSource("")).SearchUsers(context.Background(), UserSearchOptions{Query: "language:go", Sort: "followers", Order: "desc", Page: 1, PerPage: 20}) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Items) != 1 || result.Items[0].NodeID != "U_1" || result.Items[0].Login != "octocat" { + t.Fatalf("unexpected search result: %+v", result) + } +} + +func TestUserRepositoryRelationshipsUseGitHubRESTValues(t *testing.T) { + t.Parallel() + for product, want := range map[string]string{"owned": "owner", "affiliated": "member"} { + t.Run(product, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/users/octocat/repos" || r.URL.Query().Get("type") != want { + t.Errorf("request = %s?%s, want type=%s", r.URL.Path, r.URL.RawQuery, want) + } + writeJSON(w, []any{}) + })) + defer srv.Close() + if _, err := newTestClient(t, srv, StaticTokenSource("")).ListUserRepositories(context.Background(), "octocat", UserRepositoryOptions{Relationship: product}); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestUserOrganizationsUsesEnterpriseGraphQLEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/graphql" { + http.NotFound(w, r) + return + } + writeJSON(w, map[string]any{"data": map[string]any{"user": map[string]any{"organizations": map[string]any{"nodes": []any{map[string]any{"id": "O_1", "login": "acme"}}, "pageInfo": map[string]any{"hasNextPage": false, "endCursor": "cursor-1"}}}}}) + })) + defer srv.Close() + + result, err := newTestClient(t, srv, StaticTokenSource("")).ListUserOrganizations(context.Background(), "octocat", CursorPageOptions{First: 10}) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 || result.Items[0].Login != "acme" || result.Page.EndCursor != "cursor-1" { + t.Fatalf("unexpected organization result: %+v", result) + } +} + +func TestGitHubGraphQLEndpointUsesPublicAndEnterpriseLayouts(t *testing.T) { + t.Parallel() + tests := []struct { + base string + want string + }{ + {base: "https://api.github.com/", want: "https://api.github.com/graphql"}, + {base: "https://github.example/api/v3/", want: "https://github.example/api/graphql"}, + {base: "https://api.github.example/api/v3/", want: "https://api.github.example/api/graphql"}, + } + for _, test := range tests { + got, err := githubGraphQLURL(test.base) + if err != nil { + t.Fatalf("githubGraphQLURL(%q): %v", test.base, err) + } + if got != test.want { + t.Errorf("githubGraphQLURL(%q) = %q, want %q", test.base, got, test.want) + } + } +} + +func TestNewClientUsesPublicGraphQLEndpointByDefault(t *testing.T) { + t.Parallel() + client, err := NewClient(Config{}) + if err != nil { + t.Fatal(err) + } + if client.graphQLURL != "https://api.github.com/graphql" { + t.Fatalf("default GraphQL URL = %q", client.graphQLURL) + } +} + +func TestUserContributionsPreservesRestrictedAggregate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + query := string(body) + if strings.Contains(query, "issueContributions(first:100,maxRepositories") || !strings.Contains(query, "commitContributionsByRepository(maxRepositories") { + t.Errorf("invalid contribution query: %s", query) + } + writeJSON(w, map[string]any{"data": map[string]any{"user": map[string]any{"contributionsCollection": map[string]any{ + "startedAt": "2025-01-01T00:00:00Z", "endedAt": "2025-02-01T00:00:00Z", "hasAnyRestrictedContributions": true, + "restrictedContributionsCount": 3, "totalCommitContributions": 4, "totalIssueContributions": 1, "totalPullRequestContributions": 2, "totalPullRequestReviewContributions": 5, "totalRepositoryContributions": 1, + "contributionCalendar": map[string]any{"weeks": []any{}}, + "commitContributionsByRepository": []any{}, "issueContributionsByRepository": []any{}, "pullRequestContributionsByRepository": []any{}, "pullRequestReviewContributionsByRepository": []any{}, "repositoryContributions": map[string]any{"nodes": []any{}}, + }}}}) + })) + defer srv.Close() + + result, err := newTestClient(t, srv, StaticTokenSource("")).GetUserContributions(context.Background(), "octocat", UserContributionOptions{From: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC), MaxRepositories: 10}) + if err != nil { + t.Fatal(err) + } + if result.RestrictedContributions != 3 || result.TotalPullRequestReviews != 5 { + t.Fatalf("unexpected contribution aggregate: %+v", result) + } +} + +func TestUserContributionsAreIncompleteAtRepositoryGroupCap(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + group := func(id string) map[string]any { + return map[string]any{"repository": map[string]any{"id": id, "nameWithOwner": "acme/" + id}, "contributions": map[string]any{"nodes": []any{}, "pageInfo": map[string]any{"hasNextPage": false}}} + } + writeJSON(w, map[string]any{"data": map[string]any{"user": map[string]any{"contributionsCollection": map[string]any{ + "startedAt": "2025-01-01T00:00:00Z", "endedAt": "2025-02-01T00:00:00Z", + "contributionCalendar": map[string]any{"weeks": []any{}}, "commitContributionsByRepository": []any{group("one"), group("two")}, + "issueContributions": map[string]any{"nodes": []any{}, "pageInfo": map[string]any{}}, "pullRequestContributions": map[string]any{"nodes": []any{}, "pageInfo": map[string]any{}}, + "pullRequestReviewContributions": map[string]any{"nodes": []any{}, "pageInfo": map[string]any{}}, "repositoryContributions": map[string]any{"nodes": []any{}, "pageInfo": map[string]any{}}, + }}}}) + })) + defer srv.Close() + result, err := newTestClient(t, srv, StaticTokenSource("")).GetUserContributions(context.Background(), "octocat", UserContributionOptions{From: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC), MaxRepositories: 2}) + if err != nil { + t.Fatal(err) + } + if result.Complete { + t.Fatal("repository-group cap was reported as complete") + } +} + +func TestUserPinnedItemsReadsProfileShowcase(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "itemShowcase") || strings.Contains(string(body), "profileItemShowcase") { + t.Errorf("invalid showcase query: %s", body) + } + writeJSON(w, map[string]any{ + "data": map[string]any{"user": map[string]any{ + "itemShowcase": map[string]any{ + "hasPinnedItems": true, + "items": map[string]any{ + "nodes": []any{map[string]any{"__typename": "Repository", "id": "R_1", "name": "ml", "owner": map[string]any{"login": "acme"}}}, + "pageInfo": map[string]any{"hasNextPage": false}, + }, + }, + }}, + }) + })) + defer srv.Close() + + result, err := newTestClient(t, srv, StaticTokenSource("")).GetUserPinnedItems(context.Background(), "octocat", 6) + if err != nil { + t.Fatal(err) + } + if result.ShowcaseKind != "pinned" || len(result.Items) != 1 || result.Items[0].RepositoryOwner != "acme" { + t.Fatalf("unexpected pinned items: %+v", result) + } +} From 32043b4e5710df4bf2fa7bff75c1c6822e15f869 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:30:00 +0800 Subject: [PATCH 3/8] feat(mcp)!: expose atomic actor tools BREAKING CHANGE: corpus.search_code_batch is now corpus.search_code, corpus.list_pull_requests is now corpus.search_pull_requests, github.sync_ci_failures is now github.sync_pull_request_ci, and prescriptive composite workflow tools are no longer advertised. --- internal/app/mcp_actor_facets.go | 537 ++++++++++++++++++ internal/app/mcp_actors.go | 394 +++++++++++++ internal/app/mcp_actors_test.go | 39 ++ internal/app/mcp_stdio_e2e_test.go | 37 +- internal/mcpcontract/actor_contracts.go | 218 +++++++ internal/mcpcontract/tool_contracts.go | 8 +- internal/mcpserver/actors.go | 224 ++++++++ internal/mcpserver/actors_test.go | 68 +++ internal/mcpserver/agent_eval_heldout_test.go | 11 +- internal/mcpserver/agent_eval_test.go | 28 - internal/mcpserver/catalog.go | 18 + internal/mcpserver/catalog_test.go | 104 ++-- internal/mcpserver/input_modes.go | 37 +- internal/mcpserver/resource_templates.go | 11 +- internal/mcpserver/resources.go | 47 +- internal/mcpserver/scalable.go | 22 +- internal/mcpserver/server.go | 18 + internal/mcpserver/server_contract_test.go | 78 +-- internal/mcpserver/server_test.go | 9 +- 19 files changed, 1671 insertions(+), 237 deletions(-) create mode 100644 internal/app/mcp_actor_facets.go create mode 100644 internal/app/mcp_actors.go create mode 100644 internal/app/mcp_actors_test.go create mode 100644 internal/mcpcontract/actor_contracts.go create mode 100644 internal/mcpserver/actors.go create mode 100644 internal/mcpserver/actors_test.go diff --git a/internal/app/mcp_actor_facets.go b/internal/app/mcp_actor_facets.go new file mode 100644 index 0000000..f88592b --- /dev/null +++ b/internal/app/mcp_actor_facets.go @@ -0,0 +1,537 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +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) { + source, ok := reader.(github.UserSocialAccountReader) + if !ok { + return nil, errors.New("GitHub social-account reads are unavailable") + } + actor, login, err := storedActorForSelector(ctx, c, selector) + if err != nil { + return nil, err + } + items := []corpus.ActorSocialAccount{} + page := 1 + complete := false + for page <= facetMaxPages(in.MaxPages) { + result, err := source.ListUserSocialAccounts(ctx, login, github.PageOptions{Page: page, PerPage: min(100, facetMaxItems(in.MaxItems)-len(items))}) + if err != nil { + return nil, err + } + for _, item := range result.Items { + items = append(items, corpus.ActorSocialAccount{Provider: item.Provider, URL: item.URL, DisplayName: item.DisplayName}) + } + if !result.Page.HasNext || len(items) >= facetMaxItems(in.MaxItems) { + complete = !result.Page.HasNext + break + } + page = result.Page.NextPage + } + raw, _ := json.Marshal(items) + observed := r.now().UTC() + if err := c.ReplaceActorSocialAccounts(ctx, actor.ID, items, complete, observed, observed, "public", raw); err != nil { + return nil, err + } + return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": complete}, nil + }) +} + +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) { + source, ok := reader.(github.UserOrganizationReader) + if !ok { + return nil, errors.New("GitHub organization reads are unavailable") + } + actor, login, err := storedActorForSelector(ctx, c, selector) + if err != nil { + return nil, err + } + items := []corpus.ActorOrganization{} + cursor := "" + complete := false + for page := 0; page < facetMaxPages(in.MaxPages); page++ { + result, err := source.ListUserOrganizations(ctx, login, github.CursorPageOptions{First: min(100, facetMaxItems(in.MaxItems)-len(items)), After: cursor}) + if err != nil { + return nil, err + } + for _, item := range result.Items { + items = append(items, corpus.ActorOrganization{NodeID: item.NodeID, Login: item.Login}) + } + if !result.Page.HasNext || len(items) >= facetMaxItems(in.MaxItems) { + complete = !result.Page.HasNext + break + } + cursor = result.Page.EndCursor + } + raw, _ := json.Marshal(items) + observed := r.now().UTC() + if err := c.ReplaceActorOrganizations(ctx, actor.ID, items, complete, observed, observed, "public", raw); err != nil { + return nil, err + } + return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": complete}, nil + }) +} + +func (r *MCPReader) SyncUserPinnedItems(ctx context.Context, in mcpcontract.SyncUserPinnedItemsInput) (mcpcontract.JobReference, error) { + 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 { + return mcpcontract.JobReference{}, err + } + if in.Limit == 0 { + in.Limit = 6 + } + if in.Limit < 1 || in.Limit > 6 { + return mcpcontract.JobReference{}, errors.New("limit must be 1 to 6") + } + if in.MaxRequests == 0 { + in.MaxRequests = len(in.Users) + } + if in.MaxRequests < len(in.Users) || in.MaxRequests > 100 { + return mcpcontract.JobReference{}, errors.New("max_requests must admit every user and cannot exceed 100") + } + id, err := r.submitJob(ctx, "sync_user_pinned_items", in, func(ctx context.Context, report func(string, string) error) (any, error) { + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return nil, err + } + source, ok := reader.(github.UserPinnedItemReader) + if !ok { + return nil, errors.New("GitHub pinned-item reads are unavailable") + } + c, err := r.openCorpus(ctx) + if err != nil { + return nil, err + } + return r.runActorFacetItems(ctx, in.Users, "pinned_items", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + actor, login, err := storedActorForSelector(ctx, c, selector) + if err != nil { + return nil, err + } + result, err := source.GetUserPinnedItems(ctx, login, in.Limit) + if err != nil { + return nil, err + } + items := make([]corpus.ActorPinnedItem, len(result.Items)) + for i, item := range result.Items { + items[i] = corpus.ActorPinnedItem{Rank: item.Rank, Kind: item.Kind, NodeID: item.NodeID, Name: item.Name, RepositoryOwner: item.RepositoryOwner, ShowcaseKind: result.ShowcaseKind} + } + raw, _ := json.Marshal(result) + observed := r.now().UTC() + if err := c.ReplaceActorPinnedItems(ctx, actor.ID, items, result.Coverage.Complete, observed, observed, "public", raw); err != nil { + return nil, err + } + return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "showcase_kind": result.ShowcaseKind, "complete": result.Coverage.Complete}, nil + }) + }) + if err != nil { + return mcpcontract.JobReference{}, err + } + return queuedJobReference(id, "sync_user_pinned_items", "GitHub pinned-item synchronization started"), nil +} + +func (r *MCPReader) SyncUserRepositories(ctx context.Context, in mcpcontract.SyncUserRepositoriesInput) (mcpcontract.JobReference, error) { + 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 { + return mcpcontract.JobReference{}, err + } + if in.Relationship != "owned" && in.Relationship != "affiliated" && in.Relationship != "contributed" { + return mcpcontract.JobReference{}, errors.New("relationship must be owned, affiliated, or contributed") + } + if err := normalizeFacetBounds(&in.MaxPages, &in.MaxItems, &in.MaxRequests, len(in.Users)); err != nil { + return mcpcontract.JobReference{}, err + } + id, err := r.submitJob(ctx, "sync_user_repositories", in, func(ctx context.Context, report func(string, string) error) (any, error) { + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return nil, err + } + source, ok := reader.(github.UserRepositoryReader) + if !ok { + return nil, errors.New("GitHub user repository reads are unavailable") + } + c, err := r.openCorpus(ctx) + if err != nil { + return nil, err + } + return r.runActorFacetItems(ctx, in.Users, "repositories", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + actor, login, err := storedActorForSelector(ctx, c, selector) + if err != nil { + return nil, err + } + repositories := []github.Repository{} + page, cursor := 1, "" + complete := false + for attempt := 0; attempt < in.MaxPages && len(repositories) < in.MaxItems; attempt++ { + result, err := source.ListUserRepositories(ctx, login, github.UserRepositoryOptions{Relationship: in.Relationship, Sort: in.Sort, Direction: in.Order, After: cursor, PageOptions: github.PageOptions{Page: page, PerPage: min(100, in.MaxItems-len(repositories))}}) + if err != nil { + return nil, err + } + repositories = append(repositories, result.Items...) + if !result.Page.HasNext { + complete = true + break + } + page = result.Page.NextPage + cursor = result.Page.EndCursor + } + affiliations := make([]corpus.ActorRepositoryAffiliation, 0, len(repositories)) + for _, remote := range repositories { + payload, _ := json.Marshal(remote) + stored, err := c.UpsertRepository(ctx, corpusRepoFromGitHub(remote), string(payload)) + if err != nil { + return nil, err + } + if err := c.AdvanceFacet(ctx, stored.ID, nil, "metadata", remote.UpdatedAt, true, 0); err != nil { + return nil, err + } + affiliations = append(affiliations, corpus.ActorRepositoryAffiliation{RepositoryID: stored.ID, Relationship: in.Relationship}) + } + raw, _ := json.Marshal(repositories) + observed := r.now().UTC() + if err := c.ReplaceActorRepositoryAffiliations(ctx, actor.ID, in.Relationship, affiliations, complete, observed, observed, "public", raw); err != nil { + return nil, err + } + return map[string]any{"actor_id": actor.Key, "login": login, "items": len(repositories), "complete": complete, "relationship": in.Relationship}, nil + }) + }) + if err != nil { + return mcpcontract.JobReference{}, err + } + return queuedJobReference(id, "sync_user_repositories", "GitHub user repository synchronization started"), nil +} + +// SyncUserContributions maps GitHub's typed contribution union into independently queryable corpus rows. +// Keeping the mapping together makes its atomic replacement boundary visible. +// +//nolint:gocognit +func (r *MCPReader) SyncUserContributions(ctx context.Context, in mcpcontract.SyncUserContributionsInput) (mcpcontract.JobReference, error) { + 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 { + return mcpcontract.JobReference{}, err + } + from, err := time.Parse(time.RFC3339, in.From) + if err != nil { + return mcpcontract.JobReference{}, errors.New("from must be RFC 3339") + } + to, err := time.Parse(time.RFC3339, in.To) + if err != nil { + return mcpcontract.JobReference{}, errors.New("to must be RFC 3339") + } + if !to.After(from) || to.Sub(from) > 366*24*time.Hour { + return mcpcontract.JobReference{}, errors.New("contribution period must be positive and no longer than one year") + } + if in.MaxRepositories == 0 { + in.MaxRepositories = 25 + } + if in.MaxRepositories < 1 || in.MaxRepositories > 100 { + return mcpcontract.JobReference{}, errors.New("max_repositories must be 1 to 100") + } + if in.MaxRequests == 0 { + in.MaxRequests = len(in.Users) + } + if in.MaxRequests < len(in.Users) || in.MaxRequests > 100 { + return mcpcontract.JobReference{}, errors.New("max_requests must admit every user and cannot exceed 100") + } + id, err := r.submitJob(ctx, "sync_user_contributions", in, func(ctx context.Context, report func(string, string) error) (any, error) { + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return nil, err + } + source, ok := reader.(github.UserContributionReader) + if !ok { + return nil, errors.New("GitHub user contribution reads are unavailable") + } + c, err := r.openCorpus(ctx) + if err != nil { + return nil, err + } + return r.runActorFacetItems(ctx, in.Users, "contributions", report, func(selector mcpcontract.ActorSelector) (map[string]any, error) { + actor, login, err := storedActorForSelector(ctx, c, selector) + if err != nil { + return nil, err + } + result, err := source.GetUserContributions(ctx, login, github.UserContributionOptions{From: from, To: to, OrganizationNodeID: in.OrganizationNodeID, MaxRepositories: in.MaxRepositories}) + if err != nil { + return nil, err + } + repoIDs := make(map[string]int64) + ensureRepo := func(nodeID, nameWithOwner string) (*int64, error) { + if nodeID == "" || nameWithOwner == "" { + return nil, nil + } + if id, ok := repoIDs[nodeID]; ok { + return &id, nil + } + owner, name, ok := strings.Cut(nameWithOwner, "/") + if !ok { + return nil, nil + } + stored, err := c.GetRepository(ctx, owner, name) + if err != nil { + return nil, err + } + if stored == nil { + stored, err = c.UpsertRepository(ctx, corpus.Repository{Owner: owner, Name: name, ExternalID: nodeID}, `{"source":"github-contributions"}`) + if err != nil { + return nil, err + } + } + repoIDs[nodeID] = stored.ID + id := stored.ID + return &id, nil + } + items := make([]corpus.ActorContributionItem, 0, len(result.Items)) + for _, item := range result.Items { + repositoryID, err := ensureRepo(item.RepositoryNodeID, item.RepositoryNameOwner) + if err != nil { + return nil, err + } + items = append(items, corpus.ActorContributionItem{Kind: item.Kind, OccurredAt: item.OccurredAt, RepositoryID: repositoryID, TargetNodeID: item.TargetNodeID, TargetURL: item.TargetURL, Restricted: item.Restricted, Count: item.Count}) + } + totals := make([]corpus.ActorRepositoryContributionTotal, 0, len(result.RepositoryTotals)) + for _, total := range result.RepositoryTotals { + repositoryID, err := ensureRepo(total.RepositoryNodeID, total.RepositoryNameOwner) + if err != nil { + return nil, err + } + if repositoryID != nil { + totals = append(totals, corpus.ActorRepositoryContributionTotal{RepositoryID: *repositoryID, Kind: total.Kind, Count: total.Count}) + } + } + days := make([]corpus.ActorContributionDay, len(result.Days)) + for i, day := range result.Days { + days[i] = corpus.ActorContributionDay{Date: day.Date, Count: day.Count, Level: day.Level} + } + raw, _ := json.Marshal(result) + if err := c.ApplyActorContributionPeriod(ctx, corpus.ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: to, OrganizationNodeID: in.OrganizationNodeID, AuthorizationScope: "viewer", TotalCommits: intPointer(result.TotalCommits), TotalIssues: intPointer(result.TotalIssues), TotalPullRequests: intPointer(result.TotalPullRequests), TotalPullRequestReviews: intPointer(result.TotalPullRequestReviews), TotalRepositories: intPointer(result.TotalRepositories), RestrictedContributions: intPointer(result.RestrictedContributions), Complete: result.Complete, ObservedAt: r.now().UTC(), SourceUpdatedAt: result.EndedAt, Days: days, Items: items, RepositoryTotals: totals, RawPayload: raw}); err != nil { + return nil, err + } + return map[string]any{"actor_id": actor.Key, "login": login, "items": len(items), "complete": result.Complete, "from": in.From, "to": in.To}, nil + }) + }) + if err != nil { + return mcpcontract.JobReference{}, err + } + return queuedJobReference(id, "sync_user_contributions", "GitHub user contribution synchronization started"), nil +} + +// SearchContributions reads contribution observations from the local corpus. +func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.SearchContributionsInput) (mcpcontract.SearchContributionsOutput, error) { + if len(in.Actors) > 100 || len(in.Repositories) > 100 || len(in.Kinds) > 20 { + return mcpcontract.SearchContributionsOutput{}, errors.New("actors and repositories are limited to 100 items; kinds is limited to 20") + } + if in.Source == "" { + in.Source = "github_profile" + } + if in.Source != "github_profile" { + return mcpcontract.SearchContributionsOutput{}, errors.New("source must be github_profile; corpus_observation is not yet an indexed contribution source") + } + parseBound := func(name, value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s must be RFC 3339", name) + } + return parsed, nil + } + from, err := parseBound("from", in.From) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + to, err := parseBound("to", in.To) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + if !from.IsZero() && !to.IsZero() && !to.After(from) { + return mcpcontract.SearchContributionsOutput{}, errors.New("to must be after from") + } + repositories := make([]string, len(in.Repositories)) + for i, repository := range in.Repositories { + if strings.TrimSpace(repository.Owner) == "" || strings.TrimSpace(repository.Repo) == "" { + return mcpcontract.SearchContributionsOutput{}, fmt.Errorf("repositories[%d] requires owner and repo", i) + } + repositories[i] = repository.Owner + "/" + repository.Repo + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + page, err := c.SearchActorContributions(ctx, corpus.ContributionSearchOptions{ActorRefs: in.Actors, RepositoryRefs: repositories, Kinds: in.Kinds, OrganizationNodeID: in.OrganizationNodeID, From: from, To: to, Sort: in.Sort, Order: in.Order, Limit: in.Limit, Cursor: in.Cursor}) + if err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } + out := mcpcontract.SearchContributionsOutput{Items: make([]mcpcontract.ContributionOutput, len(page.Items)), Total: page.Total, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + for i, item := range page.Items { + out.Items[i] = mcpcontract.ContributionOutput{ActorID: item.ActorKey, Login: item.Login, Kind: item.Kind, Source: "github_profile", OccurredAt: formatTime(item.OccurredAt), RepositoryRef: item.RepositoryRef, TargetNodeID: item.TargetNodeID, TargetURL: item.TargetURL, Restricted: item.Restricted, Count: item.Count} + } + coverageActors := in.Actors + if len(coverageActors) == 0 { + seen := map[string]bool{} + for _, item := range page.Items { + if !seen[item.ActorKey] { + coverageActors = append(coverageActors, item.ActorKey) + seen[item.ActorKey] = true + } + } + } + for _, ref := range coverageActors { + actor, readErr := c.GetActor(ctx, ref) + if readErr != nil { + out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_read_failed", PeriodFrom: in.From, PeriodTo: in.To, OrganizationNodeID: in.OrganizationNodeID}}) + continue + } + if actor == nil { + out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: ref, Facet: mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "actor_not_indexed", PeriodFrom: in.From, PeriodTo: in.To, OrganizationNodeID: in.OrganizationNodeID}}) + continue + } + stored, readErr := c.GetActorContributionCoverage(ctx, actor.ID, in.OrganizationNodeID, from, to) + if readErr != nil { + return mcpcontract.SearchContributionsOutput{}, readErr + } + coverage := mcpcontract.ActorCoverageOutput{Facet: "contributions", Status: "unknown", Reason: "facet_not_synchronized"} + coverage.PeriodFrom, coverage.PeriodTo = in.From, in.To + coverage.OrganizationNodeID = in.OrganizationNodeID + if from.IsZero() || to.IsZero() { + coverage.Reason = "bounded_period_required" + } else if stored != nil { + coverage.Status, coverage.Reason = "complete", "" + coverage.ObservedAt = formatTime(stored.ObservedAt) + coverage.SourceUpdatedAt = formatTime(stored.SourceUpdatedAt) + coverage.AuthorizationScope = stored.AuthorizationScope + } + out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: actor.Key, Facet: coverage}) + } + 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) { + 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 { + return mcpcontract.JobReference{}, err + } + if err := normalizeFacetBounds(&in.MaxPages, &in.MaxItems, &in.MaxRequests, len(in.Users)); err != nil { + return mcpcontract.JobReference{}, err + } + id, err := r.submitJob(ctx, kind, in, func(ctx context.Context, report func(string, string) error) (any, error) { + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return nil, err + } + c, err := r.openCorpus(ctx) + 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) }) + }) + if err != nil { + return mcpcontract.JobReference{}, err + } + 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) { + items := make([]map[string]any, len(selectors)) + complete := 0 + if err := report(phase, jobProgressCounts(0, len(selectors))); err != nil { + return nil, err + } + for i, selector := range selectors { + if err := ctx.Err(); err != nil { + return nil, err + } + 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} + } else { + value["key"] = actorSelectorKey(selector) + value["status"] = "complete" + items[i] = value + complete++ + } + if err := report(phase, jobProgressCounts(i+1, len(selectors))); err != nil { + return nil, err + } + } + status := "complete" + if complete != len(selectors) { + status = "partial" + } + 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 + } + if *maxItems == 0 { + *maxItems = 100 + } + if *maxRequests == 0 { + *maxRequests = userCount * *maxPages + } + if *maxPages < 1 || *maxPages > 10 { + return errors.New("max_pages must be 1 to 10") + } + if *maxItems < 1 || *maxItems > 1000 { + return errors.New("max_items_per_user must be 1 to 1000") + } + if *maxRequests < userCount*(*maxPages) || *maxRequests > 1000 { + return errors.New("max_requests must cover users times max_pages and cannot exceed 1000") + } + return nil +} +func facetMaxPages(value int) int { + if value <= 0 { + return 1 + } + return min(value, 10) +} +func facetMaxItems(value int) int { + if value <= 0 { + return 100 + } + return min(value, 1000) +} +func intPointer(value int) *int { return &value } diff --git a/internal/app/mcp_actors.go b/internal/app/mcp_actors.go new file mode 100644 index 0000000..7e84b33 --- /dev/null +++ b/internal/app/mcp_actors.go @@ -0,0 +1,394 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +// SearchGitHubUsers performs one bounded live discovery page and persists only +// identity observations; it never expands the result into N profile reads. +func (r *MCPReader) SearchGitHubUsers(ctx context.Context, in mcpcontract.SearchGitHubUsersInput) (mcpcontract.SearchGitHubUsersOutput, error) { + in.Query = strings.TrimSpace(in.Query) + if in.Query == "" { + return mcpcontract.SearchGitHubUsersOutput{}, errors.New("query is required") + } + if in.Sort == "" { + in.Sort = "best_match" + } + if in.Order == "" { + in.Order = "desc" + } + if in.Limit == 0 { + in.Limit = 20 + } + if in.Page == 0 { + in.Page = 1 + } + if in.Limit < 1 || in.Limit > 100 || in.Page < 1 || in.Page > 10 { + return mcpcontract.SearchGitHubUsersOutput{}, errors.New("limit must be 1 to 100 and page must be 1 to 10") + } + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return mcpcontract.SearchGitHubUsersOutput{}, err + } + searcher, ok := reader.(github.UserSearcher) + if !ok { + return mcpcontract.SearchGitHubUsersOutput{}, errors.New("GitHub user search is not available") + } + result, err := searcher.SearchUsers(ctx, github.UserSearchOptions{Query: in.Query, Sort: mapBestMatch(in.Sort), Order: in.Order, Page: in.Page, PerPage: in.Limit}) + if err != nil { + return mcpcontract.SearchGitHubUsersOutput{}, err + } + c, err := r.openCorpus(ctx) + if err != nil { + return mcpcontract.SearchGitHubUsersOutput{}, err + } + observedAt := r.now().UTC() + out := mcpcontract.SearchGitHubUsersOutput{Query: in.Query, Total: result.Total, Incomplete: result.Incomplete, Page: in.Page, ObservedAt: formatTime(observedAt), Rate: githubRateOutput(result.Rate), Items: make([]mcpcontract.ActorIdentityOutput, 0, len(result.Items))} + if result.Page.NextPage > 0 { + out.NextPage = result.Page.NextPage + } + for _, actor := range result.Items { + payload, _ := json.Marshal(actor) + databaseID := actor.ID + stored, err := c.ApplyActorIdentityObservation(ctx, "github", actor.Login, actor.NodeID, &databaseID, normalizeActorKind(actor.Kind), "public", observedAt, payload) + if err != nil { + return mcpcontract.SearchGitHubUsersOutput{}, err + } + out.Items = append(out.Items, actorIdentityOutput(stored)) + } + return out, nil +} + +func mapBestMatch(sort string) string { + if sort == "best_match" { + return "" + } + return sort +} + +// SyncUsers submits a durable exact-profile acquisition batch. +func (r *MCPReader) SyncUsers(ctx context.Context, in mcpcontract.SyncUsersInput) (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 in.MaxRequests == 0 { + in.MaxRequests = len(in.Users) + } + 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 { + return mcpcontract.JobReference{}, err + } + 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) + }) + if err != nil { + return mcpcontract.JobReference{}, err + } + 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) { + reader, err := r.githubReader() //nolint:contextcheck // Client construction performs no request; operations below receive ctx. + if err != nil { + return nil, err + } + profiles, ok := reader.(github.UserProfileReader) + if !ok { + return nil, errors.New("GitHub user profile reads are not available") + } + c, err := r.openCorpus(ctx) + if err != nil { + return nil, err + } + items := make([]map[string]any, len(in.Users)) + complete := 0 + if err := report("profiles", jobProgressCounts(0, len(in.Users))); err != nil { + return nil, err + } + for index, selector := range in.Users { + if err := ctx.Err(); err != nil { + return nil, err + } + login, resolveErr := resolveActorSelectorLogin(ctx, c, selector) + 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 { + return nil, err + } + continue + } + 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 { + return nil, err + } + continue + } + payload, _ := json.Marshal(actor) + databaseID := actor.ID + stored, persistErr := c.ApplyActorProfileObservation(ctx, corpus.ActorProfileObservation{ + Provider: "github", NodeID: actor.NodeID, DatabaseID: &databaseID, Kind: normalizeActorKind(actor.Kind), Login: actor.Login, + SourceUpdatedAt: actor.UpdatedAt, ObservedAt: r.now().UTC(), AuthorizationScope: "public", RawPayload: payload, + Profile: corpus.ActorProfile{Name: actor.Name, AvatarURL: actor.AvatarURL, Bio: actor.Bio, Company: actor.Company, Location: actor.Location, WebsiteURL: actor.WebsiteURL, PublicEmail: actor.PublicEmail, TwitterUsername: actor.TwitterUsername, Hireable: actor.Hireable, Followers: actor.Followers, Following: actor.Following, PublicRepositories: actor.PublicRepositories, PublicGists: actor.PublicGists, ProviderCreatedAt: actor.CreatedAt}, + }) + if persistErr != nil { + return nil, persistErr + } + items[index] = map[string]any{"key": actorSelectorKey(selector), "status": "complete", "actor_id": stored.Key, "login": stored.Login} + complete++ + if err := report("profiles", jobProgressCounts(index+1, len(in.Users))); err != nil { + return nil, err + } + } + status := "complete" + if complete != len(in.Users) { + 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 +} + +func normalizeActorKind(kind string) string { + switch strings.ToLower(kind) { + case "user", "bot", "organization", "mannequin": + return strings.ToLower(kind) + default: + return "unknown" + } +} + +// SearchActors is a local-only actor search. +func (r *MCPReader) SearchActors(ctx context.Context, in mcpcontract.SearchActorsInput) (mcpcontract.SearchActorsOutput, error) { + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.SearchActorsOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.SearchActorsOutput{}, err + } + page, err := c.SearchActors(ctx, corpus.ActorSearchOptions{Query: in.Query, Kinds: in.Kinds, Sort: in.Sort, Limit: in.Limit, Cursor: in.Cursor}) + if err != nil { + return mcpcontract.SearchActorsOutput{}, err + } + out := mcpcontract.SearchActorsOutput{Items: make([]mcpcontract.ActorOutput, 0, len(page.Actors)), Total: page.Total, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + for _, actor := range page.Actors { + out.Items = append(out.Items, actorOutput(actor)) + } + return out, nil +} + +// GetActors performs an input-ordered local actor read. +func (r *MCPReader) GetActors(ctx context.Context, in mcpcontract.GetActorsInput) (mcpcontract.GetActorsOutput, error) { + if len(in.Actors) < 1 || len(in.Actors) > 100 { + return mcpcontract.GetActorsOutput{}, errors.New("actors must contain 1 to 100 items") + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.GetActorsOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.GetActorsOutput{}, err + } + out := mcpcontract.GetActorsOutput{Items: make([]mcpcontract.ActorBatchItem[mcpcontract.ActorOutput], len(in.Actors)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + for index, ref := range in.Actors { + item := mcpcontract.ActorBatchItem[mcpcontract.ActorOutput]{Key: ref, Status: "complete"} + actor, readErr := c.GetActor(ctx, ref) + if readErr != nil { + item.Status = "failed" + item.Reason = "actor_read_failed" + item.Message = readErr.Error() + } else if actor == nil { + item.Status = "unavailable" + item.Reason = "actor_not_indexed" + item.Message = "actor is not present in the local corpus" + } else { + value := actorOutput(*actor) + item.Value = &value + } + out.Items[index] = item + } + return out, nil +} + +// GetActorFacets reads local facet coverage and returns opaque resource URIs. +func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorFacetsInput) (mcpcontract.GetActorFacetsOutput, error) { + if len(in.Actors) < 1 || len(in.Actors) > 100 { + return mcpcontract.GetActorFacetsOutput{}, errors.New("actors must contain 1 to 100 items") + } + if len(in.Facets) < 1 || len(in.Facets) > 7 { + return mcpcontract.GetActorFacetsOutput{}, errors.New("facets must contain 1 to 7 non-period facets") + } + valid := map[string]bool{"profile": true, "social_accounts": true, "organizations": true, "pinned_items": true, "repositories:owned": true, "repositories:affiliated": true, "repositories:contributed": true} + for _, facet := range in.Facets { + if !valid[facet] { + return mcpcontract.GetActorFacetsOutput{}, fmt.Errorf("unsupported actor facet %q", facet) + } + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.GetActorFacetsOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.GetActorFacetsOutput{}, err + } + out := mcpcontract.GetActorFacetsOutput{Items: make([]mcpcontract.ActorBatchItem[mcpcontract.ActorFacetReferenceOutput], len(in.Actors)), SnapshotToken: snapshotIdentity(in.SnapshotToken, revision)} + for index, ref := range in.Actors { + item := mcpcontract.ActorBatchItem[mcpcontract.ActorFacetReferenceOutput]{Key: ref, Status: "complete"} + actor, readErr := c.GetActor(ctx, ref) + if readErr != nil { + item.Status = "failed" + item.Reason = "actor_read_failed" + item.Message = readErr.Error() + out.Items[index] = item + continue + } + if actor == nil { + item.Status = "unavailable" + item.Reason = "actor_not_indexed" + item.Message = "actor is not present in the local corpus" + out.Items[index] = item + continue + } + coverageByFacet, coverageErr := c.ListActorFacetCoverage(ctx, actor.ID, in.Facets) + if coverageErr != nil { + return mcpcontract.GetActorFacetsOutput{}, coverageErr + } + value := mcpcontract.ActorFacetReferenceOutput{ActorID: actor.Key, Facets: make([]mcpcontract.ActorCoverageOutput, 0, len(in.Facets)), URIs: make([]string, 0, len(in.Facets))} + for _, facet := range in.Facets { + coverage := mcpcontract.ActorCoverageOutput{Facet: facet, Status: "unknown", Reason: "facet_not_synchronized"} + if stored, ok := coverageByFacet[facet]; ok { + coverage.Status = map[bool]string{true: "complete", false: "truncated"}[stored.Complete] + coverage.ObservedAt = formatTime(stored.ObservedAt) + coverage.SourceUpdatedAt = formatTime(stored.SourceUpdatedAt) + coverage.AuthorizationScope = stored.AuthorizationScope + if stored.Complete { + coverage.Reason = "" + } else { + coverage.Truncated = true + coverage.Reason = "facet_incomplete" + } + } + value.Facets = append(value.Facets, coverage) + value.URIs = append(value.URIs, "gitcontribute://actor/"+url.PathEscape(actor.Key)+"/facet/"+url.PathEscape(facet)) + if coverage.Status != "complete" { + item.Status = "unavailable" + item.Reason = "actor_facet_unknown" + item.Message = "one or more requested actor facets are not completely synchronized" + } + } + item.Value = &value + out.Items[index] = item + } + return out, nil +} + +// ActorResource returns the canonical stored actor view without refreshing it. +func (r *MCPReader) ActorResource(ctx context.Context, ref, facet string) (any, error) { + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return nil, err + } + actor, err := c.GetActor(ctx, ref) + if err != nil { + return nil, err + } + if actor == nil { + return nil, mcpcontract.ErrNotFound + } + if facet == "" || facet == "profile" { + return actorOutput(*actor), nil + } + observation, err := c.GetActorFacetObservation(ctx, actor.ID, facet) + if err != nil { + return nil, err + } + if observation == nil { + return nil, mcpcontract.ErrNotFound + } + var value any + if err := json.Unmarshal(observation.Payload, &value); err != nil { + value = string(observation.Payload) + } + return map[string]any{"schema_version": "gitcontribute.actor-facet.v1", "actor_id": actor.Key, "facet": facet, "complete": observation.Complete, "observed_at": formatTime(observation.ObservedAt), "source_updated_at": formatTime(observation.SourceUpdatedAt), "authorization_scope": observation.AuthorizationScope, "value": value}, nil +} + +func actorIdentityOutput(actor corpus.Actor) mcpcontract.ActorIdentityOutput { + return mcpcontract.ActorIdentityOutput{ActorID: actor.Key, Provider: actor.Provider, NodeID: actor.NodeID, DatabaseID: actor.DatabaseID, Kind: actor.Kind, Login: actor.Login} +} + +func actorOutput(actor corpus.Actor) mcpcontract.ActorOutput { + out := mcpcontract.ActorOutput{ActorIdentityOutput: actorIdentityOutput(actor), URI: "gitcontribute://actor/" + url.PathEscape(actor.Key)} + coverage := mcpcontract.ActorCoverageOutput{Facet: "profile", Status: "unknown", Reason: "profile_not_synchronized"} + if actor.Profile != nil { + p := actor.Profile + out.Profile = &mcpcontract.ActorProfileOutput{Name: p.Name, AvatarURL: p.AvatarURL, Bio: p.Bio, Company: p.Company, Location: p.Location, WebsiteURL: p.WebsiteURL, PublicEmail: p.PublicEmail, TwitterUsername: p.TwitterUsername, Hireable: p.Hireable, Followers: p.Followers, Following: p.Following, PublicRepositories: p.PublicRepositories, PublicGists: p.PublicGists, ProviderCreatedAt: formatTime(p.ProviderCreatedAt)} + coverage.Status = "complete" + coverage.Reason = "" + coverage.ObservedAt = formatTime(p.ObservedAt) + coverage.SourceUpdatedAt = formatTime(p.SourceUpdatedAt) + coverage.AuthorizationScope = p.AuthorizationScope + } + out.Coverage = []mcpcontract.ActorCoverageOutput{coverage} + return out +} diff --git a/internal/app/mcp_actors_test.go b/internal/app/mcp_actors_test.go new file mode 100644 index 0000000..e9cfcb0 --- /dev/null +++ b/internal/app/mcp_actors_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "context" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestSearchContributionsReportsMissingActorCoverage(t *testing.T) { + t.Parallel() + reader := &MCPReader{Service: newSearchTestService(t)} + out, err := reader.SearchContributions(context.Background(), mcpcontract.SearchContributionsInput{ + Actors: []string{"github:node:U_missing"}, + From: "2025-01-01T00:00:00Z", + To: "2025-02-01T00:00:00Z", + Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(out.Items) != 0 || len(out.Coverage) != 1 || out.Coverage[0].ActorID != "github:node:U_missing" || out.Coverage[0].Facet.Reason != "actor_not_indexed" { + t.Fatalf("missing actor result = %+v", out) + } +} + +func TestActorSelectorsNormalizeWhitespaceBeforeDuplicateDetection(t *testing.T) { + t.Parallel() + err := validateActorSelectors([]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) + } +} diff --git a/internal/app/mcp_stdio_e2e_test.go b/internal/app/mcp_stdio_e2e_test.go index 9d6a0dc..a639828 100644 --- a/internal/app/mcp_stdio_e2e_test.go +++ b/internal/app/mcp_stdio_e2e_test.go @@ -84,8 +84,8 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { t.Fatalf("initialize result = %+v", initialized) } for _, phrase := range []string{ - "Prefer corpus tools for offline reads", "never refresh data implicitly", "explicit network reads", - "poll advertised job tools in batches", "Missing or truncated coverage is unknown", + "corpus.* tools are offline reads", "never refresh implicitly", "explicit bounded network reads", + "polling through jobs.get", "observations are unknown rather than negative evidence", "Only advertised tools are available", "never mutates GitHub", } { if !strings.Contains(initialized.Instructions, phrase) { @@ -100,7 +100,7 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { } tools[tool.Name] = tool } - for _, name := range []string{mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolRankThreads, mcpcontract.ToolFindPrecedents, mcpcontract.ToolSyncPortfolio, mcpcontract.ToolPreflightContribution, mcpcontract.ToolListPullRequestPortfolio, mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolSearchGitHubThreads, mcpcontract.ToolReadSourceFiles, mcpcontract.ToolSearchCodeBatch, mcpcontract.ToolSyncRepositoryContext, mcpcontract.ToolSyncThreads, mcpcontract.ToolHydrateThreads, mcpcontract.ToolEnsureCoverage, mcpcontract.ToolGetSourceAuditWorkflow, mcpcontract.ToolGetCatalogContract, mcpcontract.ToolQueryDeepWiki, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts, mcpcontract.ToolIndexPullRequestFeedback, mcpcontract.ToolSyncPullRequestFeedback, mcpcontract.ToolSearchPullRequestFeedback} { + for _, name := range []string{mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolFindPrecedents, mcpcontract.ToolSyncPortfolio, mcpcontract.ToolListPullRequestPortfolio, mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolSearchGitHubThreads, mcpcontract.ToolReadSourceFiles, mcpcontract.ToolSearchCodeBatch, mcpcontract.ToolSyncRepositoryContext, mcpcontract.ToolSyncThreads, mcpcontract.ToolHydrateThreads, mcpcontract.ToolEnsureCoverage, mcpcontract.ToolGetSourceAuditWorkflow, mcpcontract.ToolGetCatalogContract, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts, mcpcontract.ToolIndexPullRequestFeedback, mcpcontract.ToolSyncPullRequestFeedback, mcpcontract.ToolSearchPullRequestFeedback} { if tools[name] == nil { t.Errorf("tools/list missing %s", name) } @@ -129,11 +129,6 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { t.Fatalf("compact thread batch = %+v", threads) } - ranked := callMCPTool[mcpcontract.RankOpportunitiesOutput](ctx, t, session, mcpcontract.ToolRankThreads, map[string]any{"repositories": []any{map[string]any{"owner": "acme", "repo": "observed"}}, "limit": 10, "max_results_per_repository": 10}) - if len(ranked.Candidates) == 0 || ranked.Candidates[0].Number != 1 { - t.Fatalf("ranked opportunities = %+v", ranked) - } - precedents := callMCPTool[mcpcontract.FindPrecedentsOutput](ctx, t, session, mcpcontract.ToolFindPrecedents, map[string]any{"threads": []any{map[string]any{"owner": "acme", "repo": "observed", "number": 1}}, "limit": 10}) if precedents.Total == 0 || precedents.Items[0].Value == nil || precedents.Items[0].Value.Matches[0].Ref != "acme/observed#2" { t.Fatalf("precedents = %+v", precedents) @@ -144,9 +139,6 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { t.Fatalf("portfolio = %+v", portfolio) } - job := callMCPTool[mcpcontract.JobReference](ctx, t, session, mcpcontract.ToolBuildRepositoryDossier, map[string]any{"owner": "acme", "repo": "observed"}) - waitMCPJob(ctx, t, session, job.ID) - invalid, err := session.CallTool(ctx, &mcp.CallToolParams{Name: mcpcontract.ToolHydrateThreads, Arguments: map[string]any{"threads": []any{map[string]any{"owner": "acme", "repo": "observed", "number": 1}}, "facets": []any{}}}) if err != nil { t.Fatal(err) @@ -453,7 +445,17 @@ func seedMCPStdioEmptyCorpus(ctx context.Context, t *testing.T, home string) { func newMCPGitHubServer(t *testing.T) *httptest.Server { t.Helper() + now := time.Now().UTC() + timestamps := map[string]string{ + "2026-07-18T22:00:00Z": now.Add(-30 * time.Minute).Format(time.RFC3339), + "2026-07-18T21:05:00Z": now.Add(-55 * time.Minute).Format(time.RFC3339), + "2026-07-18T21:00:00Z": now.Add(-time.Hour).Format(time.RFC3339), + "2026-07-18T20:00:00Z": now.Add(-2 * time.Hour).Format(time.RFC3339), + "2026-07-18T19:00:00Z": now.Add(-3 * time.Hour).Format(time.RFC3339), + "2026-07-15T10:00:00Z": now.Add(-72 * time.Hour).Format(time.RFC3339), + } return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w = &relativeTimestampWriter{ResponseWriter: w, replacements: timestamps} if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/graphql") { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{ @@ -550,6 +552,19 @@ func newMCPGitHubServer(t *testing.T) *httptest.Server { })) } +type relativeTimestampWriter struct { + http.ResponseWriter + replacements map[string]string +} + +func (w *relativeTimestampWriter) Write(payload []byte) (int, error) { + text := string(payload) + for fixed, relative := range w.replacements { + text = strings.ReplaceAll(text, fixed, relative) + } + return w.ResponseWriter.Write([]byte(text)) +} + func waitMCPJob(ctx context.Context, t *testing.T, session *mcp.ClientSession, id string) mcpcontract.GetJobOutput { t.Helper() ticker := time.NewTicker(10 * time.Millisecond) diff --git a/internal/mcpcontract/actor_contracts.go b/internal/mcpcontract/actor_contracts.go new file mode 100644 index 0000000..102285c --- /dev/null +++ b/internal/mcpcontract/actor_contracts.go @@ -0,0 +1,218 @@ +package mcpcontract + +const ( + ToolSearchGitHubUsers = "github.search_users" + ToolSyncUsers = "github.sync_users" + ToolSyncUserSocialAccounts = "github.sync_user_social_accounts" + ToolSyncUserOrganizations = "github.sync_user_organizations" + ToolSyncUserPinnedItems = "github.sync_user_pinned_items" + ToolSyncUserRepositories = "github.sync_user_repositories" + ToolSyncUserContributions = "github.sync_user_contributions" + ToolSearchActors = "corpus.search_actors" + ToolGetActors = "corpus.get_actors" + ToolGetActorFacets = "corpus.get_actor_facets" + ToolSearchContributions = "corpus.search_contributions" +) + +// ActorSelector is a discriminated exact GitHub identity selector. +type ActorSelector struct { + Type string `json:"type" jsonschema:"Identity selector: login or node_id"` + Login string `json:"login,omitempty" jsonschema:"GitHub login when type is login"` + NodeID string `json:"node_id,omitempty" jsonschema:"GitHub GraphQL node ID when type is node_id"` +} + +type SearchGitHubUsersInput struct { + Query string `json:"query" jsonschema:"Non-empty GitHub user search query"` + Sort string `json:"sort,omitempty" jsonschema:"Provider ordering: best_match, followers, repositories, or joined"` + Order string `json:"order,omitempty" jsonschema:"Provider order: asc or desc"` + Limit int `json:"limit,omitempty" jsonschema:"Results to return from 1 to 100"` + Page int `json:"page,omitempty" jsonschema:"Provider result page from 1 to 10"` +} + +type ActorIdentityOutput struct { + ActorID string `json:"actor_id"` + Provider string `json:"provider"` + NodeID string `json:"node_id,omitempty"` + DatabaseID *int64 `json:"database_id,omitempty"` + Kind string `json:"kind"` + Login string `json:"login"` +} + +type SearchGitHubUsersOutput struct { + Query string `json:"query"` + Total int `json:"total"` + Incomplete bool `json:"incomplete"` + Page int `json:"page"` + NextPage int `json:"next_page,omitempty"` + Items []ActorIdentityOutput `json:"items"` + ObservedAt string `json:"observed_at"` + Rate GitHubRateOutput `json:"rate"` +} + +type SyncUsersInput struct { + Users []ActorSelector `json:"users" jsonschema:"One to 100 exact GitHub users"` + MaxRequests int `json:"max_requests,omitempty" jsonschema:"Total admitted GitHub requests from 1 to 100"` +} + +type SyncUserFacetInput struct { + Users []ActorSelector `json:"users" jsonschema:"One to 100 exact GitHub users"` + MaxPages int `json:"max_pages,omitempty" jsonschema:"Maximum pages per user from 1 to 10"` + MaxItems int `json:"max_items_per_user,omitempty" jsonschema:"Maximum child items per user from 1 to 1000"` + MaxRequests int `json:"max_requests,omitempty" jsonschema:"Total admitted GitHub requests from 1 to 1000"` +} + +type SyncUserPinnedItemsInput struct { + Users []ActorSelector `json:"users" jsonschema:"One to 50 exact GitHub users"` + Limit int `json:"limit,omitempty" jsonschema:"Pinned or showcase items per user from 1 to 6"` + MaxRequests int `json:"max_requests,omitempty" jsonschema:"Total admitted GitHub GraphQL requests from 1 to 100"` +} + +type SyncUserRepositoriesInput struct { + Users []ActorSelector `json:"users" jsonschema:"One to 50 exact GitHub users"` + Relationship string `json:"relationship" jsonschema:"Repository relationship: owned, affiliated, or contributed"` + Sort string `json:"sort,omitempty" jsonschema:"Provider ordering: created, updated, pushed, or full_name"` + Order string `json:"order,omitempty" jsonschema:"Provider order: asc or desc"` + MaxPages int `json:"max_pages,omitempty" jsonschema:"Maximum pages per user from 1 to 10"` + MaxItems int `json:"max_items_per_user,omitempty" jsonschema:"Maximum repositories per user from 1 to 1000"` + MaxRequests int `json:"max_requests,omitempty" jsonschema:"Total admitted GitHub requests from 1 to 1000"` +} + +type SyncUserContributionsInput struct { + Users []ActorSelector `json:"users" jsonschema:"One to 20 exact GitHub users"` + From string `json:"from" jsonschema:"Inclusive RFC 3339 period start"` + To string `json:"to" jsonschema:"Exclusive RFC 3339 period end no more than one year after from"` + OrganizationNodeID string `json:"organization_node_id,omitempty"` + MaxRepositories int `json:"max_repositories,omitempty" jsonschema:"Repository groups per contribution kind from 1 to 100"` + MaxRequests int `json:"max_requests,omitempty" jsonschema:"Total admitted GitHub GraphQL requests from 1 to 100"` +} + +type ActorProfileOutput struct { + Name *string `json:"name,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Bio *string `json:"bio,omitempty"` + Company *string `json:"company,omitempty"` + Location *string `json:"location,omitempty"` + WebsiteURL *string `json:"website_url,omitempty"` + PublicEmail *string `json:"public_email,omitempty"` + TwitterUsername *string `json:"twitter_username,omitempty"` + Hireable *bool `json:"hireable,omitempty"` + Followers *int `json:"followers,omitempty"` + Following *int `json:"following,omitempty"` + PublicRepositories *int `json:"public_repositories,omitempty"` + PublicGists *int `json:"public_gists,omitempty"` + ProviderCreatedAt string `json:"provider_created_at,omitempty"` +} + +type ActorCoverageOutput struct { + Facet string `json:"facet"` + Status string `json:"status" jsonschema:"Coverage status: complete, paginated, truncated, unknown, retryable, or unavailable"` + ObservedAt string `json:"observed_at,omitempty"` + SourceUpdatedAt string `json:"source_updated_at,omitempty"` + AuthorizationScope string `json:"authorization_scope,omitempty"` + PeriodFrom string `json:"period_from,omitempty"` + PeriodTo string `json:"period_to,omitempty"` + OrganizationNodeID string `json:"organization_node_id,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ActorOutput struct { + ActorIdentityOutput + Profile *ActorProfileOutput `json:"profile,omitempty"` + Coverage []ActorCoverageOutput `json:"coverage"` + URI string `json:"uri"` +} + +type SearchActorsInput struct { + Query string `json:"query,omitempty"` + Kinds []string `json:"kinds,omitempty"` + Sort string `json:"sort,omitempty"` + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` + SnapshotToken string `json:"snapshot_token,omitempty"` +} + +type SearchActorsOutput struct { + Items []ActorOutput `json:"items"` + Total int `json:"total"` + NextCursor string `json:"next_cursor,omitempty"` + SnapshotToken string `json:"snapshot_token"` +} + +type GetActorsInput struct { + Actors []string `json:"actors" jsonschema:"One to 100 actor IDs, node IDs, or observed logins"` + SnapshotToken string `json:"snapshot_token,omitempty"` +} + +// ActorBatchItem is intentionally small: actor reads report item state without +// embedding the catalog-wide workflow recovery union in every result schema. +type ActorBatchItem[T any] struct { + Key string `json:"key"` + Status string `json:"item_status" jsonschema:"complete, retryable, unavailable, or failed"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Value *T `json:"value,omitempty"` +} + +type GetActorsOutput struct { + Items []ActorBatchItem[ActorOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` +} + +type GetActorFacetsInput struct { + Actors []string `json:"actors" jsonschema:"One to 100 actor IDs, node IDs, or observed logins"` + Facets []string `json:"facets" jsonschema:"One to seven exact non-period actor facets"` + SnapshotToken string `json:"snapshot_token,omitempty"` +} + +type ActorFacetReferenceOutput struct { + ActorID string `json:"actor_id"` + Facets []ActorCoverageOutput `json:"facets"` + URIs []string `json:"uris,omitempty"` +} + +type GetActorFacetsOutput struct { + Items []ActorBatchItem[ActorFacetReferenceOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` +} + +type SearchContributionsInput struct { + Actors []string `json:"actors,omitempty"` + Repositories []RepositoryRef `json:"repositories,omitempty"` + Kinds []string `json:"kinds,omitempty"` + Source string `json:"source,omitempty" jsonschema:"Contribution source: github_profile or corpus_observation"` + OrganizationNodeID string `json:"organization_node_id,omitempty" jsonschema:"Exact GitHub organization node ID; empty selects global contribution periods"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` + SnapshotToken string `json:"snapshot_token,omitempty"` +} + +type ContributionOutput struct { + ActorID string `json:"actor_id"` + Login string `json:"login"` + Kind string `json:"kind"` + Source string `json:"source"` + OccurredAt string `json:"occurred_at"` + RepositoryRef string `json:"repository_ref,omitempty"` + TargetNodeID string `json:"target_node_id,omitempty"` + TargetURL string `json:"target_url,omitempty"` + Restricted bool `json:"restricted"` + Count int `json:"count"` +} + +type SearchContributionsOutput struct { + Items []ContributionOutput `json:"items"` + Total int `json:"total"` + NextCursor string `json:"next_cursor,omitempty"` + SnapshotToken string `json:"snapshot_token"` + Coverage []ActorContributionCoverage `json:"coverage"` +} + +type ActorContributionCoverage struct { + ActorID string `json:"actor_id"` + Facet ActorCoverageOutput `json:"facet"` +} diff --git a/internal/mcpcontract/tool_contracts.go b/internal/mcpcontract/tool_contracts.go index 7ade668..9ad81f5 100644 --- a/internal/mcpcontract/tool_contracts.go +++ b/internal/mcpcontract/tool_contracts.go @@ -43,8 +43,8 @@ func Unavailable(code, message string, actions ...ToolCall) error { const ( ToolSearchRepositories = "corpus.search_repositories" ToolSearchThreads = "corpus.search_threads" - ToolSearchCode = "corpus.search_code" - ToolSearchCodeBatch = "corpus.search_code_batch" + ToolSearchCode = "corpus.search_code_legacy" + ToolSearchCodeBatch = "corpus.search_code" ToolGetRepositories = "corpus.get_repositories" ToolGetThreads = "corpus.get_threads" ToolGetThreadFacets = "corpus.get_thread_facets" @@ -73,9 +73,9 @@ const ( ToolPreflightContribution = "workflow.preflight_contribution" ToolSyncPullRequestFeedback = "github.sync_pull_request_feedback" ToolIndexPullRequestFeedback = "github.index_pull_request_feedback" - ToolSyncCIFailures = "github.sync_ci_failures" + ToolSyncCIFailures = "github.sync_pull_request_ci" ToolSearchPullRequestFeedback = "corpus.search_pull_request_feedback" - ToolListPullRequestPortfolio = "corpus.list_pull_requests" + ToolListPullRequestPortfolio = "corpus.search_pull_requests" ToolFindPortfolioOverlaps = "corpus.find_pull_request_overlaps" ToolIndexRepositories = "code.index_repositories" ToolPreparePullRequests = "workspace.prepare_pull_requests" diff --git a/internal/mcpserver/actors.go b/internal/mcpserver/actors.go new file mode 100644 index 0000000..814e67f --- /dev/null +++ b/internal/mcpserver/actors.go @@ -0,0 +1,224 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func (s *Server) registerActorTools() { + readOnly := readOnlyAnnotations() + addCatalogTool(s, catalogTool[mcpcontract.SearchActorsInput, mcpcontract.SearchActorsOutput]{ + name: mcpcontract.ToolSearchActors, title: "Search stored GitHub actors", + description: "Search indexed GitHub users, bots, organizations, and other actors by stored profile facts. Offline; returns snapshot-bound facts and coverage.", + annotations: readOnly, supportedBy: supports[ActorReader], input: inputSchema[mcpcontract.SearchActorsInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "kinds", 1, 5) + setArrayEnum(sc, "kinds", "user", "bot", "organization", "mannequin", "unknown") + setEnum(sc, "sort", "relevance", "login", "followers", "public_repositories", "profile_updated_at", "observed_at") + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + }), + output: outputSchema[mcpcontract.SearchActorsOutput]("Snapshot-bound stored actor search results."), handler: s.searchActors, + }) + addCatalogTool(s, catalogTool[mcpcontract.GetActorsInput, mcpcontract.GetActorsOutput]{ + name: mcpcontract.ToolGetActors, title: "Get stored GitHub actors", + description: "Read exact stored actor identities, nullable profile facts, and profile coverage for up to 100 references. Offline.", + annotations: readOnly, supportedBy: supports[ActorReader], input: inputSchema[mcpcontract.GetActorsInput](func(sc *schemaBuilder) { setArrayBounds(sc, "actors", 1, 100) }), + output: outputSchema[mcpcontract.GetActorsOutput]("Ordered exact actor results with item-level availability."), handler: s.getActors, + }) + addCatalogTool(s, catalogTool[mcpcontract.GetActorFacetsInput, mcpcontract.GetActorFacetsOutput]{ + name: mcpcontract.ToolGetActorFacets, title: "Get stored actor facet coverage", + description: "Read coverage and canonical resource URIs for selected stored actor facets. Offline; missing observations remain unknown.", + annotations: readOnly, supportedBy: supports[ActorReader], input: inputSchema[mcpcontract.GetActorFacetsInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "actors", 1, 100) + setArrayBounds(sc, "facets", 1, 7) + setArrayEnum(sc, "facets", "profile", "social_accounts", "organizations", "pinned_items", "repositories:owned", "repositories:affiliated", "repositories:contributed") + }), + output: outputSchema[mcpcontract.GetActorFacetsOutput]("Ordered actor facet coverage and resource references."), handler: s.getActorFacets, + }) + addCatalogTool(s, catalogTool[mcpcontract.SearchGitHubUsersInput, mcpcontract.SearchGitHubUsersOutput]{ + name: mcpcontract.ToolSearchGitHubUsers, title: "Search live GitHub users", + description: "Search one bounded GitHub user page and persist identity observations. It does not hydrate result profiles.", + annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], input: inputSchema[mcpcontract.SearchGitHubUsersInput](func(sc *schemaBuilder) { + setEnum(sc, "sort", "best_match", "followers", "repositories", "joined") + setDefault(sc, "sort", "best_match") + setEnum(sc, "order", "asc", "desc") + setDefault(sc, "order", "desc") + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + setRange(sc, "page", 1, 10) + setDefault(sc, "page", 1) + }), + output: outputSchema[mcpcontract.SearchGitHubUsersOutput]("One persisted live GitHub user-search page."), handler: s.searchGitHubUsers, + }) + addCatalogTool(s, catalogTool[mcpcontract.SyncUsersInput, mcpcontract.JobReference]{ + name: mcpcontract.ToolSyncUsers, title: "Sync exact GitHub user profiles", + description: "Fetch and persist profile headers for an ordered bounded set of exact GitHub users. Returns a durable job reference.", + annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], input: inputSchema[mcpcontract.SyncUsersInput](func(sc *schemaBuilder) { + configureActorSelectorModes(sc) + setArrayBounds(sc, "users", 1, 100) + setRange(sc, "max_requests", 1, 100) + }), + output: outputSchema[mcpcontract.JobReference]("Durable exact-user profile synchronization job."), handler: s.syncUsers, + }) + registerFacet := func(name, title, description string, handler mcp.ToolHandlerFor[mcpcontract.SyncUserFacetInput, mcpcontract.JobReference]) { + addCatalogTool(s, catalogTool[mcpcontract.SyncUserFacetInput, mcpcontract.JobReference]{ + name: name, title: title, description: description, + annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], input: inputSchema[mcpcontract.SyncUserFacetInput](func(sc *schemaBuilder) { + configureActorSelectorModes(sc) + setArrayBounds(sc, "users", 1, 100) + setRange(sc, "max_pages", 1, 10) + setDefault(sc, "max_pages", 1) + setRange(sc, "max_items_per_user", 1, 1000) + setDefault(sc, "max_items_per_user", 100) + setRange(sc, "max_requests", 1, 1000) + }), output: outputSchema[mcpcontract.JobReference]("Durable bounded actor-facet synchronization job."), handler: handler, + }) + } + registerFacet(mcpcontract.ToolSyncUserSocialAccounts, "Sync GitHub user social accounts", "Fetch and replace bounded public social-account facts for exact stored users.", s.syncUserSocialAccounts) + registerFacet(mcpcontract.ToolSyncUserOrganizations, "Sync GitHub user organizations", "Fetch and replace bounded public organization memberships for exact stored users.", s.syncUserOrganizations) + addCatalogTool(s, catalogTool[mcpcontract.SyncUserPinnedItemsInput, mcpcontract.JobReference]{ + name: mcpcontract.ToolSyncUserPinnedItems, title: "Sync GitHub user pinned items", + description: "Fetch and replace the public pinned or profile-showcase items for exact stored users.", annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], + input: inputSchema[mcpcontract.SyncUserPinnedItemsInput](func(sc *schemaBuilder) { + configureActorSelectorModes(sc) + setArrayBounds(sc, "users", 1, 50) + setRange(sc, "limit", 1, 6) + setDefault(sc, "limit", 6) + setRange(sc, "max_requests", 1, 100) + }), + output: outputSchema[mcpcontract.JobReference]("Durable pinned-item synchronization job."), handler: s.syncUserPinnedItems, + }) + addCatalogTool(s, catalogTool[mcpcontract.SyncUserRepositoriesInput, mcpcontract.JobReference]{ + name: mcpcontract.ToolSyncUserRepositories, title: "Sync GitHub user repositories", + description: "Fetch and replace one explicit repository relationship for exact stored users: owned, affiliated, or contributed.", annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], + input: inputSchema[mcpcontract.SyncUserRepositoriesInput](func(sc *schemaBuilder) { + configureActorSelectorModes(sc) + setArrayBounds(sc, "users", 1, 50) + setEnum(sc, "relationship", "owned", "affiliated", "contributed") + setEnum(sc, "sort", "created", "updated", "pushed", "full_name") + setEnum(sc, "order", "asc", "desc") + setRange(sc, "max_pages", 1, 10) + setDefault(sc, "max_pages", 1) + setRange(sc, "max_items_per_user", 1, 1000) + setDefault(sc, "max_items_per_user", 100) + setRange(sc, "max_requests", 1, 1000) + }), output: outputSchema[mcpcontract.JobReference]("Durable repository-relationship synchronization job."), handler: s.syncUserRepositories, + }) + addCatalogTool(s, catalogTool[mcpcontract.SyncUserContributionsInput, mcpcontract.JobReference]{ + name: mcpcontract.ToolSyncUserContributions, title: "Sync GitHub user contributions", + description: "Fetch one explicit contribution period for exact stored users. Restricted contributions remain aggregate facts.", annotations: networkReadAnnotations(), supportedBy: supports[GitHubActorOperator], + input: inputSchema[mcpcontract.SyncUserContributionsInput](func(sc *schemaBuilder) { + configureActorSelectorModes(sc) + setArrayBounds(sc, "users", 1, 20) + setRange(sc, "max_repositories", 1, 100) + setDefault(sc, "max_repositories", 25) + setRange(sc, "max_requests", 1, 100) + }), + output: outputSchema[mcpcontract.JobReference]("Durable bounded contribution-period synchronization job."), handler: s.syncUserContributions, + }) + addCatalogTool(s, catalogTool[mcpcontract.SearchContributionsInput, mcpcontract.SearchContributionsOutput]{ + name: mcpcontract.ToolSearchContributions, title: "Search stored actor contributions", + description: "Search stored GitHub-profile contribution facts by actor, repository, kind, and time. Offline; returns explicit coverage.", annotations: readOnly, supportedBy: supports[ActorReader], + input: inputSchema[mcpcontract.SearchContributionsInput](func(sc *schemaBuilder) { + setArrayBounds(sc, "actors", 1, 100) + setArrayBounds(sc, "repositories", 1, 100) + setArrayBounds(sc, "kinds", 1, 20) + setEnum(sc, "source", "github_profile") + setDefault(sc, "source", "github_profile") + setEnum(sc, "sort", "occurred_at", "repository", "type") + setDefault(sc, "sort", "occurred_at") + setEnum(sc, "order", "asc", "desc") + setDefault(sc, "order", "desc") + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + }), + output: outputSchema[mcpcontract.SearchContributionsOutput]("Snapshot-bound contribution facts and acquisition coverage."), handler: s.searchContributions, + }) +} + +func (s *Server) searchActors(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchActorsInput) (*mcp.CallToolResult, mcpcontract.SearchActorsOutput, error) { + reader, ok := s.reader.(ActorReader) + if !ok { + return nil, mcpcontract.SearchActorsOutput{}, errors.New("actor search is not available") + } + out, err := reader.SearchActors(ctx, in) + return nil, out, err +} +func (s *Server) getActors(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.GetActorsInput) (*mcp.CallToolResult, mcpcontract.GetActorsOutput, error) { + reader, ok := s.reader.(ActorReader) + if !ok { + return nil, mcpcontract.GetActorsOutput{}, errors.New("actor reads are not available") + } + out, err := reader.GetActors(ctx, in) + return nil, out, err +} +func (s *Server) getActorFacets(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.GetActorFacetsInput) (*mcp.CallToolResult, mcpcontract.GetActorFacetsOutput, error) { + reader, ok := s.reader.(ActorReader) + if !ok { + return nil, mcpcontract.GetActorFacetsOutput{}, errors.New("actor facets are not available") + } + out, err := reader.GetActorFacets(ctx, in) + return nil, out, err +} +func (s *Server) searchGitHubUsers(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchGitHubUsersInput) (*mcp.CallToolResult, mcpcontract.SearchGitHubUsersOutput, error) { + reader, ok := s.reader.(GitHubActorOperator) + if !ok { + return nil, mcpcontract.SearchGitHubUsersOutput{}, errors.New("GitHub user search is not available") + } + out, err := reader.SearchGitHubUsers(ctx, in) + return nil, out, err +} +func (s *Server) syncUsers(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUsersInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + reader, ok := s.reader.(GitHubActorOperator) + if !ok { + return nil, mcpcontract.JobReference{}, errors.New("GitHub user synchronization is not available") + } + out, err := reader.SyncUsers(ctx, in) + return nil, out, err +} + +func (s *Server) syncUserSocialAccounts(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUserFacetInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + return actorJobCall(ctx, s, func(operator GitHubActorOperator) (mcpcontract.JobReference, error) { + return operator.SyncUserSocialAccounts(ctx, in) + }) +} +func (s *Server) syncUserOrganizations(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUserFacetInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + return actorJobCall(ctx, s, func(operator GitHubActorOperator) (mcpcontract.JobReference, error) { + return operator.SyncUserOrganizations(ctx, in) + }) +} +func (s *Server) syncUserPinnedItems(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUserPinnedItemsInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + return actorJobCall(ctx, s, func(operator GitHubActorOperator) (mcpcontract.JobReference, error) { + return operator.SyncUserPinnedItems(ctx, in) + }) +} +func (s *Server) syncUserRepositories(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUserRepositoriesInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + return actorJobCall(ctx, s, func(operator GitHubActorOperator) (mcpcontract.JobReference, error) { + return operator.SyncUserRepositories(ctx, in) + }) +} +func (s *Server) syncUserContributions(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SyncUserContributionsInput) (*mcp.CallToolResult, mcpcontract.JobReference, error) { + return actorJobCall(ctx, s, func(operator GitHubActorOperator) (mcpcontract.JobReference, error) { + return operator.SyncUserContributions(ctx, in) + }) +} +func actorJobCall[T any](_ context.Context, s *Server, call func(GitHubActorOperator) (T, error)) (*mcp.CallToolResult, T, error) { + operator, ok := s.reader.(GitHubActorOperator) + if !ok { + var zero T + return nil, zero, errors.New("GitHub actor synchronization is not available") + } + out, err := call(operator) + return nil, out, err +} +func (s *Server) searchContributions(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchContributionsInput) (*mcp.CallToolResult, mcpcontract.SearchContributionsOutput, error) { + reader, ok := s.reader.(ActorReader) + if !ok { + return nil, mcpcontract.SearchContributionsOutput{}, errors.New("actor contribution search is not available") + } + out, err := reader.SearchContributions(ctx, in) + return nil, out, err +} diff --git a/internal/mcpserver/actors_test.go b/internal/mcpserver/actors_test.go new file mode 100644 index 0000000..ff8460c --- /dev/null +++ b/internal/mcpserver/actors_test.go @@ -0,0 +1,68 @@ +package mcpserver + +import ( + "context" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type actorCapabilityReader struct{ mcpcontract.Reader } + +func (actorCapabilityReader) SearchGitHubUsers(context.Context, mcpcontract.SearchGitHubUsersInput) (mcpcontract.SearchGitHubUsersOutput, error) { + return mcpcontract.SearchGitHubUsersOutput{}, nil +} +func (actorCapabilityReader) SyncUsers(context.Context, mcpcontract.SyncUsersInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SyncUserSocialAccounts(context.Context, mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SyncUserOrganizations(context.Context, mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SyncUserPinnedItems(context.Context, mcpcontract.SyncUserPinnedItemsInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SyncUserRepositories(context.Context, mcpcontract.SyncUserRepositoriesInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SyncUserContributions(context.Context, mcpcontract.SyncUserContributionsInput) (mcpcontract.JobReference, error) { + return mcpcontract.JobReference{}, nil +} +func (actorCapabilityReader) SearchActors(context.Context, mcpcontract.SearchActorsInput) (mcpcontract.SearchActorsOutput, error) { + return mcpcontract.SearchActorsOutput{}, nil +} +func (actorCapabilityReader) GetActors(context.Context, mcpcontract.GetActorsInput) (mcpcontract.GetActorsOutput, error) { + return mcpcontract.GetActorsOutput{}, nil +} +func (actorCapabilityReader) GetActorFacets(context.Context, mcpcontract.GetActorFacetsInput) (mcpcontract.GetActorFacetsOutput, error) { + return mcpcontract.GetActorFacetsOutput{}, nil +} +func (actorCapabilityReader) SearchContributions(context.Context, mcpcontract.SearchContributionsInput) (mcpcontract.SearchContributionsOutput, error) { + return mcpcontract.SearchContributionsOutput{}, nil +} + +func TestActorCapabilitiesAdvertiseAtomicTools(t *testing.T) { + base := &fakeReader{searchStarted: make(chan struct{})} + tools, closeSessions := listedToolsFromReader(t, actorCapabilityReader{Reader: base}) + defer closeSessions() + + for _, name := range []string{ + mcpcontract.ToolSearchGitHubUsers, mcpcontract.ToolSyncUsers, + mcpcontract.ToolSyncUserSocialAccounts, mcpcontract.ToolSyncUserOrganizations, + mcpcontract.ToolSyncUserPinnedItems, mcpcontract.ToolSyncUserRepositories, + mcpcontract.ToolSyncUserContributions, mcpcontract.ToolSearchActors, + mcpcontract.ToolGetActors, mcpcontract.ToolGetActorFacets, + mcpcontract.ToolSearchContributions, + } { + if tools[name] == nil { + t.Errorf("atomic actor tool %q was not advertised", name) + } + } + for _, removed := range []string{mcpcontract.ToolRankThreads, mcpcontract.ToolBuildRepositoryDossier, mcpcontract.ToolFindRelatedWork} { + if tools[removed] != nil { + t.Errorf("removed composite tool %q was advertised", removed) + } + } +} diff --git a/internal/mcpserver/agent_eval_heldout_test.go b/internal/mcpserver/agent_eval_heldout_test.go index c54e44c..89034e5 100644 --- a/internal/mcpserver/agent_eval_heldout_test.go +++ b/internal/mcpserver/agent_eval_heldout_test.go @@ -238,17 +238,12 @@ func runHeldOutOracle(t *testing.T, scenario string, run *heldOutRun) bool { return err == nil && resource != nil && len(resource.Contents) > 0 case "audit_only_fix_pattern_preview": - result := run.tool(t, mcpcontract.ToolPreviewRepositoryFixPatterns, map[string]any{ - "repository": map[string]any{"owner": "acme", "repo": "heldout"}, - "time_window": map[string]any{"updated_after": "2026-01-01T00:00:00Z"}, - "symptom_taxonomy": []any{map[string]any{"name": "stall", "terms": []string{"stall"}}}, - "candidate_limit": 10, "representative_limit": 2, - }) + result := run.tool(t, mcpcontract.ToolSearchThreads, map[string]any{"owner": "acme", "repo": "heldout", "query": "stall", "limit": 10}) if result == nil || result.IsError { return false } - var report mcpcontract.FixPatternReport - return decodeHeldOut(result.StructuredContent, &report) && !report.Persisted && report.SnapshotToken != "" + var search mcpcontract.SearchOutput + return decodeHeldOut(result.StructuredContent, &search) } t.Fatalf("unknown held-out scenario %q", scenario) return false diff --git a/internal/mcpserver/agent_eval_test.go b/internal/mcpserver/agent_eval_test.go index c510010..c908e6b 100644 --- a/internal/mcpserver/agent_eval_test.go +++ b/internal/mcpserver/agent_eval_test.go @@ -125,15 +125,6 @@ func TestAgentEvalScriptedCurrentContracts(t *testing.T) { } }) - t.Run("exact issue-set preparation is one offline aggregate call", func(t *testing.T) { - result := callAgentEvalTool(t, client, mcpcontract.ToolPrepareIssueSet, map[string]any{ - "owner": "acme", "repo": "rocket", "issue_numbers": []int{7, 11, 14}, - }) - if result.IsError || result.StructuredContent == nil { - t.Fatalf("issue-set result = %+v; content = %q", result, agentEvalResultText(result)) - } - }) - t.Run("repository and dossier availability is one offline batch", func(t *testing.T) { result := callAgentEvalTool(t, client, mcpcontract.ToolGetRepositories, map[string]any{ "repositories": []map[string]any{ @@ -183,25 +174,6 @@ func TestAgentEvalScriptedCurrentContracts(t *testing.T) { } } }) - - t.Run("durable operation exposes a pollable job", func(t *testing.T) { - started := callAgentEvalTool(t, client, mcpcontract.ToolBuildRepositoryDossier, map[string]any{"owner": "acme", "repo": "rocket"}) - payload, err := json.Marshal(started.StructuredContent) - if err != nil { - t.Fatal(err) - } - var job mcpcontract.JobReference - if err := json.Unmarshal(payload, &job); err != nil { - t.Fatal(err) - } - if job.ID == "" || job.Ref != "job:"+job.ID || job.Status == "" || job.PollAfterMS < 1 || job.FollowUp == nil || job.FollowUp.Action.Type != "poll_job" { - t.Fatalf("job reference is not pollable: %+v", job) - } - polled := callAgentEvalTool(t, client, mcpcontract.ToolGetJob, map[string]any{"ids": []string{job.ID}}) - if polled.IsError || polled.StructuredContent == nil { - t.Fatalf("poll result = %+v", polled) - } - }) } func TestAgentEvalToolSchemasAreLegible(t *testing.T) { diff --git a/internal/mcpserver/catalog.go b/internal/mcpserver/catalog.go index a2fce96..fee7381 100644 --- a/internal/mcpserver/catalog.go +++ b/internal/mcpserver/catalog.go @@ -18,6 +18,9 @@ type catalogTool[In, Out any] struct { } func addCatalogTool[In, Out any](server *Server, tool catalogTool[In, Out]) { + if removedCompositeTools[tool.name] { + return + } if tool.supportedBy != nil && !tool.supportedBy(server.reader) { return } @@ -44,6 +47,21 @@ func addCatalogTool[In, Out any](server *Server, tool catalogTool[In, Out]) { mcp.AddTool(server.server, mcpTool, structuredToolErrors(tool.handler)) } +// removedCompositeTools is the breaking facts-first catalog boundary. The +// implementations remain temporarily available to internal callers while +// agents compose the smaller acquisition and corpus primitives instead. +var removedCompositeTools = map[string]bool{ + mcpcontract.ToolSearchCode: true, + mcpcontract.ToolRankThreads: true, + mcpcontract.ToolPrepareIssueSet: true, + mcpcontract.ToolBuildRepositoryDossier: true, + mcpcontract.ToolMineRepositoryFixPatterns: true, + mcpcontract.ToolPreviewRepositoryFixPatterns: true, + mcpcontract.ToolPreflightContribution: true, + mcpcontract.ToolQueryDeepWiki: true, + mcpcontract.ToolFindRelatedWork: true, +} + func supports[T any](reader mcpcontract.Reader) bool { _, ok := any(reader).(T) return ok diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 10b9047..2baf1f7 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -201,8 +201,8 @@ func TestOptionalCapabilitiesAreAdvertisedIndependently(t *testing.T) { tools, closeSessions := listedToolsFromReader(t, reader) defer closeSessions() - if tools[mcpcontract.ToolQueryDeepWiki] == nil { - t.Fatal("supported research tool was not advertised") + if tools[mcpcontract.ToolQueryDeepWiki] != nil { + t.Fatal("removed derived-research workflow was advertised") } for _, name := range []string{mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts, mcpcontract.ToolListPullRequestPortfolio} { if tools[name] != nil { @@ -228,14 +228,8 @@ func TestToolSchemasExposeMachineReadableContracts(t *testing.T) { t.Fatalf("thread sync description does not expose the follow-up route: %q", tools[mcpcontract.ToolSyncThreads].Description) } assertSchemaValue(t, tools[mcpcontract.ToolHydrateThreads].InputSchema, []string{"properties", "max_pages", "default"}, float64(3)) - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].InputSchema, []string{"required"}, []any{"repositories"}) assertSchemaValue(t, tools[mcpcontract.ToolCreateWorkspace].InputSchema, []string{"required"}, []any{"investigation_id"}) assertSchemaValue(t, tools[mcpcontract.ToolAdoptWorkspace].InputSchema, []string{"required"}, []any{"investigation_id", "path", "base_ref"}) - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].OutputSchema, []string{"properties", "total", "type"}, "integer") - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].OutputSchema, []string{"properties", "truncated", "type"}, "boolean") - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].OutputSchema, []string{"properties", "candidates", "items", "properties", "score", "minimum"}, float64(0)) - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].OutputSchema, []string{"properties", "candidates", "items", "properties", "score", "maximum"}, float64(100)) - assertSchemaValue(t, tools[mcpcontract.ToolRankThreads].OutputSchema, []string{"properties", "candidates", "items", "properties", "confidence", "type"}, "string") assertSchemaValue(t, tools[mcpcontract.ToolFindPrecedents].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "matches", "items", "properties", "score", "maximum"}, float64(1)) assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "item_status", "enum"}, []any{"complete", "retryable", "unavailable", "failed"}) assertSchemaValue(t, tools[mcpcontract.ToolGetJob].OutputSchema, []string{"properties", "items", "items", "properties", "value", "properties", "execution_state", "enum"}, []any{"queued", "running", "terminal"}) @@ -278,6 +272,33 @@ func TestToolSchemasExposeMachineReadableContracts(t *testing.T) { } } +func TestActorSelectorSchemaIsDiscriminated(t *testing.T) { + definition := inputSchema[mcpcontract.SyncUsersInput](func(builder *schemaBuilder) { + configureActorSelectorModes(builder) + }) + if definition.err != nil { + t.Fatal(definition.err) + } + actor := definition.schema.Defs["ActorSelector"] + if actor == nil { + for name, candidate := range definition.schema.Defs { + if strings.HasSuffix(name, "ActorSelector") { + actor = candidate + break + } + } + } + if actor == nil { + actor = definition.schema.Properties["users"].Items + } + if actor == nil || len(actor.OneOf) != 2 { + t.Fatalf("actor selector schema = %+v defs=%d users=%+v", actor, len(definition.schema.Defs), definition.schema.Properties["users"]) + } + if actor.OneOf[0].ID != "urn:gitcontribute:actor-selector:login" || actor.OneOf[1].ID != "urn:gitcontribute:actor-selector:node-id" { + t.Fatalf("actor selector modes = %+v", actor.OneOf) + } +} + func TestSchemaCustomizationErrorsAreReturned(t *testing.T) { tests := []struct { name string @@ -335,11 +356,7 @@ func TestAgentToolSelectionProxy(t *testing.T) { {"Read the complete stored body of pull request 42", mcpcontract.ToolGetThreads}, {"Refresh issue and pull request thread headers for selected repositories from GitHub", mcpcontract.ToolSyncThreads}, {"Fetch comments and reviews for one stored pull request from GitHub", mcpcontract.ToolSyncPullRequestFeedback}, - {"Rank stored open issues for contribution across selected repositories", mcpcontract.ToolRankThreads}, {"Find similar completed and rejected historical work for this issue", mcpcontract.ToolFindPrecedents}, - {"Prepare contribution evidence and linkage guidance for fourteen exact issues", mcpcontract.ToolPrepareIssueSet}, - {"Ask DeepWiki to compare the architecture of three public repositories", mcpcontract.ToolQueryDeepWiki}, - {"Refresh mergeability, checks, queue state, and changed files for my selected pull requests", mcpcontract.ToolSyncPortfolio}, {"List my stored pull requests that need contributor attention", mcpcontract.ToolListPullRequestPortfolio}, {"Acquire and index code for several repositories", mcpcontract.ToolIndexRepositories}, {"Check actual Git merge conflicts between fetched revisions", mcpcontract.ToolCheckMergeConflicts}, @@ -354,9 +371,6 @@ func TestAgentToolSelectionProxy(t *testing.T) { {"Read repository and thread coverage across several targets", mcpcontract.ToolGetCoverage}, {"Compare contribution candidates with my authored pull requests for overlap", mcpcontract.ToolFindPortfolioOverlaps}, {"Link an authored pull request to a local opportunity", mcpcontract.ToolLinkPullRequest}, - {"Rebuild and persist the repository dossier from the local corpus", mcpcontract.ToolBuildRepositoryDossier}, - {"Find open pull requests that might conflict with this opportunity", mcpcontract.ToolFindRelatedWork}, - {"Find issues that may duplicate this hypothesis", mcpcontract.ToolFindRelatedWork}, } correct := 0 @@ -466,64 +480,10 @@ func TestCoverageTargetSchemaAcceptsRepositoryAndExactThreadOnly(t *testing.T) { } func TestFixPatternWorkflowSchemaRejectsInvalidNestedInputBeforeHandler(t *testing.T) { - base := &fakeReader{searchStarted: make(chan struct{})} - optional := &fakeOptionalCapabilities{base: base} - reader := struct { - mcpcontract.Reader - FixPatternOperator - FixPatternReader - }{Reader: base, FixPatternOperator: optional, FixPatternReader: base} - client, closeSessions := connect(t, reader) + tools, closeSessions := listedTools(t) defer closeSessions() - - invalidCalls := []map[string]any{ - { - "repository": map[string]any{"owner": "acme", "repo": "rocket"}, - "time_window": map[string]any{"updated_after": "2026-07-01T00:00:00Z"}, - "symptom_taxonomy": []any{map[string]any{"name": "numeric drift", "terms": []any{}}}, - }, - { - "repository": map[string]any{"owner": "acme", "repo": "rocket"}, - "time_window": map[string]any{"updated_after": "2026-07-01T00:00:00Z"}, - "symptom_taxonomy": []any{map[string]any{"name": " ", "terms": []any{"drift"}}}, - }, - { - "repository": map[string]any{"owner": "acme", "repo": "rocket"}, - "time_window": map[string]any{"updated_after": "2026-07-01T00:00:00Z"}, - "symptom_taxonomy": []any{map[string]any{"name": "drift", "terms": []any{"drift"}}}, - "merge_outcomes": []any{"merged", "merged"}, - }, - } - for _, invalid := range invalidCalls { - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ - Name: mcpcontract.ToolMineRepositoryFixPatterns, Arguments: invalid, - }) - if err == nil && result != nil && !result.IsError { - t.Fatalf("invalid workflow input was accepted: %#v", invalid) - } - } - if optional.fixPatternCalls != 0 { - t.Fatalf("handler calls = %d, want 0", optional.fixPatternCalls) - } - - valid := map[string]any{ - "repository": map[string]any{"owner": "acme", "repo": "rocket"}, - "time_window": map[string]any{"updated_after": "2026-07-01T00:00:00Z"}, - "symptom_taxonomy": []any{map[string]any{"name": "numeric drift", "terms": []any{"wrong result", "numeric drift"}}}, - "hydration_limit": 0, - } - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ - Name: mcpcontract.ToolMineRepositoryFixPatterns, Arguments: valid, - }) - if err != nil || result == nil || result.IsError { - t.Fatalf("valid call result = %+v, err = %v", result, err) - } - structured, ok := result.StructuredContent.(map[string]any) - if !ok || structured["id"] != "job-fix-patterns" { - t.Fatalf("structured content = %#v", result.StructuredContent) - } - if optional.fixPatternCalls != 1 || optional.lastFixPatternRequest.HydrationLimit == nil || *optional.lastFixPatternRequest.HydrationLimit != 0 { - t.Fatalf("handler state = calls %d, input %+v", optional.fixPatternCalls, optional.lastFixPatternRequest) + if tools[mcpcontract.ToolMineRepositoryFixPatterns] != nil { + t.Fatal("removed fix-pattern workflow was advertised") } } diff --git a/internal/mcpserver/input_modes.go b/internal/mcpserver/input_modes.go index aa598b3..2e69ffb 100644 --- a/internal/mcpserver/input_modes.go +++ b/internal/mcpserver/input_modes.go @@ -1,6 +1,10 @@ package mcpserver -import "github.com/google/jsonschema-go/jsonschema" +import ( + "strings" + + "github.com/google/jsonschema-go/jsonschema" +) const nonWhitespacePattern = `.*\S.*` @@ -179,6 +183,37 @@ func schemaMode(discriminator, value string, required, forbidden []string) *json return schema } +func configureActorSelectorModes(builder *schemaBuilder) { + actor := builder.schema.Defs["ActorSelector"] + if actor == nil { + for name, candidate := range builder.schema.Defs { + if strings.HasSuffix(name, "ActorSelector") { + actor = candidate + break + } + } + } + if actor == nil { + if users := builder.schema.Properties["users"]; users != nil { + actor = users.Items + } + } + if actor == nil { + return + } + login := schemaMode("type", "login", []string{"login"}, []string{"node_id"}) + login.ID = "urn:gitcontribute:actor-selector:login" + node := schemaMode("type", "node_id", []string{"node_id"}, []string{"login"}) + node.ID = "urn:gitcontribute:actor-selector:node-id" + actor.OneOf = []*jsonschema.Schema{login, node} + for _, field := range []string{"login", "node_id"} { + if value := actor.Properties[field]; value != nil { + value.MinLength = jsonschema.Ptr(1) + value.Pattern = nonWhitespacePattern + } + } +} + func schemaRequiringAny(fields ...string) *jsonschema.Schema { return &jsonschema.Schema{AnyOf: schemasRequiringOne(fields...)} } diff --git a/internal/mcpserver/resource_templates.go b/internal/mcpserver/resource_templates.go index 6879663..67bcc14 100644 --- a/internal/mcpserver/resource_templates.go +++ b/internal/mcpserver/resource_templates.go @@ -12,7 +12,6 @@ func (s *Server) registerResourceTemplates() { templates := []resourceTemplateDefinition{ {"gitcontribute://repository/{owner}/{repo}", "Repository", "Local repository record"}, {"gitcontribute://thread/{owner}/{repo}/{kind}/{number}", "Thread", "Local issue or pull request"}, - {"gitcontribute://dossier/{owner}/{repo}", "Dossier", "Local source-backed repository dossier"}, {"gitcontribute://investigation/{id}", "Investigation", "Local investigation workspace"}, {"gitcontribute://opportunity/{id}", "Opportunity", "Local contribution opportunity"}, {"gitcontribute://evidence/{scope}/{id}", "Evidence", "Local evidence for an investigation or opportunity"}, @@ -25,11 +24,11 @@ func (s *Server) registerResourceTemplates() { name: "Thread facet", description: "Persisted local thread facet payload", }) } - if _, ok := s.reader.(FixPatternReader); ok { - templates = append(templates, resourceTemplateDefinition{ - template: "gitcontribute://fix-pattern-report/{job_id}", - name: "Fix-pattern report", description: "Typed repository contribution-pattern report produced by a durable workflow", - }) + if _, ok := s.reader.(actorResourceReader); ok { + templates = append(templates, + resourceTemplateDefinition{template: "gitcontribute://actor/{actor_id}", name: "Actor", description: "Current stored GitHub actor identity and profile coverage"}, + resourceTemplateDefinition{template: "gitcontribute://actor/{actor_id}/facet/{facet}", name: "Actor facet", description: "Stored actor facet coverage without implicit refresh"}, + ) } if _, ok := s.reader.(CodeIndexReader); ok { templates = append(templates, resourceTemplateDefinition{ diff --git a/internal/mcpserver/resources.go b/internal/mcpserver/resources.go index 1760564..535794b 100644 --- a/internal/mcpserver/resources.go +++ b/internal/mcpserver/resources.go @@ -39,6 +39,10 @@ type threadFacetResourceReader interface { ThreadFacetResource(context.Context, string, string, string, int, string) (map[string]any, error) } +type actorResourceReader interface { + ActorResource(context.Context, string, string) (any, error) +} + func (s *Server) readResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { uri := req.Params.URI u, err := url.Parse(uri) @@ -78,13 +82,13 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an switch req.host { case "repository": return s.readRepositoryResource(ctx, req) - case "dossier": - return s.readDossierResource(ctx, req) case "thread": if len(req.parts) == 6 && req.parts[4] == "facet" { return s.readThreadFacetResource(ctx, req) } return s.readTypedThreadResource(ctx, req) + case "actor": + return s.readActorResource(ctx, req) case "investigation": return s.readInvestigationResource(ctx, req) case "opportunity": @@ -95,8 +99,6 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an return s.readReadinessResource(ctx, req) case "lens": return s.readLensResource(ctx, req) - case "fix-pattern-report": - return s.readFixPatternReportResource(ctx, req) case "concern": return s.readConcernResource(ctx, req) case "draft": @@ -140,6 +142,25 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an } } +func (s *Server) readActorResource(ctx context.Context, req resourceRequest) (any, error) { + reader, ok := s.reader.(actorResourceReader) + if !ok || (len(req.parts) != 1 && (len(req.parts) != 3 || req.parts[1] != "facet")) || strings.TrimSpace(req.parts[0]) == "" { + return nil, mcp.ResourceNotFoundError(req.uri) + } + actorID, err := url.PathUnescape(req.parts[0]) + if err != nil { + return nil, mcp.ResourceNotFoundError(req.uri) + } + facet := "" + if len(req.parts) == 3 { + facet, err = url.PathUnescape(req.parts[2]) + if err != nil || strings.TrimSpace(facet) == "" { + return nil, mcp.ResourceNotFoundError(req.uri) + } + } + return reader.ActorResource(ctx, actorID, facet) +} + func (s *Server) readCodeIndexResource(ctx context.Context, req resourceRequest) (mcpcontract.CodeIndexArtifact, error) { if len(req.parts) != 1 || strings.TrimSpace(req.parts[0]) == "" { return mcpcontract.CodeIndexArtifact{}, mcp.ResourceNotFoundError(req.uri) @@ -288,17 +309,6 @@ func (s *Server) readManifestResource(ctx context.Context, req resourceRequest) return reader.Manifest(ctx, mcpcontract.ManifestInput{ID: req.parts[0]}) } -func (s *Server) readFixPatternReportResource(ctx context.Context, req resourceRequest) (mcpcontract.FixPatternReport, error) { - if len(req.parts) != 1 { - return mcpcontract.FixPatternReport{}, mcp.ResourceNotFoundError(req.uri) - } - reader, ok := s.reader.(FixPatternReader) - if !ok { - return mcpcontract.FixPatternReport{}, mcp.ResourceNotFoundError(req.uri) - } - return reader.GetFixPatternReport(ctx, req.parts[0]) -} - func (s *Server) readRepositoryResource(ctx context.Context, req resourceRequest) (mcpcontract.RepositoryOutput, error) { if len(req.parts) != 2 { return mcpcontract.RepositoryOutput{}, mcp.ResourceNotFoundError(req.uri) @@ -306,13 +316,6 @@ func (s *Server) readRepositoryResource(ctx context.Context, req resourceRequest return s.reader.Repository(ctx, mcpcontract.RepoInput{Owner: req.parts[0], Repo: req.parts[1]}) } -func (s *Server) readDossierResource(ctx context.Context, req resourceRequest) (mcpcontract.DossierOutput, error) { - if len(req.parts) != 2 { - return mcpcontract.DossierOutput{}, mcp.ResourceNotFoundError(req.uri) - } - return s.reader.Dossier(ctx, mcpcontract.RepoInput{Owner: req.parts[0], Repo: req.parts[1]}) -} - func (s *Server) readTypedThreadResource(ctx context.Context, req resourceRequest) (mcpcontract.ThreadOutput, error) { if len(req.parts) != 4 { return mcpcontract.ThreadOutput{}, mcp.ResourceNotFoundError(req.uri) diff --git a/internal/mcpserver/scalable.go b/internal/mcpserver/scalable.go index 1c649b4..adf50cb 100644 --- a/internal/mcpserver/scalable.go +++ b/internal/mcpserver/scalable.go @@ -12,21 +12,13 @@ import ( "github.com/morluto/gitcontribute/internal/repositorycontext" ) -const serverInstructions = "Use advertised GitContribute tools for durable, source-backed repository research and contribution tracking. " + - "Prefer corpus tools for offline reads; they never refresh data implicitly. " + - "GitHub tools perform explicit network reads and may update only the local corpus. " + - "Use workflow.get_catalog_contract when catalog parity is in doubt; it reports the running build version, post-registration catalog fingerprint, and whether the canonical pull-request feedback route is advertised. After an upgrade or registration change, create a fresh MCP connection before comparing the contract. " + - "Research tools return derived external context, never live GitHub state. " + - "The durable workflow is concern to investigation to hypothesis to opportunity to workspace to draft; use only advertised stages. " + - "Use workflow.prepare_issue_set when exact issue numbers already define the contribution scope; it is the canonical issue-audit entrypoint and returns typed recovery for missing repository or thread coverage. " + - "When an operation returns a job, poll advertised job tools in batches. " + - "Use corpus.get_thread_facets for bounded stored facet coverage and resources/read for larger facet payloads; repository, thread, and facet gaps provide the exact ordered synchronization route. " + - "For every pull-request comment written by a named reviewer across one repository, never use github.sync_portfolio or corpus.search_threads: use github.index_pull_request_feedback with state=all channels, poll jobs.get, then use corpus.search_pull_request_feedback with the exact feedback_author login (text is body search only); missing feedback is unknown until discovery and exact facets are complete. " + - "To inspect a returned resource, ask the host to perform MCP resources/read with this server and the exact URI; in Codex, call read_mcp_resource. Treat resource URIs as opaque identifiers and never shorten, pluralize, or reconstruct them. " + - "Missing or truncated coverage is unknown, not negative evidence; use each item's ordered typed recovery calls (the recovery plan's ordered typed calls), preserve exact_thread versus repository targets, poll the returned job, and reread coverage or synchronized headers before drawing conclusions. " + - "Canonical source-audit route: corpus.get_coverage -> corpus.ensure_coverage or the returned exact sync/hydration action -> jobs.get -> corpus.get_threads or corpus.get_thread_facets with the returned snapshot token -> corpus.find_clusters/find_neighbors/find_precedents -> explicit github.sync_threads -> jobs.get -> validation.attach_receipt -> workflow.prepare_contribution. Read workflow.get_source_audit_contract for machine-readable transitions. Corpus reads are offline, synchronization is bounded and explicit, missing coverage is unknown, and every returned resource URI must be consumed through MCP resources/read. " + - "github.search_threads and github.read_source_files are synchronous live acquisitions that write only local observations and immutable digest-bound artifacts; use their exact resource URI with resources/read, and treat a named source ref as non-authoritative until its resolved commit SHA is recorded. corpus.search_code_batch is an offline shared-snapshot convenience for several code queries; it never falls back to live GitHub code search. " + - "Before creating a contribution opportunity or workspace, use workflow.preflight_contribution to check current authored pull requests and optional local worktrees; it returns coverage_unknown when absence is not proven. " + +const serverInstructions = "GitContribute exposes source-backed GitHub facts. " + + "corpus.* tools are offline reads and never refresh implicitly; github.* tools are explicit bounded network reads that may update only the local corpus. " + + "Coverage is part of the result: missing, stale, paginated, or truncated observations are unknown rather than negative evidence. " + + "Tools returning a job require polling through jobs.get before rereading the corpus. " + + "Use exact returned resource URIs with MCP resources/read; treat them as opaque. " + + "github.index_pull_request_feedback plus corpus.search_pull_request_feedback is the repository-wide route for comments and reviews filtered by author. " + + "github.search_users discovers identities; the github.sync_user_* tools independently acquire profile, social, organization, pinned, repository, and contribution facets. " + "Only advertised tools are available. GitContribute never mutates GitHub." // RepositoryRef identifies one GitHub repository without implying that it has diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 5028889..0eeef6e 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -81,6 +81,23 @@ type GitHubAcquisitionOperator interface { ReadSourceFiles(context.Context, mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) } +type GitHubActorOperator interface { + SearchGitHubUsers(context.Context, mcpcontract.SearchGitHubUsersInput) (mcpcontract.SearchGitHubUsersOutput, error) + SyncUsers(context.Context, mcpcontract.SyncUsersInput) (mcpcontract.JobReference, error) + SyncUserSocialAccounts(context.Context, mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) + SyncUserOrganizations(context.Context, mcpcontract.SyncUserFacetInput) (mcpcontract.JobReference, error) + SyncUserPinnedItems(context.Context, mcpcontract.SyncUserPinnedItemsInput) (mcpcontract.JobReference, error) + SyncUserRepositories(context.Context, mcpcontract.SyncUserRepositoriesInput) (mcpcontract.JobReference, error) + SyncUserContributions(context.Context, mcpcontract.SyncUserContributionsInput) (mcpcontract.JobReference, error) +} + +type ActorReader interface { + SearchActors(context.Context, mcpcontract.SearchActorsInput) (mcpcontract.SearchActorsOutput, error) + GetActors(context.Context, mcpcontract.GetActorsInput) (mcpcontract.GetActorsOutput, error) + GetActorFacets(context.Context, mcpcontract.GetActorFacetsInput) (mcpcontract.GetActorFacetsOutput, error) + SearchContributions(context.Context, mcpcontract.SearchContributionsInput) (mcpcontract.SearchContributionsOutput, error) +} + // CodeSearchBatchReader exposes one bounded offline batch over a shared code // snapshot scope. It remains separate from Reader so existing local readers // can retain the single-query compatibility tool. @@ -427,6 +444,7 @@ func (s *Server) register() { output: outputSchema[mcpcontract.GetCoverageOutput]("Ordered local repository or thread facet coverage."), handler: s.getCoverage, }) s.registerResourceTemplates() + s.registerActorTools() s.registerContributionPrompts() s.registerV1() s.registerScalable() diff --git a/internal/mcpserver/server_contract_test.go b/internal/mcpserver/server_contract_test.go index 02613b8..903defc 100644 --- a/internal/mcpserver/server_contract_test.go +++ b/internal/mcpserver/server_contract_test.go @@ -19,23 +19,16 @@ func TestServerInstructionsContainRoutingPhrases(t *testing.T) { t.Fatal("missing initialize result") } for _, phrase := range []string{ - "Prefer corpus tools for offline reads", - "never refresh data implicitly", - "explicit network reads", - "workflow.get_catalog_contract", - "fresh MCP connection", - "concern to investigation to hypothesis to opportunity to workspace to draft", - "poll advertised job tools in batches", - "recovery plan's ordered typed calls", - "perform MCP resources/read", - "in Codex, call read_mcp_resource", - "exact URI", - "never shorten, pluralize, or reconstruct them", + "corpus.* tools are offline reads", + "never refresh implicitly", + "explicit bounded network reads", + "missing, stale, paginated, or truncated observations are unknown", + "polling through jobs.get", + "exact returned resource URIs", + "github.search_users", + "github.sync_user_*", "Only advertised tools are available", "never mutates GitHub", - "github.search_threads", - "github.read_source_files", - "corpus.search_code_batch", } { if !strings.Contains(init.Instructions, phrase) { t.Errorf("instructions missing routing phrase %q:\n%s", phrase, init.Instructions) @@ -429,9 +422,8 @@ func TestToolsAreReadOnlyAndReturnStructuredOutput(t *testing.T) { tools[tool.Name] = tool } for _, name := range []string{ - mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolSearchCode, + mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolFindClusters, mcpcontract.ToolFindNeighbors, mcpcontract.ToolGetCoverage, - mcpcontract.ToolQueryDeepWiki, } { tool := tools[name] if tool == nil { @@ -474,52 +466,18 @@ func TestToolsAreReadOnlyAndReturnStructuredOutput(t *testing.T) { } func TestRankOpportunitiesAcceptsPercentageScoreAndCategoricalConfidence(t *testing.T) { - client, closeSessions := connect(t, &fakeReader{searchStarted: make(chan struct{})}) + tools, closeSessions := listedTools(t) defer closeSessions() - - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ - Name: mcpcontract.ToolRankThreads, - Arguments: map[string]any{ - "repositories": []map[string]any{{"owner": "acme", "repo": "rocket"}}, - }, - }) - if err != nil { - t.Fatalf("call rank opportunities: %v", err) - } - if result.IsError { - t.Fatalf("rank opportunities returned tool error: %+v", result.Content) - } - payload, err := json.Marshal(result.StructuredContent) - if err != nil { - t.Fatalf("marshal structured content: %v", err) - } - var out mcpcontract.RankOpportunitiesOutput - if err := json.Unmarshal(payload, &out); err != nil { - t.Fatalf("decode structured content: %v", err) - } - if len(out.Candidates) != 1 || out.Candidates[0].Score != 87 || out.Candidates[0].Confidence != "medium" { - t.Fatalf("rank opportunities output = %+v", out) + if tools[mcpcontract.ToolRankThreads] != nil { + t.Fatal("removed ranking workflow was advertised") } } func TestRankOpportunitiesRejectsOutOfRangeOutputAtProtocolBoundary(t *testing.T) { - client, closeSessions := connect(t, &fakeReader{ - searchStarted: make(chan struct{}), - radarScore: 101, - }) + tools, closeSessions := listedTools(t) defer closeSessions() - - result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ - Name: mcpcontract.ToolRankThreads, - Arguments: map[string]any{ - "repositories": []map[string]any{{"owner": "acme", "repo": "rocket"}}, - }, - }) - if err == nil { - t.Fatalf("rank opportunities accepted out-of-range score: %+v", result) - } - if !strings.Contains(err.Error(), "validating tool output") || !strings.Contains(err.Error(), "greater than 100") { - t.Fatalf("rank opportunities error = %v, want SDK output validation error", err) + if tools[mcpcontract.ToolRankThreads] != nil { + t.Fatal("removed ranking workflow was advertised") } } @@ -538,9 +496,6 @@ func TestMultiModeSchemasAcceptEveryBranch(t *testing.T) { {"repository search structured", mcpcontract.ToolSearchGitHubRepositories, "search_github_repositories", map[string]any{"text": "cuda"}}, {"sync repository threads", mcpcontract.ToolSyncThreads, "sync_threads", map[string]any{"selection": "repositories", "repositories": []map[string]any{{"owner": "acme", "repo": "rocket"}}}}, {"sync exact threads", mcpcontract.ToolSyncThreads, "sync_threads", map[string]any{"selection": "threads", "threads": []map[string]any{{"owner": "acme", "repo": "rocket", "kind": "issue", "number": 7}}}}, - {"DeepWiki structure", mcpcontract.ToolQueryDeepWiki, "deepwiki", map[string]any{"action": "structure", "repository": "acme/rocket"}}, - {"DeepWiki contents", mcpcontract.ToolQueryDeepWiki, "deepwiki", map[string]any{"action": "contents", "repository": "acme/rocket"}}, - {"DeepWiki question", mcpcontract.ToolQueryDeepWiki, "deepwiki", map[string]any{"action": "question", "repositories": []string{"acme/rocket"}, "question": "Where is ranking implemented?"}}, {"issue draft", mcpcontract.ToolPrepareContribution, "prepare_contribution", map[string]any{"opportunity_id": "opp-1", "kind": "issue"}}, {"pull request draft", mcpcontract.ToolPrepareContribution, "prepare_contribution", map[string]any{"opportunity_id": "opp-1", "kind": "pull_request", "workspace_id": "ws-1", "approach": "Implement the fix."}}, {"commit investigation", mcpcontract.ToolStartInvestigation, "start_investigation", map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123"}}, @@ -583,9 +538,6 @@ func TestMultiModeSchemasRejectCrossModeFieldsBeforeHandler(t *testing.T) { {"repository search", mcpcontract.ToolSearchGitHubRepositories, "search_github_repositories", "github-search-", map[string]any{"raw_query": "topic:cuda", "language": "Go"}}, {"sync threads", mcpcontract.ToolSyncThreads, "sync_threads", "sync-threads-", map[string]any{"selection": "threads", "threads": []map[string]any{{"owner": "acme", "repo": "rocket", "kind": "issue", "number": 7}}, "repositories": []map[string]any{{"owner": "acme", "repo": "rocket"}}}}, {"sync portfolio", mcpcontract.ToolSyncPortfolio, "sync_portfolio", "sync-pull-request-portfolio-", map[string]any{"selection": "explicit", "pull_requests": []map[string]any{{"owner": "acme", "repo": "rocket", "kind": "pull_request", "number": 7}}, "state": "open"}}, - {"DeepWiki", mcpcontract.ToolQueryDeepWiki, "deepwiki", "deepwiki-", map[string]any{"action": "question", "repository": "acme/rocket", "repositories": []string{"acme/rocket"}, "question": "Where is ranking?"}}, - {"DeepWiki blank repository", mcpcontract.ToolQueryDeepWiki, "deepwiki", "deepwiki-contents", map[string]any{"action": "contents", "repository": " \t "}}, - {"DeepWiki blank question", mcpcontract.ToolQueryDeepWiki, "deepwiki", "deepwiki-question", map[string]any{"action": "question", "repositories": []string{"acme/rocket"}, "question": " \t "}}, {"issue draft", mcpcontract.ToolPrepareContribution, "prepare_contribution", "contribution-draft-", map[string]any{"opportunity_id": "opp-1", "kind": "issue", "workspace_id": "ws-1"}}, {"investigation", mcpcontract.ToolStartInvestigation, "start_investigation", "investigation-", map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123", "number": 7}}, {"concern", ToolCreateConcern, "create_concern", "concern-", map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123", "workspace_id": "ws-1", "title": "race", "problem_statement": "state can race", "confidence": 0.5}}, diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index e268279..89ff417 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -466,7 +466,6 @@ func TestReadOnlyToolsReturnStructuredOutput(t *testing.T) { args map[string]any wantTotal int }{ - {mcpcontract.ToolSearchCode, map[string]any{"query": "main"}, 1}, {mcpcontract.ToolFindClusters, map[string]any{"targets": []any{map[string]any{"owner": "acme", "repo": "rocket"}}}, 1}, {mcpcontract.ToolGetCoverage, map[string]any{"targets": []any{map[string]any{"type": "repository", "repository": map[string]any{"owner": "acme", "repo": "rocket"}}}}, -1}, } @@ -548,7 +547,6 @@ func TestInvestigationOpportunityEvidenceResources(t *testing.T) { "gitcontribute://evidence/investigation/inv-1", "gitcontribute://evidence/opportunity/opp-1", "gitcontribute://readiness/opp-1", - "gitcontribute://fix-pattern-report/job-fix-patterns", } for _, uri := range cases { result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: uri}) @@ -575,7 +573,7 @@ func TestFixPatternResourceTemplateTracksReaderCapability(t *testing.T) { reader mcpcontract.Reader want bool }{ - {name: "supported", reader: base, want: true}, + {name: "supported", reader: base, want: false}, {name: "unsupported", reader: struct{ mcpcontract.Reader }{Reader: base}}, } { t.Run(test.name, func(t *testing.T) { @@ -715,10 +713,9 @@ func TestV1ParityToolsAndResources(t *testing.T) { for _, name := range []string{ mcpcontract.ToolSearchRepositories, mcpcontract.ToolSearchThreads, mcpcontract.ToolExplainMatch, mcpcontract.ToolGetJob, - mcpcontract.ToolBuildRepositoryDossier, mcpcontract.ToolCreateWorkspace, mcpcontract.ToolAdoptWorkspace, mcpcontract.ToolRunValidation, mcpcontract.ToolStartInvestigation, mcpcontract.ToolRecordHypothesis, - mcpcontract.ToolFindRelatedWork, mcpcontract.ToolPromoteOpportunity, mcpcontract.ToolDefineValidation, + mcpcontract.ToolPromoteOpportunity, mcpcontract.ToolDefineValidation, mcpcontract.ToolPrepareContribution, mcpcontract.ToolCancelJob, } { if tools[name] == nil { @@ -749,13 +746,11 @@ func TestV1ParityToolsAndResources(t *testing.T) { name string args map[string]any }{ - {mcpcontract.ToolBuildRepositoryDossier, map[string]any{"owner": "acme", "repo": "rocket"}}, {mcpcontract.ToolCreateWorkspace, map[string]any{"investigation_id": "inv-1"}}, {mcpcontract.ToolAdoptWorkspace, map[string]any{"investigation_id": "inv-1", "path": "/tmp/worktree", "base_ref": "main", "name": "external"}}, {mcpcontract.ToolRunValidation, map[string]any{"id": "val-1", "target": "both", "run_count": 3, "execute": true}}, {mcpcontract.ToolStartInvestigation, map[string]any{"owner": "acme", "repo": "rocket", "commit_sha": "abc123"}}, {mcpcontract.ToolRecordHypothesis, map[string]any{"investigation_id": "inv-1", "title": "leak", "description": "memory leak", "category": "bug"}}, - {mcpcontract.ToolFindRelatedWork, map[string]any{"target": "hypothesis", "id": "hyp-1", "kinds": []string{"duplicates"}}}, {mcpcontract.ToolPromoteOpportunity, map[string]any{"hypothesis_id": "hyp-1", "problem_statement": "leak", "scope": "small", "impact": "high", "expected_effort": "1h", "confidence": 0.8}}, {mcpcontract.ToolDefineValidation, map[string]any{"investigation_id": "inv-1", "kind": "test", "command": "go test ./...", "workspace_id": "ws-1"}}, {mcpcontract.ToolPrepareContribution, map[string]any{"opportunity_id": "opp-1", "kind": "issue"}}, From 7eae3099fccbe254765f2903aa5e286a15b42897 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:30:04 +0800 Subject: [PATCH 4/8] test(upgrade): preserve npx invocation context --- internal/app/upgrade_setup_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/upgrade_setup_test.go b/internal/app/upgrade_setup_test.go index b2a17ee..c3cd20a 100644 --- a/internal/app/upgrade_setup_test.go +++ b/internal/app/upgrade_setup_test.go @@ -49,8 +49,8 @@ func TestUpgradeActivatesPrivateMCPRuntimeFromTargetRelease(t *testing.T) { } func TestUpgradeNpxActivatesPrivateMCPRuntimeFromLatestRelease(t *testing.T) { - t.Setenv("npm_command", "exec") home, _, _, _, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") + t.Setenv("npm_command", "exec") setRuntimeContract(t, "1.2.4", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) @@ -121,8 +121,8 @@ func TestUpgradeActivatesAlreadyInstalledTargetRuntime(t *testing.T) { } func TestUpgradeNpxStaleBootstrapReportsExplicitLatestRecovery(t *testing.T) { - t.Setenv("npm_command", "exec") _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.3") + t.Setenv("npm_command", "exec") setRuntimeContract(t, "1.2.3", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) From 2ebec8d87b3f4ca5cf6a5fb2975766c9d8655b78 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:30:09 +0800 Subject: [PATCH 5/8] docs(mcp): document facts-first actor workflows --- README.md | 6 + docs/actor-corpus.md | 64 +++++ docs/agent-tool-evaluation.md | 7 +- docs/architecture.md | 18 ++ docs/mcp-composed-workflows.md | 64 +++-- docs/mcp-scalable-workflows.md | 425 ++++++++++----------------------- docs/mcp-tool-redesign.md | 190 ++++++++------- 7 files changed, 332 insertions(+), 442 deletions(-) create mode 100644 docs/actor-corpus.md diff --git a/README.md b/README.md index 671c91d..b304fdf 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,11 @@ Add `--read-only` to remove tools that permit local writes or execution. See [Scalable MCP workflows](docs/mcp-scalable-workflows.md) for the tool sequence, coverage model, partial-result recovery, and side-effect boundaries. +GitHub users are stored as first-class actors. Live discovery records identity +only; profile, social-account, organization, pinned-item, repository, and +contribution facts are synchronized independently. See the +[actor corpus](docs/actor-corpus.md) for the data and freshness model. + ## Side-effect boundaries GitContribute separates corpus reads, GitHub reads, local writes, process @@ -268,6 +273,7 @@ See [Architecture](docs/architecture.md) for the complete boundary definitions. - [Onboarding and configuration](docs/onboarding.md) - [Scalable MCP workflows](docs/mcp-scalable-workflows.md) +- [Actor corpus](docs/actor-corpus.md) - [Architecture and side-effect boundaries](docs/architecture.md) - [Operational runbooks](docs/runbooks.md) - [Security policy](SECURITY.md) diff --git a/docs/actor-corpus.md b/docs/actor-corpus.md new file mode 100644 index 0000000..7fc2197 --- /dev/null +++ b/docs/actor-corpus.md @@ -0,0 +1,64 @@ +# Actor corpus + +GitContribute treats a GitHub account as an actor with independently acquired +facts. This avoids the common failure mode where a user-search result is +mistaken for a hydrated profile or a missing page is mistaken for an empty +relationship. + +## Identity and profile + +`actors` stores the current provider identity. Its product key is +`github:node:` when GitHub supplies a node ID, otherwise the fallback +is `github:login:`. `actor_aliases` records every observed +login and points it at the same actor, allowing exact reads by current or old +login. Search results write identity observations only. + +`actor_profiles` is the current complete profile projection. GitHub fields +such as name, bio, company, location, website, public email, hireability, +followers, following, public repository count, and public gist count are +nullable. Null means “not present in this observation”; it is not converted to +an empty string, zero, or false. `actor_observations` retains the raw profile or +facet payload with acquisition provenance. + +## Independently refreshable facets + +| Facet | Projection | Acquisition primitive | Important bounds | +| --- | --- | --- | --- | +| profile | `actor_profiles` | `github.sync_users` | 100 exact users | +| social accounts | `actor_social_accounts` | `github.sync_user_social_accounts` | pages, items/user, total requests | +| organizations | `actor_organization_memberships` | `github.sync_user_organizations` | cursor pages, items/user, total requests | +| pinned items | `actor_pinned_items` | `github.sync_user_pinned_items` | 1–6 items/user | +| repositories | `actor_repository_affiliations` | `github.sync_user_repositories` | explicit owned, affiliated, or contributed relationship | +| contributions | period/day/item/total tables | `github.sync_user_contributions` | explicit RFC 3339 interval of at most one year, optionally scoped by organization node ID | + +Repository affiliation is not collapsed into a single boolean. The stored +relationship explains why a repository is associated with an actor. A +contribution item records its source kind, occurrence time, optional +repository, provider target identity and URL, restricted flag, and count. +Restricted activity that GitHub cannot disclose remains an aggregate rather +than a fabricated item. Contribution observations use `viewer` authorization +scope because token-visible restricted and private activity is not a public fact. + +## Freshness and replacement + +Every facet observation carries `observed_at`, `source_updated_at`, +`authorization_scope`, and `complete`. Current projections use +`(source_updated_at, observation_sequence)` ordering. Complete child snapshots +replace atomically. Paginated or truncated reads are stored as observations +but do not replace a previous complete child set. + +Corpus reads never contact GitHub. They return coverage alongside facts so an +agent can decide whether the observation is fresh enough for its task. The +server does not encode a universal freshness threshold: profile discovery, +review cleanup, and historical research have different tolerances. + +## Query composition + +`corpus.search_actors` filters and sorts profile facts. Exact identities use +`corpus.get_actors`; larger child payloads use actor facet resource URIs. +`corpus.search_contributions` composes actor, repository, kind, source, time, +organization scope, sort, and pagination filters. An empty organization scope +selects global contribution periods; an exact node ID selects that organization's +periods. Cursors bind those filters, so they cannot be +reused against a different query. All query pages bind to a corpus snapshot +token and missing coverage remains unknown. diff --git a/docs/agent-tool-evaluation.md b/docs/agent-tool-evaluation.md index e778922..ec55325 100644 --- a/docs/agent-tool-evaluation.md +++ b/docs/agent-tool-evaluation.md @@ -86,10 +86,9 @@ contracts include: - preserving semantic references across concise and detailed responses; - returning stable, duplicate-free pagination; - avoiding poll suggestions for terminal jobs. -- comparing `workflow.mine_repository_fix_patterns` with manual - search/select/hydrate loops on a repository where closed PR headers have - unknown merge state; score confirmed merged, closed-unmerged, superseded, - open, and unknown outcomes separately. +- comparing bounded atomic search/select/hydrate loops on a repository where + closed PR headers have unknown merge state; score confirmed merged, + closed-unmerged, superseded, open, and unknown outcomes separately. Run the focused suite with: diff --git a/docs/architecture.md b/docs/architecture.md index 0690dbb..1cc29fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,6 +89,24 @@ The corpus separates source history from convenient current state: - **Facet coverage** records whether a facet fetch completed and the source revision it represents. +GitHub actors are first-class projections rather than denormalized author +strings. A stable provider node ID is the preferred identity; observed logins +are aliases, so a rename does not split one contributor into two profiles. +Profile fields remain nullable because absence may mean undisclosed, +unauthorized, or not acquired. Social accounts, public organizations, pinned +items, repository relationships, and contribution periods are independent +facets with their own observation time, source time, authorization scope, and +completeness. A child table is replaced only after its complete bounded fetch; +an incomplete observation advances coverage without destroying the last +complete projection. + +Actor acquisition is deliberately atomic. `github.search_users` stores only +identity observations. Exact `github.sync_users` profile reads and the +`github.sync_user_*` facet tools perform no implicit fan-out into one another. +`corpus.search_actors`, `corpus.get_actors`, `corpus.get_actor_facets`, and +`corpus.search_contributions` are offline and snapshot-bound. See +`docs/actor-corpus.md` for the typed provider-to-SQLite mapping. + An explicit repository-context sync checks a fixed, bounded set of conventional `CONTRIBUTING.md` and AI-policy paths. Found text is stored as an untrusted repository-level `contribution_guidance` facet with exact file provenance. diff --git a/docs/mcp-composed-workflows.md b/docs/mcp-composed-workflows.md index 578a7ba..922d322 100644 --- a/docs/mcp-composed-workflows.md +++ b/docs/mcp-composed-workflows.md @@ -41,32 +41,31 @@ corpus.get_coverage -> typed exact/repository recovery -> jobs.get ``` Coverage reads are offline and missing coverage is unknown. Synchronization is -always an explicit bounded operation. If `corpus.get_coverage` or -`workflow.prepare_issue_set` returns incomplete coverage, follow its item-level -typed recovery action, preserving exact-thread versus repository scope. Poll -the returned job, perform the offline reread, and reuse its returned -`snapshot_token` for any composed duplicate checks. Use exact resource URIs -only through MCP `resources/read` before attaching receipts or handing evidence -to a draft workflow. +always an explicit bounded operation. If `corpus.get_coverage`, an exact thread +read, or a facet read returns incomplete coverage, follow its item-level typed +recovery action while preserving exact-thread versus repository scope. Poll the +returned job, perform the offline reread, and reuse its returned +`snapshot_token` for composed duplicate checks. Use exact resource URIs only +through MCP `resources/read` before attaching receipts or handing evidence to a +draft workflow. Canonical MCP composition: -1. Use `workflow.prepare_issue_set` for supplied exact issues. It returns - stored facts, per-thread coverage gaps, related work, merged precedents, and - linkage candidates without network access. When its result is partial, - replay the returned recovery action, poll the job, and retry the same read. -2. For an exact pull request or fields not covered by that aggregate, use - `corpus.get_threads` with `response_format=detailed`. -3. Read repository guidance or a persisted dossier only when the task needs - repository-wide context. Do not treat missing coverage as a negative result. +1. Use `corpus.get_threads` for supplied exact issues or pull requests and + `corpus.get_thread_facets` for selected child coverage. +2. Use `corpus.find_clusters`, `corpus.find_neighbors`, and + `corpus.find_precedents` only for the duplicate or historical evidence the + task needs. +3. Read repository guidance only when the task needs repository-wide context. + Do not treat missing coverage as a negative result. 4. Perform a bounded GitHub sync only when current live state is required or when returned coverage recovery requests it, wait for its durable job, and then repeat the relevant offline read. -Decision: **compose**. `workflow.prepare_issue_set` already removes the -error-prone issue fan-out while preserving coverage and provenance. A second -general “research brief” MCP operation would overlap it and the exact-thread -read without demonstrated semantic benefit. +Decision: **compose**. The facts-first catalog removed the aggregate issue-set +operation. Exact reads and bounded duplicate primitives make coverage and +selection visible to the calling agent without granting another workflow tool +authority over the sequence. ## Base and candidate validation comparison @@ -88,28 +87,19 @@ loss of comparison semantics that would justify another process-capable tool. Reconsider only if controlled traces show recurring client mistakes or a material call/payload reduction that preserves authorization and proof. -## Contribution preflight +## Contribution collision checks ```text -workflow.preflight_contribution +github.search_threads (bounded current work) +github.sync_pull_request_portfolio(selection=authored) -> jobs.get +corpus.search_pull_requests | corpus.find_pull_request_overlaps +workspace.check_merge_conflicts (only after explicit acquisition) ``` -Contribution preflight is the narrow exception to the portfolio composition -above. It is a read-only, bounded routing operation for the point before an -opportunity or workspace exists. It resolves the authenticated identity, -searches only the target repository for open authored pull requests, performs -one bounded related issue/PR search, and inspects explicitly supplied local Git -worktree paths without adopting or changing them. The operation returns -`existing_pr` when an authored PR matches a title, branch, commit, or inspected -worktree; it returns `new_work` only when every required live search and local -inspection completed; otherwise it returns `coverage_unknown` with reasons and -the next action needed to retry. - -This contract closes the pre-candidate gap without duplicating portfolio -storage or local workflow links. It does not create jobs, write the corpus, -create worktrees, or mutate GitHub. The regression fixtures cover an existing -authored PR with a matching local branch, unavailable identity, and a verified -unrelated candidate. +Decision: **compose**. The catalog no longer exposes a preflight workflow that +decides whether an agent should start work. Agents select the current-work, +portfolio, overlap, or Git comparison facts appropriate to the task. Unknown +discovery or facet coverage prevents a “no competing work” conclusion. ## Unified catalog diff --git a/docs/mcp-scalable-workflows.md b/docs/mcp-scalable-workflows.md index 031595d..14a48f1 100644 --- a/docs/mcp-scalable-workflows.md +++ b/docs/mcp-scalable-workflows.md @@ -1,349 +1,164 @@ -# Scalable MCP workflows +# Composing MCP tools at scale -GitContribute exposes bounded, vectorized primitives: each tool owns one -side-effect boundary but can process a collection. This keeps agents from -building slow N+1 loops while preserving explicit control over network access, -local writes, and process execution. +GitContribute exposes bounded primitives that agents can compose without +building scalar N+1 loops. Each tool owns one side-effect boundary: offline +corpus read, explicit GitHub acquisition, local state write, Git process, or +authorized validation process. -## Repository research +## Ground rules -Use the cheapest authoritative source first, and hydrate only finalists: +- Corpus reads never contact GitHub or refresh data implicitly. +- GitHub reads are explicit, bounded, rate-limited, and may write only local + observations and projections. +- Missing, stale, paginated, or truncated coverage is unknown, not evidence of + absence. +- Durable jobs must reach a terminal state through `jobs.get` before their + observations are treated as current. +- Resource URIs are opaque. Read the exact returned URI with MCP + `resources/read` rather than reconstructing it. +- No MCP tool mutates GitHub or executes repository-controlled code. + +## Repository and thread research + +Discovery and hydration stay separate: ```text github.search_repositories -> corpus.get_repositories -github.search_threads -> resources/read (exact immutable query artifact) -github.read_source_files -> resources/read (exact immutable source bundle) +github.search_threads -> resources/read (immutable search artifact) github.sync_repository_context -> jobs.get -> corpus.get_repositories -research.query_deepwiki -github.sync_threads -> jobs.get -> corpus.rank_contribution_candidates +github.sync_threads -> jobs.get -> corpus.get_threads github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets -corpus.find_precedents -workflow.prepare_issue_set -github.index_pull_request_feedback -> jobs.get -> corpus.search_pull_request_feedback +corpus.find_clusters | corpus.find_neighbors | corpus.find_precedents ``` -When a client appears to be using an older or restricted registration, call -`workflow.get_catalog_contract`. It is a read-only diagnostic that reports the -running server version, `catalog_mode`, deterministic `catalog_fingerprint`, -tool count, and the three advertised pull-request feedback route tools. Compare -those values with `tools/list`; after setup, upgrade, or registration changes, -create a fresh MCP connection so the client is not reusing an older server -process. In `read_only` mode, the feedback index and exact-PR sync are -intentionally absent while offline search remains available when its reader is -supported. - -- `github.search_repositories` runs one bounded live search and persists the - returned repository metadata. Prefer structured filters so GitContribute can - validate and explain the query; reserve `raw_query` for unsupported GitHub - qualifiers. `response_format: concise` keeps broad discovery bounded, while - `detailed` preserves secondary metadata for finalists. Live pagination uses - `page` and `next_page` because GitHub search pages are not stable cursors. - -- `github.search_threads` runs one bounded issue-search page for one repository, - persists returned thread observations, and returns an exact - `gitcontribute://artifact/github-thread-search/` resource. Its total, - provider query, ordering, rate state, and incomplete-results flag are - preserved in that artifact, but the page does not establish repository-wide - thread coverage or prove absence. -- `github.read_source_files` resolves a commit or named ref once, then reads up - to 20 ordered repository-relative files with per-file and total-byte bounds. - Complete items preserve commit SHA, blob SHA, content digest, line range, and - source URL in a `source-bundle.v1` resource. Failed, missing, oversized, and - retryable items remain visible without discarding successful siblings. Read - the returned `gitcontribute://artifact/source-bundle/` URI locally; - repository text is untrusted and is never executed. -- `corpus.search_code_batch` is the bounded offline fan-out surface for up to - 20 queries over one repository or snapshot scope. It shares one corpus read - revision and preserves each query's coverage and truncation semantics. It - never performs live GitHub code search; `corpus.search_code` remains the - single-query compatibility operation. - -```json -{ - "text": "inference", - "match_fields": ["name", "description"], - "topics": ["cuda"], - "language": "Python", - "stars_min": 200, - "pushed_after": "2026-06-15", - "archived": false, - "fork": false, - "response_format": "concise" -} -``` +`github.search_repositories` and `github.search_threads` return one bounded +live page. Search pages do not prove repository-wide absence. Repository and +thread synchronization records coverage separately from stored row counts. +Thread headers do not contain every pull-request fact; comments, reviews, +merge details, checks, files, and other children require explicit facets. + +`github.read_source_files` resolves a ref once and reads up to 20 ordered +repository-relative files with per-file and total-byte limits. Its immutable +source-bundle resource records the resolved commit and blob provenance. -Search responses return the compiled provider `query`, a short interpretation, -request-specific warnings, semantic `repository:owner/name` references, and a -non-mandatory suggested thread-sync call. Advanced provider syntax uses the -explicit `raw_query` field; there is no deprecated alias. -- `github.sync_repository_context` fetches and persists metadata and fixed - contribution-guidance files for explicit repository identities. Use - it to recover a `repository_not_indexed` result, then poll the returned job - before reading the repository again. -- `corpus.get_repositories` returns stored metadata plus `dossier_status` and - `dossier_as_of` for up to 100 repositories. Use that batch to compare - candidates and dossier availability; load a full persisted dossier only for - a known finalist through `gitcontribute://dossier/{owner}/{repo}`. -- `corpus.get_repositories`, `corpus.get_threads`, `corpus.find_clusters`, - `corpus.find_neighbors`, `corpus.rank_contribution_candidates`, and - `corpus.find_precedents` are offline. -- `corpus.find_clusters` and `corpus.find_neighbors` accept up to 20 repository - or source-thread targets respectively. Their ordered item results isolate - missing or invalid targets instead of forcing scalar retry loops. -- Search, coverage, precedent, code-search, fix-pattern, and research-brief - results carry a query digest, observation watermark, completeness, - truncation, and unknown-coverage status. Read-only operations return an - `ephemeral:` transaction-bound identity and state that it is not reusable. - Call `corpus.ensure_coverage` when a composed workflow needs a durable - `snapshot_token`; reading that token either returns its immutable payload or - fails with a typed unavailable result. Reads never refresh the corpus or - silently substitute current projections. -- `corpus.rank_contribution_candidates` requires one to 50 repositories. Its derived ranking is - intentionally non-paginated; inspect `total` and `truncated`, then raise the - limit or narrow the repository set when more candidates are needed. Per-repo - summaries distinguish the evaluated population, returned candidates, and an - internal population cap. -- `research.query_deepwiki` is an optional public external read. Its prose is - untrusted derived context, is not persisted, and is not authority for live - GitHub state. -- `github.sync_threads` stores issue or pull-request headers. Child comments - and reviews require explicit `github.sync_thread_facets` facets. -- `github.sync_thread_facets` refreshes each selected thread header before fetching - child facets, so exact finalists do not inherit stale header coverage. The - repository metadata must already exist locally, and each item's `requests` - count includes its one exact-header request. Successful items report - `header_refreshed: true`. -- Pull-request headers do not contain merge outcomes. Until `pr_details` is - hydrated, a closed PR's `merged` value is omitted and outcome-sensitive - offline reads report it as unknown rather than closed-unmerged. -- `github.index_pull_request_feedback` is the repository-scoped feedback path - for audits such as “find every comment written by this reviewer.” Do not use - `github.sync_portfolio` (which is user-scoped when explicitly run in - `authored` mode) or full-text `corpus.search_threads` for that question. - It walks every reachable PR with `state=all` under explicit page, request, - and item bounds, persists its next discovery page, then reuses the exact PR - feedback adapter for issue comments, submitted reviews, inline comments, and - review-thread topology. Poll the returned job and use - `corpus.search_pull_request_feedback` with the exact `feedback_author` login - for offline author/state/merge/ - resolution/text/date filtering. An empty result with incomplete discovery or - facets is `partial`/`unknown`, not absence; follow its typed recovery plan. - Matching rows include exact PR, thread, comment, author, reply, anchor, - review-state, resolution, source-observation, and readable child - `gitcontribute://pull-request-feedback/{owner}/{repo}/{number}/{channel}/{feedback_id}` - references. Pagination uses an - opaque cursor scoped to the complete query; pass a durable snapshot token - when the caller needs the pages pinned to one corpus revision. - -### Repository fix-pattern mining - -The unified catalog exposes the trace-backed aggregate: +`corpus.search_code` accepts up to 20 queries over one repository or snapshot +scope. Every query uses the same offline corpus revision. It never falls back +to live GitHub code search. + +## Actor and contributor research + +User search deliberately stores identity stubs instead of hydrating every +result: ```text -workflow.mine_repository_fix_patterns - -> jobs.get - -> gitcontribute://fix-pattern-report/{job_id} +github.search_users + -> corpus.search_actors + -> github.sync_users (selected identities only) + -> github.sync_user_* (selected facets only) + -> corpus.get_actors | corpus.get_actor_facets ``` -Use it to summarize how one stored repository handled caller-defined symptom -categories over an explicit observation window. It searches the local corpus -first, refreshes only a bounded set of finalists whose merge outcome is -unknown, and persists a typed report. `candidate_limit`, `hydration_limit`, and -`representative_limit` bound search, network work, and returned context -independently. The durable operation always creates a job and persists its -report. Use `corpus.preview_fix_patterns` when the analysis must be strictly -offline and must create no job, artifact, hydration, or local write. - -Coverage reports candidate matches, unique pull requests, unknown outcomes -before and after hydration, hydration failures, and candidate truncation. -Merged, closed-unmerged, superseded, open, and unknown remain separate -outcomes. Only closed PRs with unknown merge state consume the hydration -budget. An example is marked `accepted_fix` only when refreshed state confirms -it was merged and stored pull-request text contains an explicit closing -relationship. A similar closed PR is never promoted to accepted-fix evidence. -Relationship and proof-style labels are bounded lexical projections, so the -report preserves their supporting phrase and states that similarity is not -causal proof. - -For an analysis that must not create a job, artifact, hydration, or write, use -`corpus.preview_fix_patterns`. It returns `persisted: false`, zero hydration, -and the captured snapshot identity. The durable operation remains the path for -persisted reports. - -## Exact issue-set preparation - -Use `workflow.prepare_issue_set` when the contribution is already scoped by -known issue numbers and creating opportunities would add no useful state: - -```json -{ - "owner": "acme", - "repo": "rocket", - "issue_numbers": [7, 11, 14], - "precedent_limit": 3, - "response_format": "concise" -} -``` +Available facet acquisitions are social accounts, organizations, pinned or +showcase items, repositories, and contribution periods. Each has independent +request, page, or item bounds and independent coverage. Repository facts retain +the explicit `owned`, `affiliated`, or `contributed` relationship. -The tool is an offline read. It composes exact issue facts, body and -comment/timeline coverage, related open and closed work, merge-confirmed -precedents, duplicate-cluster evidence, and precise sync or hydration recovery -calls. It does not render a draft, create an opportunity, inspect a workspace -diff, or claim that an implementation satisfies an issue. Linkage therefore -defaults to `related` and always requires caller confirmation before choosing -`Closes`, `Advances`, or `Related`. - -Repository `threads` coverage qualifies the related-work population. When that -coverage is absent or incomplete, the result remains partial and suggests an -explicit all-state pull-request sync instead of treating the stored count as -exhaustive. - -`concise` omits issue bodies and detailed relationship evidence and returns at -most five related-work records per issue. `related_work_total` distinguishes -that response shortening from missing corpus evidence. When an upstream bound -prevents an exhaustive count, `related_work_total_known` is false and the count -is a lower bound. `related_work_truncated` says explicitly that records or -evidence were omitted. Empty stored bodies are reported as unknown because the -current corpus projection cannot distinguish a known-empty body from a body -that was not captured. - -## Pull-request portfolio +Contribution research composes an explicit acquisition period with an offline +query: ```text -github.sync_pull_request_portfolio(selection=authored) -> jobs.get --> corpus.list_pull_requests --> corpus.find_pull_request_overlaps +github.sync_user_contributions -> jobs.get + -> corpus.search_contributions ``` -The status adapter stores REST pull-request details and reviews plus typed, -independently covered GraphQL snapshots for checks, unresolved review threads, -detailed merge state, merge queue, closing issues, and changed files. The -offline portfolio derives deterministic attention states only from complete -facets. A null or still-computing mergeability value remains unknown. +The offline query filters by actor, repository, contribution kind, source, organization scope, and +time. Its cursor binds those filters and cannot be reused for a different +query. Restricted GitHub activity remains an aggregate unless GitHub discloses +an item. See [Actor corpus](actor-corpus.md) for the SQLite mapping and +freshness semantics. -`corpus.find_pull_request_overlaps` compares up to 50 stored candidates with -authored pull requests using complete normalized changed-path, linked-issue, -and stored opportunity-similarity evidence. It returns `unknown` unless every -required facet is complete; it never performs network access. Use -`workflow.link_pull_request` to record an explicit local PR association with an -opportunity or workspace. That local write does not mutate GitHub. +## Pull-request feedback and CI -Issue timeline hydration is an explicit, opt-in `issue_timeline` facet. Complete -timeline observations may create versioned resolution records with exact source -observation references. Closing-issue observations remain relationship evidence -until completion is independently observed. Similar prose is not resolution -evidence. +Repository-wide feedback indexing is the route for questions such as “find +every comment by this reviewer”: -`workspace.check_merge_conflicts` is different from GitHub mergeability. It runs -a non-mutating Git comparison between already-fetched object IDs in a managed -workspace. It never fetches refs or modifies an index or worktree. +```text +github.index_pull_request_feedback(state=all) -> jobs.get + -> corpus.search_pull_request_feedback(feedback_author=exact_login) + -> resources/read +``` -## Partial results and recovery +The index records discovery coverage and then acquires issue comments, +submitted reviews, inline comments, and review-thread topology. Body text and +author are separate filters. An empty result is unknown until both discovery +and requested feedback-channel coverage are complete. -Batch outputs preserve input order. Each item has one of these statuses: +Exact PR refresh uses `github.sync_pull_request_feedback`. CI uses +`github.sync_pull_request_ci`; checks and statuses are bound to the observed +head SHA. Offline authored-PR reads use `corpus.search_pull_requests`, and +overlap analysis uses `corpus.find_pull_request_overlaps`. -- `complete`: use the value; -- `retryable`: retry that item after `retry_after_ms` when present; -- `unavailable`: follow the typed `recovery` plan or acquire the missing facet explicitly; -- `failed`: fix the input or local failure before retrying. +## Jobs, partial results, and recovery -A durable job can succeed while its result is `partial`: job success means the -bounded operation completed and recorded every item outcome. Poll concurrent -jobs together with vectorized `jobs.get`, then retry only retryable items. Never -interpret absent coverage as a zero, a passing check, or a lack of competing -work. New job references carry a semantic `job:` reference, -`poll_after_ms`, and a typed `jobs.get` follow-up with its job ID. +A durable job may succeed with a partial result. Job success means the bounded +operation ran to completion and recorded every item outcome; it does not turn +retryable or unavailable items into complete facts. Poll several IDs together +with `jobs.get`, then retry only the affected inputs. -Facet synchronization completes on the same offline read plane: use -`corpus.get_thread_facets` for bounded coverage metadata and follow each -returned `resource_uri` through MCP `resources/read` for the persisted facet -observations. A missing repository, thread, or facet returns a versioned -`recovery` plan whose `then` calls are ordered and carry typed arguments. +Per-item results distinguish: -Repository and dossier absence have different recovery paths: +- `complete`: the requested bounded observation was stored; +- `retryable`: rate limiting or a transient provider failure permits retry; +- `unavailable`: the identity, authorization, or provider capability is not + available; +- `failed`: a non-retryable request or persistence error occurred. -- `repository_not_indexed` means no local repository projection exists. Call - `github.sync_repository_context`, poll the job with `jobs.get`, and then - retry the offline read. -- `dossier_not_persisted` means the repository exists locally but has no saved - dossier. Use `corpus.get_repositories` for metadata and dossier availability; - call `workflow.build_repository_dossier` only when creating that local artifact - is actually required. +Coverage carries observation time, source time, authorization scope, and +completeness. The caller chooses a task-appropriate freshness threshold; the +server does not claim that one age limit suits discovery, review cleanup, and +historical research equally. -Reading the dossier resource again cannot resolve either state. +## Canonical resources -## Canonical source audit +Detailed persisted payloads use the `gitcontribute://` resource namespace. +Examples include thread facets, pull-request feedback, immutable source and +code-index artifacts, actors, investigations, opportunities, evidence, +manifests, drafts, concerns, and workspaces. The catalog does not advertise +dossier or fix-pattern workflow resources. -Use this order when producing a source-backed audit or contribution handoff: +## Breaking name changes -```text -corpus.get_coverage -> typed exact/repository recovery -> jobs.get - -> snapshot-bound offline reread -> duplicate checks - -> explicit live verification -> jobs.get -> receipt attachment - -> evidence/draft handoff -``` +| Previous name | Canonical name | +| --- | --- | +| `corpus.search_code_batch` | `corpus.search_code` | +| `corpus.list_pull_requests` | `corpus.search_pull_requests` | +| `github.sync_ci_failures` | `github.sync_pull_request_ci` | -Start with `corpus.get_coverage` and treat missing or incomplete coverage as -unknown. Follow the item-level typed recovery action it returns: use -`corpus.ensure_coverage` for repository bootstrap or broad target recovery, -`github.sync_threads` for exact or repository header recovery, and -`github.sync_thread_facets` for selected child facets. Poll the returned job -with `jobs.get`, then perform the offline reread. Use the reread's returned -`snapshot_token` for duplicate checks over that same state. -Perform live verification after local evidence selection, attach a producer-neutral -validation receipt, and hand the exact resource and any returned revision -references to the evidence or draft workflow. Resources that do not expose a -revision are point-in-time reads and should be reread after an explicit sync. -Larger persisted payloads are always read with MCP `resources/read` using the -exact opaque URI returned by the tool. - -Completed code-index jobs return a typed artifact containing repository, commit -SHA, snapshot token, manifest identity and digest, file/truncation counts, and -an exact `gitcontribute://artifact/code-index/` resource. Consume that URI through -`resources/read`; do not infer an artifact identity from a repository name -alone. - -`corpus.get_coverage` accepts up to 100 ordered repository or exact-thread -targets. `jobs.cancel` accepts up to 100 IDs and returns isolated item outcomes; -repeating cancellation is safe. `jobs.get` exposes structured phase and item -counts rather than requiring clients to parse event prose. - -The MCP catalog does not advertise scalar compatibility aliases or duplicate -durable-artifact getters. Read dossiers, investigations, opportunities, -evidence, readiness reports, workflows, and lenses through their -`gitcontribute://` resources. Use one-item arrays with -`corpus.get_repositories`, `corpus.get_threads`, -`github.sync_threads`, `github.sync_thread_facets`, and `jobs.get` when only one -target is needed. Configured recurring-source crawls remain a CLI/TUI workflow, -not an MCP discovery primitive. - -## Side-effect boundaries - -| Tool family | Network | Corpus/local write | Process | +Ranking, issue-set preparation, dossier, fix-pattern, preflight, related-work, +scalar code-search, and DeepWiki workflow tools are not advertised. Agents +should select the underlying acquisition and corpus facts that match the task. + +## Side-effect matrix + +| Tool family | Network | Local write | Process | | --- | ---: | ---: | ---: | -| `corpus.get_*`, rank, precedents, portfolio | no | no | no | -| `corpus.search_code`, `corpus.search_code_batch` | no | no | no | -| `workflow.link_pull_request` | no | yes | no | -| `github.search_*`, source reads, sync, hydrate | yes | yes | no | -| `research.query_deepwiki` | yes | no | no | +| `corpus.*` | no | no | no | +| `github.search_*`, `github.sync_*`, source reads | yes | observations only | no | +| `jobs.get`, `jobs.cancel` | no | cancel only | no | +| local workflow state | no | yes | no | | `code.index_repositories` | remote-dependent | yes | Git only | -| `workspace.check_merge_conflicts` | no | no | Git only | - -No tool in these workflows mutates GitHub or executes repository-controlled -code. +| `workspace.*` Git operations | remote-dependent | yes | Git only | +| `validation.run` | no by default | yes | explicit command | -## End-to-end verification +## Verification -Run the real stdio protocol tests with: +The stdio integration tests exercise a real MCP subprocess, catalog discovery, +durable job polling, offline rereads, portfolio synchronization, and resource +handoffs: ```sh go test ./internal/app -run '^TestMCPStdio(ScalableResearch|PullRequestPortfolio)Flow$' -count=1 ``` -The tests launch the application as an MCP subprocess, use a real file-backed -SQLite corpus, and route the real GitHub HTTP adapter to a controlled test -server. They cover initialization and tool discovery, repository-context synchronization, -offline batch reads, ranking, precedents, authored-PR discovery, status -hydration, portfolio classification, vectorized durable-job polling, and a -protocol-visible invalid hydration request. They do not contact live GitHub or -DeepWiki and do not run repository code. +Run `make verify` before a pull request for uncached tests, changed-code lint, +module tidiness, generated-output verification, and documentation validation. diff --git a/docs/mcp-tool-redesign.md b/docs/mcp-tool-redesign.md index ab7eeef..e273067 100644 --- a/docs/mcp-tool-redesign.md +++ b/docs/mcp-tool-redesign.md @@ -1,98 +1,96 @@ -# MCP tool redesign +# MCP tool design GitContribute targets `github.com/modelcontextprotocol/go-sdk` `v1.7.0` and -negotiates MCP `2026-07-28`. The server continues to -register generic SDK tools so the SDK owns input decoding and output-schema -validation at the protocol boundary. - -## Contract ownership - -Schema semantics live with their Go values. Probability, similarity, radar -score, progress, non-negative counts, batch status, and job status are reusable -typed schema values. A field named `score`, `confidence`, `status`, `kind`, or -`result` never receives semantics from its JSON name alone. - -Multi-mode tools keep an object root and compose draft-2020-12 schema nodes -over SDK-inferred Go structs. `oneOf`, `required`, `not`, -`dependentRequired`, bounds, defaults, and constants express protocol shape. -Handlers retain checks for repository existence, authorization, lifecycle -legality, RFC 3339 values, and stored-state consistency. There is no parallel -JSON decoder or validator. - -## Response and side-effect boundaries - -`jobs.get` returns bounded status, progress, typed artifact references, and a -suggested follow-up; it does not expose stored request or result blobs. -Repository dossiers, repository projections, and manifest statements are -typed. DeepWiki defaults to 32 KiB and directs truncated reads toward structure -or a focused question before a larger response. - -Tool results link durable concerns, dossiers, investigations, opportunities, -evidence, readiness reports, immutable draft revisions, contribution manifests, -and job artifacts with SDK-native resource links. Resources are the canonical -detailed read plane for durable artifacts; the catalog does not duplicate them -as scalar tools. Producers return compact typed references so clients follow -the exact resource URI instead of consuming duplicate structured output. -Recording a hypothesis returns its parent investigation reference because the -investigation resource is the canonical aggregate that contains hypotheses. -Bounded searches, rankings, and multi-object reads remain tools because they are -queries rather than durable-artifact representations. -Clients must support MCP `resources/read`; Codex exposes that operation as -`read_mcp_resource`. GitContribute does not provide a parallel generic read -tool or scalar artifact fallback. - -Validation receipts remain ordinary tool results. They are small execution -classifications needed immediately by the caller, so replacing them with a -second read would add ceremony without reducing a material payload. Workspaces -also remain operational tool results: their lifecycle is coupled to host -filesystem and process capabilities, and their public representation -intentionally omits host paths. Neither is an independently browsable durable -artifact today; revisit that boundary only when a concrete cross-session read -workflow needs it. - -The catalog preserves offline reads, network reads, local writes, process -execution, and external mutation as separate capabilities. The unified catalog -exposes the complete concern lifecycle rather than a partial subset. The -dossier build operation is named `workflow.build_repository_dossier` because -it writes local state. - -## Consolidation decisions - -Exact issue preparation remains the aggregate `workflow.prepare_issue_set`. -Durable submission and polling, validation definition and authorized -execution, and commit inspection and planning remain separate because each -boundary permits meaningful agent judgment or authorization. - -Live repository search includes local dossier availability. The catalog offers -`github.sync_pull_request_portfolio`, a bounded durable job with authored and -explicit selection modes. Authenticated identity, authored discovery, and -exact status refresh are internal phases and are not advertised as recovery -primitives. Feedback and CI use the separate -`github.sync_pull_request_feedback` and `github.sync_ci_failures` jobs so their -coverage and retry behavior remain visible. - -The catalog offers -`workflow.mine_repository_fix_patterns`. It consolidates the observed -search-select-hydrate-rescan loop while preserving the real durable-job, -network-read, and local-write boundaries. The triggering agent trace found 587 -otherwise matching pull requests with unknown merge state, then required 26 -exact hydrations to recover 21 confirmed merged examples; one persistence step -also encountered `SQLITE_BUSY`. The aggregate therefore hydrates only bounded -unknown-state finalists, reports unknowns before and after hydration, and -separates confirmed merged fixes from merely similar closed work. - -DeepWiki retains one tool with three discriminated modes. Host-native tool -search is the capability-discovery mechanism; the server does not mutate its -catalog or add a custom discovery meta-tool. - -## Evidence gate - -Catalog byte measurements are regression proxies, not model evidence. The v5 -fixture under `internal/mcpserver/testdata/agent-eval/v5` records the unified -catalog decision and compares eager loading with host-native tool search. Each -condition requires at least three trials with frozen model, sampling settings, -catalog, snapshot token, permissions, prompt, and token budget. - -Semantic correctness and side-effect correctness are gates. Only afterward may -invalid calls, redundant calls, result tokens, latency, and recovery success be -compared. The fixture does not claim that model trials have run. +negotiates MCP `2026-07-28`. Typed Go input and output values own protocol +semantics. The SDK performs input decoding and output-schema validation; schema +customization adds bounds, enums, defaults, and discriminated `oneOf` branches +without a parallel decoder. + +## Facts before workflows + +The public catalog exposes atomic, composable facts: + +- `corpus.*` operations are offline reads and never refresh implicitly; +- `github.*` operations are explicit, bounded network reads that may update + only the local corpus; +- `jobs.*` controls durable work; +- resource URIs are the canonical detailed read plane for persisted payloads; +- workspace and validation tools retain their separate local-write and process + boundaries. + +Tool descriptions state what a call reads or writes, its bounds, and what +unknown or incomplete output means. They do not prescribe an agent workflow. +Missing, paginated, truncated, stale, unauthorized, and unavailable facts stay +distinct from an observed empty value. + +Batch inputs remain useful when every item has the same capability boundary. +They preserve input order and isolate per-item failures. Actor results use a +small item contract rather than embedding the catalog-wide recovery union in +every output schema. + +## Actor primitives + +`github.search_users` persists one bounded page of identity observations. It +does not fan out into profile reads. Exact profile and child facts use separate +tools: + +```text +github.sync_users +github.sync_user_social_accounts +github.sync_user_organizations +github.sync_user_pinned_items +github.sync_user_repositories +github.sync_user_contributions +``` + +Selectors are a discriminated union: exactly one login or GitHub node ID. +Repository synchronization requires an explicit `owned`, `affiliated`, or +`contributed` relationship. Contribution synchronization requires an explicit +period no longer than one year. Pages, items, repositories, and total requests +are bounded in the input schema. + +Offline consumers compose `corpus.search_actors`, `corpus.get_actors`, +`corpus.get_actor_facets`, and `corpus.search_contributions`. Results expose +observation and source timestamps, authorization scope, completeness, snapshot +identity, and opaque actor resource URIs. See [Actor corpus](actor-corpus.md). + +## Resources and structured output + +Small query results remain structured tool output. Larger or durable payloads +are read with MCP `resources/read` using the exact URI returned by a tool. +Actor resources use: + +```text +gitcontribute://actor/{actor_id} +gitcontribute://actor/{actor_id}/facet/{facet} +``` + +Facet resources contain the stored typed payload and its provenance; reading a +resource never contacts GitHub. Job results expose structured progress and +artifact references rather than raw request or result blobs. + +## Breaking catalog changes + +The facts-first catalog removes overlapping workflow-shaped operations for +ranking candidates, preparing issue sets, building dossiers, mining or +previewing fix patterns, contribution preflight, related-work discovery, and +DeepWiki reads. Their component corpus, GitHub, resource, workspace, and +validation capabilities remain independently selectable where applicable. + +Canonical names changed as follows: + +| Previous name | Canonical name | +| --- | --- | +| `corpus.search_code_batch` | `corpus.search_code` | +| `corpus.list_pull_requests` | `corpus.search_pull_requests` | +| `github.sync_ci_failures` | `github.sync_pull_request_ci` | + +There is no scalar code-search compatibility alias. The canonical code search +accepts several bounded queries over one shared offline snapshot. + +## Side effects + +MCP annotations reflect the actual capability boundary. Corpus reads are +read-only and idempotent. GitHub reads are open-world operations that persist +local observations. Local workflow writes, Git acquisition, and validation +execution remain separate. GitContribute exposes no GitHub mutation tool. From b1a082e9a062cdd07906575e59209c2d2fa0a11a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:42:33 +0800 Subject: [PATCH 6/8] refactor(github): isolate user REST adapter --- internal/github/client.go | 124 -------------------------------- internal/github/user_rest.go | 132 +++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 124 deletions(-) create mode 100644 internal/github/user_rest.go diff --git a/internal/github/client.go b/internal/github/client.go index 8ae22a0..727b70e 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -49,40 +49,6 @@ type RepositorySearcher interface { SearchRepositories(ctx context.Context, opts RepositorySearchOptions) (RepositorySearchResult, error) } -// UserSearcher discovers GitHub account identities without hydrating every -// result profile. -type UserSearcher interface { - SearchUsers(context.Context, UserSearchOptions) (UserSearchResult, error) -} - -// UserProfileReader reads one exact public or viewer-visible profile header. -type UserProfileReader interface { - GetUser(context.Context, string) (Actor, RateInfo, error) -} - -// UserSocialAccountReader reads one bounded page of public social accounts. -type UserSocialAccountReader interface { - ListUserSocialAccounts(context.Context, string, PageOptions) (ListResult[SocialAccount], error) -} - -// UserRepositoryReader reads one bounded page of repositories related to a -// user. Contributed relationships require the GraphQL capability below. -type UserRepositoryReader interface { - ListUserRepositories(context.Context, string, UserRepositoryOptions) (ListResult[Repository], error) -} - -type UserOrganizationReader interface { - ListUserOrganizations(context.Context, string, CursorPageOptions) (ListResult[OrganizationIdentity], error) -} - -type UserPinnedItemReader interface { - GetUserPinnedItems(context.Context, string, int) (PinnedItemsResult, error) -} - -type UserContributionReader interface { - GetUserContributions(context.Context, string, UserContributionOptions) (UserContributionCollection, error) -} - // IdentityReader resolves the authenticated GitHub account without granting // any mutation capability. type IdentityReader interface { @@ -299,96 +265,6 @@ func (c *Client) SearchRepositories(ctx context.Context, opts RepositorySearchOp }, nil } -// SearchUsers reads one page from GitHub's user Search API. Search results are -// identity stubs and intentionally do not trigger per-result profile reads. -func (c *Client) SearchUsers(ctx context.Context, opts UserSearchOptions) (UserSearchResult, error) { - result, resp, err := c.gh.Search.Users(ctx, opts.Query, &gh.SearchOptions{ - Sort: opts.Sort, Order: opts.Order, - ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, - }) - if err != nil { - return UserSearchResult{}, classifyError(err) - } - items := make([]Actor, 0, len(result.Users)) - for _, user := range result.Users { - items = append(items, convertActor(user)) - } - return UserSearchResult{Total: result.GetTotal(), Incomplete: result.GetIncompleteResults(), Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil -} - -// GetUser reads one exact profile header. -func (c *Client) GetUser(ctx context.Context, login string) (Actor, RateInfo, error) { - user, resp, err := c.gh.Users.Get(ctx, login) - if err != nil { - return Actor{}, RateInfo{}, classifyError(err) - } - return convertActor(user), rateInfo(resp.Rate), nil -} - -// ListUserSocialAccounts reads one bounded social-account page. -func (c *Client) ListUserSocialAccounts(ctx context.Context, login string, opts PageOptions) (ListResult[SocialAccount], error) { - accounts, resp, err := c.gh.Users.ListUserSocialAccounts(ctx, login, &gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}) - if err != nil { - return ListResult[SocialAccount]{}, classifyError(err) - } - items := make([]SocialAccount, 0, len(accounts)) - for _, account := range accounts { - if account == nil { - continue - } - items = append(items, SocialAccount{Provider: account.GetProvider(), URL: account.GetURL()}) - } - return ListResult[SocialAccount]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil -} - -// ListUserRepositories reads one bounded owned or affiliated repository page. -func (c *Client) ListUserRepositories(ctx context.Context, login string, opts UserRepositoryOptions) (ListResult[Repository], error) { - if opts.Relationship == "contributed" { - return c.listUserContributedRepositories(ctx, login, opts) - } - relation := opts.Relationship - switch relation { - case "owned": - relation = "owner" - case "affiliated": - relation = "member" - } - repositories, resp, err := c.gh.Repositories.ListByUser(ctx, login, &gh.RepositoryListByUserOptions{ - Type: relation, Sort: opts.Sort, Direction: opts.Direction, - ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, - }) - if err != nil { - return ListResult[Repository]{}, classifyError(err) - } - items := make([]Repository, 0, len(repositories)) - for _, repository := range repositories { - items = append(items, convertRepository(repository)) - } - return ListResult[Repository]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil -} - -func convertActor(user *gh.User) Actor { - if user == nil { - return Actor{} - } - actor := Actor{ - Login: user.GetLogin(), ID: user.GetID(), NodeID: user.GetNodeID(), Kind: strings.ToLower(user.GetType()), - AvatarURL: user.AvatarURL, Name: user.Name, Bio: user.Bio, Company: user.Company, Location: user.Location, - WebsiteURL: user.Blog, PublicEmail: user.Email, TwitterUsername: user.TwitterUsername, Hireable: user.Hireable, - Followers: user.Followers, Following: user.Following, PublicRepositories: user.PublicRepos, PublicGists: user.PublicGists, - } - if user.CreatedAt != nil { - actor.CreatedAt = user.CreatedAt.Time - } - if user.UpdatedAt != nil { - actor.UpdatedAt = user.UpdatedAt.Time - } - if actor.Kind == "" { - actor.Kind = "unknown" - } - return actor -} - // ListIssues reads one page of issues and pull-request markers for a repository. func (c *Client) ListIssues(ctx context.Context, owner, name string, opts ListIssueOptions) (ListResult[Issue], error) { gopts := &gh.IssueListByRepoOptions{ diff --git a/internal/github/user_rest.go b/internal/github/user_rest.go new file mode 100644 index 0000000..dda1251 --- /dev/null +++ b/internal/github/user_rest.go @@ -0,0 +1,132 @@ +package github + +import ( + "context" + "strings" + + gh "github.com/google/go-github/v89/github" +) + +// UserSearcher discovers GitHub account identities without hydrating every +// result profile. +type UserSearcher interface { + SearchUsers(context.Context, UserSearchOptions) (UserSearchResult, error) +} + +// UserProfileReader reads one exact public or viewer-visible profile header. +type UserProfileReader interface { + GetUser(context.Context, string) (Actor, RateInfo, error) +} + +// UserSocialAccountReader reads one bounded page of public social accounts. +type UserSocialAccountReader interface { + ListUserSocialAccounts(context.Context, string, PageOptions) (ListResult[SocialAccount], error) +} + +// UserRepositoryReader reads one bounded page of repositories related to a +// user. Contributed relationships require the GraphQL capability below. +type UserRepositoryReader interface { + ListUserRepositories(context.Context, string, UserRepositoryOptions) (ListResult[Repository], error) +} + +type UserOrganizationReader interface { + ListUserOrganizations(context.Context, string, CursorPageOptions) (ListResult[OrganizationIdentity], error) +} + +type UserPinnedItemReader interface { + GetUserPinnedItems(context.Context, string, int) (PinnedItemsResult, error) +} + +type UserContributionReader interface { + GetUserContributions(context.Context, string, UserContributionOptions) (UserContributionCollection, error) +} + +// SearchUsers reads one page from GitHub's user Search API. Search results are +// identity stubs and intentionally do not trigger per-result profile reads. +func (c *Client) SearchUsers(ctx context.Context, opts UserSearchOptions) (UserSearchResult, error) { + result, resp, err := c.gh.Search.Users(ctx, opts.Query, &gh.SearchOptions{ + Sort: opts.Sort, Order: opts.Order, + ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, + }) + if err != nil { + return UserSearchResult{}, classifyError(err) + } + items := make([]Actor, 0, len(result.Users)) + for _, user := range result.Users { + items = append(items, convertActor(user)) + } + return UserSearchResult{Total: result.GetTotal(), Incomplete: result.GetIncompleteResults(), Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +// GetUser reads one exact profile header. +func (c *Client) GetUser(ctx context.Context, login string) (Actor, RateInfo, error) { + user, resp, err := c.gh.Users.Get(ctx, login) + if err != nil { + return Actor{}, RateInfo{}, classifyError(err) + } + return convertActor(user), rateInfo(resp.Rate), nil +} + +// ListUserSocialAccounts reads one bounded social-account page. +func (c *Client) ListUserSocialAccounts(ctx context.Context, login string, opts PageOptions) (ListResult[SocialAccount], error) { + accounts, resp, err := c.gh.Users.ListUserSocialAccounts(ctx, login, &gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}) + if err != nil { + return ListResult[SocialAccount]{}, classifyError(err) + } + items := make([]SocialAccount, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + items = append(items, SocialAccount{Provider: account.GetProvider(), URL: account.GetURL()}) + } + return ListResult[SocialAccount]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +// ListUserRepositories reads one bounded owned or affiliated repository page. +func (c *Client) ListUserRepositories(ctx context.Context, login string, opts UserRepositoryOptions) (ListResult[Repository], error) { + if opts.Relationship == "contributed" { + return c.listUserContributedRepositories(ctx, login, opts) + } + relation := opts.Relationship + switch relation { + case "owned": + relation = "owner" + case "affiliated": + relation = "member" + } + repositories, resp, err := c.gh.Repositories.ListByUser(ctx, login, &gh.RepositoryListByUserOptions{ + Type: relation, Sort: opts.Sort, Direction: opts.Direction, + ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, + }) + if err != nil { + return ListResult[Repository]{}, classifyError(err) + } + items := make([]Repository, 0, len(repositories)) + for _, repository := range repositories { + items = append(items, convertRepository(repository)) + } + return ListResult[Repository]{Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate)}, nil +} + +func convertActor(user *gh.User) Actor { + if user == nil { + return Actor{} + } + actor := Actor{ + Login: user.GetLogin(), ID: user.GetID(), NodeID: user.GetNodeID(), Kind: strings.ToLower(user.GetType()), + AvatarURL: user.AvatarURL, Name: user.Name, Bio: user.Bio, Company: user.Company, Location: user.Location, + WebsiteURL: user.Blog, PublicEmail: user.Email, TwitterUsername: user.TwitterUsername, Hireable: user.Hireable, + Followers: user.Followers, Following: user.Following, PublicRepositories: user.PublicRepos, PublicGists: user.PublicGists, + } + if user.CreatedAt != nil { + actor.CreatedAt = user.CreatedAt.Time + } + if user.UpdatedAt != nil { + actor.UpdatedAt = user.UpdatedAt.Time + } + if actor.Kind == "" { + actor.Kind = "unknown" + } + return actor +} From 8bc7ca559859995e187cbe71707c2caf3baa8585 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:11:50 +0800 Subject: [PATCH 7/8] fix(actors): preserve identity and snapshot integrity --- internal/app/mcp_actor_facets.go | 3 + internal/app/mcp_actors.go | 9 + internal/corpus/actor_facets.go | 7 +- internal/corpus/actors.go | 61 ++++- internal/corpus/actors_test.go | 86 +++++- internal/corpus/migration_test.go | 28 +- .../migrations/015_actor_corpus_revision.sql | 259 ++++++++++++++++++ internal/corpus/revision_test.go | 20 ++ 8 files changed, 450 insertions(+), 23 deletions(-) create mode 100644 internal/corpus/migrations/015_actor_corpus_revision.sql diff --git a/internal/app/mcp_actor_facets.go b/internal/app/mcp_actor_facets.go index f88592b..ec602c8 100644 --- a/internal/app/mcp_actor_facets.go +++ b/internal/app/mcp_actor_facets.go @@ -426,6 +426,9 @@ func (r *MCPReader) SearchContributions(ctx context.Context, in mcpcontract.Sear } out.Coverage = append(out.Coverage, mcpcontract.ActorContributionCoverage{ActorID: actor.Key, Facet: coverage}) } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.SearchContributionsOutput{}, err + } return out, nil } diff --git a/internal/app/mcp_actors.go b/internal/app/mcp_actors.go index 7e84b33..d623b33 100644 --- a/internal/app/mcp_actors.go +++ b/internal/app/mcp_actors.go @@ -233,6 +233,9 @@ func (r *MCPReader) SearchActors(ctx context.Context, in mcpcontract.SearchActor for _, actor := range page.Actors { out.Items = append(out.Items, actorOutput(actor)) } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.SearchActorsOutput{}, err + } return out, nil } @@ -267,6 +270,9 @@ func (r *MCPReader) GetActors(ctx context.Context, in mcpcontract.GetActorsInput } out.Items[index] = item } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.GetActorsOutput{}, err + } return out, nil } @@ -340,6 +346,9 @@ func (r *MCPReader) GetActorFacets(ctx context.Context, in mcpcontract.GetActorF item.Value = &value out.Items[index] = item } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.GetActorFacetsOutput{}, err + } return out, nil } diff --git a/internal/corpus/actor_facets.go b/internal/corpus/actor_facets.go index f5c66f4..86b304f 100644 --- a/internal/corpus/actor_facets.go +++ b/internal/corpus/actor_facets.go @@ -163,12 +163,13 @@ func (c *Corpus) ApplyActorContributionPeriod(ctx context.Context, input ActorCo if _, err := tx.ExecContext(ctx, `INSERT INTO actor_observations(actor_id,facet,source_updated_at,observation_sequence,observed_at,complete,authorization_scope,payload) VALUES(?, 'contributions', ?, ?, ?, ?, ?, ?)`, input.ActorID, encodeTime(input.SourceUpdatedAt), sequence, encodeTime(input.ObservedAt), boolToInt(input.Complete), input.AuthorizationScope, string(payload)); err != nil { return err } + var existingComplete bool var existingSource, existingSequence int64 - err = tx.QueryRowContext(ctx, `SELECT source_updated_at,observation_sequence FROM actor_contribution_periods WHERE actor_id=? AND period_start=? AND period_end=? AND organization_node_id=? AND authorization_scope=?`, input.ActorID, encodeTime(input.From), encodeTime(input.To), input.OrganizationNodeID, input.AuthorizationScope).Scan(&existingSource, &existingSequence) + err = tx.QueryRowContext(ctx, `SELECT complete,source_updated_at,observation_sequence FROM actor_contribution_periods WHERE actor_id=? AND period_start=? AND period_end=? AND organization_node_id=? AND authorization_scope=?`, input.ActorID, encodeTime(input.From), encodeTime(input.To), input.OrganizationNodeID, input.AuthorizationScope).Scan(&existingComplete, &existingSource, &existingSequence) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } - if err == nil && !orderingNewer(encodeTime(input.SourceUpdatedAt), sequence, existingSource, existingSequence) { + if err == nil && (!orderingNewer(encodeTime(input.SourceUpdatedAt), sequence, existingSource, existingSequence) || (!input.Complete && existingComplete)) { return tx.Commit() } var periodID int64 @@ -303,7 +304,7 @@ func (c *Corpus) SearchActorContributions(ctx context.Context, opts Contribution for i := range opts.ActorRefs { placeholders[i] = "?" } - where += ` AND (a.actor_key IN (` + strings.Join(placeholders, ",") + `) OR a.node_id IN (` + strings.Join(placeholders, ",") + `) OR a.id IN (SELECT actor_id FROM actor_aliases WHERE normalized_login IN (` + strings.Join(placeholders, ",") + `)))` + where += ` AND (a.actor_key IN (` + strings.Join(placeholders, ",") + `) OR a.node_id IN (` + strings.Join(placeholders, ",") + `) OR a.id IN (SELECT actor_id FROM actor_aliases WHERE active=1 AND normalized_login IN (` + strings.Join(placeholders, ",") + `)))` for _, ref := range opts.ActorRefs { args = append(args, strings.TrimSpace(ref)) } diff --git a/internal/corpus/actors.go b/internal/corpus/actors.go index 2fd97bc..44d7dd0 100644 --- a/internal/corpus/actors.go +++ b/internal/corpus/actors.go @@ -203,11 +203,7 @@ func (c *Corpus) ApplyActorIdentityObservation(ctx context.Context, provider, lo `, actorKey(provider, nodeID, login), nodeID, databaseID, kind, login, sequence, encodeTime(observedAt), actorID); err != nil { return Actor{}, fmt.Errorf("advance actor identity: %w", err) } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) - VALUES (?, ?, ?, 1, ?, ?) - ON CONFLICT(actor_id, normalized_login) DO UPDATE SET login=excluded.login, active=1, last_observed_at=excluded.last_observed_at - `, actorID, login, normalizeLogin(login), encodeTime(observedAt), encodeTime(observedAt)); err != nil { + if err := activateActorAlias(ctx, tx, actorID, provider, login, nodeID, encodeTime(observedAt)); err != nil { return Actor{}, fmt.Errorf("upsert actor identity alias: %w", err) } if err := refreshActorFTS(ctx, tx, actorID); err != nil { @@ -313,12 +309,7 @@ func (c *Corpus) ApplyActorProfileObservation(ctx context.Context, input ActorPr encodeTime(profile.ObservedAt), profile.AuthorizationScope); err != nil { return Actor{}, fmt.Errorf("advance actor profile: %w", err) } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) - VALUES (?, ?, ?, 1, ?, ?) - ON CONFLICT(actor_id, normalized_login) DO UPDATE SET - login=excluded.login, active=1, last_observed_at=excluded.last_observed_at - `, actorID, input.Login, normalizeLogin(input.Login), encodeTime(input.ObservedAt), encodeTime(input.ObservedAt)); err != nil { + if err := activateActorAlias(ctx, tx, actorID, input.Provider, input.Login, input.NodeID, encodeTime(input.ObservedAt)); err != nil { return Actor{}, fmt.Errorf("upsert actor alias: %w", err) } if err := refreshActorFTS(ctx, tx, actorID); err != nil { @@ -348,11 +339,14 @@ func resolveActorID(ctx context.Context, tx *sql.Tx, input ActorProfileObservati return 0, fmt.Errorf("resolve actor node id: %w", err) } } - err := tx.QueryRowContext(ctx, ` + aliasQuery := ` SELECT a.id FROM actor_aliases aa JOIN actors a ON a.id=aa.actor_id - WHERE a.provider=? AND aa.normalized_login=? AND aa.active=1 - ORDER BY aa.last_observed_at DESC, a.id DESC LIMIT 1 - `, input.Provider, normalizeLogin(input.Login)).Scan(&id) + WHERE a.provider=? AND aa.normalized_login=? AND aa.active=1` + if input.NodeID != "" { + aliasQuery += ` AND (a.node_id IS NULL OR a.node_id='')` + } + aliasQuery += ` ORDER BY aa.last_observed_at DESC, a.id DESC LIMIT 1` + err := tx.QueryRowContext(ctx, aliasQuery, input.Provider, normalizeLogin(input.Login)).Scan(&id) if err == nil { return id, nil } @@ -375,6 +369,43 @@ func resolveActorID(ctx context.Context, tx *sql.Tx, input ActorProfileObservati return id, nil } +// activateActorAlias keeps a reused login historical on the old node-backed +// identity while making the newly observed node-backed identity current. +func activateActorAlias(ctx context.Context, tx *sql.Tx, actorID int64, provider, login, nodeID string, observedAt int64) error { + normalized := normalizeLogin(login) + active := true + if nodeID != "" { + var currentObservedAt int64 + err := tx.QueryRowContext(ctx, ` + SELECT aa.last_observed_at + FROM actor_aliases aa JOIN actors a ON a.id=aa.actor_id + WHERE aa.normalized_login=? AND aa.actor_id<>? AND aa.active=1 AND a.provider=? + ORDER BY aa.last_observed_at DESC, aa.actor_id DESC LIMIT 1 + `, normalized, actorID, provider).Scan(¤tObservedAt) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + active = errors.Is(err, sql.ErrNoRows) || observedAt >= currentObservedAt + if active { + if _, err := tx.ExecContext(ctx, ` + UPDATE actor_aliases SET active=0 + WHERE normalized_login=? AND actor_id<>? + AND actor_id IN (SELECT id FROM actors WHERE provider=?) + `, normalized, actorID, provider); err != nil { + return err + } + } + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO actor_aliases (actor_id, login, normalized_login, active, first_observed_at, last_observed_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(actor_id, normalized_login) DO UPDATE SET + login=excluded.login, active=excluded.active, + last_observed_at=MAX(actor_aliases.last_observed_at, excluded.last_observed_at) + `, actorID, login, normalized, boolToInt(active), observedAt, observedAt) + return err +} + func actorKey(provider, nodeID, login string) string { if nodeID != "" { return provider + ":node:" + nodeID diff --git a/internal/corpus/actors_test.go b/internal/corpus/actors_test.go index 3008b1c..5e49362 100644 --- a/internal/corpus/actors_test.go +++ b/internal/corpus/actors_test.go @@ -58,6 +58,79 @@ func TestActorProfileObservationReconcilesLoginToNodeIDAndPreservesNewerProjecti } } +func TestActorObservationDoesNotMergeReusedLoginAcrossNodeIDs(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + otherProvider, err := c.ApplyActorIdentityObservation(ctx, "gitlab", "mona", "GL_1", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + first, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_1", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + second, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_2", nil, "user", "public", time.Unix(2, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + if first.ID == second.ID || first.NodeID != "U_1" || second.NodeID != "U_2" { + t.Fatalf("reused login identities merged: first=%+v second=%+v", first, second) + } + byFirstNode, err := c.GetActor(ctx, "U_1") + if err != nil { + t.Fatal(err) + } + byLogin, err := c.GetActor(ctx, "mona") + if err != nil { + t.Fatal(err) + } + if byFirstNode == nil || byFirstNode.ID != first.ID || byLogin == nil || byLogin.ID != second.ID { + t.Fatalf("reused login lookup: first node=%+v current login=%+v", byFirstNode, byLogin) + } + if _, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_1", nil, "user", "public", time.Unix(1, 0).UTC(), nil); err != nil { + t.Fatal(err) + } + byLogin, err = c.GetActor(ctx, "mona") + if err != nil { + t.Fatal(err) + } + if byLogin == nil || byLogin.ID != second.ID { + t.Fatalf("delayed older observation reclaimed reused login: %+v", byLogin) + } + var otherProviderAliasActive bool + if err := c.db.QueryRowContext(ctx, `SELECT active FROM actor_aliases WHERE actor_id=? AND normalized_login='mona'`, otherProvider.ID).Scan(&otherProviderAliasActive); err != nil { + t.Fatal(err) + } + if !otherProviderAliasActive { + t.Fatal("reusing a GitHub login deactivated the same alias for another provider") + } +} + +func TestIncompleteContributionPeriodMaterializesUntilCompleteSnapshotExists(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ + ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), Complete: false, + ObservedAt: from.Add(25 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour), + }); err != nil { + t.Fatal(err) + } + var complete bool + if err := c.db.QueryRowContext(ctx, `SELECT complete FROM actor_contribution_periods WHERE actor_id=?`, actor.ID).Scan(&complete); err != nil { + t.Fatal(err) + } + if complete { + t.Fatal("incomplete contribution period was materialized as complete") + } +} + func TestSearchActorsReturnsNullableProfilesAndBoundedCursor(t *testing.T) { t.Parallel() ctx := context.Background() @@ -155,12 +228,19 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ActorID: actor.ID, From: from, To: from.Add(24 * time.Hour), Complete: false, ObservedAt: from.Add(27 * time.Hour), SourceUpdatedAt: from.Add(24 * time.Hour)}); err != nil { t.Fatal(err) } - partial, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(time.Hour), from.Add(12*time.Hour)) + retained, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(time.Hour), from.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + if retained == nil || !retained.Complete { + t.Fatalf("partial refresh replaced complete coverage: %+v", retained) + } + pageAfterPartial, err := c.SearchActorContributions(ctx, ContributionSearchOptions{ActorRefs: []string{"alice"}, Sort: "occurred_at", Limit: 10}) if err != nil { t.Fatal(err) } - if partial != nil { - t.Fatalf("partial refresh retained complete coverage: %+v", partial) + if pageAfterPartial.Total != 2 { + t.Fatalf("partial refresh replaced complete contribution items: %+v", pageAfterPartial) } organizationCovered, err := c.GetActorContributionCoverage(ctx, actor.ID, "O_acme", from.Add(time.Hour), from.Add(12*time.Hour)) if err != nil { diff --git a/internal/corpus/migration_test.go b/internal/corpus/migration_test.go index 35168dd..6839b4e 100644 --- a/internal/corpus/migration_test.go +++ b/internal/corpus/migration_test.go @@ -37,6 +37,19 @@ func TestBaselineMigrationCreatesCurrentSchema(t *testing.T) { t.Fatalf("table %s missing after baseline migration", table) } } + for _, table := range []string{ + "actors", "actor_aliases", "actor_observations", "actor_profiles", "actor_social_accounts", + "actor_organization_memberships", "actor_pinned_items", "actor_repository_affiliations", + "actor_contribution_periods", "actor_contribution_days", "actor_contribution_items", + "actor_repository_contribution_totals", + } { + for _, suffix := range []string{"ai", "au", "ad"} { + trigger := "corpus_revision_" + table + "_" + suffix + if !migrationTriggerExists(ctx, t, c.db, trigger) { + t.Fatalf("trigger %s missing after baseline migration", trigger) + } + } + } for _, col := range []string{"merged_known", "author_association", "assignees", "draft", "locked", "state_reason", "milestone"} { if !migrationColumnExists(ctx, t, c.db, "threads", col) { @@ -60,8 +73,10 @@ func TestActorMigrationDeduplicatesExistingLoginsCaseInsensitively(t *testing.T) if err != nil { t.Fatal(err) } - if _, err := provider.Down(ctx); err != nil { - t.Fatal(err) + for range 2 { + if _, err := provider.Down(ctx); err != nil { + t.Fatal(err) + } } if err := logger.Err(); err != nil { t.Fatal(err) @@ -103,3 +118,12 @@ func migrationColumnExists(ctx context.Context, t *testing.T, db *sql.DB, table, } return found == 1 } + +func migrationTriggerExists(ctx context.Context, t *testing.T, db *sql.DB, trigger string) bool { + t.Helper() + var found int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name=?`, trigger).Scan(&found); err != nil { + t.Fatalf("query migration trigger %s: %v", trigger, err) + } + return found == 1 +} diff --git a/internal/corpus/migrations/015_actor_corpus_revision.sql b/internal/corpus/migrations/015_actor_corpus_revision.sql new file mode 100644 index 0000000..a968c4c --- /dev/null +++ b/internal/corpus/migrations/015_actor_corpus_revision.sql @@ -0,0 +1,259 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TRIGGER corpus_revision_actors_ai +AFTER INSERT ON actors +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actors_au +AFTER UPDATE ON actors +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actors_ad +AFTER DELETE ON actors +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_aliases_ai +AFTER INSERT ON actor_aliases +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_aliases_au +AFTER UPDATE ON actor_aliases +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_aliases_ad +AFTER DELETE ON actor_aliases +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_observations_ai +AFTER INSERT ON actor_observations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_observations_au +AFTER UPDATE ON actor_observations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_observations_ad +AFTER DELETE ON actor_observations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_profiles_ai +AFTER INSERT ON actor_profiles +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_profiles_au +AFTER UPDATE ON actor_profiles +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_profiles_ad +AFTER DELETE ON actor_profiles +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_social_accounts_ai +AFTER INSERT ON actor_social_accounts +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_social_accounts_au +AFTER UPDATE ON actor_social_accounts +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_social_accounts_ad +AFTER DELETE ON actor_social_accounts +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_organization_memberships_ai +AFTER INSERT ON actor_organization_memberships +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_organization_memberships_au +AFTER UPDATE ON actor_organization_memberships +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_organization_memberships_ad +AFTER DELETE ON actor_organization_memberships +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_pinned_items_ai +AFTER INSERT ON actor_pinned_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_pinned_items_au +AFTER UPDATE ON actor_pinned_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_pinned_items_ad +AFTER DELETE ON actor_pinned_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_affiliations_ai +AFTER INSERT ON actor_repository_affiliations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_affiliations_au +AFTER UPDATE ON actor_repository_affiliations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_affiliations_ad +AFTER DELETE ON actor_repository_affiliations +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_periods_ai +AFTER INSERT ON actor_contribution_periods +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_periods_au +AFTER UPDATE ON actor_contribution_periods +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_periods_ad +AFTER DELETE ON actor_contribution_periods +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_days_ai +AFTER INSERT ON actor_contribution_days +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_days_au +AFTER UPDATE ON actor_contribution_days +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_days_ad +AFTER DELETE ON actor_contribution_days +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_items_ai +AFTER INSERT ON actor_contribution_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_items_au +AFTER UPDATE ON actor_contribution_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_contribution_items_ad +AFTER DELETE ON actor_contribution_items +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_contribution_totals_ai +AFTER INSERT ON actor_repository_contribution_totals +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_contribution_totals_au +AFTER UPDATE ON actor_repository_contribution_totals +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +CREATE TRIGGER corpus_revision_actor_repository_contribution_totals_ad +AFTER DELETE ON actor_repository_contribution_totals +BEGIN + UPDATE corpus_state SET revision = revision + 1 WHERE id = 1; +END; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS corpus_revision_actors_ai; +DROP TRIGGER IF EXISTS corpus_revision_actors_au; +DROP TRIGGER IF EXISTS corpus_revision_actors_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_aliases_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_aliases_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_aliases_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_observations_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_observations_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_observations_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_profiles_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_profiles_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_profiles_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_social_accounts_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_social_accounts_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_social_accounts_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_organization_memberships_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_organization_memberships_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_organization_memberships_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_pinned_items_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_pinned_items_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_pinned_items_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_affiliations_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_affiliations_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_affiliations_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_periods_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_periods_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_periods_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_days_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_days_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_days_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_items_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_items_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_contribution_items_ad; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_contribution_totals_ai; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_contribution_totals_au; +DROP TRIGGER IF EXISTS corpus_revision_actor_repository_contribution_totals_ad; +-- +goose StatementEnd diff --git a/internal/corpus/revision_test.go b/internal/corpus/revision_test.go index 18d78a9..1bdf3a4 100644 --- a/internal/corpus/revision_test.go +++ b/internal/corpus/revision_test.go @@ -63,3 +63,23 @@ func TestCorpusRevisionIsMonotonicAndDetectsStaleReads(t *testing.T) { t.Fatalf("job cancellation changed corpus revision from %d to %d", current, unchanged) } } + +func TestActorWritesAdvanceCorpusRevision(t *testing.T) { + t.Parallel() + c, _ := openTestCorpus(t) + ctx := context.Background() + before, err := c.CorpusRevision(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := c.ApplyActorIdentityObservation(ctx, "github", "mona", "U_1", nil, "user", "public", time.Unix(1, 0).UTC(), nil); err != nil { + t.Fatal(err) + } + after, err := c.CorpusRevision(ctx) + if err != nil { + t.Fatal(err) + } + if after <= before { + t.Fatalf("actor write left corpus revision at %d, want greater than %d", after, before) + } +} From 835f4abe105a9a99352f0e4f8343c5c16e8b7991 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:28:40 +0800 Subject: [PATCH 8/8] fix(corpus): preserve authoritative actor projections --- internal/corpus/actor_facets.go | 10 ++---- internal/corpus/actors.go | 5 ++- internal/corpus/actors_test.go | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/internal/corpus/actor_facets.go b/internal/corpus/actor_facets.go index 86b304f..8bae5e5 100644 --- a/internal/corpus/actor_facets.go +++ b/internal/corpus/actor_facets.go @@ -60,23 +60,19 @@ func (c *Corpus) GetActorContributionCoverage(ctx context.Context, actorID int64 return nil, nil } var coverage ActorFacetCoverage - var complete bool var sourceUpdated, observedAt int64 err := c.db.QueryRowContext(ctx, ` - SELECT complete, source_updated_at, observation_sequence, observed_at, authorization_scope + SELECT source_updated_at, observation_sequence, observed_at, authorization_scope FROM actor_contribution_periods - WHERE actor_id=? AND organization_node_id=? AND period_start<=? AND period_end>=? + WHERE actor_id=? AND organization_node_id=? AND period_start<=? AND period_end>=? AND complete=1 ORDER BY (period_end-period_start) ASC, source_updated_at DESC, observation_sequence DESC LIMIT 1 - `, actorID, organizationNodeID, encodeTime(from), encodeTime(to)).Scan(&complete, &sourceUpdated, &coverage.ObservationSequence, &observedAt, &coverage.AuthorizationScope) + `, actorID, organizationNodeID, encodeTime(from), encodeTime(to)).Scan(&sourceUpdated, &coverage.ObservationSequence, &observedAt, &coverage.AuthorizationScope) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get actor contribution coverage: %w", err) } - if !complete { - return nil, nil - } coverage.Facet = "contributions" coverage.Complete = true coverage.SourceUpdatedAt, coverage.ObservedAt = scanTime(sourceUpdated), scanTime(observedAt) diff --git a/internal/corpus/actors.go b/internal/corpus/actors.go index 44d7dd0..1a0c524 100644 --- a/internal/corpus/actors.go +++ b/internal/corpus/actors.go @@ -199,7 +199,10 @@ func (c *Corpus) ApplyActorIdentityObservation(ctx context.Context, provider, lo } if _, err := tx.ExecContext(ctx, ` UPDATE actors SET actor_key=?, node_id=NULLIF(?,''), database_id=?, kind=?, current_login=?, - observation_sequence=?, updated_at=? WHERE id=? + observation_sequence=?, updated_at=? + WHERE id=? AND NOT EXISTS ( + SELECT 1 FROM actor_profiles WHERE actor_id=actors.id + ) `, actorKey(provider, nodeID, login), nodeID, databaseID, kind, login, sequence, encodeTime(observedAt), actorID); err != nil { return Actor{}, fmt.Errorf("advance actor identity: %w", err) } diff --git a/internal/corpus/actors_test.go b/internal/corpus/actors_test.go index 5e49362..187be3c 100644 --- a/internal/corpus/actors_test.go +++ b/internal/corpus/actors_test.go @@ -107,6 +107,31 @@ func TestActorObservationDoesNotMergeReusedLoginAcrossNodeIDs(t *testing.T) { } } +func TestActorIdentityObservationDoesNotReplaceHydratedProjection(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + profileDatabaseID := int64(1) + profile, err := c.ApplyActorProfileObservation(ctx, ActorProfileObservation{ + Provider: "github", Login: "new-login", NodeID: "U_1", DatabaseID: &profileDatabaseID, Kind: "user", + SourceUpdatedAt: time.Unix(20, 0).UTC(), ObservedAt: time.Unix(21, 0).UTC(), + }) + if err != nil { + t.Fatal(err) + } + stubDatabaseID := int64(2) + if _, err := c.ApplyActorIdentityObservation(ctx, "github", "old-login", "U_1", &stubDatabaseID, "bot", "public", time.Unix(22, 0).UTC(), nil); err != nil { + t.Fatal(err) + } + stored, err := c.GetActorByID(ctx, profile.ID) + if err != nil { + t.Fatal(err) + } + if stored == nil || stored.Login != "new-login" || stored.Kind != "user" || stored.DatabaseID == nil || *stored.DatabaseID != profileDatabaseID || stored.ObservationSequence != profile.ObservationSequence { + t.Fatalf("identity stub replaced hydrated projection: profile=%+v stored=%+v", profile, stored) + } +} + func TestIncompleteContributionPeriodMaterializesUntilCompleteSnapshotExists(t *testing.T) { t.Parallel() ctx := context.Background() @@ -251,6 +276,36 @@ func TestActorContributionSearchBindsCursorToFilters(t *testing.T) { } } +func TestActorContributionCoveragePrefersCompleteContainingPeriod(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + actor, err := c.ApplyActorIdentityObservation(ctx, "github", "alice", "U_alice", nil, "user", "public", time.Unix(1, 0).UTC(), nil) + if err != nil { + t.Fatal(err) + } + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ + ActorID: actor.ID, From: from, To: from.Add(48 * time.Hour), Complete: true, + ObservedAt: from.Add(50 * time.Hour), SourceUpdatedAt: from.Add(49 * time.Hour), + }); err != nil { + t.Fatal(err) + } + if err := c.ApplyActorContributionPeriod(ctx, ActorContributionPeriodInput{ + ActorID: actor.ID, From: from.Add(12 * time.Hour), To: from.Add(36 * time.Hour), Complete: false, + ObservedAt: from.Add(37 * time.Hour), SourceUpdatedAt: from.Add(36 * time.Hour), + }); err != nil { + t.Fatal(err) + } + coverage, err := c.GetActorContributionCoverage(ctx, actor.ID, "", from.Add(18*time.Hour), from.Add(30*time.Hour)) + if err != nil { + t.Fatal(err) + } + if coverage == nil || !coverage.Complete || !coverage.SourceUpdatedAt.Equal(from.Add(49*time.Hour)) { + t.Fatalf("complete containing period was masked by incomplete period: %+v", coverage) + } +} + func TestActorContributionSearchDeduplicatesOverlappingPeriods(t *testing.T) { t.Parallel() ctx := context.Background()