diff --git a/README.md b/README.md index 0259c96..e8c99e5 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,21 @@ gitcontribute evidence add --type=manual_observation --relation=supporting \ --opportunity ``` +Export a digest-bound contribution evidence manifest from stored facts and an +optional managed workspace: + +```sh +gitcontribute export manifest \ + --workspace \ + --pull-request owner/repo#42 \ + --output manifest.json +``` + +This command does not contact GitHub. Sync the exact pull request first when +current checks or reviews matter. Missing or stale facets remain explicit +manifest gaps. Issue and PR drafts can reference the stored result with +`--manifest-id` without copying its claims into the rendered body. + Evidence shown through the CLI, exports, and MCP includes a derived freshness status. `github_source` evidence recorded from a started thread carries the exact corpus source revision it used, so later issue, pull request, facet, or @@ -657,7 +672,7 @@ Most non-interactive commands accept `--json`. Machine-readable output goes to stdout; progress and status messages go to stderr. List commands accept `--limit`, and paginated searches return an opaque `next_cursor` where supported. -`dossier export`, `export dossier`, `export evidence`, and `tracking export` +`dossier export`, `export dossier`, `export evidence`, `export manifest`, and `tracking export` accept `--output `. diff --git a/docs/architecture.md b/docs/architecture.md index 440a335..befdb9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -399,6 +399,23 @@ prompts that point agents at local resources first. Those prompts must preserve the same side-effect boundary: they may suggest explicit tools, but they cannot authorize network reads, local writes, process execution, or GitHub mutation. +Contribution evidence manifests are explicit local-write exports. They use an +in-toto Statement v1 envelope around a product-owned predicate and bind claims +to repository, opportunity, and optional managed-workspace identities. The +workspace identity covers HEAD, staged and unstaged patches, bounded untracked +content, submodules, and commit metadata; every omitted resource becomes an +explicit gap. Candidate validations are usable only when their pre/post +snapshot is complete, unchanged, and equal to the exported candidate snapshot. +Stored GitHub facets and evidence are evaluated independently, so missing, +stale, or unknown data cannot become a passing claim. + +Manifest generation never refreshes GitHub. Callers explicitly sync the exact +repository or pull-request facets first, then export from SQLite and optional +non-mutating Git reads. The content ID excludes generation/evaluation clocks, +is recomputed before persistence, and rejects mismatched subjects or payloads. +Drafts may store a manifest ID as structured metadata; renderers do not copy +manifest claims into public prose. + ## Schema changes Migrations are embedded from `internal/corpus/migrations` and applied by Goose, diff --git a/internal/app/contribution.go b/internal/app/contribution.go index 57a25a8..5d0711f 100644 --- a/internal/app/contribution.go +++ b/internal/app/contribution.go @@ -13,6 +13,7 @@ import ( "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/evidence" "github.com/morluto/gitcontribute/internal/investigation" + "github.com/morluto/gitcontribute/internal/manifest" ) const maxPreparedDiffBytes = 1 << 20 @@ -28,6 +29,9 @@ func (s *Service) PrepareIssue(ctx context.Context, opportunityID string, opts c if err != nil { return nil, err } + if err := validateDraftManifest(ctx, c, opportunityID, opts.ManifestID); err != nil { + return nil, err + } allEvidence, err := s.loadOpportunityEvidence(ctx, c, opportunityID) if err != nil { @@ -49,12 +53,13 @@ func (s *Service) PrepareIssue(ctx context.Context, opportunityID string, opts c Guidance: guidance, Repo: inv.Repo, Success: opts.Success, + ManifestID: opts.ManifestID, }) if err != nil { return nil, err } - return draftResult("issue", draft.OpportunityID, draft.Title, draft.Body, draft.RenderedAt), nil + return draftResult("issue", draft.OpportunityID, draft.Title, draft.Body, draft.RenderedAt, draft.ManifestID), nil } // PreparePullRequest renders and stores a pull request draft for an opportunity. @@ -68,6 +73,9 @@ func (s *Service) PreparePullRequest(ctx context.Context, opportunityID string, if err != nil { return nil, err } + if err := validateDraftManifest(ctx, c, opportunityID, opts.ManifestID); err != nil { + return nil, err + } if opts.Approach == "" { return nil, fmt.Errorf("%w: approach is required", contribution.ErrMissingApproach) @@ -109,12 +117,13 @@ func (s *Service) PreparePullRequest(ctx context.Context, opportunityID string, Compatibility: opts.Compatibility, Limitations: opts.Limitations, LinkedIssue: opts.LinkedIssue, + ManifestID: opts.ManifestID, }) if err != nil { return nil, err } - return draftResult("pull_request", draft.OpportunityID, draft.Title, draft.Body, draft.RenderedAt), nil + return draftResult("pull_request", draft.OpportunityID, draft.Title, draft.Body, draft.RenderedAt, draft.ManifestID), nil } func (s *Service) loadOpportunityAndRepo(ctx context.Context, c *corpus.Corpus, opportunityID string) (*investigation.Opportunity, *investigation.Investigation, error) { @@ -158,6 +167,20 @@ func (s *Service) loadOpportunityEvidence(ctx context.Context, c *corpus.Corpus, return items, nil } +func validateDraftManifest(ctx context.Context, c *corpus.Corpus, opportunityID, manifestID string) error { + if manifestID == "" { + return nil + } + statement, err := c.GetContributionManifest(ctx, manifestID) + if err != nil { + return err + } + if statement.Predicate.Opportunity.ID != opportunityID { + return fmt.Errorf("%w: manifest %q belongs to opportunity %q", manifest.ErrIdentityMismatch, manifestID, statement.Predicate.Opportunity.ID) + } + return nil +} + func (s *Service) workspaceDiff(ctx context.Context, workspaceID string, inv *investigation.Investigation) (string, error) { c, err := s.openReadOnlyCorpus(ctx) if err != nil { @@ -380,12 +403,13 @@ func diffMatchesOpportunity(diff *WorkspaceDiffResult, inv *investigation.Invest return diff.Repo.Owner == inv.Repo.Owner && diff.Repo.Repo == inv.Repo.Repo } -func draftResult(kind, opportunityID, title, body string, renderedAt time.Time) *cli.DraftResult { +func draftResult(kind, opportunityID, title, body string, renderedAt time.Time, manifestID string) *cli.DraftResult { return &cli.DraftResult{ OpportunityID: opportunityID, Kind: kind, Title: title, Body: body, RenderedAt: formatTime(renderedAt), + ManifestID: manifestID, } } diff --git a/internal/app/evidence.go b/internal/app/evidence.go index f6e420d..5192a80 100644 --- a/internal/app/evidence.go +++ b/internal/app/evidence.go @@ -5,11 +5,14 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/shlex" "github.com/morluto/gitcontribute/internal/cli" + "github.com/morluto/gitcontribute/internal/corpus" "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/workspace" ) // DefineValidation stores a validation definition for an investigation. @@ -30,7 +33,7 @@ func (s *Service) DefineValidation(ctx context.Context, investigationID string, if len(command) == 0 { return nil, errors.New("validation command is required") } - if opts.WorkingDir == "" && (opts.BaseWorkingDir == "" || opts.CandidateDir == "") { + if opts.WorkspaceID == "" && opts.BaseWorkspaceID == "" && opts.CandidateWorkspaceID == "" && opts.WorkingDir == "" && (opts.BaseWorkingDir == "" || opts.CandidateDir == "") { return nil, errors.New("validation working directory is required") } @@ -38,19 +41,25 @@ func (s *Service) DefineValidation(ctx context.Context, investigationID string, if err != nil { return nil, err } + if err := s.resolveValidationWorkspaces(ctx, c, inv.ID, &opts); err != nil { + return nil, err + } def := &evidence.ValidationDefinition{ - InvestigationID: inv.ID, - Name: opts.Kind, - Kind: opts.Kind, - Command: command, - WorkingDir: opts.WorkingDir, - BaseWorkingDir: opts.BaseWorkingDir, - CandidateDir: opts.CandidateDir, - Env: opts.Env, - Timeout: opts.Timeout, - MaxOutputBytes: opts.MaxOutputBytes, - Observation: observationContractToEvidence(opts.Observation), + InvestigationID: inv.ID, + Name: opts.Kind, + Kind: opts.Kind, + Command: command, + WorkingDir: opts.WorkingDir, + BaseWorkingDir: opts.BaseWorkingDir, + CandidateDir: opts.CandidateDir, + WorkspaceID: opts.WorkspaceID, + BaseWorkspaceID: opts.BaseWorkspaceID, + CandidateWorkspaceID: opts.CandidateWorkspaceID, + Env: opts.Env, + Timeout: opts.Timeout, + MaxOutputBytes: opts.MaxOutputBytes, + Observation: observationContractToEvidence(opts.Observation), } evSvc := evidence.NewService(c, evidence.NewExecRunner()) @@ -88,14 +97,132 @@ func (s *Service) RunValidation(ctx context.Context, id string, opts cli.RunVali if err != nil { return nil, err } + def, err := c.GetValidationDefinition(ctx, id) + if err != nil { + return nil, mapEvidenceError(err) + } + workspaceID := def.WorkspaceID + if runKind == evidence.RunKindBase && def.BaseWorkspaceID != "" { + workspaceID = def.BaseWorkspaceID + } + if runKind == evidence.RunKindCandidate && def.CandidateWorkspaceID != "" { + workspaceID = def.CandidateWorkspaceID + } + var before workspace.Snapshot + var beforeErr error + var managedWorkspace *workspace.Workspace + if workspaceID != "" { + managedWorkspace, beforeErr = c.GetWorkspace(ctx, workspaceID) + if beforeErr == nil { + var manager *workspace.Manager + manager, beforeErr = s.workspaceReader() + if beforeErr == nil { + before, beforeErr = manager.SnapshotByPath(ctx, managedWorkspace.Path, managedWorkspace.BaseSHA, managedWorkspace.MergeBase) + } + } + } evSvc := evidence.NewService(c, evidence.NewExecRunner()) run, err := evSvc.RunValidation(ctx, id, runKind) if err != nil { return nil, mapEvidenceError(err) } + if err := bindValidationWorkspace(ctx, s, c, run, managedWorkspace, before, beforeErr); err != nil { + return nil, err + } return validationRunResult(run), nil } +func (s *Service) resolveValidationWorkspaces(ctx context.Context, c *corpus.Corpus, investigationID string, opts *cli.DefineValidationOptions) error { + if opts.WorkspaceID == "" && opts.BaseWorkspaceID == "" && opts.CandidateWorkspaceID == "" { + return nil + } + manager, err := s.workspaceReader() + if err != nil { + return fmt.Errorf("open managed workspaces: %w", err) + } + resolve := func(id string) (*workspace.Workspace, error) { + item, err := c.GetWorkspace(ctx, id) + if err != nil { + return nil, mapWorkspaceError(err) + } + if item.InvestigationID != investigationID { + return nil, errors.New("workspace does not belong to the validation investigation") + } + if err := manager.ValidateWorkspacePath(item.Path); err != nil { + return nil, fmt.Errorf("workspace %q path is not managed: %w", id, err) + } + return item, nil + } + if opts.WorkspaceID != "" { + if opts.BaseWorkspaceID != "" || opts.CandidateWorkspaceID != "" || opts.WorkingDir != "" || opts.BaseWorkingDir != "" || opts.CandidateDir != "" { + return errors.New("workspace-id cannot be combined with other workspace selectors") + } + item, err := resolve(opts.WorkspaceID) + if err != nil { + return mapWorkspaceError(err) + } + opts.WorkingDir = item.Path + return nil + } + if opts.BaseWorkspaceID != "" || opts.CandidateWorkspaceID != "" { + if opts.BaseWorkspaceID == "" || opts.CandidateWorkspaceID == "" || opts.WorkingDir != "" || opts.BaseWorkingDir != "" || opts.CandidateDir != "" { + return errors.New("base-workspace-id and candidate-workspace-id must be provided together without directory selectors") + } + base, err := resolve(opts.BaseWorkspaceID) + if err != nil { + return mapWorkspaceError(err) + } + candidate, err := resolve(opts.CandidateWorkspaceID) + if err != nil { + return mapWorkspaceError(err) + } + opts.BaseWorkingDir, opts.CandidateDir = base.Path, candidate.Path + } + return nil +} + +func bindValidationWorkspace(ctx context.Context, service *Service, c *corpus.Corpus, run *evidence.ValidationRun, managed *workspace.Workspace, before workspace.Snapshot, beforeErr error) error { + run.WorkspaceBindingStatus = "unavailable" + switch { + case beforeErr != nil: + run.WorkspaceBindingReason = "capture pre-run workspace snapshot: " + beforeErr.Error() + case managed == nil: + run.WorkspaceBindingReason = "validation did not declare a managed workspace" + default: + run.WorkspaceSnapshotBefore = before.SHA256 + manager, err := service.workspaceReader() + if err != nil { + run.WorkspaceBindingReason = "open workspace reader after validation: " + err.Error() + break + } + snapshotCtx, snapshotCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + after, err := manager.SnapshotByPath(snapshotCtx, managed.Path, managed.BaseSHA, managed.MergeBase) + snapshotCancel() + if err != nil { + run.WorkspaceBindingReason = "capture post-run workspace snapshot: " + err.Error() + break + } + run.WorkspaceSnapshotAfter = after.SHA256 + switch { + case !before.Complete || !after.Complete: + run.WorkspaceBindingStatus = "incomplete" + run.WorkspaceBindingReason = "workspace snapshot contains explicitly unbound content" + case before.SHA256 != after.SHA256: + run.WorkspaceBindingStatus = "changed" + run.WorkspaceBindingReason = "workspace changed while validation was running" + default: + run.WorkspaceBindingStatus = "bound" + run.WorkspaceBindingReason = "pre-run and post-run workspace identities match" + } + } + saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := c.SaveValidationRun(saveCtx, run); err != nil { + return fmt.Errorf("save validation workspace binding: %w", err) + } + return nil +} + // CompareValidation compares a base validation run with a candidate validation run. func (s *Service) CompareValidation(ctx context.Context, baseRunID, candidateRunID string) (*cli.ValidationComparisonResult, error) { c, err := s.openReadOnlyCorpus(ctx) @@ -237,37 +364,44 @@ func validationResult(def *evidence.ValidationDefinition) *cli.ValidationResult timeout = def.Timeout.String() } return &cli.ValidationResult{ - ID: def.ID, - InvestigationID: def.InvestigationID, - Kind: def.Kind, - Command: def.Command, - WorkingDir: def.WorkingDir, - BaseWorkingDir: def.BaseWorkingDir, - CandidateDir: def.CandidateDir, - Env: append([]string(nil), def.Env...), - Timeout: timeout, - MaxOutputBytes: def.MaxOutputBytes, - Observation: observationContractToCLI(def.Observation), - CreatedAt: formatTime(def.CreatedAt), + ID: def.ID, + InvestigationID: def.InvestigationID, + Kind: def.Kind, + Command: def.Command, + WorkingDir: def.WorkingDir, + BaseWorkingDir: def.BaseWorkingDir, + CandidateDir: def.CandidateDir, + WorkspaceID: def.WorkspaceID, + BaseWorkspaceID: def.BaseWorkspaceID, + CandidateWorkspaceID: def.CandidateWorkspaceID, + Env: append([]string(nil), def.Env...), + Timeout: timeout, + MaxOutputBytes: def.MaxOutputBytes, + Observation: observationContractToCLI(def.Observation), + CreatedAt: formatTime(def.CreatedAt), } } func validationRunResult(run *evidence.ValidationRun) *cli.ValidationRunResult { return &cli.ValidationRunResult{ - ID: run.ID, - DefinitionID: run.DefinitionID, - InvestigationID: run.InvestigationID, - Kind: string(run.Kind), - ExitCode: run.ExitCode, - Stdout: run.Stdout, - Stderr: run.Stderr, - Truncated: run.Truncated, - Error: run.Error, - Classification: string(run.Classification), - ObservationStatus: string(run.ObservationStatus), - Observations: observationResultsToCLI(run.Observations), - StartedAt: formatTime(run.StartedAt), - CompletedAt: formatTime(run.CompletedAt), + ID: run.ID, + DefinitionID: run.DefinitionID, + InvestigationID: run.InvestigationID, + Kind: string(run.Kind), + ExitCode: run.ExitCode, + Stdout: run.Stdout, + Stderr: run.Stderr, + Truncated: run.Truncated, + Error: run.Error, + Classification: string(run.Classification), + ObservationStatus: string(run.ObservationStatus), + Observations: observationResultsToCLI(run.Observations), + StartedAt: formatTime(run.StartedAt), + CompletedAt: formatTime(run.CompletedAt), + WorkspaceSnapshotBefore: run.WorkspaceSnapshotBefore, + WorkspaceSnapshotAfter: run.WorkspaceSnapshotAfter, + WorkspaceBindingStatus: run.WorkspaceBindingStatus, + WorkspaceBindingReason: run.WorkspaceBindingReason, } } diff --git a/internal/app/manifest.go b/internal/app/manifest.go new file mode 100644 index 0000000..dfdd0e9 --- /dev/null +++ b/internal/app/manifest.go @@ -0,0 +1,447 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/cli" + "github.com/morluto/gitcontribute/internal/contribution" + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/manifest" + "github.com/morluto/gitcontribute/internal/workspace" +) + +const manifestFacetStaleAfter = 14 * 24 * time.Hour + +// ManifestOptions selects optional local workspace and exact stored PR inputs. +type ManifestOptions struct { + WorkspaceID string + PullRequest *ManifestPullRequest +} + +// ManifestPullRequest identifies one exact stored pull request. +type ManifestPullRequest struct { + Owner string + Repo string + Number int +} + +// ContributionManifest assembles and persists one bounded local evidence statement. +func (s *Service) ContributionManifest(ctx context.Context, opportunityID string, opts ManifestOptions) (*manifest.Statement, error) { + c, err := s.openCorpus(ctx) + if err != nil { + return nil, err + } + opp, inv, err := s.loadOpportunityAndRepo(ctx, c, opportunityID) + if err != nil { + return nil, err + } + now := s.now().UTC() + predicate := manifest.Predicate{ + GeneratedAt: now, + Repository: manifest.RepositoryIdentity{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo, CommitSHA: inv.CommitSHA}, + Opportunity: manifest.OpportunityRecord{ + ID: opp.ID, InvestigationID: opp.InvestigationID, HypothesisID: opp.HypothesisID, + ProblemStatement: opp.ProblemStatement, Scope: opp.Scope, Impact: opp.Impact, + Status: string(opp.Status), SourceRefs: append([]domain.SourceRef(nil), opp.SourceRefs...), + }, + } + if err := s.addManifestWorkspace(ctx, c, inv.ID, inv.Repo.Owner, inv.Repo.Repo, opts.WorkspaceID, &predicate); err != nil { + return nil, err + } + if err := addManifestValidations(ctx, c, &predicate); err != nil { + return nil, err + } + if err := addManifestEvidence(ctx, c, &predicate); err != nil { + return nil, err + } + if err := s.addManifestReadiness(ctx, opp.ID, &predicate); err != nil { + return nil, err + } + pullRequestRef := "" + if opts.PullRequest != nil { + pullRequestRef, err = s.addManifestPullRequest(ctx, c, inv.Repo.Owner, inv.Repo.Repo, *opts.PullRequest, now, &predicate) + if err != nil { + return nil, err + } + } else { + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "pull_request", Status: "not_requested", Reason: "no exact pull request was selected"}) + } + if err := addManifestDrafts(ctx, c, opp.ID, &predicate); err != nil { + return nil, err + } + predicate.Status = "complete" + if len(predicate.Gaps) > 0 { + predicate.Status = "incomplete" + } + sortManifestPredicate(&predicate) + statement, err := manifest.Finalize(predicate) + if err != nil { + return nil, err + } + if err := c.SaveContributionManifest(ctx, &statement, opts.WorkspaceID, pullRequestRef); err != nil { + return nil, err + } + return &statement, nil +} + +// ExportManifest generates, persists, and renders one local JSON statement. +func (s *Service) ExportManifest(ctx context.Context, opportunityID string, opts cli.ManifestExportOptions) (*cli.ExportResult, error) { + manifestOpts := ManifestOptions{WorkspaceID: opts.WorkspaceID} + if opts.PullRequest != nil { + manifestOpts.PullRequest = &ManifestPullRequest{Owner: opts.PullRequest.Owner, Repo: opts.PullRequest.Repo, Number: opts.PullRequest.Number} + } + statement, err := s.ContributionManifest(ctx, opportunityID, manifestOpts) + if err != nil { + return nil, err + } + payload, err := json.MarshalIndent(statement, "", " ") + if err != nil { + return nil, fmt.Errorf("encode contribution manifest: %w", err) + } + return &cli.ExportResult{Kind: "manifest", Format: "json", Content: string(payload)}, nil +} + +func (s *Service) addManifestWorkspace(ctx context.Context, c *corpus.Corpus, investigationID, owner, repo, workspaceID string, predicate *manifest.Predicate) error { + if workspaceID == "" { + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "workspace", Status: "not_requested", Reason: "no workspace was selected"}) + return nil + } + item, err := c.GetWorkspace(ctx, workspaceID) + if err != nil { + return mapWorkspaceError(err) + } + if item.InvestigationID != investigationID || !strings.EqualFold(item.RepoOwner, owner) || !strings.EqualFold(item.RepoName, repo) { + return fmt.Errorf("%w: workspace %q belongs to another investigation or repository", manifest.ErrIdentityMismatch, workspaceID) + } + manager, err := s.workspaceReader() + if err != nil { + return err + } + snapshot, err := manager.SnapshotByPath(ctx, item.Path, item.BaseSHA, item.MergeBase) + if err != nil { + return fmt.Errorf("snapshot workspace %q: %w", workspaceID, err) + } + predicate.Workspace = &snapshot + status, reason := "complete", "workspace content is fully digest-bound" + if !snapshot.Complete { + status, reason = "incomplete", "workspace snapshot has explicitly unbound content" + for _, gap := range snapshot.Gaps { + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: gap.Code, Facet: "workspace", Reason: gap.Reason}) + } + } + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "workspace", Status: status, Reason: reason}) + return nil +} + +func addManifestValidations(ctx context.Context, c *corpus.Corpus, predicate *manifest.Predicate) error { + definitions, err := c.ListValidationDefinitions(ctx, predicate.Opportunity.ID) + if err != nil { + return err + } + runs, err := c.ListValidationRuns(ctx, predicate.Opportunity.ID) + if err != nil { + return err + } + byID := make(map[string]*evidence.ValidationDefinition, len(definitions)) + runCount := make(map[string]int, len(definitions)) + selectedRuns := latestValidationRuns(runs) + for _, definition := range definitions { + byID[definition.ID] = definition + } + for _, run := range runs { + runCount[run.DefinitionID]++ + definition := byID[run.DefinitionID] + if definition == nil { + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validation_definition_missing", Facet: "validations", Reason: "run " + run.ID + " has no stored definition"}) + continue + } + record, gaps, err := buildManifestValidation(definition, run, predicate.Workspace, selectedRuns[run.DefinitionID+"\x00"+string(run.Kind)] == run.ID) + if err != nil { + return err + } + predicate.Gaps = append(predicate.Gaps, gaps...) + predicate.Validations = append(predicate.Validations, record) + } + for _, definition := range definitions { + if runCount[definition.ID] == 0 { + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validation_run_missing", Facet: "validations", Reason: "definition " + definition.ID + " has no stored run"}) + } + } + status, reason := "complete", "all stored validation runs have compatible workspace bindings and observations" + if len(definitions) == 0 { + status, reason = "unknown", "no validation definitions are stored" + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validations_missing", Facet: "validations", Reason: reason}) + } else if len(runs) == 0 { + status, reason = "unknown", "no validation runs are stored" + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "validations_missing", Facet: "validations", Reason: reason}) + } else if hasManifestGap(predicate.Gaps, "validations") { + status, reason = "incomplete", "one or more validation claims are stale, unknown, or unverified" + } + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "validations", Status: status, Reason: reason}) + return nil +} + +func buildManifestValidation(definition *evidence.ValidationDefinition, run *evidence.ValidationRun, current *workspace.Snapshot, selected bool) (manifest.ValidationRecord, []manifest.Gap, error) { + commandDigest, err := digestJSON(definition.Command) + if err != nil { + return manifest.ValidationRecord{}, nil, err + } + executionDigest, err := digestJSON(validationExecutionContract{ + Command: definition.Command, WorkingDir: definition.WorkingDir, BaseWorkingDir: definition.BaseWorkingDir, + CandidateDir: definition.CandidateDir, WorkspaceID: definition.WorkspaceID, BaseWorkspaceID: definition.BaseWorkspaceID, + CandidateWorkspaceID: definition.CandidateWorkspaceID, Env: definition.Env, Timeout: definition.Timeout, + MaxOutputBytes: definition.MaxOutputBytes, Observation: definition.Observation, + }) + if err != nil { + return manifest.ValidationRecord{}, nil, err + } + record := manifest.ValidationRecord{ + DefinitionID: definition.ID, RunID: run.ID, Kind: string(run.Kind), Command: append([]string(nil), definition.Command...), + CommandSHA256: commandDigest, ExecutionContractSHA256: executionDigest, EnvironmentAllowlist: append([]string(nil), definition.Env...), + Timeout: definition.Timeout.String(), MaxOutputBytes: definition.MaxOutputBytes, Observation: definition.Observation, + Classification: string(run.Classification), ObservationStatus: string(run.ObservationStatus), + Observations: append([]evidence.ObservationResult(nil), run.Observations...), StartedAt: run.StartedAt, CompletedAt: run.CompletedAt, + WorkspaceSnapshotBefore: run.WorkspaceSnapshotBefore, WorkspaceSnapshotAfter: run.WorkspaceSnapshotAfter, + WorkspaceBindingStatus: run.WorkspaceBindingStatus, Selected: selected, + } + record.WorkspaceCompatibility, record.CompatibilityReason = validationWorkspaceCompatibility(run, current) + if !selected { + return record, nil, nil + } + var gaps []manifest.Gap + if record.WorkspaceCompatibility != "compatible" { + gaps = append(gaps, manifest.Gap{Code: "validation_workspace_" + record.WorkspaceCompatibility, Facet: "validations", Reason: "run " + run.ID + ": " + record.CompatibilityReason}) + } + if run.Classification == evidence.RunClassificationError || run.Classification == evidence.RunClassificationCancelled || + (run.Kind == evidence.RunKindCandidate && run.Classification != evidence.RunClassificationPassing) { + gaps = append(gaps, manifest.Gap{Code: "validation_outcome_" + string(run.Classification), Facet: "validations", Reason: "run " + run.ID + " cannot support the candidate claim"}) + } + if definition.Observation != nil && run.ObservationStatus != evidence.ObservationMatched { + gaps = append(gaps, manifest.Gap{Code: "validation_observation_unverified", Facet: "validations", Reason: "run " + run.ID + " did not match its expected observation contract"}) + } + return record, gaps, nil +} + +func latestValidationRuns(runs []*evidence.ValidationRun) map[string]string { + selected := make(map[string]*evidence.ValidationRun) + for _, run := range runs { + key := run.DefinitionID + "\x00" + string(run.Kind) + current := selected[key] + if current == nil || run.CompletedAt.After(current.CompletedAt) || (run.CompletedAt.Equal(current.CompletedAt) && run.ID > current.ID) { + selected[key] = run + } + } + ids := make(map[string]string, len(selected)) + for key, run := range selected { + ids[key] = run.ID + } + return ids +} + +type validationExecutionContract struct { + Command []string + WorkingDir string + BaseWorkingDir string + CandidateDir string + WorkspaceID string + BaseWorkspaceID string + CandidateWorkspaceID string + Env []string + Timeout time.Duration + MaxOutputBytes int64 + Observation *evidence.ObservationContract +} + +func validationWorkspaceCompatibility(run *evidence.ValidationRun, current *workspace.Snapshot) (string, string) { + if run.WorkspaceBindingStatus != "bound" { + status := run.WorkspaceBindingStatus + if status == "" { + status = "unknown" + } + return status, run.WorkspaceBindingReason + } + if run.Kind != evidence.RunKindCandidate { + return "compatible", "base validation has a stable pre/post workspace identity" + } + if current == nil { + return "unknown", "no current candidate workspace was selected for comparison" + } + if run.WorkspaceSnapshotBefore != current.SHA256 { + return "stale", "current candidate workspace identity differs from the validated identity" + } + return "compatible", "current candidate workspace matches the validated identity" +} + +func addManifestEvidence(ctx context.Context, c *corpus.Corpus, predicate *manifest.Predicate) error { + items, err := c.ListEvidence(ctx, evidence.EvidenceFilter{OpportunityID: predicate.Opportunity.ID}) + if err != nil { + return err + } + freshness := evidence.NewFreshnessEvaluator(c) + for _, item := range items { + assessment, err := freshness.Evaluate(ctx, item) + if err != nil { + return err + } + record := manifest.EvidenceRecord{ + ID: item.ID, Type: string(item.Type), Relation: string(item.Relation), Description: item.Description, + ValidationRunID: item.ValidationRunID, SourceRefs: append([]domain.SourceRef(nil), item.SourceRefs...), + SourceProvenance: append([]evidence.SourceRevision(nil), item.SourceProvenance...), + Freshness: string(assessment.Status), FreshnessReason: assessment.Reason, + } + predicate.Evidence = append(predicate.Evidence, record) + if assessment.Status == evidence.FreshnessStale || assessment.Status == evidence.FreshnessUnknown { + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "evidence_" + string(assessment.Status), Facet: "evidence", Reason: "evidence " + item.ID + ": " + assessment.Reason}) + } + } + status, reason := "complete", "all evidence is fresh or local-only" + if len(items) == 0 { + status, reason = "unknown", "no evidence is scoped to the opportunity" + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "evidence_missing", Facet: "evidence", Reason: reason}) + } else if hasManifestGap(predicate.Gaps, "evidence") { + status, reason = "incomplete", "some evidence is stale or has unknown freshness" + } + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "evidence", Status: status, Reason: reason}) + return nil +} + +func (s *Service) addManifestReadiness(ctx context.Context, opportunityID string, predicate *manifest.Predicate) error { + report, err := s.OpportunityReadiness(ctx, opportunityID) + if err != nil { + return err + } + predicate.Readiness = manifest.ReadinessRecord{RuleSetVersion: report.RuleSetVersion, Status: report.Status, EvaluatedAt: report.EvaluatedAt} + for _, check := range report.Checks { + predicate.Readiness.Checks = append(predicate.Readiness.Checks, manifest.ReadinessCheck{ + RuleID: check.RuleID, RuleVersion: check.RuleVersion, Status: check.Status, + Summary: check.Summary, EvidenceRefs: append([]string(nil), check.EvidenceRefs...), + }) + if check.Status == readinessUnknown || check.Status == readinessBlock { + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "readiness_" + check.Status, Facet: "readiness", Reason: check.RuleID + ": " + check.Summary}) + } + } + status, reason := "complete", "readiness has no blocking or unknown checks" + if hasManifestGap(predicate.Gaps, "readiness") { + status, reason = "incomplete", "readiness includes blocking or unknown checks" + } + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "readiness", Status: status, Reason: reason}) + return nil +} + +func (s *Service) addManifestPullRequest(ctx context.Context, c *corpus.Corpus, owner, repo string, selector ManifestPullRequest, now time.Time, predicate *manifest.Predicate) (string, error) { + if !strings.EqualFold(selector.Owner, owner) || !strings.EqualFold(selector.Repo, repo) || selector.Number <= 0 { + return "", fmt.Errorf("%w: pull request does not match the opportunity repository", manifest.ErrIdentityMismatch) + } + storedRepo, err := c.GetRepository(ctx, selector.Owner, selector.Repo) + if err != nil { + return "", err + } + thread, err := c.GetThread(ctx, storedRepo.ID, corpus.ThreadKindPullRequest, selector.Number) + if err != nil { + return "", err + } + item, err := portfolioItem(ctx, c, corpus.PortfolioPullRequest{Owner: storedRepo.Owner, Repo: storedRepo.Name, Thread: *thread}, now) + if err != nil { + return "", err + } + if predicate.Workspace != nil && item.HeadSHA != "" && item.HeadSHA != predicate.Workspace.HeadSHA { + return "", fmt.Errorf("%w: pull request head %s differs from workspace head %s", manifest.ErrIdentityMismatch, item.HeadSHA, predicate.Workspace.HeadSHA) + } + record := manifest.PullRequestRecord{ + Owner: item.Owner, Repo: item.Repo, Number: item.Number, State: item.State, + HeadSHA: item.HeadSHA, BaseSHA: item.BaseSHA, ChecksStatus: item.ChecksStatus, + ReviewDecision: item.ReviewDecision, UnresolvedReviewThreads: item.UnresolvedReviewThreads, + MergeStateStatus: item.MergeStateStatus, MergeQueueState: item.MergeQueueState, + Attention: item.Attention, SourceUpdatedAt: item.SourceUpdatedAt, + } + complete := true + for _, facet := range item.Facets { + status := facet.Status + if status == "complete" { + updatedAt, parseErr := time.Parse(time.RFC3339, facet.UpdatedAt) + switch { + case parseErr != nil: + status = "unknown" + case updatedAt.After(now.Add(5 * time.Minute)): + status = "unknown" + case now.Sub(updatedAt) > manifestFacetStaleAfter: + status = "stale" + } + } + record.Facets = append(record.Facets, manifest.FacetStatus{Facet: facet.Facet, Status: status, UpdatedAt: facet.UpdatedAt}) + if status != "complete" { + complete = false + predicate.Gaps = append(predicate.Gaps, manifest.Gap{Code: "pull_request_facet_" + status, Facet: "pull_request", Reason: facet.Facet + " is " + status}) + } + } + predicate.PullRequest = &record + status, reason := "complete", "all requested pull-request health facets are complete and current" + if !complete { + status, reason = "incomplete", "one or more pull-request health facets are missing, stale, or incomplete" + } + predicate.Completeness = append(predicate.Completeness, manifest.CompletenessFacet{Facet: "pull_request", Status: status, Reason: reason}) + return fmt.Sprintf("%s/%s#%d", item.Owner, item.Repo, item.Number), nil +} + +func addManifestDrafts(ctx context.Context, c *corpus.Corpus, opportunityID string, predicate *manifest.Predicate) error { + if draft, err := c.GetIssueDraft(ctx, opportunityID); err == nil { + predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: "issue", Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) + } else if !errors.Is(err, contribution.ErrNotFound) { + return err + } + if draft, err := c.GetPullRequestDraft(ctx, opportunityID); err == nil { + predicate.Drafts = append(predicate.Drafts, manifest.DraftRecord{Kind: "pull_request", Title: draft.Title, RenderedAt: draft.RenderedAt, ManifestID: draft.ManifestID}) + } else if !errors.Is(err, contribution.ErrNotFound) { + return err + } + return nil +} + +func sortManifestPredicate(predicate *manifest.Predicate) { + sort.Slice(predicate.Validations, func(i, j int) bool { + if !predicate.Validations[i].StartedAt.Equal(predicate.Validations[j].StartedAt) { + return predicate.Validations[i].StartedAt.Before(predicate.Validations[j].StartedAt) + } + return predicate.Validations[i].RunID < predicate.Validations[j].RunID + }) + sort.Slice(predicate.Evidence, func(i, j int) bool { return predicate.Evidence[i].ID < predicate.Evidence[j].ID }) + sort.Slice(predicate.Drafts, func(i, j int) bool { return predicate.Drafts[i].Kind < predicate.Drafts[j].Kind }) + sort.Slice(predicate.Completeness, func(i, j int) bool { return predicate.Completeness[i].Facet < predicate.Completeness[j].Facet }) + sort.Slice(predicate.Gaps, func(i, j int) bool { + if predicate.Gaps[i].Facet != predicate.Gaps[j].Facet { + return predicate.Gaps[i].Facet < predicate.Gaps[j].Facet + } + if predicate.Gaps[i].Code != predicate.Gaps[j].Code { + return predicate.Gaps[i].Code < predicate.Gaps[j].Code + } + return predicate.Gaps[i].Reason < predicate.Gaps[j].Reason + }) +} + +func hasManifestGap(gaps []manifest.Gap, facet string) bool { + for _, gap := range gaps { + if gap.Facet == facet { + return true + } + } + return false +} + +func digestJSON(value any) (string, error) { + payload, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode manifest digest input: %w", err) + } + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]), nil +} diff --git a/internal/app/manifest_test.go b/internal/app/manifest_test.go new file mode 100644 index 0000000..058ce1b --- /dev/null +++ b/internal/app/manifest_test.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/cli" + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/investigation" + "github.com/morluto/gitcontribute/internal/manifest" + "github.com/morluto/gitcontribute/internal/research" +) + +func TestContributionManifestInvalidatesValidationWhenUntrackedContentChanges(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + ctx := context.Background() + svc := newLocalService(t) + defer func() { _ = svc.Close() }() + remote, _, candidateSHA := setupAppGitRemote(t) + inv, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, candidateSHA, "") + if err != nil { + t.Fatal(err) + } + hypothesis, err := svc.CreateHypothesis(ctx, inv.ID, investigation.CreateHypothesisInput{Title: "bind workspace", Description: "validation must match candidate content", Category: investigation.CategoryBug}) + if err != nil { + t.Fatal(err) + } + opportunity, err := svc.PromoteOpportunity(ctx, hypothesis.ID, "candidate proof can become stale", "workspace", "unsupported claims", "small", 0.8) + if err != nil { + t.Fatal(err) + } + ws, err := svc.CreateWorkspace(ctx, inv.ID, cli.WorkspaceCreateOptions{Remote: remote, BaseRef: "master", CandidateRef: "feature", Name: "manifest"}) + if err != nil { + t.Fatal(err) + } + writeAppFile(t, filepath.Join(ws.Path, "untracked.txt"), "first") + manager, err := svc.workspaceReader() + if err != nil { + t.Fatal(err) + } + snapshot, err := manager.SnapshotByPath(ctx, ws.Path, ws.BaseSHA, ws.MergeBase) + if err != nil { + t.Fatal(err) + } + c, err := svc.openCorpus(ctx) + if err != nil { + t.Fatal(err) + } + now := time.Unix(100, 0).UTC() + if _, err := c.ApplyRepositoryObservation(ctx, "owner", "repo", "repo-id", now, `{}`); err != nil { + t.Fatal(err) + } + definition := &evidence.ValidationDefinition{ID: "def", InvestigationID: inv.ID, HypothesisID: hypothesis.ID, OpportunityID: opportunity.ID, Command: []string{"go", "test", "./..."}, WorkspaceID: ws.ID, CreatedAt: now} + if err := c.SaveValidationDefinition(ctx, definition); err != nil { + t.Fatal(err) + } + run := &evidence.ValidationRun{ + ID: "run", DefinitionID: definition.ID, InvestigationID: inv.ID, HypothesisID: hypothesis.ID, OpportunityID: opportunity.ID, + Kind: evidence.RunKindCandidate, Classification: evidence.RunClassificationPassing, ObservationStatus: evidence.ObservationNotEvaluated, + StartedAt: now, CompletedAt: now.Add(time.Minute), WorkspaceSnapshotBefore: snapshot.SHA256, WorkspaceSnapshotAfter: snapshot.SHA256, + WorkspaceBindingStatus: "bound", WorkspaceBindingReason: "pre-run and post-run workspace identities match", + } + if err := c.SaveValidationRun(ctx, run); err != nil { + t.Fatal(err) + } + older := *run + older.ID = "run-old-failure" + older.Classification = evidence.RunClassificationFailing + older.StartedAt = now.Add(-2 * time.Minute) + older.CompletedAt = now.Add(-time.Minute) + if err := c.SaveValidationRun(ctx, &older); err != nil { + t.Fatal(err) + } + first, err := svc.ContributionManifest(ctx, opportunity.ID, ManifestOptions{WorkspaceID: ws.ID}) + if err != nil { + t.Fatal(err) + } + assertCurrentValidationSelected(t, first) + assertFailingCandidateManifest(ctx, t, svc, c, opportunity.ID, ws.ID, run) + writeAppFile(t, filepath.Join(ws.Path, "untracked.txt"), "second") + second, err := svc.ContributionManifest(ctx, opportunity.ID, ManifestOptions{WorkspaceID: ws.ID}) + if err != nil { + t.Fatal(err) + } + if first.Predicate.Workspace.SHA256 == second.Predicate.Workspace.SHA256 { + t.Fatal("untracked content change did not change workspace identity") + } + validation := second.Predicate.Validations[0] + if validation.WorkspaceCompatibility != "stale" || !strings.Contains(validation.CompatibilityReason, "differs") { + t.Fatalf("mutated workspace validation = %+v", validation) + } + if second.Predicate.Status != "incomplete" || !hasManifestGap(second.Predicate.Gaps, "validations") { + t.Fatalf("manifest did not expose stale validation: status=%q gaps=%+v", second.Predicate.Status, second.Predicate.Gaps) + } + draft, err := svc.PrepareIssue(ctx, opportunity.ID, cli.PrepareIssueOptions{ManifestID: second.Predicate.ManifestID}) + if err != nil { + t.Fatal(err) + } + if draft.ManifestID != second.Predicate.ManifestID || strings.Contains(draft.Body, second.Predicate.ManifestID) { + t.Fatalf("draft manifest reference = %q body=%q", draft.ManifestID, draft.Body) + } +} + +func assertCurrentValidationSelected(t *testing.T, statement *manifest.Statement) { + t.Helper() + if got := statement.Predicate.Validations[0].WorkspaceCompatibility; got != "compatible" { + t.Fatalf("initial compatibility = %q", got) + } + if manifestHasGapCode(statement.Predicate.Gaps, "validation_outcome_failing") || selectedValidationCount(statement.Predicate.Validations) != 1 { + t.Fatalf("superseded failure affected completeness: validations=%+v gaps=%+v", statement.Predicate.Validations, statement.Predicate.Gaps) + } +} + +func assertFailingCandidateManifest(ctx context.Context, t *testing.T, svc *Service, c *corpus.Corpus, opportunityID, workspaceID string, run *evidence.ValidationRun) { + t.Helper() + run.Classification = evidence.RunClassificationFailing + if err := c.SaveValidationRun(ctx, run); err != nil { + t.Fatal(err) + } + failed, err := svc.ContributionManifest(ctx, opportunityID, ManifestOptions{WorkspaceID: workspaceID}) + if err != nil { + t.Fatal(err) + } + if !manifestHasGapCode(failed.Predicate.Gaps, "validation_outcome_failing") { + t.Fatalf("failing candidate validation was not exposed: %+v", failed.Predicate.Gaps) + } + run.Classification = evidence.RunClassificationPassing + if err := c.SaveValidationRun(ctx, run); err != nil { + t.Fatal(err) + } +} + +func manifestHasGapCode(gaps []manifest.Gap, code string) bool { + for _, gap := range gaps { + if gap.Code == code { + return true + } + } + return false +} + +func selectedValidationCount(records []manifest.ValidationRecord) int { + selected := 0 + for _, record := range records { + if record.Selected { + selected++ + } + } + return selected +} + +func TestContributionManifestKeepsMissingPullRequestFacetsIncomplete(t *testing.T) { + fixture := newResearchFixture(t) + started, err := fixture.svc.StartInvestigationFromThread(fixture.ctx, research.ThreadRef{ + Repo: domain.RepoRef{Owner: "owner", Repo: "repo"}, Kind: domain.IssueKind, Number: 1, + }) + if err != nil { + t.Fatal(err) + } + opportunity, err := fixture.svc.PromoteOpportunity(fixture.ctx, started.Hypothesis.ID, "stored PR health may be partial", "portfolio", "unsupported readiness", "small", 0.7) + if err != nil { + t.Fatal(err) + } + statement, err := fixture.svc.ContributionManifest(fixture.ctx, opportunity.ID, ManifestOptions{ + PullRequest: &ManifestPullRequest{Owner: "owner", Repo: "repo", Number: 9}, + }) + if err != nil { + t.Fatal(err) + } + if statement.Predicate.PullRequest == nil || statement.Predicate.Status != "incomplete" { + t.Fatalf("partial PR manifest = %+v", statement.Predicate) + } + for _, facet := range statement.Predicate.PullRequest.Facets { + if facet.Status != "complete" && !hasManifestGap(statement.Predicate.Gaps, "pull_request") { + t.Fatalf("facet %q=%q was not represented as a gap", facet.Facet, facet.Status) + } + } + if _, err := fixture.svc.ContributionManifest(fixture.ctx, opportunity.ID, ManifestOptions{ + PullRequest: &ManifestPullRequest{Owner: "other", Repo: "repo", Number: 9}, + }); err == nil || !strings.Contains(err.Error(), manifest.ErrIdentityMismatch.Error()) { + t.Fatalf("repository mismatch error = %v", err) + } +} diff --git a/internal/app/mcp_v1.go b/internal/app/mcp_v1.go index 6696e81..3c9a0d9 100644 --- a/internal/app/mcp_v1.go +++ b/internal/app/mcp_v1.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -597,20 +598,16 @@ func (r *MCPReader) DefineValidation(ctx context.Context, in mcpserver.DefineVal } timeout = d } - workingDir, baseDir, candidateDir, err := r.validationWorkspacePaths(ctx, in) - if err != nil { - return mcpserver.ValidationOutput{}, err - } opts := cli.DefineValidationOptions{ - Kind: in.Kind, - Command: in.Command, - WorkingDir: workingDir, - BaseWorkingDir: baseDir, - CandidateDir: candidateDir, - Env: append([]string(nil), in.Env...), - Timeout: timeout, - MaxOutputBytes: in.MaxOutputBytes, - Observation: observationContractMCPToCLI(in.Observation), + Kind: in.Kind, + Command: in.Command, + WorkspaceID: in.WorkspaceID, + BaseWorkspaceID: in.BaseWorkspaceID, + CandidateWorkspaceID: in.CandidateWorkspaceID, + Env: append([]string(nil), in.Env...), + Timeout: timeout, + MaxOutputBytes: in.MaxOutputBytes, + Observation: observationContractMCPToCLI(in.Observation), } res, err := r.Service.DefineValidation(ctx, in.InvestigationID, opts) if err != nil { @@ -619,57 +616,23 @@ func (r *MCPReader) DefineValidation(ctx context.Context, in mcpserver.DefineVal return validationResultToMCP(res), nil } -func (r *MCPReader) validationWorkspacePaths(ctx context.Context, in mcpserver.DefineValidationInput) (string, string, string, error) { - c, err := r.openReadOnlyCorpus(ctx) - if err != nil { - return "", "", "", err - } - mgr, err := r.workspaceReader() - if err != nil { - return "", "", "", fmt.Errorf("open managed workspaces: %w", err) - } - resolve := func(id string) (string, error) { - ws, err := c.GetWorkspace(ctx, id) - if err != nil { - return "", mapWorkspaceError(err) - } - if ws.InvestigationID != in.InvestigationID { - return "", fmt.Errorf("workspace %q does not belong to investigation %q", id, in.InvestigationID) - } - if err := mgr.ValidateWorkspacePath(ws.Path); err != nil { - return "", fmt.Errorf("workspace %q path is not managed: %w", id, err) - } - return ws.Path, nil - } - if in.WorkspaceID != "" { - path, err := resolve(in.WorkspaceID) - return path, "", "", err - } - base, err := resolve(in.BaseWorkspaceID) - if err != nil { - return "", "", "", err - } - candidate, err := resolve(in.CandidateWorkspaceID) - if err != nil { - return "", "", "", err - } - return "", base, candidate, nil -} - func validationResultToMCP(res *cli.ValidationResult) mcpserver.ValidationOutput { return mcpserver.ValidationOutput{ - ID: res.ID, - InvestigationID: res.InvestigationID, - Kind: res.Kind, - Command: res.Command, - WorkingDir: res.WorkingDir, - BaseWorkingDir: res.BaseWorkingDir, - CandidateDir: res.CandidateDir, - Env: res.Env, - Timeout: res.Timeout, - MaxOutputBytes: res.MaxOutputBytes, - Observation: observationContractCLIToMCP(res.Observation), - CreatedAt: res.CreatedAt, + ID: res.ID, + InvestigationID: res.InvestigationID, + Kind: res.Kind, + Command: res.Command, + WorkingDir: res.WorkingDir, + BaseWorkingDir: res.BaseWorkingDir, + CandidateDir: res.CandidateDir, + WorkspaceID: res.WorkspaceID, + BaseWorkspaceID: res.BaseWorkspaceID, + CandidateWorkspaceID: res.CandidateWorkspaceID, + Env: res.Env, + Timeout: res.Timeout, + MaxOutputBytes: res.MaxOutputBytes, + Observation: observationContractCLIToMCP(res.Observation), + CreatedAt: res.CreatedAt, } } @@ -726,8 +689,9 @@ func (r *MCPReader) PrepareContribution(ctx context.Context, in mcpserver.Prepar switch in.Kind { case "issue": draft, err = r.Service.PrepareIssue(ctx, in.OpportunityID, cli.PrepareIssueOptions{ - Guidance: in.Guidance, - Success: in.Success, + Guidance: in.Guidance, + Success: in.Success, + ManifestID: in.ManifestID, }) case "pull_request": draft, err = r.Service.PreparePullRequest(ctx, in.OpportunityID, cli.PreparePROptions{ @@ -738,6 +702,7 @@ func (r *MCPReader) PrepareContribution(ctx context.Context, in mcpserver.Prepar Limitations: in.Limitations, LinkedIssue: in.LinkedIssue, Guidance: in.Guidance, + ManifestID: in.ManifestID, }) default: return mcpserver.DraftOutput{}, fmt.Errorf("unsupported contribution kind %q", in.Kind) @@ -755,5 +720,30 @@ func draftResultToMCP(d *cli.DraftResult) mcpserver.DraftOutput { Title: d.Title, Body: d.Body, RenderedAt: d.RenderedAt, + ManifestID: d.ManifestID, + } +} + +// ExportManifest assembles a bounded local contribution evidence statement. +func (r *MCPReader) ExportManifest(ctx context.Context, in mcpserver.ExportManifestInput) (mcpserver.ManifestOutput, error) { + opts := ManifestOptions{WorkspaceID: strings.TrimSpace(in.WorkspaceID)} + if in.PullRequest != nil { + opts.PullRequest = &ManifestPullRequest{Owner: strings.TrimSpace(in.PullRequest.Owner), Repo: strings.TrimSpace(in.PullRequest.Repo), Number: in.PullRequest.Number} + } + statement, err := r.ContributionManifest(ctx, in.OpportunityID, opts) + if err != nil { + return mcpserver.ManifestOutput{}, err + } + payload, err := json.Marshal(statement) + if err != nil { + return mcpserver.ManifestOutput{}, err + } + var structured map[string]any + if err := json.Unmarshal(payload, &structured); err != nil { + return mcpserver.ManifestOutput{}, err } + return mcpserver.ManifestOutput{ + ManifestID: statement.Predicate.ManifestID, ContentSHA256: statement.Predicate.ContentSHA256, + SchemaVersion: statement.Predicate.SchemaVersion, Status: statement.Predicate.Status, Statement: structured, + }, nil } diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 340300d..f8b4d10 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -53,7 +53,11 @@ func TestMCPValidationResolvesManagedWorkspaceAndRejectsCrossInvestigation(t *te if defined.WorkingDir != path { t.Fatalf("working directory = %q, want managed path %q", defined.WorkingDir, path) } - if _, err := reader.DefineValidation(ctx, mcpserver.DefineValidationInput{InvestigationID: "different", Kind: "test", Command: "go test ./...", WorkspaceID: "managed"}); err == nil || !strings.Contains(err.Error(), "does not belong") { + other, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, "def456", "") + if err != nil { + t.Fatal(err) + } + if _, err := reader.DefineValidation(ctx, mcpserver.DefineValidationInput{InvestigationID: other.ID, Kind: "test", Command: "go test ./...", WorkspaceID: "managed"}); err == nil || !strings.Contains(err.Error(), "does not belong") { t.Fatalf("cross-investigation validation error = %v", err) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index fed3fb7..dde4ff1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -419,17 +419,20 @@ type validationCmd struct { } type defineValidationCmd struct { - InvestigationID string `arg:"" help:"Investigation ID"` - Kind string `name:"kind" required:"" help:"Validation kind"` - Command string `name:"command" required:"" help:"Command argv as a single string"` - WorkingDir string `name:"working-dir" help:"Working directory for both runs"` - BaseWorkingDir string `name:"base-working-dir" help:"Base workspace directory"` - CandidateDir string `name:"candidate-dir" help:"Candidate workspace directory"` - Env []string `name:"env" help:"Host environment variable names to pass through"` - Timeout time.Duration `name:"timeout" help:"Maximum execution time"` - MaxOutput int64 `name:"max-output" help:"Maximum captured output bytes per stream"` - Observation string `name:"observation-contract" help:"JSON observation contract for base and candidate output"` - JSON bool `name:"json" help:"Print the result as JSON"` + InvestigationID string `arg:"" help:"Investigation ID"` + Kind string `name:"kind" required:"" help:"Validation kind"` + Command string `name:"command" required:"" help:"Command argv as a single string"` + WorkingDir string `name:"working-dir" help:"Working directory for both runs"` + BaseWorkingDir string `name:"base-working-dir" help:"Base workspace directory"` + CandidateDir string `name:"candidate-dir" help:"Candidate workspace directory"` + WorkspaceID string `name:"workspace-id" help:"Managed workspace ID for both runs"` + BaseWorkspaceID string `name:"base-workspace-id" help:"Managed base workspace ID"` + CandidateWorkspaceID string `name:"candidate-workspace-id" help:"Managed candidate workspace ID"` + Env []string `name:"env" help:"Host environment variable names to pass through"` + Timeout time.Duration `name:"timeout" help:"Maximum execution time"` + MaxOutput int64 `name:"max-output" help:"Maximum captured output bytes per stream"` + Observation string `name:"observation-contract" help:"JSON observation contract for base and candidate output"` + JSON bool `name:"json" help:"Print the result as JSON"` } type runValidationCmd struct { @@ -461,6 +464,7 @@ type issueCmd struct { OpportunityID string `arg:"" help:"Opportunity ID"` Guidance string `name:"guidance" help:"Repository contribution guidance"` Success string `name:"success" help:"Success criteria"` + ManifestID string `name:"manifest-id" help:"Stored evidence manifest to reference"` JSON bool `name:"json" help:"Print the result as JSON"` } @@ -473,6 +477,7 @@ type prCmd struct { Limitations string `name:"limitations" help:"Limitations"` LinkedIssue string `name:"linked-issue" help:"Linked issue"` Guidance string `name:"guidance" help:"Repository contribution guidance"` + ManifestID string `name:"manifest-id" help:"Stored evidence manifest to reference"` JSON bool `name:"json" help:"Print the result as JSON"` } @@ -509,23 +514,6 @@ type neighborsCmd struct { JSON bool `name:"json" help:"Print the result as JSON"` } -type exportCmd struct { - Dossier exportDossierCmd `cmd:"" help:"Export a repository dossier"` - Evidence exportEvidenceCmd `cmd:"" help:"Export investigation evidence"` -} - -type exportDossierCmd struct { - OwnerRepo string `arg:"" name:"owner/repo" help:"Repository as OWNER/REPO"` - Format string `name:"format" default:"markdown" enum:"json,markdown,md" help:"Export format"` - Output string `name:"output" help:"Write to a file instead of stdout"` -} - -type exportEvidenceCmd struct { - InvestigationID string `arg:"" help:"Investigation ID"` - Format string `name:"format" default:"markdown" enum:"json,markdown,md" help:"Export format"` - Output string `name:"output" help:"Write to a file instead of stdout"` -} - type lensCmd struct { Add lensAddCmd `cmd:"" help:"Add or replace a saved lens from a JSON file"` List lensListCmd `cmd:"" help:"List saved lenses"` @@ -1519,8 +1507,7 @@ func (c *CLI) runPrepare(ctx context.Context, command string, cmd *prepareCmd) e case "prepare issue": fmt.Fprintf(c.stderr, "preparing issue draft for opportunity %s...\n", cmd.Issue.OpportunityID) result, err := service.PrepareIssue(ctx, cmd.Issue.OpportunityID, PrepareIssueOptions{ - Guidance: cmd.Issue.Guidance, - Success: cmd.Issue.Success, + Guidance: cmd.Issue.Guidance, Success: cmd.Issue.Success, ManifestID: cmd.Issue.ManifestID, }) if err != nil { return c.mapError(err) @@ -1536,6 +1523,7 @@ func (c *CLI) runPrepare(ctx context.Context, command string, cmd *prepareCmd) e Limitations: cmd.PR.Limitations, LinkedIssue: cmd.PR.LinkedIssue, Guidance: cmd.PR.Guidance, + ManifestID: cmd.PR.ManifestID, }) if err != nil { return c.mapError(err) @@ -1926,6 +1914,17 @@ func (c *CLI) runExport(ctx context.Context, command string, cmd *exportCmd) err case "export evidence": result, err = service.ExportEvidence(ctx, cmd.Evidence.InvestigationID, cmd.Evidence.Format) output = cmd.Evidence.Output + case "export manifest": + opts := ManifestExportOptions{WorkspaceID: cmd.Manifest.WorkspaceID} + if cmd.Manifest.PullRequest != "" { + repo, number, parseErr := parseThreadRef(cmd.Manifest.PullRequest) + if parseErr != nil { + return NewCLIError(ExitUsage, parseErr) + } + opts.PullRequest = &ManifestPullRequestRef{Owner: repo.Owner, Repo: repo.Repo, Number: number} + } + result, err = service.ExportManifest(ctx, cmd.Manifest.OpportunityID, opts) + output = cmd.Manifest.Output default: return NewCLIError(ExitUsage, fmt.Errorf("unknown export command: %s", command)) } diff --git a/internal/cli/export_commands.go b/internal/cli/export_commands.go new file mode 100644 index 0000000..3b458d0 --- /dev/null +++ b/internal/cli/export_commands.go @@ -0,0 +1,26 @@ +package cli + +type exportCmd struct { + Dossier exportDossierCmd `cmd:"" help:"Export a repository dossier"` + Evidence exportEvidenceCmd `cmd:"" help:"Export investigation evidence"` + Manifest exportManifestCmd `cmd:"" help:"Export a contribution evidence manifest"` +} + +type exportDossierCmd struct { + OwnerRepo string `arg:"" name:"owner/repo" help:"Repository as OWNER/REPO"` + Format string `name:"format" default:"markdown" enum:"json,markdown,md" help:"Export format"` + Output string `name:"output" help:"Write to a file instead of stdout"` +} + +type exportEvidenceCmd struct { + InvestigationID string `arg:"" help:"Investigation ID"` + Format string `name:"format" default:"markdown" enum:"json,markdown,md" help:"Export format"` + Output string `name:"output" help:"Write to a file instead of stdout"` +} + +type exportManifestCmd struct { + OpportunityID string `arg:"" help:"Opportunity ID"` + WorkspaceID string `name:"workspace" help:"Managed workspace ID to bind"` + PullRequest string `name:"pull-request" help:"Exact stored pull request as OWNER/REPO#NUMBER"` + Output string `name:"output" help:"Write to a file instead of stdout"` +} diff --git a/internal/cli/export_contracts.go b/internal/cli/export_contracts.go new file mode 100644 index 0000000..4fcfe9e --- /dev/null +++ b/internal/cli/export_contracts.go @@ -0,0 +1,30 @@ +package cli + +import "context" + +// ExportService renders redacted, deterministic local bundles. +type ExportService interface { + ExportDossier(ctx context.Context, repo RepoRef, format string) (*ExportResult, error) + ExportEvidence(ctx context.Context, investigationID, format string) (*ExportResult, error) + ExportManifest(ctx context.Context, opportunityID string, opts ManifestExportOptions) (*ExportResult, error) +} + +// ManifestExportOptions selects optional local identities for a manifest export. +type ManifestExportOptions struct { + WorkspaceID string + PullRequest *ManifestPullRequestRef +} + +// ManifestPullRequestRef identifies one exact stored pull request. +type ManifestPullRequestRef struct { + Owner string + Repo string + Number int +} + +// ExportResult contains one rendered local export. +type ExportResult struct { + Kind string `json:"kind"` + Format string `json:"format"` + Content string `json:"content"` +} diff --git a/internal/cli/interfaces.go b/internal/cli/interfaces.go index cddd020..6c9a16a 100644 --- a/internal/cli/interfaces.go +++ b/internal/cli/interfaces.go @@ -456,49 +456,59 @@ type RunValidationOptions struct { // DefineValidationOptions carries an explicit validation definition. type DefineValidationOptions struct { - Kind string - Command string - WorkingDir string - BaseWorkingDir string - CandidateDir string - Env []string - Timeout time.Duration - MaxOutputBytes int64 - Observation *ValidationObservationContract + Kind string + Command string + WorkingDir string + BaseWorkingDir string + CandidateDir string + WorkspaceID string + BaseWorkspaceID string + CandidateWorkspaceID string + Env []string + Timeout time.Duration + MaxOutputBytes int64 + Observation *ValidationObservationContract } // ValidationResult is a stored validation definition view. type ValidationResult struct { - ID string `json:"id"` - InvestigationID string `json:"investigation_id"` - Kind string `json:"kind"` - Command []string `json:"command"` - WorkingDir string `json:"working_dir"` - BaseWorkingDir string `json:"base_working_dir,omitempty"` - CandidateDir string `json:"candidate_dir,omitempty"` - Env []string `json:"environment_allowlist,omitempty"` - Timeout string `json:"timeout,omitempty"` - MaxOutputBytes int64 `json:"max_output_bytes,omitempty"` - Observation *ValidationObservationContract `json:"observation,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + InvestigationID string `json:"investigation_id"` + Kind string `json:"kind"` + Command []string `json:"command"` + WorkingDir string `json:"working_dir"` + BaseWorkingDir string `json:"base_working_dir,omitempty"` + CandidateDir string `json:"candidate_dir,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + BaseWorkspaceID string `json:"base_workspace_id,omitempty"` + CandidateWorkspaceID string `json:"candidate_workspace_id,omitempty"` + Env []string `json:"environment_allowlist,omitempty"` + Timeout string `json:"timeout,omitempty"` + MaxOutputBytes int64 `json:"max_output_bytes,omitempty"` + Observation *ValidationObservationContract `json:"observation,omitempty"` + CreatedAt string `json:"created_at"` } // ValidationRunResult is the captured outcome of one validation run. type ValidationRunResult struct { - ID string `json:"id"` - DefinitionID string `json:"definition_id"` - InvestigationID string `json:"investigation_id"` - Kind string `json:"kind"` - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - Truncated bool `json:"truncated"` - Error string `json:"error,omitempty"` - Classification string `json:"classification"` - ObservationStatus string `json:"observation_status"` - Observations []ValidationObservationResult `json:"observations,omitempty"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at"` + ID string `json:"id"` + DefinitionID string `json:"definition_id"` + InvestigationID string `json:"investigation_id"` + Kind string `json:"kind"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Truncated bool `json:"truncated"` + Error string `json:"error,omitempty"` + Classification string `json:"classification"` + ObservationStatus string `json:"observation_status"` + Observations []ValidationObservationResult `json:"observations,omitempty"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + WorkspaceSnapshotBefore string `json:"workspace_snapshot_before,omitempty"` + WorkspaceSnapshotAfter string `json:"workspace_snapshot_after,omitempty"` + WorkspaceBindingStatus string `json:"workspace_binding_status,omitempty"` + WorkspaceBindingReason string `json:"workspace_binding_reason,omitempty"` } // ValidationComparisonResult classifies a base run against a candidate run. @@ -522,8 +532,9 @@ type ContributionService interface { // PrepareIssueOptions carries optional fields for issue preparation. type PrepareIssueOptions struct { - Guidance string - Success string + Guidance string + Success string + ManifestID string } // PreparePROptions carries explicit and optional fields for PR preparation. @@ -535,6 +546,7 @@ type PreparePROptions struct { Limitations string LinkedIssue string Guidance string + ManifestID string } // DraftResult is a rendered, locally-stored contribution draft. @@ -544,6 +556,7 @@ type DraftResult struct { Title string `json:"title"` Body string `json:"body"` RenderedAt string `json:"rendered_at"` + ManifestID string `json:"manifest_id,omitempty"` } // LensService is the optional saved-lens management capability used by the CLI. @@ -714,18 +727,6 @@ type NeighborResult struct { Reason string `json:"reason"` } -// ExportService renders redacted, deterministic local bundles. -type ExportService interface { - ExportDossier(ctx context.Context, repo RepoRef, format string) (*ExportResult, error) - ExportEvidence(ctx context.Context, investigationID, format string) (*ExportResult, error) -} - -type ExportResult struct { - Kind string `json:"kind"` - Format string `json:"format"` - Content string `json:"content"` -} - // TrackingService exposes local triage, contribution, and metadata portability // operations. Implementations must keep local state separate from GitHub state // and must not perform network access. diff --git a/internal/cli/surfaces_test.go b/internal/cli/surfaces_test.go index 73ad2a4..f878c94 100644 --- a/internal/cli/surfaces_test.go +++ b/internal/cli/surfaces_test.go @@ -217,6 +217,11 @@ func (f *fakeSurfacesService) ExportEvidence(ctx context.Context, investigationI return &cli.ExportResult{Kind: "evidence", Format: format, Content: "# evidence\n"}, f.err } +func (f *fakeSurfacesService) ExportManifest(_ context.Context, opportunityID string, _ cli.ManifestExportOptions) (*cli.ExportResult, error) { + f.exportCalled = true + return &cli.ExportResult{Kind: "manifest", Format: "json", Content: `{"manifest":"` + opportunityID + `"}`}, f.err +} + func newSurfacesCLI(svc *fakeSurfacesService) (*cli.CLI, *strings.Builder, *strings.Builder) { var stdout, stderr strings.Builder return cli.New(svc, nil, &stdout, &stderr), &stdout, &stderr diff --git a/internal/cli/validation_cli.go b/internal/cli/validation_cli.go index 8cb8482..b997ef8 100644 --- a/internal/cli/validation_cli.go +++ b/internal/cli/validation_cli.go @@ -38,6 +38,7 @@ func (c *CLI) defineValidation(ctx context.Context, service ValidationService, c result, err := service.DefineValidation(ctx, cmd.InvestigationID, DefineValidationOptions{ Kind: cmd.Kind, Command: cmd.Command, WorkingDir: cmd.WorkingDir, BaseWorkingDir: cmd.BaseWorkingDir, CandidateDir: cmd.CandidateDir, + WorkspaceID: cmd.WorkspaceID, BaseWorkspaceID: cmd.BaseWorkspaceID, CandidateWorkspaceID: cmd.CandidateWorkspaceID, Env: cmd.Env, Timeout: cmd.Timeout, MaxOutputBytes: cmd.MaxOutput, Observation: observation, }) diff --git a/internal/contribution/models.go b/internal/contribution/models.go index 7a25ac1..5c557ff 100644 --- a/internal/contribution/models.go +++ b/internal/contribution/models.go @@ -14,6 +14,7 @@ type IssueDraft struct { Title string Body string RenderedAt time.Time + ManifestID string } // PullRequestDraft is a rendered PR body ready for review. @@ -22,6 +23,7 @@ type PullRequestDraft struct { Title string Body string RenderedAt time.Time + ManifestID string } // IssueInput supplies the verified facts and repository guidance used to render an issue. @@ -31,6 +33,7 @@ type IssueInput struct { Guidance string Repo domain.RepoRef Success string + ManifestID string } // PullRequestInput supplies the verified facts and repository guidance used to render a PR. @@ -44,4 +47,5 @@ type PullRequestInput struct { Compatibility string Limitations string LinkedIssue string + ManifestID string } diff --git a/internal/contribution/renderer.go b/internal/contribution/renderer.go index a134296..b509602 100644 --- a/internal/contribution/renderer.go +++ b/internal/contribution/renderer.go @@ -58,6 +58,7 @@ func (r *Renderer) RenderIssue(in IssueInput) (*IssueDraft, error) { Title: o.Title, Body: strings.TrimSpace(b.String()), RenderedAt: time.Now().UTC(), + ManifestID: in.ManifestID, }, nil } @@ -102,6 +103,7 @@ func (r *Renderer) RenderPullRequest(in PullRequestInput) (*PullRequestDraft, er Title: o.Title, Body: strings.TrimSpace(b.String()), RenderedAt: time.Now().UTC(), + ManifestID: in.ManifestID, }, nil } diff --git a/internal/corpus/migration_test.go b/internal/corpus/migration_test.go index a335f45..ed2b360 100644 --- a/internal/corpus/migration_test.go +++ b/internal/corpus/migration_test.go @@ -22,6 +22,7 @@ func TestBaselineMigrationCreatesCurrentSchema(t *testing.T) { "facet_coverage", "facet_observations", "code_snapshots", "code_documents", "threads_fts", "facet_observations_fts", "code_documents_fts", "projection_states", "investigations", "opportunities", "workspaces", "dossiers", "cluster_runs", "clusters", + "contribution_manifests", } { if !migrationTableExists(ctx, t, c.db, table) { t.Fatalf("table %s missing after baseline migration", table) diff --git a/internal/corpus/migrations/005_contribution_manifests.sql b/internal/corpus/migrations/005_contribution_manifests.sql new file mode 100644 index 0000000..6d5aa16 --- /dev/null +++ b/internal/corpus/migrations/005_contribution_manifests.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE contribution_manifests ( + id TEXT PRIMARY KEY, + opportunity_id TEXT NOT NULL, + workspace_id TEXT NOT NULL DEFAULT '', + pull_request_ref TEXT NOT NULL DEFAULT '', + content_sha256 TEXT NOT NULL, + payload TEXT NOT NULL, + generated_at INTEGER NOT NULL, + FOREIGN KEY (opportunity_id) REFERENCES opportunities (id) ON DELETE CASCADE +); +CREATE INDEX idx_contribution_manifests_opportunity_generated + ON contribution_manifests (opportunity_id, generated_at DESC, id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS contribution_manifests; +-- +goose StatementEnd diff --git a/internal/corpus/workflow.go b/internal/corpus/workflow.go index 9f6b663..1bfb7cd 100644 --- a/internal/corpus/workflow.go +++ b/internal/corpus/workflow.go @@ -12,6 +12,7 @@ import ( "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" "github.com/morluto/gitcontribute/internal/investigation" + "github.com/morluto/gitcontribute/internal/manifest" "github.com/morluto/gitcontribute/internal/workspace" ) @@ -544,3 +545,69 @@ func (c *Corpus) getDraft(ctx context.Context, opportunityID, kind string, targe } return unmarshalWorkflow(payload, target) } + +// SaveContributionManifest persists one deterministic evidence statement. +func (c *Corpus) SaveContributionManifest(ctx context.Context, item *manifest.Statement, workspaceID, pullRequestRef string) error { + if item == nil { + return errors.New("contribution manifest is required") + } + if err := item.Validate(); err != nil { + return err + } + payload, err := marshalWorkflow(item) + if err != nil { + return err + } + _, err = c.db.ExecContext(ctx, ` + INSERT INTO contribution_manifests (id, opportunity_id, workspace_id, pull_request_ref, content_sha256, payload, generated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET workspace_id=excluded.workspace_id, + pull_request_ref=excluded.pull_request_ref, content_sha256=excluded.content_sha256, payload=excluded.payload, + generated_at=excluded.generated_at + `, item.Predicate.ManifestID, item.Predicate.Opportunity.ID, workspaceID, pullRequestRef, + item.Predicate.ContentSHA256, payload, encodeTime(item.Predicate.GeneratedAt)) + if err != nil { + return fmt.Errorf("save contribution manifest: %w", err) + } + return nil +} + +// GetContributionManifest reads one persisted evidence statement. +func (c *Corpus) GetContributionManifest(ctx context.Context, id string) (*manifest.Statement, error) { + var payload string + err := c.db.QueryRowContext(ctx, `SELECT payload FROM contribution_manifests WHERE id=?`, id).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) { + return nil, manifest.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get contribution manifest: %w", err) + } + var item manifest.Statement + if err := unmarshalWorkflow(payload, &item); err != nil { + return nil, err + } + if err := item.Validate(); err != nil { + return nil, fmt.Errorf("validate contribution manifest: %w", err) + } + return &item, nil +} + +// LatestContributionManifest reads the newest manifest for an opportunity. +func (c *Corpus) LatestContributionManifest(ctx context.Context, opportunityID string) (*manifest.Statement, error) { + var payload string + err := c.db.QueryRowContext(ctx, `SELECT payload FROM contribution_manifests WHERE opportunity_id=? ORDER BY generated_at DESC, id LIMIT 1`, opportunityID).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) { + return nil, manifest.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get latest contribution manifest: %w", err) + } + var item manifest.Statement + if err := unmarshalWorkflow(payload, &item); err != nil { + return nil, err + } + if err := item.Validate(); err != nil { + return nil, fmt.Errorf("validate contribution manifest: %w", err) + } + return &item, nil +} diff --git a/internal/corpus/workflow_test.go b/internal/corpus/workflow_test.go index 58fde90..a630231 100644 --- a/internal/corpus/workflow_test.go +++ b/internal/corpus/workflow_test.go @@ -2,6 +2,7 @@ package corpus import ( "context" + "encoding/json" "errors" "path/filepath" "testing" @@ -11,9 +12,62 @@ import ( "github.com/morluto/gitcontribute/internal/domain" "github.com/morluto/gitcontribute/internal/evidence" "github.com/morluto/gitcontribute/internal/investigation" + "github.com/morluto/gitcontribute/internal/manifest" "github.com/morluto/gitcontribute/internal/workspace" ) +func TestContributionManifestPersistsAndSelectsLatest(t *testing.T) { + t.Parallel() + ctx := context.Background() + c, _ := openTestCorpus(t) + svc := investigation.NewService(c, c) + inv, err := svc.StartInvestigation(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, "sha", "") + if err != nil { + t.Fatal(err) + } + hypothesis, err := svc.RecordHypothesis(ctx, inv.ID, "proof", "description", investigation.CategoryBug, nil) + if err != nil { + t.Fatal(err) + } + opportunity, err := svc.PromoteOpportunity(ctx, hypothesis.ID, "problem", "scope", "impact", "small", 0.8) + if err != nil { + t.Fatal(err) + } + statement, err := manifest.Finalize(manifest.Predicate{ + GeneratedAt: time.Unix(100, 0).UTC(), Repository: manifest.RepositoryIdentity{Owner: "owner", Repo: "repo", CommitSHA: "sha"}, + Opportunity: manifest.OpportunityRecord{ID: opportunity.ID, InvestigationID: inv.ID}, Status: "incomplete", + }) + if err != nil { + t.Fatal(err) + } + if err := c.SaveContributionManifest(ctx, &statement, "", ""); err != nil { + t.Fatal(err) + } + got, err := c.GetContributionManifest(ctx, statement.Predicate.ManifestID) + if err != nil { + t.Fatal(err) + } + latest, err := c.LatestContributionManifest(ctx, opportunity.ID) + if err != nil { + t.Fatal(err) + } + if got.Predicate.ContentSHA256 != statement.Predicate.ContentSHA256 || latest.Predicate.ManifestID != statement.Predicate.ManifestID { + t.Fatalf("manifest roundtrip mismatch: got=%+v latest=%+v", got.Predicate, latest.Predicate) + } + tampered := statement + tampered.Predicate.Opportunity.ProblemStatement = "tampered" + payload, err := json.Marshal(tampered) + if err != nil { + t.Fatal(err) + } + if _, err := c.db.ExecContext(ctx, `UPDATE contribution_manifests SET payload=? WHERE id=?`, payload, statement.Predicate.ManifestID); err != nil { + t.Fatal(err) + } + if _, err := c.GetContributionManifest(ctx, statement.Predicate.ManifestID); err == nil { + t.Fatal("tampered persisted manifest passed validation") + } +} + func TestContributionWorkflowPersistsAcrossReopen(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/evidence/models.go b/internal/evidence/models.go index b430c04..83fd7f5 100644 --- a/internal/evidence/models.go +++ b/internal/evidence/models.go @@ -148,41 +148,48 @@ type ObservationResult struct { // ValidationDefinition captures an explicit validation command and its workspace. type ValidationDefinition struct { - ID string - InvestigationID string - HypothesisID string - OpportunityID string - Name string - Kind string - Command []string - WorkingDir string - BaseWorkingDir string - CandidateDir string - Env []string // variable names allowed through from the host environment - Timeout time.Duration - MaxOutputBytes int64 - Observation *ObservationContract - CreatedAt time.Time + ID string + InvestigationID string + HypothesisID string + OpportunityID string + Name string + Kind string + Command []string + WorkingDir string + BaseWorkingDir string + CandidateDir string + WorkspaceID string + BaseWorkspaceID string + CandidateWorkspaceID string + Env []string // variable names allowed through from the host environment + Timeout time.Duration + MaxOutputBytes int64 + Observation *ObservationContract + CreatedAt time.Time } // ValidationRun records the outcome of one execution of a validation definition. type ValidationRun struct { - ID string - DefinitionID string - InvestigationID string - HypothesisID string - OpportunityID string - Kind RunKind - StartedAt time.Time - CompletedAt time.Time - ExitCode int - Stdout string - Stderr string - Truncated bool - Error string - Classification RunClassification - ObservationStatus ObservationStatus - Observations []ObservationResult + ID string + DefinitionID string + InvestigationID string + HypothesisID string + OpportunityID string + Kind RunKind + StartedAt time.Time + CompletedAt time.Time + ExitCode int + Stdout string + Stderr string + Truncated bool + Error string + Classification RunClassification + ObservationStatus ObservationStatus + Observations []ObservationResult + WorkspaceSnapshotBefore string + WorkspaceSnapshotAfter string + WorkspaceBindingStatus string + WorkspaceBindingReason string } // Evidence is a piece of supporting, contradicting, or inconclusive proof. diff --git a/internal/manifest/models.go b/internal/manifest/models.go new file mode 100644 index 0000000..27faef0 --- /dev/null +++ b/internal/manifest/models.go @@ -0,0 +1,254 @@ +// Package manifest owns the stable contribution evidence export contract. +package manifest + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/evidence" + "github.com/morluto/gitcontribute/internal/workspace" +) + +// ErrNotFound means no persisted manifest matched the requested identity. +var ( + ErrNotFound = errors.New("contribution manifest not found") + ErrIdentityMismatch = errors.New("contribution manifest identity mismatch") +) + +const ( + // StatementType identifies the standard in-toto statement envelope. + StatementType = "https://in-toto.io/Statement/v1" + // PredicateType identifies the GitContribute evidence predicate. + PredicateType = "https://github.com/morluto/gitcontribute/attestation/contribution-evidence/v1" + // SchemaVersion identifies the predicate's product-owned schema. + SchemaVersion = "contribution-evidence.v1" + // ManifestIDPrefix identifies the digest algorithm used by manifest IDs. + ManifestIDPrefix = "sha256:" +) + +// ResourceDescriptor follows the in-toto v1 subject identity shape. +type ResourceDescriptor struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` +} + +// Statement wraps the product predicate in an in-toto Statement v1 envelope. +type Statement struct { + Type string `json:"_type"` + Subject []ResourceDescriptor `json:"subject"` + PredicateType string `json:"predicateType"` + Predicate Predicate `json:"predicate"` +} + +// Predicate is the self-contained contribution evidence manifest. +type Predicate struct { + SchemaVersion string `json:"schema_version"` + ManifestID string `json:"manifest_id"` + ContentSHA256 string `json:"content_sha256"` + GeneratedAt time.Time `json:"generated_at"` + Repository RepositoryIdentity `json:"repository"` + Opportunity OpportunityRecord `json:"opportunity"` + Workspace *workspace.Snapshot `json:"workspace,omitempty"` + Validations []ValidationRecord `json:"validations"` + Evidence []EvidenceRecord `json:"evidence"` + Readiness ReadinessRecord `json:"readiness"` + PullRequest *PullRequestRecord `json:"pull_request,omitempty"` + Drafts []DraftRecord `json:"drafts"` + Status string `json:"status"` + Completeness []CompletenessFacet `json:"completeness"` + Gaps []Gap `json:"gaps"` +} + +// RepositoryIdentity binds the manifest to the investigated source revision. +type RepositoryIdentity struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommitSHA string `json:"investigation_commit_sha"` +} + +// OpportunityRecord captures the scoped contribution outcome. +type OpportunityRecord struct { + ID string `json:"id"` + InvestigationID string `json:"investigation_id"` + HypothesisID string `json:"hypothesis_id,omitempty"` + ProblemStatement string `json:"problem_statement"` + Scope string `json:"scope"` + Impact string `json:"impact"` + Status string `json:"status"` + SourceRefs []domain.SourceRef `json:"source_refs"` +} + +// ValidationRecord binds a stored run to its command and candidate identity. +type ValidationRecord struct { + DefinitionID string `json:"definition_id"` + RunID string `json:"run_id"` + Kind string `json:"kind"` + Command []string `json:"command"` + CommandSHA256 string `json:"command_sha256"` + ExecutionContractSHA256 string `json:"execution_contract_sha256"` + EnvironmentAllowlist []string `json:"environment_allowlist"` + Timeout string `json:"timeout"` + MaxOutputBytes int64 `json:"max_output_bytes"` + Observation *evidence.ObservationContract `json:"observation,omitempty"` + Classification string `json:"classification"` + ObservationStatus string `json:"observation_status"` + Observations []evidence.ObservationResult `json:"observations"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` + WorkspaceSnapshotBefore string `json:"workspace_snapshot_before,omitempty"` + WorkspaceSnapshotAfter string `json:"workspace_snapshot_after,omitempty"` + WorkspaceBindingStatus string `json:"workspace_binding_status"` + WorkspaceCompatibility string `json:"workspace_compatibility"` + CompatibilityReason string `json:"compatibility_reason"` + Selected bool `json:"selected_for_completeness"` +} + +// EvidenceRecord captures a stored evidence item and evaluated freshness. +type EvidenceRecord struct { + ID string `json:"id"` + Type string `json:"type"` + Relation string `json:"relation"` + Description string `json:"description"` + ValidationRunID string `json:"validation_run_id,omitempty"` + SourceRefs []domain.SourceRef `json:"source_refs"` + SourceProvenance []evidence.SourceRevision `json:"source_provenance"` + Freshness string `json:"freshness"` + FreshnessReason string `json:"freshness_reason"` +} + +// ReadinessRecord captures the deterministic readiness rule evaluation. +type ReadinessRecord struct { + RuleSetVersion string `json:"rule_set_version"` + Status string `json:"status"` + EvaluatedAt string `json:"evaluated_at"` + Checks []ReadinessCheck `json:"checks"` +} + +// ReadinessCheck captures one versioned readiness rule result. +type ReadinessCheck struct { + RuleID string `json:"rule_id"` + RuleVersion string `json:"rule_version"` + Status string `json:"status"` + Summary string `json:"summary"` + EvidenceRefs []string `json:"evidence_refs"` +} + +// FacetStatus reports freshness and completeness for one GitHub projection. +type FacetStatus struct { + Facet string `json:"facet"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// PullRequestRecord captures explicitly selected, locally stored PR health. +type PullRequestRecord struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + State string `json:"state"` + HeadSHA string `json:"head_sha,omitempty"` + BaseSHA string `json:"base_sha,omitempty"` + ChecksStatus string `json:"checks_status,omitempty"` + ReviewDecision string `json:"review_decision,omitempty"` + UnresolvedReviewThreads *int `json:"unresolved_review_threads,omitempty"` + MergeStateStatus string `json:"merge_state_status,omitempty"` + MergeQueueState string `json:"merge_queue_state,omitempty"` + Attention string `json:"attention"` + SourceUpdatedAt string `json:"source_updated_at"` + Facets []FacetStatus `json:"facets"` +} + +// DraftRecord identifies a locally prepared contribution draft. +type DraftRecord struct { + Kind string `json:"kind"` + Title string `json:"title"` + RenderedAt time.Time `json:"rendered_at"` + ManifestID string `json:"manifest_id,omitempty"` +} + +// CompletenessFacet reports whether one evidence area is usable. +type CompletenessFacet struct { + Facet string `json:"facet"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +// Gap records evidence that is missing, stale, unknown, or incompatible. +type Gap struct { + Code string `json:"code"` + Facet string `json:"facet"` + Reason string `json:"reason"` +} + +// Finalize computes the deterministic content identity and in-toto subject. +func Finalize(predicate Predicate) (Statement, error) { + predicate.SchemaVersion = SchemaVersion + contentDigest, err := predicateIdentityDigest(predicate) + if err != nil { + return Statement{}, err + } + predicate.ContentSHA256 = contentDigest + predicate.ManifestID = ManifestIDPrefix + predicate.ContentSHA256 + subjectDigest := predicate.ContentSHA256 + if predicate.Workspace != nil && predicate.Workspace.SHA256 != "" { + subjectDigest = predicate.Workspace.SHA256 + } + statement := Statement{ + Type: StatementType, + Subject: []ResourceDescriptor{{Name: predicate.Repository.Owner + "/" + predicate.Repository.Repo, Digest: map[string]string{"sha256": subjectDigest}}}, + PredicateType: PredicateType, + Predicate: predicate, + } + if err := statement.Validate(); err != nil { + return Statement{}, err + } + return statement, nil +} + +// Validate checks the stable envelope and identity fields before persistence. +func (s Statement) Validate() error { + if s.Type != StatementType || s.PredicateType != PredicateType || s.Predicate.SchemaVersion != SchemaVersion { + return errors.New("manifest schema identity is invalid") + } + if len(s.Subject) != 1 || len(s.Subject[0].Digest["sha256"]) != sha256.Size*2 { + return errors.New("manifest subject requires one SHA-256 digest") + } + if s.Predicate.ManifestID != ManifestIDPrefix+s.Predicate.ContentSHA256 || len(s.Predicate.ContentSHA256) != sha256.Size*2 { + return errors.New("manifest content identity is invalid") + } + if s.Predicate.Repository.Owner == "" || s.Predicate.Repository.Repo == "" || s.Predicate.Opportunity.ID == "" { + return errors.New("manifest repository and opportunity are required") + } + wantContent, err := predicateIdentityDigest(s.Predicate) + if err != nil { + return err + } + if s.Predicate.ContentSHA256 != wantContent { + return errors.New("manifest content digest does not match predicate") + } + wantSubject := wantContent + if s.Predicate.Workspace != nil && s.Predicate.Workspace.SHA256 != "" { + wantSubject = s.Predicate.Workspace.SHA256 + } + if s.Subject[0].Name != s.Predicate.Repository.Owner+"/"+s.Predicate.Repository.Repo || s.Subject[0].Digest["sha256"] != wantSubject { + return errors.New("manifest subject does not match predicate identity") + } + return nil +} + +func predicateIdentityDigest(predicate Predicate) (string, error) { + identity := predicate + identity.ManifestID, identity.ContentSHA256, identity.GeneratedAt = "", "", time.Time{} + identity.Readiness.EvaluatedAt = "" + payload, err := json.Marshal(identity) + if err != nil { + return "", fmt.Errorf("encode manifest identity: %w", err) + } + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]), nil +} diff --git a/internal/manifest/models_test.go b/internal/manifest/models_test.go new file mode 100644 index 0000000..ef8b3c7 --- /dev/null +++ b/internal/manifest/models_test.go @@ -0,0 +1,65 @@ +package manifest + +import ( + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/workspace" +) + +func TestFinalizeUsesDeterministicContentIdentity(t *testing.T) { + predicate := Predicate{ + GeneratedAt: time.Unix(100, 0).UTC(), + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo", CommitSHA: "commit"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv", ProblemStatement: "problem"}, + Readiness: ReadinessRecord{Status: "unknown", EvaluatedAt: "2025-01-01T00:00:00Z"}, + Status: "incomplete", + } + first, err := Finalize(predicate) + if err != nil { + t.Fatal(err) + } + predicate.GeneratedAt = time.Unix(200, 0).UTC() + predicate.Readiness.EvaluatedAt = "2026-01-01T00:00:00Z" + second, err := Finalize(predicate) + if err != nil { + t.Fatal(err) + } + if first.Predicate.ManifestID != second.Predicate.ManifestID || first.Predicate.ContentSHA256 != second.Predicate.ContentSHA256 { + t.Fatalf("generation timestamps changed content identity: %q != %q", first.Predicate.ManifestID, second.Predicate.ManifestID) + } +} + +func TestFinalizeBindsSubjectToWorkspaceSnapshot(t *testing.T) { + predicate := Predicate{ + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv"}, + Workspace: &workspace.Snapshot{SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Status: "incomplete", + } + statement, err := Finalize(predicate) + if err != nil { + t.Fatal(err) + } + if got := statement.Subject[0].Digest["sha256"]; got != predicate.Workspace.SHA256 { + t.Fatalf("subject digest = %q, want workspace digest", got) + } + if err := statement.Validate(); err != nil { + t.Fatalf("validate finalized statement: %v", err) + } +} + +func TestValidateRejectsTamperedPredicate(t *testing.T) { + statement, err := Finalize(Predicate{ + Repository: RepositoryIdentity{Owner: "owner", Repo: "repo"}, + Opportunity: OpportunityRecord{ID: "opp", InvestigationID: "inv", ProblemStatement: "original"}, + Status: "incomplete", + }) + if err != nil { + t.Fatal(err) + } + statement.Predicate.Opportunity.ProblemStatement = "tampered" + if err := statement.Validate(); err == nil { + t.Fatal("tampered predicate passed validation") + } +} diff --git a/internal/mcpserver/catalog.go b/internal/mcpserver/catalog.go index 1aaa98e..3740ff2 100644 --- a/internal/mcpserver/catalog.go +++ b/internal/mcpserver/catalog.go @@ -50,6 +50,7 @@ const ( ToolFindCompetingWork = "workflow.find_competing_work" ToolPromoteOpportunity = "workflow.promote_opportunity" ToolPrepareContribution = "workflow.prepare_contribution" + ToolExportManifest = "workflow.export_manifest" ToolLinkPullRequest = "workflow.link_pull_request" ) @@ -95,6 +96,7 @@ var canonicalToolNames = []string{ ToolFindCompetingWork, ToolPromoteOpportunity, ToolPrepareContribution, + ToolExportManifest, ToolLinkPullRequest, } @@ -171,7 +173,7 @@ var toolsets = map[string][]string{ ToolSearchGitHubRepositories, ToolSyncRepositoryMetadata, ToolSyncThreads, ToolHydrateThreads, ToolStartInvestigation, ToolRecordHypothesis, ToolCheckDuplicates, ToolFindCompetingWork, ToolPromoteOpportunity, ToolGetInvestigation, ToolListOpportunities, ToolGetOpportunity, - ToolGetEvidence, ToolGetReadiness, ToolPrepareContribution, + ToolGetEvidence, ToolGetReadiness, ToolPrepareContribution, ToolExportManifest, }, "code": { ToolSearchCode, ToolIndexRepositories, ToolCreateWorkspace, ToolCheckMergeConflicts, diff --git a/internal/mcpserver/contribution_v1.go b/internal/mcpserver/contribution_v1.go new file mode 100644 index 0000000..9bcdda7 --- /dev/null +++ b/internal/mcpserver/contribution_v1.go @@ -0,0 +1,100 @@ +package mcpserver + +import ( + "context" + "errors" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// PrepareContributionInput renders a local issue or pull-request draft. +type PrepareContributionInput struct { + OpportunityID string `json:"opportunity_id" jsonschema:"Opportunity ID"` + Kind string `json:"kind" jsonschema:"Contribution kind: issue or pull_request"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"Workspace ID for pull_request drafts"` + Approach string `json:"approach,omitempty" jsonschema:"Approach summary for pull requests"` + Changes string `json:"changes,omitempty" jsonschema:"Changes summary for pull requests"` + Compatibility string `json:"compatibility,omitempty" jsonschema:"Compatibility notes for pull requests"` + Limitations string `json:"limitations,omitempty" jsonschema:"Limitations for pull requests"` + LinkedIssue string `json:"linked_issue,omitempty" jsonschema:"Linked issue for pull requests"` + Guidance string `json:"guidance,omitempty" jsonschema:"Optional guidance to include"` + Success string `json:"success,omitempty" jsonschema:"Success criteria for issue drafts"` + ManifestID string `json:"manifest_id,omitempty" jsonschema:"Stored evidence manifest ID to reference without copying its claims"` +} + +// DraftOutput contains a rendered contribution draft. +type DraftOutput struct { + OpportunityID string `json:"opportunity_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + RenderedAt string `json:"rendered_at"` + ManifestID string `json:"manifest_id,omitempty" jsonschema:"Referenced stored evidence manifest ID"` +} + +// ExportManifestInput selects bounded local evidence for one contribution manifest. +type ExportManifestInput struct { + OpportunityID string `json:"opportunity_id" jsonschema:"Opportunity ID"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"Managed workspace ID to bind"` + PullRequest *ManifestPullRequestInput `json:"pull_request,omitempty" jsonschema:"Exact stored pull request to include"` +} + +// ManifestPullRequestInput identifies one exact stored pull request. +type ManifestPullRequestInput struct { + Owner string `json:"owner" jsonschema:"GitHub repository owner"` + Repo string `json:"repo" jsonschema:"GitHub repository name"` + Number int `json:"number" jsonschema:"Positive pull request number"` +} + +// ManifestOutput returns the stable identity and full in-toto-shaped statement. +type ManifestOutput struct { + ManifestID string `json:"manifest_id" jsonschema:"Stable sha256-prefixed manifest ID"` + ContentSHA256 string `json:"content_sha256" jsonschema:"Hex SHA-256 of stable manifest content"` + SchemaVersion string `json:"schema_version" jsonschema:"Contribution manifest predicate schema version"` + Status string `json:"status" jsonschema:"Overall completeness status"` + Statement map[string]any `json:"statement" jsonschema:"Full in-toto-shaped evidence statement"` +} + +func (s *Server) prepareContribution(ctx context.Context, _ *mcp.CallToolRequest, in PrepareContributionInput) (*mcp.CallToolResult, DraftOutput, error) { + if _, err := normalizeID("opportunity_id", in.OpportunityID); err != nil { + return nil, DraftOutput{}, err + } + in.Kind = strings.ToLower(strings.TrimSpace(in.Kind)) + if in.Kind != "issue" && in.Kind != "pull_request" { + return nil, DraftOutput{}, errors.New("kind must be issue or pull_request") + } + if in.Kind == "pull_request" && strings.TrimSpace(in.WorkspaceID) == "" { + return nil, DraftOutput{}, errors.New("workspace_id is required for pull_request drafts") + } + if in.Kind == "pull_request" && strings.TrimSpace(in.Approach) == "" { + return nil, DraftOutput{}, errors.New("approach is required for pull_request drafts") + } + if in.Kind == "issue" && (in.WorkspaceID != "" || in.Approach != "" || in.Changes != "" || in.Compatibility != "" || in.Limitations != "" || in.LinkedIssue != "") { + return nil, DraftOutput{}, errors.New("pull-request-only fields are not accepted for issue drafts") + } + if in.Kind == "pull_request" && in.Success != "" { + return nil, DraftOutput{}, errors.New("success is only accepted for issue drafts") + } + operator, ok := s.reader.(Operator) + if !ok { + return nil, DraftOutput{}, errors.New("contribution preparation is not available") + } + out, err := operator.PrepareContribution(ctx, in) + return nil, out, err +} + +func (s *Server) exportManifest(ctx context.Context, _ *mcp.CallToolRequest, in ExportManifestInput) (*mcp.CallToolResult, ManifestOutput, error) { + if _, err := normalizeID("opportunity_id", in.OpportunityID); err != nil { + return nil, ManifestOutput{}, err + } + if in.PullRequest != nil && (strings.TrimSpace(in.PullRequest.Owner) == "" || strings.TrimSpace(in.PullRequest.Repo) == "" || in.PullRequest.Number <= 0) { + return nil, ManifestOutput{}, InvalidArgument("pull_request", "owner, repo, and a positive number are required", map[string]any{"owner": "acme", "repo": "rocket", "number": 42}) + } + operator, ok := s.reader.(Operator) + if !ok { + return nil, ManifestOutput{}, errors.New("manifest export is not available") + } + out, err := operator.ExportManifest(ctx, in) + return nil, out, err +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index abd926e..c376283 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -101,6 +101,7 @@ type Operator interface { DefineValidation(context.Context, DefineValidationInput) (ValidationOutput, error) RunValidation(context.Context, RunValidationInput) (JobReference, error) PrepareContribution(context.Context, PrepareContributionInput) (DraftOutput, error) + ExportManifest(context.Context, ExportManifestInput) (ManifestOutput, error) CancelJobs(context.Context, CancelJobInput) (GetJobsOutput, error) } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 0aac981..b57db61 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -242,6 +242,10 @@ func (*fakeReader) PrepareContribution(_ context.Context, in PrepareContribution return DraftOutput{OpportunityID: in.OpportunityID, Kind: in.Kind, Title: "draft", Body: "body"}, nil } +func (*fakeReader) ExportManifest(_ context.Context, in ExportManifestInput) (ManifestOutput, error) { + return ManifestOutput{ManifestID: "sha256:test", ContentSHA256: "test", SchemaVersion: "contribution-evidence.v1", Status: "incomplete", Statement: map[string]any{"opportunity_id": in.OpportunityID}}, nil +} + func (*fakeReader) CancelJobs(_ context.Context, in CancelJobInput) (GetJobsOutput, error) { items := make([]BatchItem[GetJobOutput], len(in.IDs)) for i, id := range in.IDs { diff --git a/internal/mcpserver/v1.go b/internal/mcpserver/v1.go index 3d12215..a5d9d6a 100644 --- a/internal/mcpserver/v1.go +++ b/internal/mcpserver/v1.go @@ -260,41 +260,21 @@ type ValidationObservationContract struct { // ValidationOutput is the stable MCP representation of a validation definition. type ValidationOutput struct { - ID string `json:"id"` - InvestigationID string `json:"investigation_id"` - Kind string `json:"kind"` - Command []string `json:"command"` - WorkingDir string `json:"working_dir"` - BaseWorkingDir string `json:"base_working_dir,omitempty"` - CandidateDir string `json:"candidate_dir,omitempty"` - Env []string `json:"environment_allowlist,omitempty"` - Timeout string `json:"timeout,omitempty"` - MaxOutputBytes int64 `json:"max_output_bytes,omitempty"` - Observation *ValidationObservationContract `json:"observation,omitempty"` - CreatedAt string `json:"created_at"` -} - -// PrepareContributionInput renders a local issue or pull-request draft. -type PrepareContributionInput struct { - OpportunityID string `json:"opportunity_id" jsonschema:"Opportunity ID"` - Kind string `json:"kind" jsonschema:"Contribution kind: issue or pull_request"` - WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"Workspace ID for pull_request drafts"` - Approach string `json:"approach,omitempty" jsonschema:"Approach summary for pull requests"` - Changes string `json:"changes,omitempty" jsonschema:"Changes summary for pull requests"` - Compatibility string `json:"compatibility,omitempty" jsonschema:"Compatibility notes for pull requests"` - Limitations string `json:"limitations,omitempty" jsonschema:"Limitations for pull requests"` - LinkedIssue string `json:"linked_issue,omitempty" jsonschema:"Linked issue for pull requests"` - Guidance string `json:"guidance,omitempty" jsonschema:"Optional guidance to include"` - Success string `json:"success,omitempty" jsonschema:"Success criteria for issue drafts"` -} - -// DraftOutput contains a rendered contribution draft. -type DraftOutput struct { - OpportunityID string `json:"opportunity_id"` - Kind string `json:"kind"` - Title string `json:"title"` - Body string `json:"body"` - RenderedAt string `json:"rendered_at"` + ID string `json:"id"` + InvestigationID string `json:"investigation_id"` + Kind string `json:"kind"` + Command []string `json:"command"` + WorkingDir string `json:"working_dir"` + BaseWorkingDir string `json:"base_working_dir,omitempty"` + CandidateDir string `json:"candidate_dir,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"Managed workspace ID used for both run kinds"` + BaseWorkspaceID string `json:"base_workspace_id,omitempty" jsonschema:"Managed base workspace ID"` + CandidateWorkspaceID string `json:"candidate_workspace_id,omitempty" jsonschema:"Managed candidate workspace ID"` + Env []string `json:"environment_allowlist,omitempty"` + Timeout string `json:"timeout,omitempty"` + MaxOutputBytes int64 `json:"max_output_bytes,omitempty"` + Observation *ValidationObservationContract `json:"observation,omitempty"` + CreatedAt string `json:"created_at"` } // CancelJobInput selects durable jobs for bounded, persisted cancellation. @@ -417,6 +397,12 @@ func (s *Server) registerV1() { setEnum(schema, "kind", "issue", "pull_request") }), output: outputSchema[DraftOutput]("Newly rendered and persisted local contribution draft."), handler: s.prepareContribution, }) + addCatalogTool(s, catalogTool[ExportManifestInput, ManifestOutput]{ + name: ToolExportManifest, title: "Export contribution evidence manifest", + description: "Generate and persist a deterministic local evidence manifest from SQLite and an optional managed workspace snapshot. It may run non-mutating Git commands but never contacts GitHub; sync exact GitHub facets separately before export.", + annotations: localWrite, supportedBy: supports[Operator], input: inputSchema[ExportManifestInput](nil), + output: outputSchema[ManifestOutput]("Digest-bound contribution evidence statement with explicit completeness gaps."), handler: s.exportManifest, + }) addCatalogTool(s, catalogTool[CancelJobInput, GetJobsOutput]{ name: ToolCancelJob, title: "Cancel durable jobs in one batch", description: "Cancel up to 100 durable jobs in order with isolated item outcomes; repeated cancellation is safe.", @@ -709,34 +695,6 @@ func (s *Server) defineValidation(ctx context.Context, _ *mcp.CallToolRequest, i return nil, out, err } -func (s *Server) prepareContribution(ctx context.Context, _ *mcp.CallToolRequest, in PrepareContributionInput) (*mcp.CallToolResult, DraftOutput, error) { - if _, err := normalizeID("opportunity_id", in.OpportunityID); err != nil { - return nil, DraftOutput{}, err - } - in.Kind = strings.ToLower(strings.TrimSpace(in.Kind)) - if in.Kind != "issue" && in.Kind != "pull_request" { - return nil, DraftOutput{}, errors.New("kind must be issue or pull_request") - } - if in.Kind == "pull_request" && strings.TrimSpace(in.WorkspaceID) == "" { - return nil, DraftOutput{}, errors.New("workspace_id is required for pull_request drafts") - } - if in.Kind == "pull_request" && strings.TrimSpace(in.Approach) == "" { - return nil, DraftOutput{}, errors.New("approach is required for pull_request drafts") - } - if in.Kind == "issue" && (in.WorkspaceID != "" || in.Approach != "" || in.Changes != "" || in.Compatibility != "" || in.Limitations != "" || in.LinkedIssue != "") { - return nil, DraftOutput{}, errors.New("pull-request-only fields are not accepted for issue drafts") - } - if in.Kind == "pull_request" && in.Success != "" { - return nil, DraftOutput{}, errors.New("success is only accepted for issue drafts") - } - operator, ok := s.reader.(Operator) - if !ok { - return nil, DraftOutput{}, errors.New("contribution preparation is not available") - } - out, err := operator.PrepareContribution(ctx, in) - return nil, out, err -} - func (s *Server) cancelJob(ctx context.Context, _ *mcp.CallToolRequest, in CancelJobInput) (*mcp.CallToolResult, GetJobsOutput, error) { if len(in.IDs) < 1 || len(in.IDs) > 100 { return nil, GetJobsOutput{}, errors.New("ids must contain 1 to 100 items") diff --git a/internal/workspace/snapshot.go b/internal/workspace/snapshot.go new file mode 100644 index 0000000..d9dd16d --- /dev/null +++ b/internal/workspace/snapshot.go @@ -0,0 +1,319 @@ +package workspace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const ( + // WorkspaceSnapshotVersion identifies the composite candidate identity contract. + WorkspaceSnapshotVersion = "workspace-snapshot.v1" + maxSnapshotUntracked = 1000 + maxSnapshotFileBytes = 16 << 20 + maxSnapshotTotalBytes = 64 << 20 + maxSnapshotCommits = 100 + maxSnapshotSubmodules = 100 +) + +// ContentDigest identifies bounded content without embedding it. +type ContentDigest struct { + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +// UntrackedResource identifies one untracked path and its content when bounded. +type UntrackedResource struct { + Path string `json:"path"` + Kind string `json:"kind"` + Mode uint32 `json:"mode"` + SHA256 string `json:"sha256,omitempty"` + Bytes int64 `json:"bytes,omitempty"` +} + +// SubmoduleIdentity binds the index and checked-out identities of a submodule. +type SubmoduleIdentity struct { + Path string `json:"path"` + IndexSHA string `json:"index_sha"` + HeadSHA string `json:"head_sha,omitempty"` + Dirty *bool `json:"dirty,omitempty"` +} + +// CommitSummary is one bounded commit header between base and HEAD. +type CommitSummary struct { + SHA string `json:"sha"` + Subject string `json:"subject"` +} + +// SnapshotGap explicitly records candidate content that could not be bound. +type SnapshotGap struct { + Code string `json:"code"` + Path string `json:"path,omitempty"` + Reason string `json:"reason"` +} + +// Snapshot is a deterministic composite identity for a managed worktree. +type Snapshot struct { + Version string `json:"version"` + Ownership string `json:"ownership"` + BaseSHA string `json:"base_sha,omitempty"` + HeadSHA string `json:"head_sha"` + MergeBase string `json:"merge_base,omitempty"` + Staged ContentDigest `json:"staged"` + Unstaged ContentDigest `json:"unstaged"` + Untracked []UntrackedResource `json:"untracked"` + Submodules []SubmoduleIdentity `json:"submodules"` + ChangedFiles []string `json:"changed_files"` + Commits []CommitSummary `json:"commits"` + CommitTotal int `json:"commit_total"` + CommitsTruncated bool `json:"commits_truncated"` + Complete bool `json:"complete"` + Gaps []SnapshotGap `json:"gaps"` + SHA256 string `json:"sha256"` +} + +// SnapshotByPath derives a bounded, no-hook identity for a managed worktree. +func (m *Manager) SnapshotByPath(ctx context.Context, path, baseSHA, mergeBase string) (Snapshot, error) { + managed, err := m.managedPath(path) + if err != nil { + return Snapshot{}, err + } + snapshot := Snapshot{Version: WorkspaceSnapshotVersion, Ownership: "managed", BaseSHA: strings.TrimSpace(baseSHA), MergeBase: strings.TrimSpace(mergeBase), Complete: true} + if snapshot.HeadSHA, err = trimmedGit(m.git(ctx, managed, "rev-parse", "HEAD")); err != nil { + return Snapshot{}, fmt.Errorf("resolve workspace HEAD: %w", err) + } + staged, err := m.git(ctx, managed, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "--") + if err != nil { + return Snapshot{}, fmt.Errorf("read staged diff: %w", err) + } + unstaged, err := m.git(ctx, managed, "diff", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "--") + if err != nil { + return Snapshot{}, fmt.Errorf("read unstaged diff: %w", err) + } + snapshot.Staged, snapshot.Unstaged = digestString(staged), digestString(unstaged) + if snapshot.ChangedFiles, err = m.ChangedFilesByPath(ctx, managed, snapshot.BaseSHA); err != nil { + return Snapshot{}, err + } + sort.Strings(snapshot.ChangedFiles) + if err := m.addUntrackedSnapshot(ctx, managed, &snapshot); err != nil { + return Snapshot{}, err + } + if err := m.addSubmoduleSnapshot(ctx, managed, &snapshot); err != nil { + return Snapshot{}, err + } + if err := m.addCommitSnapshot(ctx, managed, &snapshot); err != nil { + return Snapshot{}, err + } + sort.Slice(snapshot.Gaps, func(i, j int) bool { + if snapshot.Gaps[i].Code != snapshot.Gaps[j].Code { + return snapshot.Gaps[i].Code < snapshot.Gaps[j].Code + } + return snapshot.Gaps[i].Path < snapshot.Gaps[j].Path + }) + snapshot.Complete = len(snapshot.Gaps) == 0 + snapshot.SHA256, err = snapshotDigest(snapshot) + if err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func (m *Manager) addUntrackedSnapshot(ctx context.Context, managed string, snapshot *Snapshot) error { + ignored, err := m.git(ctx, managed, "ls-files", "--others", "--ignored", "--exclude-standard", "-z") + if err != nil { + return fmt.Errorf("list ignored files: %w", err) + } + if ignoredPaths := splitNUL(ignored); len(ignoredPaths) > 0 { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "ignored_content_unbound", Reason: fmt.Sprintf("%d ignored paths are present and may affect validation", len(ignoredPaths))}) + } + out, err := m.git(ctx, managed, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return fmt.Errorf("list untracked files: %w", err) + } + paths := splitNUL(out) + sort.Strings(paths) + if len(paths) > maxSnapshotUntracked { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_paths_truncated", Reason: fmt.Sprintf("%d paths exceed the %d-path bound", len(paths), maxSnapshotUntracked)}) + paths = paths[:maxSnapshotUntracked] + } + root, err := os.OpenRoot(managed) + if err != nil { + return fmt.Errorf("open workspace root: %w", err) + } + var total int64 + for _, gitPath := range paths { + localPath := filepath.FromSlash(gitPath) + if !filepath.IsLocal(localPath) { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_path_invalid", Path: gitPath, Reason: "Git returned a path outside the workspace"}) + continue + } + info, err := root.Lstat(localPath) + if err != nil { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_unavailable", Path: gitPath, Reason: err.Error()}) + continue + } + entry := UntrackedResource{Path: gitPath, Mode: uint32(info.Mode().Perm()), Bytes: info.Size()} + switch { + case info.Mode().IsRegular(): + entry.Kind = "file" + if info.Size() > maxSnapshotFileBytes || total+info.Size() > maxSnapshotTotalBytes { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_content_omitted", Path: gitPath, Reason: "content exceeds the snapshot byte bound"}) + break + } + digest, bytesRead, err := digestRootFile(root, localPath, info.Size()) + if err != nil { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_content_changed", Path: gitPath, Reason: err.Error()}) + break + } + entry.SHA256, entry.Bytes, total = digest, bytesRead, total+bytesRead + case info.Mode()&os.ModeSymlink != 0: + entry.Kind = "symlink" + target, err := root.Readlink(localPath) + if err != nil { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_symlink_unavailable", Path: gitPath, Reason: err.Error()}) + break + } + entry.SHA256 = digestBytes([]byte(target)) + entry.Bytes = int64(len(target)) + default: + entry.Kind = "unsupported" + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "untracked_type_unsupported", Path: gitPath, Reason: info.Mode().String()}) + } + snapshot.Untracked = append(snapshot.Untracked, entry) + } + if err := root.Close(); err != nil { + return fmt.Errorf("close workspace root: %w", err) + } + return nil +} + +func (m *Manager) addSubmoduleSnapshot(ctx context.Context, managed string, snapshot *Snapshot) error { + out, err := m.git(ctx, managed, "ls-files", "--stage", "-z") + if err != nil { + return fmt.Errorf("list index entries: %w", err) + } + for _, record := range splitNUL(out) { + metadata, gitPath, ok := strings.Cut(record, "\t") + fields := strings.Fields(metadata) + if !ok || len(fields) != 3 || fields[0] != "160000" { + continue + } + if len(snapshot.Submodules) == maxSnapshotSubmodules { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "submodules_truncated", Reason: fmt.Sprintf("more than %d submodules are present", maxSnapshotSubmodules)}) + break + } + entry := SubmoduleIdentity{Path: gitPath, IndexSHA: fields[1]} + localPath := filepath.FromSlash(gitPath) + if !filepath.IsLocal(localPath) { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "submodule_path_invalid", Path: gitPath, Reason: "Git returned a path outside the workspace"}) + snapshot.Submodules = append(snapshot.Submodules, entry) + continue + } + submodulePath := filepath.Join(managed, localPath) + head, headErr := trimmedGit(m.git(ctx, submodulePath, "rev-parse", "HEAD")) + if headErr != nil { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "submodule_head_unavailable", Path: gitPath, Reason: headErr.Error()}) + } else { + entry.HeadSHA = head + status, statusErr := m.git(ctx, submodulePath, "status", "--porcelain=v2", "--untracked-files=normal") + if statusErr != nil { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "submodule_status_unavailable", Path: gitPath, Reason: statusErr.Error()}) + } else { + dirty := status != "" + entry.Dirty = &dirty + if dirty { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "submodule_content_unbound", Path: gitPath, Reason: "dirty submodule content is not included in the parent snapshot"}) + } + } + } + snapshot.Submodules = append(snapshot.Submodules, entry) + } + sort.Slice(snapshot.Submodules, func(i, j int) bool { return snapshot.Submodules[i].Path < snapshot.Submodules[j].Path }) + return nil +} + +func (m *Manager) addCommitSnapshot(ctx context.Context, managed string, snapshot *Snapshot) error { + if snapshot.BaseSHA == "" { + return nil + } + countText, err := trimmedGit(m.git(ctx, managed, "rev-list", "--count", snapshot.BaseSHA+"..HEAD")) + if err != nil { + return fmt.Errorf("count workspace commits: %w", err) + } + snapshot.CommitTotal, err = strconv.Atoi(countText) + if err != nil { + return fmt.Errorf("parse workspace commit count %q: %w", countText, err) + } + out, err := m.git(ctx, managed, "log", "-z", "--max-count="+strconv.Itoa(maxSnapshotCommits), "--format=%H%x00%s", snapshot.BaseSHA+"..HEAD") + if err != nil { + return fmt.Errorf("read workspace commits: %w", err) + } + parts := splitNUL(out) + if len(parts)%2 != 0 { + return errors.New("parse workspace commits: Git returned an incomplete record") + } + for i := 0; i < len(parts); i += 2 { + snapshot.Commits = append(snapshot.Commits, CommitSummary{SHA: parts[i], Subject: parts[i+1]}) + } + snapshot.CommitsTruncated = snapshot.CommitTotal > len(snapshot.Commits) + if snapshot.CommitsTruncated { + snapshot.Gaps = append(snapshot.Gaps, SnapshotGap{Code: "commits_truncated", Reason: fmt.Sprintf("%d commits exceed the %d-commit metadata bound", snapshot.CommitTotal, maxSnapshotCommits)}) + } + return nil +} + +func digestRootFile(root *os.Root, path string, expected int64) (digest string, bytesRead int64, err error) { + file, err := root.Open(path) + if err != nil { + return "", 0, err + } + defer func() { err = errors.Join(err, file.Close()) }() + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(file, expected+1)) + if err != nil { + return "", n, err + } + if n != expected { + return "", n, fmt.Errorf("file size changed while hashing: got %d bytes, expected %d", n, expected) + } + return hex.EncodeToString(hash.Sum(nil)), n, nil +} + +func snapshotDigest(snapshot Snapshot) (string, error) { + snapshot.SHA256 = "" + payload, err := json.Marshal(snapshot) + if err != nil { + return "", fmt.Errorf("encode workspace snapshot: %w", err) + } + return digestBytes(payload), nil +} + +func digestString(value string) ContentDigest { + return ContentDigest{SHA256: digestBytes([]byte(value)), Bytes: int64(len(value))} +} + +func digestBytes(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func splitNUL(value string) []string { + parts := strings.Split(value, "\x00") + if len(parts) > 0 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + return parts +} + +func trimmedGit(value string, err error) (string, error) { + return strings.TrimSpace(value), err +} diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index fec7831..9f920e8 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -219,6 +219,78 @@ func TestManager_CreateAndInspect(t *testing.T) { } } +func TestWorkspaceSnapshotBindsStagedUnstagedAndUntrackedContent(t *testing.T) { + ctx := context.Background() + remote, baseSHA, _ := setupRemote(t) + mgr := newManager(t) + if err := mgr.Clone(ctx, remote, "origin"); err != nil { + t.Fatal(err) + } + ws, err := mgr.Create(ctx, "origin", "master", "feature", "snapshot") + if err != nil { + t.Fatal(err) + } + initial, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + if !initial.Complete || initial.SHA256 == "" || initial.HeadSHA != ws.CandidateSHA || initial.CommitTotal != 1 { + t.Fatalf("initial snapshot = %+v", initial) + } + + writeFile(t, filepath.Join(ws.Path, "feature.txt"), "staged") + runGit(t, ws.Path, "add", "feature.txt") + staged, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + if staged.SHA256 == initial.SHA256 || staged.Staged.SHA256 == initial.Staged.SHA256 { + t.Fatal("staged mutation did not change snapshot identity") + } + + writeFile(t, filepath.Join(ws.Path, "feature.txt"), "unstaged") + unstaged, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + if unstaged.SHA256 == staged.SHA256 || unstaged.Unstaged.SHA256 == staged.Unstaged.SHA256 { + t.Fatal("unstaged mutation did not change snapshot identity") + } + + untrackedPath := filepath.Join(ws.Path, "untracked.txt") + writeFile(t, untrackedPath, "first") + untracked, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + writeFile(t, untrackedPath, "second") + replaced, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + if len(untracked.Untracked) != 1 || untracked.SHA256 == replaced.SHA256 || untracked.Untracked[0].SHA256 == replaced.Untracked[0].SHA256 { + t.Fatalf("untracked replacement was not bound: before=%+v after=%+v", untracked.Untracked, replaced.Untracked) + } + writeFile(t, filepath.Join(ws.Path, ".gitignore"), "ignored.log\n") + writeFile(t, filepath.Join(ws.Path, "ignored.log"), "may affect validation") + withIgnored, err := mgr.SnapshotByPath(ctx, ws.Path, baseSHA, baseSHA) + if err != nil { + t.Fatal(err) + } + if withIgnored.Complete || !snapshotHasGap(withIgnored.Gaps, "ignored_content_unbound") { + t.Fatalf("ignored content was not exposed as incomplete: %+v", withIgnored.Gaps) + } +} + +func snapshotHasGap(gaps []SnapshotGap, code string) bool { + for _, gap := range gaps { + if gap.Code == code { + return true + } + } + return false +} + func TestManager_Fetch(t *testing.T) { t.Parallel() ctx := context.Background()