Skip to content

Commit 3944e77

Browse files
committed
feat(mcp): complete scalable PR health contracts
1 parent db38478 commit 3944e77

37 files changed

Lines changed: 3592 additions & 386 deletions

docs/architecture.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,20 @@ The corpus separates source history from convenient current state:
7777
revision it represents.
7878

7979
Authored pull requests use the ordinary repository and thread projections.
80-
REST `pr_details` and `pr_reviews` facets add mergeability, revision, and review
81-
facts for the portfolio view. Checks, unresolved review threads, detailed merge
82-
state, and merge-queue state are not currently acquired and therefore remain
83-
explicitly unavailable rather than inferred.
80+
REST `pr_details` and `pr_reviews` facets are combined with typed GraphQL
81+
facets for checks, unresolved review threads, detailed merge state, merge queue,
82+
closing issues, and changed files. Each facet has independent coverage; an
83+
incomplete refresh preserves the previous complete child snapshot but marks
84+
the newer coverage incomplete. Offline portfolio reads therefore return
85+
`unknown` instead of treating missing checks as passing or missing overlap
86+
signals as no overlap.
87+
88+
Portfolio relationships and derived resolution records are local product
89+
contracts. Their normalized snapshots carry rule versions and exact source
90+
observation references. Explicit timeline events may produce a resolution;
91+
closing-issue relationships remain relationship evidence until completion is
92+
independently observed. Lexical similarity alone never becomes a root-cause
93+
claim. Corpus portfolio and resolution reads perform no network access.
8494

8595
Repository and thread projections use this ordering:
8696

@@ -128,6 +138,10 @@ Terminal states do not transition again. Cancellation is first persisted, then
128138
delivered to an in-process worker directly or observed by its polling loop from
129139
another process. Reconciliation uses an immediate SQLite transaction so a
130140
heartbeat cannot interleave between the liveness read and stale-owner update.
141+
MCP job reads expose structured phase, completed-item, total-item, percentage,
142+
and retry-delay fields. Batch reads and cancellation preserve input order and
143+
isolate per-item failures; free-form durable event text is not an MCP progress
144+
contract.
131145

132146
### Bounded batch operations
133147

