Skip to content

Commit 8b75204

Browse files
committed
feat(#1): spec-issue-linker — Linker interface, resolvers, sync integration
Define Linker interface and chained resolver pattern. Branch name, marker, cache, and external resolvers. Wire Linker into sync: when no cached ref exists, the Linker resolves the issue ref from branch name, marker, or other sources; the sync then updates the existing issue instead of creating a new one. Spec-first creation remains the default when no link resolves (existing behavior).
1 parent 27ef5ec commit 8b75204

4 files changed

Lines changed: 558 additions & 4 deletions

File tree

linker.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package specsync
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
)
10+
11+
// Linker resolves a change to its issue ref by consulting multiple sources
12+
// in priority order. The first hit wins; the result is cached for the
13+
// duration of the sync run.
14+
type Linker interface {
15+
// Resolve returns the Ref for the given change directory. Returns nil if
16+
// no resolver found a match.
17+
Resolve(ctx context.Context, changeDir string) (*Ref, error)
18+
}
19+
20+
// ChainLinker tries each resolver in order until one returns a non-nil Ref.
21+
// The first hit wins; subsequent resolvers are not consulted.
22+
type ChainLinker struct {
23+
resolvers []Resolver
24+
}
25+
26+
// Resolver is a single link resolution strategy. Returns (nil, nil) if it
27+
// cannot resolve (not a match), or (nil, err) if it encounters an error.
28+
type Resolver interface {
29+
// Resolve returns the Ref if this resolver can match, or (nil, nil) to
30+
// pass to the next resolver.
31+
Resolve(ctx context.Context, changeDir string) (*Ref, error)
32+
}
33+
34+
// NewChainLinker creates a ChainLinker with the given resolvers in priority
35+
// order. The first resolver to return a non-nil Ref wins.
36+
func NewChainLinker(resolvers ...Resolver) *ChainLinker {
37+
return &ChainLinker{resolvers: resolvers}
38+
}
39+
40+
func (c *ChainLinker) Resolve(ctx context.Context, changeDir string) (*Ref, error) {
41+
for _, r := range c.resolvers {
42+
ref, err := r.Resolve(ctx, changeDir)
43+
if err != nil {
44+
return nil, fmt.Errorf("resolver: %w", err)
45+
}
46+
if ref != nil {
47+
return ref, nil
48+
}
49+
}
50+
return nil, nil
51+
}
52+
53+
// BranchResolver extracts an issue number from the current git branch name.
54+
// The pattern is configurable; default: `feat/(\d+)-.*` or `fix/(\d+)-.*`.
55+
type BranchResolver struct {
56+
repo string // "owner/name" — required to build the URL
57+
pats []*regexp.Regexp
58+
}
59+
60+
// NewBranchResolver creates a BranchResolver with the given repo and patterns.
61+
// If repo is empty, the resolver cannot resolve (returns nil). Default patterns
62+
// are `feat/(\d+)-.*` and `fix/(\d+)-.*`.
63+
func NewBranchResolver(repo string, pats ...*regexp.Regexp) *BranchResolver {
64+
if len(pats) == 0 {
65+
pats = []*regexp.Regexp{
66+
regexp.MustCompile(`^(?:feat|fix)/(\d+)-.*`),
67+
}
68+
}
69+
return &BranchResolver{repo: repo, pats: pats}
70+
}
71+
72+
func (b *BranchResolver) Resolve(_ context.Context, changeDir string) (*Ref, error) {
73+
if b.repo == "" {
74+
return nil, nil
75+
}
76+
77+
branch, err := currentBranch()
78+
if err != nil || branch == "" {
79+
return nil, nil
80+
}
81+
82+
for _, pat := range b.pats {
83+
matches := pat.FindStringSubmatch(branch)
84+
if len(matches) < 2 {
85+
continue
86+
}
87+
num := matches[1]
88+
url := fmt.Sprintf("https://github.com/%s/issues/%s", b.repo, num)
89+
return &Ref{
90+
Provider: "github:" + b.repo,
91+
ID: num,
92+
URL: url,
93+
}, nil
94+
}
95+
96+
return nil, nil
97+
}
98+
99+
// MarkerResolver resolves via the <!-- specsync:change=<slug> --> marker in
100+
// an existing issue body. It uses the provider's Find method.
101+
type MarkerResolver struct {
102+
provider WorkProvider
103+
}
104+
105+
// NewMarkerResolver creates a MarkerResolver that uses the given provider's
106+
// Find method to search for the specsync marker.
107+
func NewMarkerResolver(provider WorkProvider) *MarkerResolver {
108+
return &MarkerResolver{provider: provider}
109+
}
110+
111+
func (m *MarkerResolver) Resolve(ctx context.Context, changeDir string) (*Ref, error) {
112+
openspecDir := resolveOpenSpecDir(changeDir)
113+
if openspecDir == "" {
114+
return nil, nil
115+
}
116+
c, err := LoadChangeBySlug(openspecDir, filepath.Base(changeDir))
117+
if err != nil {
118+
return nil, nil
119+
}
120+
ref, err := m.provider.Find(ctx, c.Slug)
121+
if err != nil {
122+
return nil, fmt.Errorf("marker find: %w", err)
123+
}
124+
return ref, nil
125+
}
126+
127+
// CacheResolver reads the local .specsync/refs.json cache.
128+
type CacheResolver struct {
129+
providerName string // e.g. "github:owner/repo" or "github"
130+
}
131+
132+
// NewCacheResolver creates a CacheResolver for the given provider name.
133+
func NewCacheResolver(providerName string) *CacheResolver {
134+
return &CacheResolver{providerName: providerName}
135+
}
136+
137+
func (c *CacheResolver) Resolve(_ context.Context, changeDir string) (*Ref, error) {
138+
refs, err := loadRefs(changeDir)
139+
if err != nil {
140+
return nil, fmt.Errorf("cache read: %w", err)
141+
}
142+
143+
// Try the exact provider key first.
144+
if ref, ok := refs[c.providerName]; ok {
145+
return &ref, nil
146+
}
147+
148+
// Try the legacy bare "github" key for github: prefixed providers.
149+
if strings.HasPrefix(c.providerName, "github:") {
150+
if ref, ok := refs["github"]; ok {
151+
return &ref, nil
152+
}
153+
}
154+
155+
return nil, nil
156+
}
157+
158+
// ExternalResolver is a configurable hook for external relation sources
159+
// (e.g. MCP, database, or custom logic).
160+
type ExternalResolver struct {
161+
fn func(ctx context.Context, changeDir string) (*Ref, error)
162+
}
163+
164+
// NewExternalResolver creates an ExternalResolver with the given function.
165+
func NewExternalResolver(fn func(ctx context.Context, changeDir string) (*Ref, error)) *ExternalResolver {
166+
return &ExternalResolver{fn: fn}
167+
}
168+
169+
func (e *ExternalResolver) Resolve(ctx context.Context, changeDir string) (*Ref, error) {
170+
return e.fn(ctx, changeDir)
171+
}
172+
173+
// currentBranch returns the current git branch name, or "" if not on a branch.
174+
func currentBranch() (string, error) {
175+
out, err := runGit(context.Background(), "rev-parse", "--abbrev-ref", "HEAD")
176+
if err != nil {
177+
return "", err
178+
}
179+
return strings.TrimSpace(out), nil
180+
}
181+
182+
// resolveOpenSpecDir returns the openspec/ directory that contains the given
183+
// change directory. It walks up from changeDir looking for a parent named
184+
// "changes" or "archive", then returns that parent.
185+
func resolveOpenSpecDir(changeDir string) string {
186+
dir := changeDir
187+
for {
188+
parent := filepath.Dir(dir)
189+
base := filepath.Base(dir)
190+
if base == "changes" || base == "archive" {
191+
return parent
192+
}
193+
dir = parent
194+
if dir == "/" || dir == "" || dir == "." {
195+
break
196+
}
197+
}
198+
return ""
199+
}

0 commit comments

Comments
 (0)