relay-flow 0.2.0-alpha → 0.2.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
// Package beads is the Beads task-system adapter. Beads owns its storage and
|
|
2
|
+
// workspace semantics; this package keeps those details behind the task.System
|
|
3
|
+
// boundary and talks to the bd CLI through bdcli.
|
|
4
|
+
package beads
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"regexp"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Config is the Beads-owned task configuration shared by root, repository,
|
|
24
|
+
// workflow, and node scopes.
|
|
25
|
+
type Config struct {
|
|
26
|
+
BeadsDir string `yaml:"beadsDir"`
|
|
27
|
+
Assignee string `yaml:"assignee,omitempty"`
|
|
28
|
+
Filters Filters `yaml:"filters,omitempty"`
|
|
29
|
+
Transition TransitionTo `yaml:"transitionTo,omitempty"`
|
|
30
|
+
Templates Templates `yaml:"templates,omitempty"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Filters are the structured, locally evaluated Beads ticket filters.
|
|
34
|
+
type Filters struct {
|
|
35
|
+
ParentStatuses []string `yaml:"parentStatuses,omitempty"`
|
|
36
|
+
IssueTypes []string `yaml:"issueTypes,omitempty"`
|
|
37
|
+
Labels []string `yaml:"labels,omitempty"`
|
|
38
|
+
Assignees []string `yaml:"assignees,omitempty"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// TransitionTo carries shared parent and mailbox status transitions.
|
|
42
|
+
type TransitionTo struct {
|
|
43
|
+
ParentStatus string `yaml:"parentStatus,omitempty"`
|
|
44
|
+
TaskStatus string `yaml:"taskStatus,omitempty"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Templates are Beads-owned mailbox and comment templates.
|
|
48
|
+
type Templates struct {
|
|
49
|
+
MailboxDescription string `yaml:"mailboxDescription"`
|
|
50
|
+
SummaryComment string `yaml:"summaryComment"`
|
|
51
|
+
FeedbackComment string `yaml:"feedbackComment"`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const (
|
|
55
|
+
defaultMailboxDescription = `Parent ticket: {{ticket}}
|
|
56
|
+
Workflow: {{workflow}}
|
|
57
|
+
Node: {{node}}
|
|
58
|
+
Node type: {{nodeType}}
|
|
59
|
+
Agent: {{agent}}
|
|
60
|
+
Mailbox: {{mailbox}}
|
|
61
|
+
|
|
62
|
+
Node work:
|
|
63
|
+
{{nodeDescription}}
|
|
64
|
+
|
|
65
|
+
Read this mailbox's comments for feedback from previous nodes.`
|
|
66
|
+
defaultSummaryComment = `Summary for {{node}}
|
|
67
|
+
|
|
68
|
+
{{summaryReport}}`
|
|
69
|
+
defaultFeedbackComment = `Feedback from {{sourceNode}} to {{targetNode}} mailbox {{mailbox}}
|
|
70
|
+
|
|
71
|
+
{{feedbackReport}}`
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// DefaultConfig supplies only Beads task-system text defaults. A fresh map is
|
|
75
|
+
// returned for every call so callers can merge or modify it independently.
|
|
76
|
+
func DefaultConfig() config.RawValues {
|
|
77
|
+
return config.RawValues{"templates": map[string]any{
|
|
78
|
+
"mailboxDescription": defaultMailboxDescription,
|
|
79
|
+
"summaryComment": defaultSummaryComment,
|
|
80
|
+
"feedbackComment": defaultFeedbackComment,
|
|
81
|
+
}}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func init() {
|
|
85
|
+
task.Register("beads", task.Factory{
|
|
86
|
+
RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
|
|
87
|
+
TaskScopeKey: beadsTaskScopeKey,
|
|
88
|
+
Auth: beadsAuth,
|
|
89
|
+
DefaultConfig: DefaultConfig,
|
|
90
|
+
ValidateTextConfig: validateTextConfig,
|
|
91
|
+
New: newSystem,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Beads status names relay-flow itself applies. Every other Beads status is
|
|
96
|
+
// treated as a human-owned state that blocks a transition.
|
|
97
|
+
const (
|
|
98
|
+
statusOpen = "open"
|
|
99
|
+
statusInProgress = "in_progress"
|
|
100
|
+
statusClosed = "closed"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// system is one repo-bound Beads task system. Its CLI client serializes
|
|
104
|
+
// commands for the selected workspace, making the system safe for concurrent
|
|
105
|
+
// Repo Poller and durable-activity use.
|
|
106
|
+
type system struct {
|
|
107
|
+
cli bdcli.Client
|
|
108
|
+
mailboxMu sync.Mutex
|
|
109
|
+
base config.RawValues
|
|
110
|
+
effective Config
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// beadsTaskScopeKey returns the canonical physical Beads workspace. The
|
|
114
|
+
// workspace must be supplied by repoConfig; a root-level value never satisfies
|
|
115
|
+
// the required repo-scoped key.
|
|
116
|
+
func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
117
|
+
var root Config
|
|
118
|
+
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
119
|
+
return "", fmt.Errorf("root task config: %w", err)
|
|
120
|
+
}
|
|
121
|
+
var repo Config
|
|
122
|
+
if err := config.DecodeStrict(repoConfig, &repo); err != nil {
|
|
123
|
+
return "", fmt.Errorf("repo task config: %w", err)
|
|
124
|
+
}
|
|
125
|
+
if strings.TrimSpace(repo.BeadsDir) == "" {
|
|
126
|
+
return "", errors.New("beads task scope requires repo beadsDir")
|
|
127
|
+
}
|
|
128
|
+
return canonicalBeadsDir(repo.BeadsDir)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func canonicalBeadsDir(value string) (string, error) {
|
|
132
|
+
trimmed := strings.TrimSpace(value)
|
|
133
|
+
if trimmed == "" {
|
|
134
|
+
return "", errors.New("beadsDir must not be empty")
|
|
135
|
+
}
|
|
136
|
+
abs, err := filepath.Abs(trimmed)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return "", fmt.Errorf("resolve beadsDir %q: %w", trimmed, err)
|
|
139
|
+
}
|
|
140
|
+
info, err := os.Stat(abs)
|
|
141
|
+
if err != nil {
|
|
142
|
+
return "", fmt.Errorf("stat beadsDir %q: %w", trimmed, err)
|
|
143
|
+
}
|
|
144
|
+
if !info.IsDir() {
|
|
145
|
+
return "", fmt.Errorf("beadsDir %q is not a directory", trimmed)
|
|
146
|
+
}
|
|
147
|
+
resolved, err := filepath.EvalSymlinks(abs)
|
|
148
|
+
if err != nil {
|
|
149
|
+
return "", fmt.Errorf("canonicalize beadsDir %q: %w", trimmed, err)
|
|
150
|
+
}
|
|
151
|
+
return filepath.Clean(resolved), nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// newSystem constructs and probes a repo-bound Beads task system. It does not
|
|
155
|
+
// initialize a workspace or start any Beads/Dolt server.
|
|
156
|
+
func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
157
|
+
if strings.TrimSpace(spec.Name) == "" {
|
|
158
|
+
return nil, errors.New("beads: repo name is required")
|
|
159
|
+
}
|
|
160
|
+
// Validate the repo-scoped key before merging with root values. This keeps a
|
|
161
|
+
// root beadsDir from silently satisfying repository registration.
|
|
162
|
+
beadsDir, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
|
|
165
|
+
}
|
|
166
|
+
merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
|
|
167
|
+
cfg, err := decodeConfig(merged)
|
|
168
|
+
if err != nil {
|
|
169
|
+
return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
|
|
170
|
+
}
|
|
171
|
+
if err := validateTextConfig(merged); err != nil {
|
|
172
|
+
return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
|
|
173
|
+
}
|
|
174
|
+
cli := bdcli.New(spec.Path, beadsDir)
|
|
175
|
+
if err := cli.Probe(ctx); err != nil {
|
|
176
|
+
return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
|
|
177
|
+
}
|
|
178
|
+
return &system{cli: cli, base: merged, effective: cfg}, nil
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func decodeConfig(raw config.RawValues) (Config, error) {
|
|
182
|
+
var cfg Config
|
|
183
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
184
|
+
return Config{}, err
|
|
185
|
+
}
|
|
186
|
+
return cfg, nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// validateTextConfig performs strict Beads config and template validation.
|
|
190
|
+
func validateTextConfig(raw config.RawValues) error {
|
|
191
|
+
merged := config.Merge(DefaultConfig(), raw)
|
|
192
|
+
cfg, err := decodeConfig(merged)
|
|
193
|
+
if err != nil {
|
|
194
|
+
return err
|
|
195
|
+
}
|
|
196
|
+
return validateTemplates(cfg.Templates)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
var (
|
|
200
|
+
textVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
201
|
+
knownTextVars = map[string]bool{
|
|
202
|
+
"runID": true, "ticket": true, "workflow": true,
|
|
203
|
+
"repo": true, "node": true, "nodeType": true, "agent": true,
|
|
204
|
+
"nodeDescription": true, "nextSteps": true, "successRoutes": true,
|
|
205
|
+
"failureRoutes": true, "mailbox": true, "sourceNode": true,
|
|
206
|
+
"targetNode": true, "summaryReport": true, "feedbackReport": true,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
func validateTemplates(templates Templates) error {
|
|
211
|
+
for name, tmpl := range map[string]string{
|
|
212
|
+
"mailboxDescription": templates.MailboxDescription,
|
|
213
|
+
"summaryComment": templates.SummaryComment,
|
|
214
|
+
"feedbackComment": templates.FeedbackComment,
|
|
215
|
+
} {
|
|
216
|
+
for _, match := range textVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
217
|
+
if !knownTextVars[match[1]] {
|
|
218
|
+
return fmt.Errorf("%s: unknown template variable {{%s}}", name, match[1])
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
|
|
223
|
+
return errors.New("summaryComment must contain {{summaryReport}}")
|
|
224
|
+
}
|
|
225
|
+
if !strings.Contains(templates.FeedbackComment, "{{feedbackReport}}") {
|
|
226
|
+
return errors.New("feedbackComment must contain {{feedbackReport}}")
|
|
227
|
+
}
|
|
228
|
+
return nil
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// beadsAuth intentionally has no credential flow. Beads authentication and
|
|
232
|
+
// server credentials belong to the bd workspace; relay-flow must not create a
|
|
233
|
+
// credentials file. Non-empty arguments are rejected rather than ignored.
|
|
234
|
+
func beadsAuth(_ context.Context, args []string, _ io.Reader) error {
|
|
235
|
+
if len(args) != 0 {
|
|
236
|
+
return errors.New("beads task auth does not accept arguments")
|
|
237
|
+
}
|
|
238
|
+
return nil
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Poll reads ready and relay-owned active issues once each, merges overlapping
|
|
242
|
+
// results by issue ID, and returns only top-level issues. The CLI's
|
|
243
|
+
// --no-parent flag is an optimization rather than the correctness boundary:
|
|
244
|
+
// every returned issue is checked again before normalization.
|
|
245
|
+
func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
246
|
+
ready, err := s.cli.ListReady(ctx)
|
|
247
|
+
if err != nil {
|
|
248
|
+
return nil, err
|
|
249
|
+
}
|
|
250
|
+
claimed, err := s.cli.ListClaimed(ctx)
|
|
251
|
+
if err != nil {
|
|
252
|
+
return nil, err
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
issues := make(map[string]bdcli.Issue, len(ready)+len(claimed))
|
|
256
|
+
order := make([]string, 0, len(ready)+len(claimed))
|
|
257
|
+
for _, issue := range ready {
|
|
258
|
+
if _, exists := issues[issue.ID]; !exists {
|
|
259
|
+
order = append(order, issue.ID)
|
|
260
|
+
}
|
|
261
|
+
issues[issue.ID] = issue
|
|
262
|
+
}
|
|
263
|
+
for _, issue := range claimed {
|
|
264
|
+
if _, exists := issues[issue.ID]; !exists {
|
|
265
|
+
order = append(order, issue.ID)
|
|
266
|
+
}
|
|
267
|
+
// Claimed results are read after ready results and therefore replace an
|
|
268
|
+
// overlapping ready copy with its current labels/status.
|
|
269
|
+
issues[issue.ID] = issue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
tickets := make([]task.Ticket, 0, len(order))
|
|
273
|
+
for _, issueID := range order {
|
|
274
|
+
issue := issues[issueID]
|
|
275
|
+
if strings.TrimSpace(issue.Parent) != "" {
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
tickets = append(tickets, issueToTicket(issue))
|
|
279
|
+
}
|
|
280
|
+
return tickets, nil
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// issueToTicket converts the small Beads issue shape into the core ticket
|
|
284
|
+
// contract. Beads issue IDs are stable identities for both Ticket.ID and
|
|
285
|
+
// Ticket.Key; workflow labels are retained separately for routing and in the
|
|
286
|
+
// normalized fields for adapter-owned filter matching.
|
|
287
|
+
func issueToTicket(issue bdcli.Issue) task.Ticket {
|
|
288
|
+
return task.Ticket{
|
|
289
|
+
ID: issue.ID,
|
|
290
|
+
Key: issue.ID,
|
|
291
|
+
Title: issue.Title,
|
|
292
|
+
WorkflowClaims: extractWorkflowClaims(issue.Labels),
|
|
293
|
+
Fields: normalizeFields(issue),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func normalizeFields(issue bdcli.Issue) map[string]any {
|
|
298
|
+
return map[string]any{
|
|
299
|
+
"status": issue.Status,
|
|
300
|
+
"issueType": issue.IssueType,
|
|
301
|
+
"assignee": issue.Assignee,
|
|
302
|
+
"priority": issue.Priority,
|
|
303
|
+
"description": issue.Description,
|
|
304
|
+
"labels": append([]string(nil), issue.Labels...),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func extractWorkflowClaims(labels []string) []string {
|
|
309
|
+
claims := make([]string, 0)
|
|
310
|
+
for _, label := range labels {
|
|
311
|
+
if strings.HasPrefix(label, "wf:") && len(label) > len("wf:") {
|
|
312
|
+
claims = append(claims, label)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return claims
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func claimLabel(workflow string) string {
|
|
319
|
+
return "wf:" + workflow
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// CompileFilter compiles Beads-owned structured filters into a local matcher.
|
|
323
|
+
// No Beads query language is accepted or sent to the CLI.
|
|
324
|
+
func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
|
|
325
|
+
merged := config.Merge(s.base, workflowTaskConfig)
|
|
326
|
+
cfg, err := decodeConfig(merged)
|
|
327
|
+
if err != nil {
|
|
328
|
+
return nil, err
|
|
329
|
+
}
|
|
330
|
+
f := cfg.Filters
|
|
331
|
+
if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
|
|
332
|
+
f.Assignees = []string{cfg.Assignee}
|
|
333
|
+
}
|
|
334
|
+
return func(ticket task.Ticket) bool {
|
|
335
|
+
if len(f.ParentStatuses) > 0 && !containsExact(f.ParentStatuses, stringField(ticket.Fields, "status")) {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
if len(f.IssueTypes) > 0 && !containsExact(f.IssueTypes, stringField(ticket.Fields, "issueType")) {
|
|
339
|
+
return false
|
|
340
|
+
}
|
|
341
|
+
if len(f.Labels) > 0 {
|
|
342
|
+
labels := stringSliceField(ticket.Fields, "labels")
|
|
343
|
+
for _, required := range f.Labels {
|
|
344
|
+
if !containsExact(labels, required) {
|
|
345
|
+
return false
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if len(f.Assignees) > 0 && !containsFold(f.Assignees, stringField(ticket.Fields, "assignee")) {
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
return true
|
|
353
|
+
}, nil
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
func hasAssigneeFilter(raw config.RawValues) bool {
|
|
357
|
+
switch filters := raw["filters"].(type) {
|
|
358
|
+
case map[string]any:
|
|
359
|
+
_, ok := filters["assignees"]
|
|
360
|
+
return ok
|
|
361
|
+
case config.RawValues:
|
|
362
|
+
_, ok := filters["assignees"]
|
|
363
|
+
return ok
|
|
364
|
+
default:
|
|
365
|
+
return false
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
func containsExact(values []string, want string) bool {
|
|
370
|
+
for _, value := range values {
|
|
371
|
+
if value == want {
|
|
372
|
+
return true
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func containsFold(values []string, want string) bool {
|
|
379
|
+
for _, value := range values {
|
|
380
|
+
if strings.EqualFold(value, want) {
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
func stringField(fields map[string]any, key string) string {
|
|
388
|
+
value, _ := fields[key].(string)
|
|
389
|
+
return value
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
func stringSliceField(fields map[string]any, key string) []string {
|
|
393
|
+
value, _ := fields[key].([]string)
|
|
394
|
+
return value
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Claim adds the permanent workflow label to the parent issue. Routing has
|
|
398
|
+
// already resolved the workflow before this method runs, so no Beads ready or
|
|
399
|
+
// claim command is used and status/assignee are left untouched.
|
|
400
|
+
func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
|
|
401
|
+
if strings.TrimSpace(ticket.Key) == "" {
|
|
402
|
+
return errors.New("beads: ticket key is required to claim")
|
|
403
|
+
}
|
|
404
|
+
if strings.TrimSpace(workflow) == "" {
|
|
405
|
+
return errors.New("beads: workflow is required to claim")
|
|
406
|
+
}
|
|
407
|
+
return s.cli.Update(ctx, ticket.Key, bdcli.UpdateInput{
|
|
408
|
+
AddLabels: []string{claimLabel(workflow)},
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ValidateConfig strictly validates the merged workflow and node task
|
|
413
|
+
// configuration. It is intentionally local: no Beads command is needed to
|
|
414
|
+
// validate structured filters, statuses, or templates.
|
|
415
|
+
func (s *system) ValidateConfig(_ context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error {
|
|
416
|
+
workflowRaw := config.Merge(s.base, workflowTaskConfig)
|
|
417
|
+
workflowCfg, err := decodeConfig(workflowRaw)
|
|
418
|
+
if err != nil {
|
|
419
|
+
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
420
|
+
}
|
|
421
|
+
if err := validateBeadsConfig(workflowCfg); err != nil {
|
|
422
|
+
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
423
|
+
}
|
|
424
|
+
if _, ok := workflowTaskConfig["templates"]; ok {
|
|
425
|
+
return errors.New("template overrides are unsupported at workflow/node scope")
|
|
426
|
+
}
|
|
427
|
+
for node, raw := range nodeTaskConfigs {
|
|
428
|
+
if _, ok := raw["templates"]; ok {
|
|
429
|
+
return fmt.Errorf("node %q taskConfig: template overrides are unsupported at workflow/node scope", node)
|
|
430
|
+
}
|
|
431
|
+
cfg, err := decodeConfig(config.Merge(s.base, workflowTaskConfig, raw))
|
|
432
|
+
if err != nil {
|
|
433
|
+
return fmt.Errorf("node %q taskConfig: %w", node, err)
|
|
434
|
+
}
|
|
435
|
+
if err := validateBeadsConfig(cfg); err != nil {
|
|
436
|
+
return fmt.Errorf("node %q taskConfig: %w", node, err)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return nil
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
func validateBeadsConfig(cfg Config) error {
|
|
443
|
+
if err := validateTemplates(cfg.Templates); err != nil {
|
|
444
|
+
return fmt.Errorf("templates: %w", err)
|
|
445
|
+
}
|
|
446
|
+
if err := validateFilterValues(cfg.Filters); err != nil {
|
|
447
|
+
return fmt.Errorf("filters: %w", err)
|
|
448
|
+
}
|
|
449
|
+
if err := validateStatusValue("parentStatus", cfg.Transition.ParentStatus); err != nil {
|
|
450
|
+
return err
|
|
451
|
+
}
|
|
452
|
+
return validateStatusValue("taskStatus", cfg.Transition.TaskStatus)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
func validateFilterValues(filters Filters) error {
|
|
456
|
+
for name, values := range map[string][]string{
|
|
457
|
+
"parentStatuses": filters.ParentStatuses,
|
|
458
|
+
"issueTypes": filters.IssueTypes,
|
|
459
|
+
"labels": filters.Labels,
|
|
460
|
+
"assignees": filters.Assignees,
|
|
461
|
+
} {
|
|
462
|
+
for i, value := range values {
|
|
463
|
+
if strings.TrimSpace(value) == "" {
|
|
464
|
+
return fmt.Errorf("%s[%d] must not be empty", name, i)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return nil
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func validateStatusValue(name, value string) error {
|
|
472
|
+
if strings.TrimSpace(value) == "" {
|
|
473
|
+
return nil
|
|
474
|
+
}
|
|
475
|
+
// These are the status names used by the bd contract. Keep this validation
|
|
476
|
+
// local and reject typos before durable workflows are submitted.
|
|
477
|
+
switch value {
|
|
478
|
+
case "open", "in_progress", "blocked", "deferred", "hooked", "closed":
|
|
479
|
+
return nil
|
|
480
|
+
default:
|
|
481
|
+
return fmt.Errorf("transitionTo.%s %q is not a supported Beads status", name, value)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// RenderText expands the adapter-owned task-system templates using the same
|
|
486
|
+
// simple replacement rules as the other task adapters.
|
|
487
|
+
func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, error) {
|
|
488
|
+
var template string
|
|
489
|
+
switch kind {
|
|
490
|
+
case task.TextMailboxDescription:
|
|
491
|
+
template = s.effective.Templates.MailboxDescription
|
|
492
|
+
case task.TextSummaryComment:
|
|
493
|
+
template = s.effective.Templates.SummaryComment
|
|
494
|
+
case task.TextFeedbackComment:
|
|
495
|
+
template = s.effective.Templates.FeedbackComment
|
|
496
|
+
default:
|
|
497
|
+
return "", fmt.Errorf("beads: unknown task text kind %q", kind)
|
|
498
|
+
}
|
|
499
|
+
values := map[string]string{
|
|
500
|
+
"runID": data.RunID, "ticket": data.Ticket, "workflow": data.Workflow,
|
|
501
|
+
"repo": data.Repo, "node": data.Node, "nodeType": data.NodeType,
|
|
502
|
+
"agent": data.Agent, "nodeDescription": data.NodeDescription,
|
|
503
|
+
"nextSteps": data.NextSteps, "successRoutes": data.SuccessRoutes,
|
|
504
|
+
"failureRoutes": data.FailureRoutes, "mailbox": data.Mailbox,
|
|
505
|
+
"sourceNode": data.SourceNode, "targetNode": data.TargetNode,
|
|
506
|
+
"summaryReport": data.SummaryReport, "feedbackReport": data.FeedbackReport,
|
|
507
|
+
}
|
|
508
|
+
return textVarPattern.ReplaceAllStringFunc(template, func(match string) string {
|
|
509
|
+
parts := textVarPattern.FindStringSubmatch(match)
|
|
510
|
+
if len(parts) != 2 {
|
|
511
|
+
return match
|
|
512
|
+
}
|
|
513
|
+
return values[parts[1]]
|
|
514
|
+
}), nil
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// EnsureMailboxes finds reusable child issues by their stable title, updates
|
|
518
|
+
// existing descriptions/labels, creates only missing children, and returns a
|
|
519
|
+
// complete node-to-mailbox map.
|
|
520
|
+
func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, workflow string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
521
|
+
s.mailboxMu.Lock()
|
|
522
|
+
defer s.mailboxMu.Unlock()
|
|
523
|
+
|
|
524
|
+
parentID := parent.Key
|
|
525
|
+
if strings.TrimSpace(parentID) == "" {
|
|
526
|
+
parentID = parent.ID
|
|
527
|
+
}
|
|
528
|
+
if strings.TrimSpace(parentID) == "" {
|
|
529
|
+
return nil, errors.New("beads: parent key is required to ensure mailboxes")
|
|
530
|
+
}
|
|
531
|
+
if strings.TrimSpace(workflow) == "" {
|
|
532
|
+
return nil, errors.New("beads: workflow is required to ensure mailboxes")
|
|
533
|
+
}
|
|
534
|
+
children, err := s.cli.ListChildren(ctx, parentID)
|
|
535
|
+
if err != nil {
|
|
536
|
+
return nil, fmt.Errorf("list children of %q: %w", parentID, err)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
seenSpecs := make(map[string]struct{}, len(specs))
|
|
540
|
+
for _, spec := range specs {
|
|
541
|
+
if _, exists := seenSpecs[spec.Node]; exists {
|
|
542
|
+
return nil, fmt.Errorf("duplicate mailbox node %q", spec.Node)
|
|
543
|
+
}
|
|
544
|
+
seenSpecs[spec.Node] = struct{}{}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
type requestedMailbox struct {
|
|
548
|
+
spec task.MailboxSpec
|
|
549
|
+
title string
|
|
550
|
+
issue bdcli.Issue
|
|
551
|
+
}
|
|
552
|
+
requested := make([]requestedMailbox, 0, len(specs))
|
|
553
|
+
for _, spec := range specs {
|
|
554
|
+
title := mailboxTitle(parentID, spec.Node)
|
|
555
|
+
issue, err := findMailbox(children, title)
|
|
556
|
+
if err == nil {
|
|
557
|
+
requested = append(requested, requestedMailbox{spec: spec, title: title, issue: issue})
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
if !errors.Is(err, errMailboxNotFound) {
|
|
561
|
+
return nil, err
|
|
562
|
+
}
|
|
563
|
+
requested = append(requested, requestedMailbox{spec: spec, title: title})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
out := make(map[string]task.Mailbox, len(specs))
|
|
567
|
+
missing := make([]task.MailboxSpec, 0, len(specs))
|
|
568
|
+
for _, mailbox := range requested {
|
|
569
|
+
if mailbox.issue.ID != "" {
|
|
570
|
+
description := mailbox.spec.Description
|
|
571
|
+
if err := s.cli.Update(ctx, mailbox.issue.ID, bdcli.UpdateInput{
|
|
572
|
+
Description: &description,
|
|
573
|
+
AddLabels: []string{claimLabel(workflow)},
|
|
574
|
+
}); err != nil {
|
|
575
|
+
return nil, fmt.Errorf("reconcile mailbox %q: %w", mailbox.issue.ID, err)
|
|
576
|
+
}
|
|
577
|
+
out[mailbox.spec.Node] = issueToMailbox(mailbox.issue, mailbox.spec.Node)
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
missing = append(missing, task.MailboxSpec{
|
|
581
|
+
Node: mailbox.spec.Node,
|
|
582
|
+
Title: mailbox.title,
|
|
583
|
+
Description: mailbox.spec.Description,
|
|
584
|
+
TaskConfig: mailbox.spec.TaskConfig,
|
|
585
|
+
TextData: mailbox.spec.TextData,
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for _, spec := range missing {
|
|
590
|
+
issue, err := s.cli.CreateChild(ctx, parentID, spec.Title, spec.Description, claimLabel(workflow))
|
|
591
|
+
if err != nil {
|
|
592
|
+
return nil, fmt.Errorf("create mailbox %q: %w", spec.Title, err)
|
|
593
|
+
}
|
|
594
|
+
if issue.ID == "" {
|
|
595
|
+
return nil, fmt.Errorf("create mailbox %q returned no issue ID", spec.Title)
|
|
596
|
+
}
|
|
597
|
+
out[spec.Node] = issueToMailbox(issue, spec.Node)
|
|
598
|
+
}
|
|
599
|
+
return out, nil
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
func mailboxTitle(parentID, node string) string {
|
|
603
|
+
return parentID + ":" + node
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
var errMailboxNotFound = errors.New("mailbox not found")
|
|
607
|
+
|
|
608
|
+
func findMailbox(children []bdcli.Issue, title string) (bdcli.Issue, error) {
|
|
609
|
+
var found bdcli.Issue
|
|
610
|
+
foundMatch := false
|
|
611
|
+
for _, child := range children {
|
|
612
|
+
if child.Title != title {
|
|
613
|
+
continue
|
|
614
|
+
}
|
|
615
|
+
if foundMatch {
|
|
616
|
+
return bdcli.Issue{}, fmt.Errorf("duplicate mailbox title %q", title)
|
|
617
|
+
}
|
|
618
|
+
found = child
|
|
619
|
+
foundMatch = true
|
|
620
|
+
}
|
|
621
|
+
if !foundMatch {
|
|
622
|
+
return bdcli.Issue{}, fmt.Errorf("%w: %q", errMailboxNotFound, title)
|
|
623
|
+
}
|
|
624
|
+
if found.ID == "" {
|
|
625
|
+
return bdcli.Issue{}, fmt.Errorf("mailbox %q has no issue ID", title)
|
|
626
|
+
}
|
|
627
|
+
return found, nil
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
func issueToMailbox(issue bdcli.Issue, node string) task.Mailbox {
|
|
631
|
+
return task.Mailbox{ID: issue.ID, Key: issue.ID, Node: node}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ApplyTaskConfig applies the shared transitionTo values and the optional
|
|
635
|
+
// assignee to the operation's target. Inherited root/repo values reach this
|
|
636
|
+
// method through the lifecycle defaults, so the effective configuration is
|
|
637
|
+
// already merged in precedence order by the caller.
|
|
638
|
+
func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
|
|
639
|
+
cfg, err := decodeConfig(taskConfig)
|
|
640
|
+
if err != nil {
|
|
641
|
+
return err
|
|
642
|
+
}
|
|
643
|
+
if target.Mailbox != nil {
|
|
644
|
+
// The mailbox carries the node's own status and assignee; the parent is
|
|
645
|
+
// touched from a mailbox target only when parentStatus is configured.
|
|
646
|
+
if err := s.reconcileIssue(ctx, target.Mailbox.Key,
|
|
647
|
+
mailboxSources(cfg.Transition.TaskStatus), cfg.Transition.TaskStatus, cfg.Assignee); err != nil {
|
|
648
|
+
return err
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if cfg.Transition.ParentStatus == "" {
|
|
652
|
+
return nil
|
|
653
|
+
}
|
|
654
|
+
return s.reconcileIssue(ctx, target.Parent.Key,
|
|
655
|
+
parentSources(cfg.Transition.ParentStatus), cfg.Transition.ParentStatus, "")
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// mailboxSources lists the states a mailbox may hold before relay-flow moves
|
|
659
|
+
// it to target. A mailbox is entered from its initial open state or from the
|
|
660
|
+
// closed state left by CompleteMailbox on a workflow revisit, and is completed
|
|
661
|
+
// only from in_progress. Every other state is human-owned.
|
|
662
|
+
func mailboxSources(target string) []string {
|
|
663
|
+
switch target {
|
|
664
|
+
case statusInProgress:
|
|
665
|
+
return []string{statusOpen, statusClosed}
|
|
666
|
+
case statusClosed:
|
|
667
|
+
return []string{statusInProgress}
|
|
668
|
+
default:
|
|
669
|
+
return []string{statusOpen}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// parentSources lists the states a parent may hold before relay-flow moves it
|
|
674
|
+
// to target. relay-flow only ever drives a parent through open and
|
|
675
|
+
// in_progress, so a parent parked in any other state (for example a human
|
|
676
|
+
// setting blocked or deferred) blocks the transition instead of being
|
|
677
|
+
// overwritten, matching the mailbox rule.
|
|
678
|
+
func parentSources(target string) []string {
|
|
679
|
+
if target == statusClosed {
|
|
680
|
+
return []string{statusOpen, statusInProgress}
|
|
681
|
+
}
|
|
682
|
+
return []string{statusOpen}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// reconcileIssue reads the issue once and applies at most one bd update. A
|
|
686
|
+
// status already at target is an idempotent success, an unexpected status is a
|
|
687
|
+
// retryable conflict, and an expected status receives an unconditional update.
|
|
688
|
+
// The small race between this read and write is an accepted last-writer-wins
|
|
689
|
+
// behavior for this adapter; do not add --if-status or a fallback path.
|
|
690
|
+
func (s *system) reconcileIssue(ctx context.Context, issueID string, expected []string, target, assignee string) error {
|
|
691
|
+
if target == "" && assignee == "" {
|
|
692
|
+
return nil
|
|
693
|
+
}
|
|
694
|
+
if strings.TrimSpace(issueID) == "" {
|
|
695
|
+
return errors.New("beads: issue key is required for status reconciliation")
|
|
696
|
+
}
|
|
697
|
+
issue, err := s.cli.Show(ctx, issueID)
|
|
698
|
+
if err != nil {
|
|
699
|
+
return err
|
|
700
|
+
}
|
|
701
|
+
var input bdcli.UpdateInput
|
|
702
|
+
if target != "" && issue.Status != target {
|
|
703
|
+
if !containsExact(expected, issue.Status) {
|
|
704
|
+
return retry.ConflictError(fmt.Errorf(
|
|
705
|
+
"issue %q has status %q; expected one of [%s] before changing to %q",
|
|
706
|
+
issueID, issue.Status, strings.Join(expected, " "), target))
|
|
707
|
+
}
|
|
708
|
+
input.Status = target
|
|
709
|
+
}
|
|
710
|
+
if assignee != "" && issue.Assignee != assignee {
|
|
711
|
+
input.Assignee = assignee
|
|
712
|
+
}
|
|
713
|
+
if input.Status == "" && input.Assignee == "" {
|
|
714
|
+
return nil
|
|
715
|
+
}
|
|
716
|
+
return s.cli.Update(ctx, issueID, input)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// CompleteMailbox closes only the supplied mailbox. It performs the same
|
|
720
|
+
// read-before-write reconciliation as other Beads status operations: a closed
|
|
721
|
+
// mailbox is an idempotent success, an in-progress mailbox is unconditionally
|
|
722
|
+
// updated to closed, and an incompatible state is a retryable conflict.
|
|
723
|
+
func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
|
|
724
|
+
if strings.TrimSpace(mailbox.Key) == "" {
|
|
725
|
+
return errors.New("beads: mailbox key is required to complete")
|
|
726
|
+
}
|
|
727
|
+
return s.reconcileIssue(ctx, mailbox.Key, mailboxSources(statusClosed), statusClosed, "")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
func targetIssueID(target task.Target) string {
|
|
731
|
+
if target.Mailbox != nil && strings.TrimSpace(target.Mailbox.Key) != "" {
|
|
732
|
+
return target.Mailbox.Key
|
|
733
|
+
}
|
|
734
|
+
return target.Parent.Key
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// HasComment checks the selected issue's comments for a stable marker.
|
|
738
|
+
func (s *system) HasComment(ctx context.Context, target task.Target, marker string) (bool, error) {
|
|
739
|
+
issueID := targetIssueID(target)
|
|
740
|
+
if strings.TrimSpace(issueID) == "" {
|
|
741
|
+
return false, errors.New("beads: comment target key is required")
|
|
742
|
+
}
|
|
743
|
+
comments, err := s.cli.ListComments(ctx, issueID)
|
|
744
|
+
if err != nil {
|
|
745
|
+
return false, err
|
|
746
|
+
}
|
|
747
|
+
for _, comment := range comments {
|
|
748
|
+
if strings.Contains(comment.Text, marker) {
|
|
749
|
+
return true, nil
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return false, nil
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Comment checks for an existing marker before writing the marked body
|
|
756
|
+
// through bdcli's stdin-safe comment operation.
|
|
757
|
+
func (s *system) Comment(ctx context.Context, target task.Target, body, marker string) error {
|
|
758
|
+
issueID := targetIssueID(target)
|
|
759
|
+
if strings.TrimSpace(issueID) == "" {
|
|
760
|
+
return errors.New("beads: comment target key is required")
|
|
761
|
+
}
|
|
762
|
+
exists, err := s.HasComment(ctx, target, marker)
|
|
763
|
+
if err != nil {
|
|
764
|
+
return err
|
|
765
|
+
}
|
|
766
|
+
if exists {
|
|
767
|
+
return nil
|
|
768
|
+
}
|
|
769
|
+
return s.cli.AddComment(ctx, issueID, body+"\n\n<!-- "+marker+" -->")
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Lifecycle defaults carry both the built-in Beads lifecycle behavior and the
|
|
773
|
+
// inherited root/repo values for this operation. Returning them together is
|
|
774
|
+
// what makes the documented precedence hold at runtime: the caller merges
|
|
775
|
+
// these values underneath the workflow/node configuration, producing
|
|
776
|
+
// built-in default < root < repo < workflow < node.
|
|
777
|
+
|
|
778
|
+
// StartDefaults moves the parent to in_progress when a run starts, matching
|
|
779
|
+
// the Jira adapter. A claimed parent stays visible to the claimed-parent poll
|
|
780
|
+
// because that query includes in_progress.
|
|
781
|
+
func (s *system) StartDefaults() config.RawValues {
|
|
782
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", statusInProgress))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// WorkDefaults starts a work mailbox in progress and leaves the parent alone.
|
|
786
|
+
func (s *system) WorkDefaults() config.RawValues {
|
|
787
|
+
return s.lifecycleDefaults(transitionDefault("taskStatus", statusInProgress))
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// EndDefaults closes the parent after workflow completion.
|
|
791
|
+
func (s *system) EndDefaults() config.RawValues {
|
|
792
|
+
return s.lifecycleDefaults(transitionDefault("parentStatus", statusClosed))
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func transitionDefault(key, value string) config.RawValues {
|
|
796
|
+
return config.RawValues{"transitionTo": map[string]any{key: value}}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// lifecycleDefaults layers the inherited root/repo operation values over the
|
|
800
|
+
// built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
|
|
801
|
+
// carried, so unrelated configuration never enters durable activity inputs.
|
|
802
|
+
func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
|
|
803
|
+
inherited := config.RawValues{}
|
|
804
|
+
for _, key := range []string{"transitionTo", "assignee"} {
|
|
805
|
+
if value, ok := s.base[key]; ok {
|
|
806
|
+
inherited[key] = value
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return config.Merge(builtin, inherited)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ResetForRecovery reopens the parent and every known mailbox, clearing any
|
|
813
|
+
// deferred state while preserving comments, labels, descriptions, history,
|
|
814
|
+
// and issues themselves.
|
|
815
|
+
func (s *system) ResetForRecovery(ctx context.Context, parent task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
|
|
816
|
+
parentID := parent.Key
|
|
817
|
+
if strings.TrimSpace(parentID) == "" {
|
|
818
|
+
parentID = parent.ID
|
|
819
|
+
}
|
|
820
|
+
if strings.TrimSpace(parentID) == "" {
|
|
821
|
+
return errors.New("beads: parent key is required for recovery reset")
|
|
822
|
+
}
|
|
823
|
+
if err := s.cli.Update(ctx, parentID, bdcli.UpdateInput{Status: statusOpen, ClearDefer: true}); err != nil {
|
|
824
|
+
return fmt.Errorf("reset parent %q: %w", parentID, err)
|
|
825
|
+
}
|
|
826
|
+
for _, mailbox := range mailboxes {
|
|
827
|
+
if strings.TrimSpace(mailbox.Key) == "" {
|
|
828
|
+
return errors.New("beads: mailbox key is required for recovery reset")
|
|
829
|
+
}
|
|
830
|
+
if err := s.cli.Update(ctx, mailbox.Key, bdcli.UpdateInput{Status: statusOpen, ClearDefer: true}); err != nil {
|
|
831
|
+
return fmt.Errorf("reset mailbox %q: %w", mailbox.Key, err)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return nil
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
var (
|
|
838
|
+
_ task.System = (*system)(nil)
|
|
839
|
+
_ task.LifecycleDefaults = (*system)(nil)
|
|
840
|
+
)
|