docs/mcp-scalable-workflows.md

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,27 @@ github.get_authenticated_identity
3636
-> github.sync_authored_pull_requests -> jobs.get
3737
-> github.sync_pull_request_status -> jobs.get
3838
-> corpus.list_pull_request_portfolio
39+
-> corpus.find_portfolio_overlaps
3940
```
4041

41-
The current status adapter stores REST pull-request details and reviews. The
42-
portfolio can classify merged, closed-unmerged, conflicted, changes-requested,
43-
approved, stale, awaiting-review, and unknown states from those facts.
44-
45-
The following facts are not currently fetched and remain explicit in coverage
46-
and reason fields:
47-
48-
- check rollups and failing checks;
49-
- unresolved review conversations;
50-
- detailed merge state and merge queue position;
51-
- closing issue links and cross-portfolio overlap.
42+
The status adapter stores REST pull-request details and reviews plus typed,
43+
independently covered GraphQL snapshots for checks, unresolved review threads,
44+
detailed merge state, merge queue, closing issues, and changed files. The
45+
offline portfolio derives deterministic attention states only from complete
46+
facets. A null or still-computing mergeability value remains unknown.
47+
48+
`corpus.find_portfolio_overlaps` compares up to 50 stored candidates with
49+
authored pull requests using complete normalized changed-path, linked-issue,
50+
and stored opportunity-similarity evidence. It returns `unknown` unless every
51+
required facet is complete; it never performs network access. Use
52+
`workflow.link_pull_request` to record an explicit local PR association with an
53+
opportunity or workspace. That local write does not mutate GitHub.
54+
55+
Issue timeline hydration is an explicit, opt-in `issue_timeline` facet. Complete
56+
timeline observations may create versioned resolution records with exact source
57+
observation references. Closing-issue observations remain relationship evidence
58+
until completion is independently observed. Similar prose is not resolution
59+
evidence.
5260

5361
`workspace.check_merge_conflicts` is different from GitHub mergeability. It runs
5462
a non-mutating Git comparison between already-fetched object IDs in a managed
@@ -69,6 +77,11 @@ jobs together with vectorized `jobs.get`, then retry only retryable items. Never
6977
interpret absent coverage as a zero, a passing check, or a lack of competing
7078
work.
7179

80+
`corpus.get_coverage` accepts up to 100 ordered repository or exact-thread
81+
targets. `jobs.cancel` accepts up to 100 IDs and returns isolated item outcomes;
82+
repeating cancellation is safe. `jobs.get` exposes structured phase and item
83+
counts rather than requiring clients to parse event prose.
84+
7285
The MCP catalog does not advertise scalar compatibility aliases. Use one-item
7386
arrays with `corpus.get_repositories`, `corpus.get_threads`,
7487
`github.sync_threads`, `github.hydrate_threads`, and `jobs.get` when only one
@@ -80,6 +93,7 @@ not an MCP discovery primitive.
8093
| Tool family | Network | Corpus/local write | Process |
8194
| --- | ---: | ---: | ---: |
8295
| `corpus.get_*`, rank, precedents, portfolio | no | no | no |
96+
| `workflow.link_pull_request` | no | yes | no |
8397
| `github.search_*`, sync, hydrate | yes | yes | no |
8498
| `research.query_deepwiki` | yes | no | no |
8599
| `code.index_repositories` | remote-dependent | yes | Git only |

docs/scalable-research-and-portfolio-plan.md

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22

33
Status: partially implemented
44

5-
The bounded metadata, thread, Radar, precedent, authored-PR, REST status,
6-
DeepWiki, code-indexing, local conflict, and vectorized job-read tools are
7-
implemented. This document also retains target contracts that are not yet
8-
implemented; the table below is authoritative for current coverage.
5+
The bounded metadata, thread, Radar, precedent, authored-PR, PR-health,
6+
portfolio-relationship, DeepWiki, code-indexing, local conflict, and vectorized
7+
job tools are implemented. This document also retains longer-term target
8+
contracts; the table below is authoritative for current coverage.
99

1010
| Area | Status | Current coverage |
1111
| --- | --- | --- |
1212
| Batch repository and thread reads | Implemented | Ordered item results, typed nullable metadata, compact/full threads |
1313
| Metadata, thread, and selected-facet synchronization | Implemented | Durable bounded jobs with server-side concurrency |
1414
| Cross-repository Radar and historical precedents | Implemented | Offline ranking and direct similarity over stored resolved threads |
1515
| DeepWiki | Implemented | One bounded non-persisting external-read primitive |
16-
| Authored PR discovery and portfolio | Partial | Identity, authored search, REST details/reviews, deterministic attention |
17-
| PR health | Deferred | Checks, unresolved review threads, detailed merge state, merge queue, closing issues |
18-
| Portfolio relationships | Deferred | Cross-PR overlap and explicit opportunity/workspace links |
19-
| Remaining vectorization | Deferred | Batch coverage reads and batch job cancellation |
20-
| Rich derived resolutions | Deferred | Timeline/file/closing-PR facets and persisted resolution projections |
16+
| Authored PR discovery and portfolio | Implemented | Identity, authored search, REST details/reviews, typed health facets, deterministic attention |
17+
| PR health | Implemented | Checks, unresolved review threads, detailed merge state, merge queue, closing issues, changed files |
18+
| Portfolio relationships | Implemented | Offline normalized overlap and explicit opportunity/workspace links |
19+
| Remaining vectorization | Implemented | Batch coverage reads, job reads, and idempotent batch cancellation |
20+
| Rich derived resolutions | Implemented | Opt-in timeline-derived projections plus changed-file/closing-issue relationship facets |
2121

2222
See [Scalable MCP workflows](mcp-scalable-workflows.md) for the current tool
2323
sequence, recovery rules, test boundary, and limitations.
@@ -295,8 +295,8 @@ filters. Repository mode does not accept exact thread references.
295295

296296
An empty facet list must be rejected. "Everything" is not a safe default.
297297

298-
`github.sync_pull_request_status` should prefer one bounded GraphQL query per
299-
batch and project at least:
298+
`github.sync_pull_request_status` uses bounded typed GraphQL reads per pull
299+
request and projects:
300300

301301
- state and draft state;
302302
- author and repository identity;
@@ -309,9 +309,9 @@ batch and project at least:
309309
- closing issue references;
310310
- updated, closed, and merged times.
311311

312-
REST adapters remain useful for paginated child facets and fallback behavior.
313-
GitHub `mergeable: null` is returned as a retryable item with a suggested delay,
314-
not as a terminal error or a persisted `false`.
312+
REST adapters remain responsible for details, reviews, and issue timelines.
313+
GraphQL collection pagination is bounded by the job input. GitHub
314+
`mergeable: null` remains unknown rather than becoming persisted `false`.
315315

316316
### 4.3 DeepWiki adapter
317317

@@ -620,16 +620,11 @@ against:
620620
- linked issues from authored pull requests;
621621
- explicit cross-references;
622622
- changed-file paths when that facet is present;
623-
- local hypothesis and opportunity text;
624-
- deterministic thread similarity;
625-
- currently competing upstream pull requests.
623+
- stored opportunity-similarity signals.
626624

627-
The output distinguishes:
628-
629-
- Git merge conflict;
630-
- competing upstream implementation;
631-
- overlap with the user's own portfolio;
632-
- weak textual similarity.
625+
The output distinguishes exact observed overlap, complete no-overlap, and
626+
unknown coverage. Local merge conflicts and competing upstream work remain
627+
separate primitives rather than being inferred by this tool.
633628

634629
Opportunity ranking should exclude or clearly mark candidates already covered
635630
by the user's work.

internal/app/hydration.go

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const (
1919
FacetPRDetails = "pr_details"
2020
FacetPRReviews = "pr_reviews"
2121
FacetPRReviewComments = "pr_review_comments"
22+
FacetPRChecks = "pr_checks"
23+
FacetPRReviewThreads = "pr_review_threads"
24+
FacetPRMergeState = "pr_merge_state"
25+
FacetPRMergeQueue = "pr_merge_queue"
26+
FacetPRClosingIssues = "pr_closing_issues"
27+
FacetPRFiles = "pr_files"
28+
FacetIssueTimeline = "issue_timeline"
2229
)
2330

2431
var issueFacets = []string{FacetIssueComments}
@@ -166,6 +173,8 @@ func (s *Service) HydrateThread(ctx context.Context, repo cli.RepoRef, number in
166173
facetResult, err = f.hydratePullRequestReviews()
167174
case FacetPRReviewComments:
168175
facetResult, err = f.hydratePullRequestReviewComments()
176+
case FacetIssueTimeline:
177+
facetResult, err = f.hydrateIssueTimeline()
169178
default:
170179
hydrateErr = fmt.Errorf("unknown facet %q", facet)
171180
return nil, hydrateErr
@@ -208,6 +217,9 @@ func selectFacets(kind string, requested []string) ([]string, error) {
208217
if len(requested) == 0 {
209218
return allowed, nil
210219
}
220+
// Timeline history is intentionally opt-in because it can be much larger
221+
// than the default hydration set.
222+
allowed = append(append([]string(nil), allowed...), FacetIssueTimeline)
211223

212224
allowedSet := make(map[string]struct{}, len(allowed))
213225
for _, f := range allowed {
@@ -229,6 +241,119 @@ func selectFacets(kind string, requested []string) ([]string, error) {
229241
return out, nil
230242
}
231243

244+
func (f *facetRunner) hydrateIssueTimeline() (HydratedFacet, error) {
245+
reader, ok := f.reader.(github.IssueTimelineReader)
246+
if !ok {
247+
return HydratedFacet{}, errors.New("GitHub reader does not support issue timelines")
248+
}
249+
opts := github.PageOptions{Page: 1, PerPage: 100}
250+
var total, pages int
251+
var complete bool
252+
var pageObservations []corpus.FacetObservationInput
253+
sourceUpdatedAt := f.thread.SourceUpdatedAt
254+
var events []github.IssueTimelineEvent
255+
for pages < f.maxPages {
256+
if err := f.ctx.Err(); err != nil {
257+
return HydratedFacet{}, err
258+
}
259+
res, err := reader.ListIssueTimeline(f.ctx, f.ref.Owner, f.ref.Repo, f.thread.Number, opts)
260+
if err != nil {
261+
return HydratedFacet{}, err
262+
}
263+
pages++
264+
pageUpdatedAt := sourceUpdatedAt
265+
for _, event := range res.Items {
266+
if event.CreatedAt.After(pageUpdatedAt) {
267+
pageUpdatedAt = event.CreatedAt
268+
}
269+
}
270+
payload, err := json.Marshal(res.Items)
271+
if err != nil {
272+
return HydratedFacet{}, fmt.Errorf("marshal issue timeline: %w", err)
273+
}
274+
pageObservations = append(pageObservations, corpus.FacetObservationInput{SourceUpdatedAt: pageUpdatedAt, Payload: string(payload)})
275+
events = append(events, res.Items...)
276+
total += len(res.Items)
277+
if pageUpdatedAt.After(sourceUpdatedAt) {
278+
sourceUpdatedAt = pageUpdatedAt
279+
}
280+
if !res.Page.HasNext {
281+
complete = true
282+
break
283+
}
284+
opts.Page = res.Page.NextPage
285+
}
286+
if !complete {
287+
if err := f.c.AdvanceFacet(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline, sourceUpdatedAt, false, f.runID); err != nil {
288+
return HydratedFacet{}, err
289+
}
290+
return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: false}, nil
291+
}
292+
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline, sourceUpdatedAt, pageObservations, true, f.runID); err != nil {
293+
return HydratedFacet{}, err
294+
}
295+
coverage, err := f.c.GetCoverage(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline)
296+
if err != nil {
297+
return HydratedFacet{}, err
298+
}
299+
if coverage == nil || !coverage.Complete || !coverage.SourceUpdatedAt.Equal(sourceUpdatedAt.Truncate(time.Second)) {
300+
// A newer stored snapshot won the stale-write comparison. Do not attach
301+
// this older derivation to that snapshot's observation identities.
302+
return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil
303+
}
304+
if err := f.persistTimelineResolution(events, sourceUpdatedAt); err != nil {
305+
return HydratedFacet{}, err
306+
}
307+
return HydratedFacet{Facet: FacetIssueTimeline, Count: total, Pages: pages, Complete: true}, nil
308+
}
309+
310+
func (f *facetRunner) persistTimelineResolution(events []github.IssueTimelineEvent, sourceUpdatedAt time.Time) error {
311+
kind, summary := "", ""
312+
selectedCommit := ""
313+
if f.thread.StateReason == "not_planned" {
314+
kind, summary = "not_planned", "GitHub records this issue as closed without planned work."
315+
}
316+
for _, event := range events {
317+
if event.Event == "closed" && event.CommitID != "" {
318+
kind, summary = "fixed_by_commit", "GitHub records an explicit closing commit: "+event.CommitID
319+
selectedCommit = event.CommitID
320+
}
321+
}
322+
if kind == "" {
323+
return nil
324+
}
325+
var refs []corpus.ObservationRef
326+
if selectedCommit == "" {
327+
observation, err := f.c.GetThreadObservationRevision(f.ctx, f.threadID, f.thread.SourceUpdatedAt, f.thread.ObservationSequence)
328+
if err != nil {
329+
return err
330+
}
331+
refs = []corpus.ObservationRef{{Kind: "thread", ID: observation.ID}}
332+
} else {
333+
observations, _, err := f.c.ListFacetObservationsBounded(f.ctx, f.repoID, &f.threadID, FacetIssueTimeline, 100)
334+
if err != nil {
335+
return err
336+
}
337+
for _, observation := range observations {
338+
var page []github.IssueTimelineEvent
339+
if err := json.Unmarshal([]byte(observation.Payload), &page); err != nil {
340+
return fmt.Errorf("decode issue timeline provenance: %w", err)
341+
}
342+
for _, event := range page {
343+
if event.Event == "closed" && event.CommitID == selectedCommit {
344+
refs = append(refs, corpus.ObservationRef{Kind: "facet", ID: observation.ID})
345+
break
346+
}
347+
}
348+
}
349+
if len(refs) == 0 {
350+
return errors.New("closing commit timeline observation is unavailable")
351+
}
352+
}
353+
_, err := f.c.SaveResolutionRecord(f.ctx, corpus.ResolutionRecord{ThreadID: f.threadID, Kind: kind, Summary: summary, RuleVersion: "resolution.v1", SourceUpdatedAt: sourceUpdatedAt, SourceObservationRefs: refs})
354+
return err
355+
}
356+
232357
type facetRunner struct {
233358
ctx context.Context
234359
c *corpus.Corpus
@@ -289,7 +414,13 @@ func (f *facetRunner) hydrateIssueComments() (HydratedFacet, error) {
289414
if err := f.ctx.Err(); err != nil {
290415
return HydratedFacet{}, err
291416
}
292-
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetIssueComments, sourceUpdatedAt, pageObservations, complete, f.runID); err != nil {
417+
if !complete {
418+
if err := f.c.AdvanceFacet(f.ctx, f.repoID, &f.threadID, FacetIssueComments, sourceUpdatedAt, false, f.runID); err != nil {
419+
return HydratedFacet{}, err
420+
}
421+
return HydratedFacet{Facet: FacetIssueComments, Count: total, Pages: pages, Complete: false}, nil
422+
}
423+
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetIssueComments, sourceUpdatedAt, pageObservations, true, f.runID); err != nil {
293424
return HydratedFacet{}, err
294425
}
295426

@@ -367,7 +498,13 @@ func (f *facetRunner) hydratePullRequestReviews() (HydratedFacet, error) {
367498
if err := f.ctx.Err(); err != nil {
368499
return HydratedFacet{}, err
369500
}
370-
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetPRReviews, sourceUpdatedAt, pageObservations, complete, f.runID); err != nil {
501+
if !complete {
502+
if err := f.c.AdvanceFacet(f.ctx, f.repoID, &f.threadID, FacetPRReviews, sourceUpdatedAt, false, f.runID); err != nil {
503+
return HydratedFacet{}, err
504+
}
505+
return HydratedFacet{Facet: FacetPRReviews, Count: total, Pages: pages, Complete: false}, nil
506+
}
507+
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetPRReviews, sourceUpdatedAt, pageObservations, true, f.runID); err != nil {
371508
return HydratedFacet{}, err
372509
}
373510

@@ -422,7 +559,13 @@ func (f *facetRunner) hydratePullRequestReviewComments() (HydratedFacet, error)
422559
if err := f.ctx.Err(); err != nil {
423560
return HydratedFacet{}, err
424561
}
425-
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetPRReviewComments, sourceUpdatedAt, pageObservations, complete, f.runID); err != nil {
562+
if !complete {
563+
if err := f.c.AdvanceFacet(f.ctx, f.repoID, &f.threadID, FacetPRReviewComments, sourceUpdatedAt, false, f.runID); err != nil {
564+
return HydratedFacet{}, err
565+
}
566+
return HydratedFacet{Facet: FacetPRReviewComments, Count: total, Pages: pages, Complete: false}, nil
567+
}
568+
if err := f.c.ApplyFacetObservationSet(f.ctx, f.repoID, &f.threadID, FacetPRReviewComments, sourceUpdatedAt, pageObservations, true, f.runID); err != nil {
426569
return HydratedFacet{}, err
427570
}
428571

0 commit comments

Comments
 (0)