Skip to content

Commit b7adb53

Browse files
authored
Merge pull request #272 from morluto/agent/fork-freshness-preflight
feat(preflight): detect stale contributor forks
2 parents fe872b2 + 8f05963 commit b7adb53

12 files changed

Lines changed: 521 additions & 2 deletions

docs/mcp-composed-workflows.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ decides whether an agent should start work. Agents select the current-work,
114114
portfolio, overlap, or Git comparison facts appropriate to the task. Unknown
115115
discovery or facet coverage prevents a “no competing work” conclusion.
116116

117+
`workflow.preflight_contribution` also reports `fork_freshness` when the caller
118+
supplies a fork or the inspected workspace provides one unambiguous contributor
119+
remote. The read resolves both default-branch tips and compares them through
120+
the upstream fork network, preserving merge-base evidence and the current
121+
contribution branch when available. `current`, `behind`, and `diverged` are
122+
verified states; an unavailable fork or incomplete comparison remains unknown.
123+
The check never fetches local refs, synchronizes a fork, force-pushes, or
124+
mutates GitHub.
125+
117126
## Unified catalog
118127

119128
The managed server advertises the unified `all` catalog. Current Codex and
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/morluto/gitcontribute/internal/github"
8+
"github.com/morluto/gitcontribute/internal/gitremote"
9+
"github.com/morluto/gitcontribute/internal/mcpcontract"
10+
"github.com/morluto/gitcontribute/internal/workspace"
11+
)
12+
13+
const forkFreshnessRequestCost = 5
14+
15+
type preflightForkContext struct {
16+
ref mcpcontract.RepositoryRef
17+
branch string
18+
sha string
19+
}
20+
21+
// checkPreflightForkFreshness performs only provider reads. It never fetches
22+
// local refs or updates the fork; the compare API supplies the merge-base
23+
// evidence across the upstream repository network.
24+
func checkPreflightForkFreshness(
25+
ctx context.Context,
26+
reader github.Reader,
27+
upstream mcpcontract.RepositoryRef,
28+
fork *mcpcontract.RepositoryRef,
29+
identity string,
30+
candidate mcpcontract.ContributionPreflightCandidate,
31+
worktrees []workspace.LocalWorktree,
32+
existing *preflightExisting,
33+
maxRequests int,
34+
requests int,
35+
) (mcpcontract.ForkFreshnessOutput, bool, error) {
36+
forkContext, shouldCheck, reason := resolvePreflightFork(upstream, fork, identity, candidate, worktrees, existing)
37+
if !shouldCheck {
38+
return mcpcontract.ForkFreshnessOutput{}, false, nil
39+
}
40+
result := newForkFreshnessOutput(upstream, reason)
41+
if forkContext != nil {
42+
result.Fork = forkContext.ref
43+
result.ContributionBranch = forkContext.branch
44+
result.ContributionSHA = forkContext.sha
45+
}
46+
if reason != "" {
47+
return result, true, nil
48+
}
49+
if forkContext == nil {
50+
result.Reason = "contributor fork could not be identified from the supplied context"
51+
return result, true, nil
52+
}
53+
if maxRequests-requests < forkFreshnessRequestCost {
54+
result.Reason = "request budget cannot fund complete fork freshness coverage"
55+
return result, true, nil
56+
}
57+
branchReader, hasBranchReader := reader.(github.BranchReader)
58+
comparisonReader, hasComparisonReader := reader.(github.CommitComparisonReader)
59+
if !hasBranchReader {
60+
result.Reason = "configured GitHub reader does not support branch-tip reads"
61+
return result, true, nil
62+
}
63+
if !hasComparisonReader {
64+
result.Reason = "configured GitHub reader does not support fork ancestry comparison"
65+
return result, true, nil
66+
}
67+
68+
upstreamRepo, _, err := reader.GetRepository(ctx, upstream.Owner, upstream.Repo)
69+
if err != nil {
70+
return forkFreshnessUnavailable(result, "upstream repository metadata could not be read", err)
71+
}
72+
forkRepo, _, err := reader.GetRepository(ctx, forkContext.ref.Owner, forkContext.ref.Repo)
73+
if err != nil {
74+
return forkFreshnessUnavailable(result, "fork repository metadata could not be read", err)
75+
}
76+
if !forkRepo.Fork || forkRepo.Parent == nil || !sameGitHubRepository(forkRepo.Parent.Owner, forkRepo.Parent.Name, upstream) {
77+
result.Reason = "selected repository is not a fork of the requested upstream repository"
78+
return result, true, nil
79+
}
80+
if strings.TrimSpace(upstreamRepo.DefaultBranch) == "" || strings.TrimSpace(forkRepo.DefaultBranch) == "" {
81+
result.Reason = "upstream or fork default branch is unavailable"
82+
return result, true, nil
83+
}
84+
result.UpstreamBranch = upstreamRepo.DefaultBranch
85+
result.ForkBranch = forkRepo.DefaultBranch
86+
87+
upstreamBranch, _, err := branchReader.GetBranch(ctx, upstream.Owner, upstream.Repo, upstreamRepo.DefaultBranch)
88+
if err != nil {
89+
return forkFreshnessUnavailable(result, "upstream default branch could not be read", err)
90+
}
91+
forkBranch, _, err := branchReader.GetBranch(ctx, forkContext.ref.Owner, forkContext.ref.Repo, forkRepo.DefaultBranch)
92+
if err != nil {
93+
return forkFreshnessUnavailable(result, "fork default branch could not be read", err)
94+
}
95+
result.UpstreamSHA = upstreamBranch.CommitSHA
96+
result.ForkSHA = forkBranch.CommitSHA
97+
if result.UpstreamSHA == "" || result.ForkSHA == "" {
98+
result.Reason = "upstream or fork default branch did not include a commit SHA"
99+
return result, true, nil
100+
}
101+
102+
comparison, _, err := comparisonReader.CompareCommits(ctx, upstream.Owner, upstream.Repo, upstreamRepo.DefaultBranch, forkContext.ref.Owner+":"+forkRepo.DefaultBranch)
103+
if err != nil {
104+
return forkFreshnessUnavailable(result, "upstream and fork default branches could not be compared", err)
105+
}
106+
if comparison.BaseSHA != "" && !strings.EqualFold(comparison.BaseSHA, result.UpstreamSHA) {
107+
result.Reason = "comparison base SHA did not match the resolved upstream default branch"
108+
return result, true, nil
109+
}
110+
if comparison.MergeBaseSHA == "" {
111+
result.Reason = "comparison did not provide merge-base evidence"
112+
return result, true, nil
113+
}
114+
result.Status, result.NextAction = classifyForkFreshness(comparison.Status)
115+
if result.Status == "unavailable" {
116+
result.Reason = "GitHub returned an unsupported fork comparison status"
117+
return result, true, nil
118+
}
119+
result.MergeBaseSHA = comparison.MergeBaseSHA
120+
result.AheadBy = comparison.AheadBy
121+
result.BehindBy = comparison.BehindBy
122+
result.Coverage = "verified"
123+
result.EffectiveDiffRisk = result.Status != "current"
124+
return result, true, nil
125+
}
126+
127+
func resolvePreflightFork(
128+
upstream mcpcontract.RepositoryRef,
129+
explicit *mcpcontract.RepositoryRef,
130+
identity string,
131+
candidate mcpcontract.ContributionPreflightCandidate,
132+
worktrees []workspace.LocalWorktree,
133+
existing *preflightExisting,
134+
) (*preflightForkContext, bool, string) {
135+
if explicit != nil {
136+
return &preflightForkContext{ref: *explicit, branch: strings.TrimSpace(candidate.HeadRef), sha: strings.TrimSpace(candidate.HeadSHA)}, true, ""
137+
}
138+
if existing != nil && existing.details.HeadOwner != "" && existing.details.HeadRepo != "" && !sameGitHubRepository(existing.details.HeadOwner, existing.details.HeadRepo, upstream) {
139+
return &preflightForkContext{
140+
ref: mcpcontract.RepositoryRef{Owner: existing.details.HeadOwner, Repo: existing.details.HeadRepo},
141+
branch: existing.details.HeadRef,
142+
sha: existing.details.HeadSHA,
143+
}, true, ""
144+
}
145+
if len(worktrees) == 0 {
146+
return nil, false, ""
147+
}
148+
149+
var candidates []preflightForkContext
150+
seen := make(map[string]struct{})
151+
for _, worktree := range worktrees {
152+
for _, urls := range worktree.Remotes {
153+
for _, remote := range urls {
154+
identityRef, err := gitremote.ParseRepositoryIdentity(remote)
155+
if err != nil || !strings.EqualFold(identityRef.Owner, identity) || sameGitHubRepository(identityRef.Owner, identityRef.Repo, upstream) {
156+
continue
157+
}
158+
ref := mcpcontract.RepositoryRef{Owner: identityRef.Owner, Repo: identityRef.Repo}
159+
key := strings.ToLower(ref.Owner + "/" + ref.Repo)
160+
if _, ok := seen[key]; ok {
161+
continue
162+
}
163+
seen[key] = struct{}{}
164+
branch, sha := worktree.Branch, worktree.HeadSHA
165+
if candidate.HeadRef != "" {
166+
branch = candidate.HeadRef
167+
}
168+
if candidate.HeadSHA != "" {
169+
sha = candidate.HeadSHA
170+
}
171+
candidates = append(candidates, preflightForkContext{ref: ref, branch: branch, sha: sha})
172+
}
173+
}
174+
}
175+
if len(candidates) == 1 {
176+
return &candidates[0], true, ""
177+
}
178+
if len(candidates) > 1 {
179+
return nil, true, "multiple contributor fork remotes were found; provide fork explicitly"
180+
}
181+
return nil, true, "no contributor fork remote was found in the supplied workspaces"
182+
}
183+
184+
func newForkFreshnessOutput(upstream mcpcontract.RepositoryRef, reason string) mcpcontract.ForkFreshnessOutput {
185+
return mcpcontract.ForkFreshnessOutput{
186+
Status: "unavailable",
187+
Coverage: "unavailable",
188+
Upstream: upstream,
189+
Reason: reason,
190+
NextAction: "provide_fork_context_or_retry_freshness_check",
191+
}
192+
}
193+
194+
func forkFreshnessUnavailable(result mcpcontract.ForkFreshnessOutput, reason string, err error) (mcpcontract.ForkFreshnessOutput, bool, error) {
195+
if contextError(err) {
196+
return mcpcontract.ForkFreshnessOutput{}, false, err
197+
}
198+
result.Reason = reason
199+
return result, true, nil
200+
}
201+
202+
func classifyForkFreshness(providerStatus string) (string, string) {
203+
switch providerStatus {
204+
case "identical":
205+
return "current", "publish_contribution"
206+
case "behind":
207+
return "behind", "sync_fork_or_fast_forward_only_update"
208+
case "ahead", "diverged":
209+
return "diverged", "inspect_fork_history_before_publishing"
210+
default:
211+
return "unavailable", "retry_freshness_check"
212+
}
213+
}
214+
215+
func sameGitHubRepository(owner, repo string, ref mcpcontract.RepositoryRef) bool {
216+
return strings.EqualFold(strings.TrimSpace(owner), strings.TrimSpace(ref.Owner)) && strings.EqualFold(strings.TrimSpace(repo), strings.TrimSpace(ref.Repo))
217+
}

