Skip to content

Commit ac1a0fc

Browse files
authored
feat: start investigations from threads (#9)
* feat: start investigations from threads * refactor: keep investigation CLI types focused
1 parent 82bafec commit ac1a0fc

26 files changed

Lines changed: 1201 additions & 59 deletions

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,7 @@ a hypothesis, and check for duplicate or competing work before committing time.
139139
gitcontribute dossier build owner/repo
140140
gitcontribute research brief issue:owner/repo#42 --format markdown
141141
gitcontribute health owner/repo --json
142-
gitcontribute investigation start owner/repo --json
143-
gitcontribute hypothesis add --title="Fix retry timeout" \
144-
--description="Reproduce and isolate the timeout." \
145-
--category=bug <investigation-id>
142+
gitcontribute investigation start-thread issue:owner/repo#42 --json
146143
gitcontribute duplicates check <hypothesis-id>
147144
gitcontribute collisions check <hypothesis-id>
148145
```
@@ -397,6 +394,30 @@ pagination; `search all` and lens-ranked searches do not.
397394
<details>
398395
<summary><strong>Investigations, evidence, tracking, and collections</strong></summary>
399396

397+
Start an investigation and its initial hypothesis from one exact stored thread
398+
revision:
399+
400+
```sh
401+
gitcontribute investigation start-thread issue:owner/repo#42 --json
402+
```
403+
404+
This is a local-write operation with no network or process execution. The
405+
investigation saves the immutable observation ID, source update time, sequence,
406+
and source reference used for its title and bounded description. Repeating the
407+
command returns the existing open pair with `"created": false`; it never updates
408+
that baseline silently. The seed category is `other` because the command does
409+
not infer scope or defect type from untrusted issue text.
410+
411+
The manual two-command path remains available when no stored thread should be
412+
the baseline:
413+
414+
```sh
415+
gitcontribute investigation start owner/repo --json
416+
gitcontribute hypothesis add --title="Fix retry timeout" \
417+
--description="Reproduce and isolate the timeout." \
418+
--category=bug <investigation-id>
419+
```
420+
400421
Record supporting or contradicting evidence:
401422

402423
```sh

docs/architecture.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ application and domain packages expose product-owned values and interfaces.
4444
| Capability | Examples | Network | Local write | Process execution | GitHub mutation |
4545
| --- | --- | ---: | ---: | ---: | ---: |
4646
| Corpus read | search, health, dossier show, research brief, MCP resources | no | no | no | no |
47-
| Corpus write | investigations, evidence, lenses, tracking | no | yes | no | no |
47+
| Corpus write | investigations, start-thread, evidence, lenses, tracking | no | yes | no | no |
4848
| GitHub read | sync, crawl, hydrate | yes | yes | no | no |
4949
| Git acquisition | acquire, workspace create | remote-dependent | yes | `git` only | no |
5050
| Validation | validation run with explicit execution | no by default | yes | yes | no |
@@ -159,6 +159,14 @@ required facets are incomplete. A research-brief section must carry a source
159159
reference or an explicit unknown reason; untrusted thread text remains data and
160160
cannot grant an adapter additional authority.
161161

162+
Starting an investigation from a thread is an explicit corpus-write capability.
163+
The investigation and seed hypothesis are committed in one transaction and
164+
carry the exact thread observation ID, source timestamp, and observation
165+
sequence used as their baseline. A partial unique origin key returns the
166+
existing open pair on repeated or concurrent requests; later thread projections
167+
do not rewrite that baseline, and a closed investigation releases the origin
168+
for a deliberate new start.
169+
162170
## Schema changes
163171

164172
Migrations are embedded from `internal/corpus/migrations` and applied by Goose

internal/app/control.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (s *Service) Metadata(ctx context.Context) (*cli.MetadataResult, error) {
4444
capabilities := []string{
4545
"archive", "clustering", "collections", "contribution-radar", "dossiers", "evidence",
4646
"github-read", "investigations", "local-search", "mcp-stdio",
47-
"thread-research-brief", "validation", "workspaces",
47+
"thread-investigation-start", "thread-research-brief", "validation", "workspaces",
4848
}
4949
sort.Strings(capabilities)
5050
return &cli.MetadataResult{
@@ -58,12 +58,13 @@ func (s *Service) Metadata(ctx context.Context) (*cli.MetadataResult, error) {
5858
CorpusPath: cfg.Database,
5959
Capabilities: capabilities,
6060
Features: map[string]bool{
61-
"contribution_radar": true,
62-
"github_mutations": false,
63-
"mcp_stdio": true,
64-
"semantic_search": false,
65-
"thread_research": true,
66-
"validation_exec": true,
61+
"contribution_radar": true,
62+
"github_mutations": false,
63+
"mcp_stdio": true,
64+
"semantic_search": false,
65+
"thread_investigation": true,
66+
"thread_research": true,
67+
"validation_exec": true,
6768
},
6869
}, nil
6970
}

internal/app/control_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ func TestMetadataIsLocalAndDoesNotCreateCorpus(t *testing.T) {
4444
if !result.Features["thread_research"] || !containsString(result.Capabilities, "thread-research-brief") {
4545
t.Fatalf("thread research capability missing from metadata: %+v", result)
4646
}
47+
if !result.Features["thread_investigation"] || !containsString(result.Capabilities, "thread-investigation-start") {
48+
t.Fatalf("thread investigation capability missing from metadata: %+v", result)
49+
}
4750
if _, err := os.Stat(result.CorpusPath); !os.IsNotExist(err) {
4851
t.Fatalf("metadata created corpus %q: %v", result.CorpusPath, err)
4952
}

internal/app/investigation.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,11 @@ func (s *Service) investigationSvc(ctx context.Context) (*investigation.Service,
225225

226226
func investigationResult(inv *investigation.Investigation) *cli.InvestigationResult {
227227
return &cli.InvestigationResult{
228-
ID: inv.ID,
229-
Repo: cli.RepoRef{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo},
230-
CommitSHA: inv.CommitSHA,
231-
Lens: inv.Lens,
232-
Status: string(inv.Status),
233-
CreatedAt: formatTime(inv.CreatedAt),
234-
UpdatedAt: formatTime(inv.UpdatedAt),
228+
ID: inv.ID, Repo: cli.RepoRef{Owner: inv.Repo.Owner, Repo: inv.Repo.Repo},
229+
CommitSHA: inv.CommitSHA, Lens: inv.Lens, Status: string(inv.Status),
230+
ThreadBaseline: threadBaselineResult(inv.ThreadBaseline), SeedHypothesisID: inv.SeedHypothesisID,
231+
AuditTrail: workflowAuditResults(inv.AuditTrail),
232+
CreatedAt: formatTime(inv.CreatedAt), UpdatedAt: formatTime(inv.UpdatedAt),
235233
}
236234
}
237235

@@ -243,6 +241,9 @@ func hypothesisResult(h *investigation.Hypothesis) *cli.HypothesisResult {
243241
Description: h.Description,
244242
Category: string(h.Category),
245243
Status: string(h.Status),
244+
SourceRefs: workflowSourceRefResults(h.SourceRefs),
245+
Links: workflowLinkResults(h.Links),
246+
AuditTrail: workflowAuditResults(h.AuditTrail),
246247
CreatedAt: formatTime(h.CreatedAt),
247248
UpdatedAt: formatTime(h.UpdatedAt),
248249
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"unicode/utf8"
9+
10+
"github.com/morluto/gitcontribute/internal/cli"
11+
"github.com/morluto/gitcontribute/internal/corpus"
12+
"github.com/morluto/gitcontribute/internal/domain"
13+
"github.com/morluto/gitcontribute/internal/investigation"
14+
"github.com/morluto/gitcontribute/internal/research"
15+
)
16+
17+
const maxThreadSeedDescription = 5000
18+
19+
// StartInvestigationFromThread atomically creates an investigation and seed
20+
// hypothesis from one exact local observation. It performs no network access
21+
// or process execution.
22+
func (s *Service) StartInvestigationFromThread(ctx context.Context, requested research.ThreadRef) (*cli.ThreadInvestigationResult, error) {
23+
if err := requested.Validate(); err != nil {
24+
return nil, err
25+
}
26+
if err := ctx.Err(); err != nil {
27+
return nil, err
28+
}
29+
c, err := s.openCorpus(ctx)
30+
if err != nil {
31+
return nil, err
32+
}
33+
repo, err := c.GetRepository(ctx, requested.Repo.Owner, requested.Repo.Repo)
34+
if err != nil {
35+
return nil, fmt.Errorf("get thread investigation repository: %w", err)
36+
}
37+
if repo == nil {
38+
return nil, cli.NewCLIError(cli.ExitNotFound, fmt.Errorf("%w: %s", errRepositoryNotFound, requested.Repo))
39+
}
40+
thread, err := c.GetThreadByNumber(ctx, repo.ID, requested.Number)
41+
if err != nil {
42+
return nil, fmt.Errorf("get thread investigation source: %w", err)
43+
}
44+
if thread == nil {
45+
return nil, cli.NewCLIError(cli.ExitNotFound, fmt.Errorf("%w: %s#%d", research.ErrThreadNotFound, requested.Repo, requested.Number))
46+
}
47+
storedKind := domain.ThreadKind(thread.Kind)
48+
if requested.Kind != "" && requested.Kind != storedKind {
49+
return nil, cli.NewCLIError(cli.ExitNotFound, research.KindMismatchError(requested.Kind, storedKind))
50+
}
51+
resolved := research.ThreadRef{Repo: requested.Repo, Kind: storedKind, Number: requested.Number}
52+
observation, err := c.GetThreadObservationRevision(ctx, thread.ID, thread.SourceUpdatedAt, thread.ObservationSequence)
53+
if err != nil {
54+
if errors.Is(err, corpus.ErrThreadObservationRevisionNotFound) {
55+
return nil, fmt.Errorf("%w: projection %s has no matching observation revision", investigation.ErrInvalidThreadBaseline, resolved)
56+
}
57+
return nil, fmt.Errorf("read thread investigation baseline: %w", err)
58+
}
59+
description, truncated := boundedThreadSeedDescription(thread.Body)
60+
source := domain.SourceRef{
61+
Source: "github:rest", URL: fmt.Sprintf("https://api.github.com/repos/%s/issues/%d", resolved.Repo, resolved.Number),
62+
ObservedAt: observation.ObservedAt, AsOf: observation.SourceUpdatedAt,
63+
}
64+
result, err := investigation.NewService(c, c).StartFromThread(ctx, investigation.StartFromThreadInput{
65+
Baseline: investigation.ThreadBaseline{
66+
Repo: resolved.Repo, Kind: resolved.Kind, Number: resolved.Number,
67+
ObservationID: observation.ID, SourceUpdatedAt: observation.SourceUpdatedAt,
68+
ObservationSequence: observation.ObservationSequence, ObservedAt: observation.ObservedAt,
69+
Source: source, DescriptionTruncated: truncated,
70+
},
71+
Title: thread.Title, Description: description,
72+
})
73+
if err != nil {
74+
if errors.Is(err, investigation.ErrInvalidThreadBaseline) {
75+
return nil, err
76+
}
77+
return nil, fmt.Errorf("start investigation from thread: %w", err)
78+
}
79+
return &cli.ThreadInvestigationResult{
80+
Created: result.Created, Investigation: investigationResult(result.Investigation),
81+
Hypothesis: hypothesisResult(result.Hypothesis),
82+
}, nil
83+
}
84+
85+
func boundedThreadSeedDescription(value string) (string, bool) {
86+
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
87+
if utf8.RuneCountInString(value) <= maxThreadSeedDescription {
88+
return value, false
89+
}
90+
runes := []rune(value)
91+
return strings.TrimSpace(string(runes[:maxThreadSeedDescription])) + "…", true
92+
}
93+
94+
func threadBaselineResult(value *investigation.ThreadBaseline) *cli.ThreadBaselineResult {
95+
if value == nil {
96+
return nil
97+
}
98+
return &cli.ThreadBaselineResult{
99+
Ref: value.Ref(), Repository: value.Repo.String(), Kind: string(value.Kind), Number: value.Number,
100+
ObservationID: value.ObservationID, SourceUpdatedAt: formatTime(value.SourceUpdatedAt),
101+
ObservationSequence: value.ObservationSequence, ObservedAt: formatTime(value.ObservedAt),
102+
Source: workflowSourceRefResult(value.Source), DescriptionTruncated: value.DescriptionTruncated,
103+
}
104+
}
105+
106+
func workflowSourceRefResult(value domain.SourceRef) cli.WorkflowSourceRefResult {
107+
return cli.WorkflowSourceRefResult{
108+
Source: value.Source, URL: value.URL, CommitSHA: value.CommitSHA,
109+
ObservedAt: formatTime(value.ObservedAt), AsOf: formatTime(value.AsOf),
110+
}
111+
}
112+
113+
func workflowAuditResults(values []investigation.StatusChange) []cli.WorkflowAuditResult {
114+
if len(values) == 0 {
115+
return nil
116+
}
117+
result := make([]cli.WorkflowAuditResult, len(values))
118+
for index, value := range values {
119+
result[index] = cli.WorkflowAuditResult{
120+
From: value.From, To: value.To, Rationale: value.Rationale, At: formatTime(value.At),
121+
}
122+
}
123+
return result
124+
}
125+
126+
func workflowSourceRefResults(values []domain.SourceRef) []cli.WorkflowSourceRefResult {
127+
if len(values) == 0 {
128+
return nil
129+
}
130+
result := make([]cli.WorkflowSourceRefResult, len(values))
131+
for index, value := range values {
132+
result[index] = workflowSourceRefResult(value)
133+
}
134+
return result
135+
}
136+
137+
func workflowLinkResults(values []investigation.Link) []cli.WorkflowLinkResult {
138+
if len(values) == 0 {
139+
return nil
140+
}
141+
result := make([]cli.WorkflowLinkResult, len(values))
142+
for index, value := range values {
143+
result[index] = cli.WorkflowLinkResult{Kind: value.Kind, Ref: value.Ref, Source: workflowSourceRefResult(value.Source)}
144+
}
145+
return result
146+
}

0 commit comments

Comments
 (0)