-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpull.go
More file actions
272 lines (255 loc) · 8.38 KB
/
Copy pathpull.go
File metadata and controls
272 lines (255 loc) · 8.38 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
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")
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
IssueURL string
Proposal string
Tasks string
Links []string // URLs from the ## Related section, for dry-run display
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())
}
if strings.TrimSpace(opts.IssueID) == "" {
return PullResult{}, fmt.Errorf("issue id is required")
}
item, err := reader.Get(ctx, opts.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)
}
proposal, tasks, relatedURLs := splitBody(item.Body, item.Title)
res := PullResult{
Slug: slug,
Dir: filepath.Join(opts.OpenSpecDir, "changes", slug),
IssueURL: item.URL,
Proposal: proposal,
Tasks: tasks,
Links: relatedURLs,
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}
plan, perr := bp.ProjectOntoBoard(ctx, opts.Project, ref, pulledItem, opts.DryRun)
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)
}
}
// 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, refs); err != nil {
return PullResult{}, fmt.Errorf("write links.md: %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}
if err := saveRef(res.Dir, opts.Provider.Name(), ref); err != nil {
return PullResult{}, err
}
// 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
}
// splitBody separates an issue body into proposal, tasks, and related-issue
// URLs. It drops the specsync identity marker and the managed sections
// (## Tasks, ## Related), and guarantees the proposal opens with an H1
// derived from the issue title. This is the inverse of WorkItemFor rendering.
func splitBody(body, title string) (proposal, tasks string, relatedURLs []string) {
var prop, tsk []string
inTasks := false
inRelated := false
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "<!-- specsync:change=") {
continue
}
trimmed := strings.TrimSpace(line)
if !inTasks && !inRelated && trimmed == "## Tasks" {
inTasks = true
continue
}
if !inTasks && !inRelated && trimmed == "## Related" {
inRelated = true
continue
}
// A new H2 ends the managed sections and returns to proposal content.
if (inTasks || inRelated) && strings.HasPrefix(trimmed, "## ") {
inTasks = false
inRelated = false
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)
}
}
default:
prop = append(prop, line)
}
}
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"
}
return proposal, tasks, relatedURLs
}
// 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])
}
// slugify turns a title into a kebab-case slug: lowercase, with each run of
// non-alphanumeric characters collapsed to a single hyphen and trimmed.
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 b.String()
}