Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,21 @@ gitcontribute evidence add --type=manual_observation --relation=supporting \
--opportunity <opportunity-id>
```

Export a digest-bound contribution evidence manifest from stored facts and an
optional managed workspace:

```sh
gitcontribute export manifest <opportunity-id> \
--workspace <workspace-id> \
--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
Expand Down Expand Up @@ -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 <file>`.

</details>
Expand Down
17 changes: 17 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 27 additions & 3 deletions internal/app/contribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
Loading
Loading