Repository navigation
Expand file tree
/
Copy pathpull.go
More file actions
595 lines (569 loc) · 21.1 KB
/
Copy pathpull.go
File metadata and controls
595 lines (569 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
package specsync
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
// PullOptions configures an issue-first pull: reading an existing tracker issue
// and materializing it as a local change.
type PullOptions struct {
OpenSpecDir string // path to the spec root (openspec/, beads/, etc.)
Provider WorkProvider // must implement IssueReader
IssueID string // provider id of the issue to pull (e.g. "42"); when empty, resolved from branch name
Slug string // change slug; derived from the issue when empty
DryRun bool // when true, render but never touch disk
Project BoardTarget // optional GitHub Projects board; unset = no board operations
}
// PullResult reports what a pull produced (or would produce on a dry run).
type PullResult struct {
Slug string
Dir string
IssueID string // resolved issue ID (from -issue flag or branch name)
IssueURL string
Proposal string
Tasks string
Links []string // URLs from the ## Related section, for dry-run display
OriginalAsk string // from ## Original ask section of the issue
DesignNotes string // from ## Design notes section of the issue
Discoveries string // from ## Discoveries section of the issue
Marker string // identity marker upserted into the source issue
// MarkerPresent reports whether the source issue already carried the marker,
// i.e. no write was (or would be) needed. Drives the dry-run preview.
MarkerPresent bool
// TitleSuggestion is a tighter variant of the issue title, set only when
// shortenTitle would modify it. Pull writes the title verbatim — rewriting
// someone else's issue title is not specsync's call — but surfaces the
// suggestion so the author can tighten the proposal H1 after pulling.
TitleSuggestion string
// Board reports the board projection; BoardConfigured is false when no target
// project was configured (Board is zero and no board calls ran).
BoardConfigured bool
Board BoardPlan
}
// Pull materializes a local change from an existing issue. The change
// is linked to the source issue (via a cached ref) so a later push updates that
// same issue instead of creating a duplicate. The provider must implement
// IssueReader.
func Pull(ctx context.Context, opts PullOptions) (PullResult, error) {
if opts.Provider == nil {
return PullResult{}, fmt.Errorf("provider is required")
}
reader, ok := opts.Provider.(IssueReader)
if !ok {
return PullResult{}, fmt.Errorf("provider %q cannot read issues", opts.Provider.Name())
}
issueID := strings.TrimSpace(opts.IssueID)
if issueID == "" {
if opts.Provider.Name() != "github" && !strings.HasPrefix(opts.Provider.Name(), "github:") {
return PullResult{}, fmt.Errorf("issue id is required for non-github providers")
}
// Resolve issue from branch name (e.g., feat/42-change → #42).
repo := opts.Provider.Name()
if strings.HasPrefix(repo, "github:") {
repo = strings.TrimPrefix(repo, "github:")
}
br := NewBranchResolver(repo)
result, err := br.Resolve(ctx, "")
if err != nil {
return PullResult{}, fmt.Errorf("resolve issue from branch: %w", err)
}
if result == nil || result.Ref == nil {
return PullResult{}, fmt.Errorf("could not resolve issue from branch name; specify -issue or use an issue-linked branch (e.g., feat/42-change-name)")
}
issueID = result.Ref.ID
}
item, err := reader.Get(ctx, issueID)
if err != nil {
return PullResult{}, err
}
slug := opts.Slug
if slug == "" {
slug = slugFromMarker(item.Body)
}
if slug == "" {
slug = slugify(item.Title)
}
if slug == "" {
return PullResult{}, fmt.Errorf("could not derive a change name from issue %s; pass -change", opts.IssueID)
}
// A slug collision (two issues landing on the same directory — a
// generated slug and, more rarely, an explicit -change) must not
// silently overwrite an unrelated change's proposal.md/tasks.md.
//
// Two cases are left alone (existing behavior): re-pulling the *same*
// issue into its own existing directory (the intentional refresh path),
// and an explicit -change into a directory with no ref cache at all
// (a hand-authored, never-synced local change — the human named this
// exact directory, and binding an issue's content into it is the normal
// spec-first-then-pull flow; see TestPullRecordsTaskBase).
//
// A genuine conflict — the directory's ref cache names a *different*
// issue — refuses an explicit -change (pass a different one, or
// `specsync adopt`) and suffixes an auto-derived one instead of
// clobbering the other change. An auto-derived slug colliding with an
// untracked hand-authored directory is also suffixed, since nothing
// asked for that specific name.
if candidateDir := filepath.Join(opts.OpenSpecDir, "changes", slug); dirExists(candidateDir) {
existing, _ := LoadRefs(candidateDir)
conflictsWithDifferentIssue := len(existing) > 0 && !refsContainIssue(existing, issueID)
collidesWithUntrackedDir := len(existing) == 0 && opts.Slug == ""
if conflictsWithDifferentIssue {
if opts.Slug != "" {
return PullResult{}, fmt.Errorf("pull: change %q already exists and is not linked to issue %s; pass a different -change or use `specsync adopt`", slug, issueID)
}
slug = nextAvailableSlug(opts.OpenSpecDir, slug)
} else if collidesWithUntrackedDir {
slug = nextAvailableSlug(opts.OpenSpecDir, slug)
}
}
proposal, tasks, relatedURLs, origAsk, design, disc := splitBody(item.Body, item.Title)
// The body may carry only a linked stub for an overflowed design.md;
// recover the real content from the comment when supported.
if isDesignNotesStub(design) {
if reader, ok := opts.Provider.(DesignNotesCommentReader); ok {
if content, found, rerr := reader.ReadDesignNotesComment(ctx, issueID, slug); rerr == nil && found {
design = content
}
}
}
res := PullResult{
Slug: slug,
Dir: filepath.Join(opts.OpenSpecDir, "changes", slug),
IssueID: issueID,
IssueURL: item.URL,
Proposal: proposal,
Tasks: tasks,
Links: relatedURLs,
OriginalAsk: origAsk,
DesignNotes: design,
Discoveries: disc,
Marker: marker(slug),
MarkerPresent: strings.Contains(item.Body, marker(slug)),
TitleSuggestion: titleSuggestion(item.Title),
}
// Project onto the board when a target is configured and the provider supports
// it. Done for both dry and real runs (the projector makes no mutation on a
// dry run); skipped entirely when unconfigured.
res.BoardConfigured = opts.Project.Configured()
if opts.Project.Configured() {
if bp, ok := opts.Provider.(BoardProjector); ok {
pulledItem := WorkItem{Slug: slug, Title: item.Title, Stage: StageActive}
ref := Ref{Provider: opts.Provider.Name(), ID: item.ID, URL: item.URL}
changeDir := filepath.Join(opts.OpenSpecDir, "changes", slug)
plan, perr := bp.ProjectOntoBoard(ctx, opts.Project, ref, pulledItem, opts.DryRun, changeDir)
if perr != nil {
return PullResult{}, perr
}
res.Board = plan
}
}
if opts.DryRun {
return res, nil
}
if err := os.MkdirAll(res.Dir, 0o755); err != nil {
return PullResult{}, fmt.Errorf("create change dir: %w", err)
}
if err := os.WriteFile(filepath.Join(res.Dir, "proposal.md"), []byte(proposal), 0o644); err != nil {
return PullResult{}, fmt.Errorf("write proposal: %w", err)
}
if tasks != "" {
if err := os.WriteFile(filepath.Join(res.Dir, "tasks.md"), []byte(tasks), 0o644); err != nil {
return PullResult{}, fmt.Errorf("write tasks: %w", err)
}
// Save baseline task count for tracking added tasks later.
tc := countTaskStates(tasks)
baseline := tc.Total()
meta, _ := LoadChangeMetadata(res.Dir)
if meta == nil {
meta = &ChangeMetadata{}
}
meta.BaselineTaskCount = &baseline
if err := SaveChangeMetadata(res.Dir, *meta); err != nil {
return PullResult{}, fmt.Errorf("save baseline task count: %w", err)
}
}
// Preserve Related links as links.md so the next push renders them.
if len(relatedURLs) > 0 {
var refs []Ref
for _, u := range relatedURLs {
if r := refFromURL(u); r != nil {
refs = append(refs, *r)
}
}
if err := saveLinksToMD(res.Dir, opts.OpenSpecDir, refs); err != nil {
return PullResult{}, fmt.Errorf("write links.md: %w", err)
}
}
// Save original ask from the first pull (never overwritten on re-pull).
// On first pull the issue won't have ## Original ask yet, so we seed it
// from the proposal. On re-pull we strip it but never overwrite the file.
askPath := filepath.Join(res.Dir, "original-ask.md")
if _, err := os.Stat(askPath); os.IsNotExist(err) {
// Use the rendered original ask if available, otherwise the proposal.
askContent := res.OriginalAsk
if askContent == "" {
askContent = res.Proposal
}
if err := os.WriteFile(askPath, []byte(askContent), 0o644); err != nil {
return PullResult{}, fmt.Errorf("write original-ask: %w", err)
}
}
// Write-once, like original-ask.md, unlike discoveries.md: local
// design.md may already be richer than what synced (mid-edit), so a
// pull must never clobber it once it exists. Unlike original-ask.md,
// it's not seeded from the proposal — most changes have no design.md.
if res.DesignNotes != "" {
designPath := filepath.Join(res.Dir, "design.md")
if _, err := os.Stat(designPath); os.IsNotExist(err) {
if err := os.WriteFile(designPath, []byte(res.DesignNotes), 0o644); err != nil {
return PullResult{}, fmt.Errorf("write design notes: %w", err)
}
}
}
// Save discoveries from the issue (appended, not overwritten, to preserve local notes).
if res.Discoveries != "" {
discPath := filepath.Join(res.Dir, "discoveries.md")
existing, _ := os.ReadFile(discPath)
if len(existing) == 0 {
if err := os.WriteFile(discPath, []byte(res.Discoveries), 0o644); err != nil {
return PullResult{}, fmt.Errorf("write discoveries: %w", err)
}
}
}
// Link the change to the source issue so the next push updates it.
ref := Ref{Provider: opts.Provider.Name(), ID: item.ID, URL: item.URL}
// Carry the merge bases forward: a re-pull links the change, it does not reset
// its history. Building a bare ref here used to drop both of them.
//
// For open/closed, losing the base silently revokes specsync's license to
// reopen an item it closed itself. We deliberately do NOT seed it from
// item.Closed — adopting the observed remote state would license undoing a
// close specsync never made, which is the clobber the base exists to prevent.
if prior, err := LoadRefs(res.Dir); err == nil {
if p, ok := prior[opts.Provider.Name()]; ok {
ref.BaseClosed = p.BaseClosed
ref.Base, ref.BaseSHA = p.Base, p.BaseSHA
}
}
// The task base is different: when pull wrote tasks.md from the issue body,
// local and remote are now *identical*, and that is precisely what a merge base
// records — so the pulled content becomes the new base rather than being
// carried over stale. This is what lets the next sync run a real three-way
// merge; with no base it falls back to a monotonic union, where a task
// unchecked on the issue can never propagate back. When the issue carried no
// "## Tasks" section, tasks.md was left untouched and the prior base still
// describes it, so it stands.
if tasks != "" {
ref.Base, ref.BaseSHA = tasks, taskSHA(tasks)
}
if err := saveRef(res.Dir, opts.Provider.Name(), ref); err != nil {
return PullResult{}, err
}
// Transition stage:intake → stage:active when pulling an intake issue.
if la, ok := opts.Provider.(LabelApplier); ok {
for _, lbl := range item.Labels {
if lbl == "stage:intake" {
if err := la.ApplyLabelDelta(ctx, item.ID, []string{"stage:active"}, []string{"stage:intake"}); err != nil {
fmt.Fprintf(os.Stderr, "specsync: warning: could not transition intake → active: %v\n", err)
}
break
}
}
}
// Persist the identity marker into the source issue so the link is durable:
// even if the ref cache is deleted, a later sync rediscovers it via Find.
if mw, ok := opts.Provider.(IssueMarkerWriter); ok {
if _, err := mw.EnsureMarker(ctx, item.ID, slug, item.Body); err != nil {
return PullResult{}, fmt.Errorf("persist identity marker: %w", err)
}
}
return res, nil
}
// extractSection pulls a WorkItemFor-rendered <details> block out of body by
// its "<!-- specsync:section=ID -->" marker, returning the block's inner
// content and body with the whole <details>...</details> block (including
// its wrapper tags) removed. found is false when the marker isn't present —
// the section was rendered by an older specsync as a bare "## Heading"
// instead, which the caller falls back to parsing the legacy way.
func extractSection(body, id string) (content, remainder string, found bool) {
marker := "<!-- specsync:section=" + id + " -->"
mi := strings.Index(body, marker)
if mi < 0 {
return "", body, false
}
open := strings.LastIndex(body[:mi], "<details>")
if open < 0 {
open = mi
}
closeRel := strings.Index(body[mi:], "</details>")
if closeRel < 0 {
return "", body, false
}
closeIdx := mi + closeRel
content = strings.TrimSpace(body[mi+len(marker) : closeIdx])
remainder = body[:open] + body[closeIdx+len("</details>"):]
return content, remainder, true
}
// splitBody separates an issue body into proposal, tasks, related-issue URLs,
// original ask, design notes, and discoveries. It drops the specsync identity
// marker and the managed sections (## Tasks, ## Related, ## Original ask,
// ## Design notes, ## Discoveries, ## Plan changes), and guarantees the
// proposal opens with an H1 derived from the issue title. This is the inverse
// of WorkItemFor rendering.
//
// Proposal, Original ask, Design notes, and Discoveries are extracted
// primarily by their "<!-- specsync:section=X -->" marker (the collapsed
// <details> format); bodies synced by an older specsync — bare "## Original
// ask" etc. headings, no <details> — fall back to the legacy line-based parse
// below, so pulling an issue that hasn't been re-synced yet still works. A
// design-notes section that overflowed to a linked comment renders as a
// short stub with neither the marker nor real content, so it naturally
// yields an empty designNotes here — callers needing the overflowed content
// use the comment-reading path (ReadDesignNotesComment) instead.
func splitBody(body, title string) (proposal, tasks string, relatedURLs []string, originalAsk, designNotes, discoveries string) {
propNew, body, haveProp := extractSection(body, "proposal")
askNew, body, haveAsk := extractSection(body, "original-ask")
designNew, body, haveDesign := extractSection(body, "design-notes")
discNew, body, haveDisc := extractSection(body, "discoveries")
var prop, tsk, ask, design, disc []string
inTasks := false
inRelated := false
inOriginalAsk := false
inDesignNotes := false
inDiscoveries := false
inPlanChanges := false
resetSections := func() {
inTasks = false
inRelated = false
inOriginalAsk = false
inDesignNotes = false
inDiscoveries = false
inPlanChanges = false
}
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "<!-- specsync:change=") {
continue
}
trimmed := strings.TrimSpace(line)
// Check managed section headers first — they can appear even while
// inside another managed section (e.g. ## Tasks after ## Original ask).
switch trimmed {
case "## Tasks":
resetSections()
inTasks = true
continue
case "## Related":
resetSections()
inRelated = true
continue
case "## Original ask":
resetSections()
inOriginalAsk = true
continue
case "## Design notes":
resetSections()
inDesignNotes = true
continue
case "## Discoveries":
resetSections()
inDiscoveries = true
continue
case "## Plan changes":
resetSections()
inPlanChanges = true
continue
}
// A new (non-managed) H2 ends the current managed section and returns
// to proposal content.
if strings.HasPrefix(trimmed, "## ") {
resetSections()
prop = append(prop, line)
continue
}
switch {
case inTasks:
tsk = append(tsk, line)
case inRelated:
// Collect URLs from "- [label](url)" or "- url" entries.
if strings.HasPrefix(trimmed, "- ") {
entry := strings.TrimSpace(trimmed[2:])
if u := extractURL(entry); u != "" {
relatedURLs = append(relatedURLs, u)
}
}
case inOriginalAsk:
ask = append(ask, line)
case inDesignNotes:
design = append(design, line)
case inDiscoveries:
disc = append(disc, line)
case inPlanChanges:
// discard plan changes footer content
default:
prop = append(prop, line)
}
}
if haveProp {
proposal = strings.TrimSpace(propNew)
} else {
proposal = strings.TrimSpace(strings.Join(prop, "\n"))
}
if !startsWithH1(proposal) {
h1 := "# " + strings.TrimSpace(title)
if proposal == "" {
proposal = h1
} else {
proposal = h1 + "\n\n" + proposal
}
}
proposal += "\n"
tasks = strings.TrimSpace(strings.Join(tsk, "\n"))
if tasks != "" {
tasks += "\n"
}
if haveAsk {
originalAsk = strings.TrimSpace(askNew)
} else {
originalAsk = strings.TrimSpace(strings.Join(ask, "\n"))
}
if originalAsk != "" {
originalAsk += "\n"
}
if haveDesign {
designNotes = strings.TrimSpace(designNew)
} else {
designNotes = strings.TrimSpace(strings.Join(design, "\n"))
}
if designNotes != "" {
designNotes += "\n"
}
if haveDisc {
discoveries = strings.TrimSpace(discNew)
} else {
discoveries = strings.TrimSpace(strings.Join(disc, "\n"))
}
if discoveries != "" {
discoveries += "\n"
}
return proposal, tasks, relatedURLs, originalAsk, designNotes, discoveries
}
// extractURL pulls the href out of "[label](url)" or returns the string as-is
// if it looks like a bare URL.
func extractURL(s string) string {
if strings.HasPrefix(s, "[") {
if i := strings.Index(s, "]("); i >= 0 {
rest := s[i+2:]
if j := strings.Index(rest, ")"); j >= 0 {
return rest[:j]
}
}
}
if strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") {
return s
}
return ""
}
// startsWithH1 reports whether the first non-blank line is a markdown H1.
func startsWithH1(md string) bool {
for _, line := range strings.Split(md, "\n") {
t := strings.TrimSpace(line)
if t == "" {
continue
}
return strings.HasPrefix(t, "# ")
}
return false
}
// slugFromMarker returns the slug encoded in a specsync identity marker, or "".
func slugFromMarker(body string) string {
const open = "<!-- specsync:change="
i := strings.Index(body, open)
if i < 0 {
return ""
}
rest := body[i+len(open):]
j := strings.Index(rest, "-->")
if j < 0 {
return ""
}
return strings.TrimSpace(rest[:j])
}
// maxSlugLen caps a generated slug's length. Long enough for
// add-agent-help-command-style names with room to spare, short enough to
// type as a -change flag and to keep in every log line and path. Applies to
// the slug itself, not to a prefix a caller adds afterward (e.g. epic.go's
// "epic:" marker prefix) — the prefix is fixed overhead, not part of the
// generated name.
const maxSlugLen = 48
// slugify turns a title into a kebab-case slug: lowercase, with each run of
// non-alphanumeric characters collapsed to a single hyphen and trimmed, then
// capped at maxSlugLen.
func slugify(s string) string {
var b strings.Builder
pendingHyphen := false
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
if pendingHyphen && b.Len() > 0 {
b.WriteByte('-')
}
pendingHyphen = false
b.WriteRune(r)
default:
pendingHyphen = true
}
}
return capSlug(b.String(), maxSlugLen)
}
// capSlug truncates s to at most max characters, preferring a word boundary
// (the last hyphen at or before the cut) so truncation never splits a word.
// When the first word alone exceeds max, it hard-truncates instead of
// returning "" — an empty slug is treated as failure by every caller.
func capSlug(s string, max int) string {
if len(s) <= max {
return s
}
cut := s[:max]
if i := strings.LastIndexByte(cut, '-'); i > 0 {
cut = cut[:i]
}
return strings.TrimRight(cut, "-")
}
// dirExists reports whether path exists (any type), for the pull collision
// guard above.
func dirExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// refsContainIssue reports whether any cached ref in refs points at issueID —
// i.e. the existing directory tracks the very issue being pulled, making this
// an intentional refresh rather than a collision.
func refsContainIssue(refs map[string]Ref, issueID string) bool {
for _, r := range refs {
if r.ID == issueID {
return true
}
}
return false
}
// nextAvailableSlug finds the first "<base>-2", "<base>-3", ... whose change
// directory doesn't exist yet, trimming base to keep the result within
// maxSlugLen when the numeric suffix would otherwise push it over.
func nextAvailableSlug(openspecDir, base string) string {
for i := 2; i < 1000; i++ {
suffix := fmt.Sprintf("-%d", i)
candidate := base + suffix
if len(candidate) > maxSlugLen {
candidate = capSlug(base, maxSlugLen-len(suffix)) + suffix
}
if !dirExists(filepath.Join(openspecDir, "changes", candidate)) {
return candidate
}
}
// Exhausted a generous range of suffixes — fall back to the last one
// tried rather than looping forever; pull will simply overwrite it,
// same as today's pre-existing behavior.
return fmt.Sprintf("%s-999", base)
}