internal/app/mcp_contribution_preflight.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co
2929
}
3030
in.Repository.Owner = strings.TrimSpace(in.Repository.Owner)
3131
in.Repository.Repo = strings.TrimSpace(in.Repository.Repo)
32+
if in.Fork != nil {
33+
in.Fork.Owner = strings.TrimSpace(in.Fork.Owner)
34+
in.Fork.Repo = strings.TrimSpace(in.Fork.Repo)
35+
if err := (domain.RepoRef{Owner: in.Fork.Owner, Repo: in.Fork.Repo}).Validate(); err != nil {
36+
return mcpcontract.ContributionPreflightOutput{}, fmt.Errorf("validate fork repository: %w", err)
37+
}
38+
if sameGitHubRepository(in.Fork.Owner, in.Fork.Repo, in.Repository) {
39+
return mcpcontract.ContributionPreflightOutput{}, errors.New("fork repository must differ from the upstream repository")
40+
}
41+
}
3242
if in.Limit == 0 {
3343
in.Limit = defaultContributionPreflightLimit
3444
}
@@ -182,6 +192,16 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co
182192
out.Status = "existing_pr"
183193
out.NextAction = "review_or_follow_through"
184194
}
195+
forkFreshness, forkChecked, forkErr := checkPreflightForkFreshness(ctx, reader, in.Repository, in.Fork, identity.Login, in.Candidate, worktrees, existingMatch(existing), in.MaxRequests, requests)
196+
if forkErr != nil {
197+
return mcpcontract.ContributionPreflightOutput{}, forkErr
198+
}
199+
if forkChecked {
200+
out.ForkFreshness = &forkFreshness
201+
if forkFreshness.Coverage != "verified" {
202+
out.CoverageReasons = append(out.CoverageReasons, "fork freshness coverage is unavailable: "+forkFreshness.Reason)
203+
}
204+
}
185205
if len(out.CoverageReasons) == 0 {
186206
out.Coverage = "live_verified"
187207
if out.Existing == nil {
@@ -192,6 +212,13 @@ func (r *MCPReader) PreflightContribution(ctx context.Context, in mcpcontract.Co
192212
return out, nil
193213
}
194214

215+
func existingMatch(existing []preflightExisting) *preflightExisting {
216+
if len(existing) == 0 {
217+
return nil
218+
}
219+
return &existing[0]
220+
}
221+
195222
type preflightExisting struct {
196223
marker github.Issue
197224
details github.PullRequestDetails

0 commit comments

Comments
 (0)