relay-flow 0.3.2-alpha → 0.3.3-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 +11 -11
- package/examples/beads-workflow.yaml +1 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/task/beads/beads.go +5 -2
- package/internal/task/beads/lifecycle_inheritance_test.go +44 -12
- package/internal/task/beads/runtime_config_test.go +4 -3
- package/internal/task/beads/status_test.go +34 -0
- package/internal/task/jira/jira.go +10 -6
- package/internal/task/jira/lifecycle_inheritance_test.go +25 -2
- package/internal/task/jira/rest/adf.go +16 -7
- package/internal/task/jira/rest/adf_test.go +82 -2
- package/internal/task/jira/rest/client_test.go +54 -0
- package/internal/task/jira/status_defaults_test.go +2 -2
- package/internal/task/jira/transition_defaults_test.go +37 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
24
24
|
**OpenCode**
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
|
-
opencode plugin relay-flow-plugin@0.3.
|
|
27
|
+
opencode plugin relay-flow-plugin@0.3.3-alpha
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
**Pi**
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
33
|
+
pi install npm:relay-flow-plugin@0.3.3-alpha
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -160,7 +160,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
160
160
|
```json
|
|
161
161
|
{
|
|
162
162
|
"$schema": "https://opencode.ai/config.json",
|
|
163
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
163
|
+
"plugin": ["relay-flow-plugin@0.3.3-alpha"]
|
|
164
164
|
}
|
|
165
165
|
```
|
|
166
166
|
|
|
@@ -169,7 +169,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
169
169
|
```json
|
|
170
170
|
{
|
|
171
171
|
"$schema": "https://opencode.ai/tui.json",
|
|
172
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
172
|
+
"plugin": ["relay-flow-plugin@0.3.3-alpha"]
|
|
173
173
|
}
|
|
174
174
|
```
|
|
175
175
|
|
|
@@ -185,7 +185,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
185
185
|
settings before starting a Pi harness session:
|
|
186
186
|
|
|
187
187
|
```sh
|
|
188
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
188
|
+
pi install npm:relay-flow-plugin@0.3.3-alpha
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -379,7 +379,7 @@ Jira and Beads use the same conceptual `Task` issue type, but each adapter
|
|
|
379
379
|
keeps its provider-native spelling: Jira uses `Task` and Beads uses `task`.
|
|
380
380
|
Filter values are exact and are not translated between providers.
|
|
381
381
|
|
|
382
|
-
The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`,
|
|
382
|
+
The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, both the parent and current work-node mailbox to `in_progress` when work begins, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
|
|
383
383
|
|
|
384
384
|
Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow; each registered code repository still supplies its `beadsDir`, and repositories that share that workspace are isolated by their derived `repo:` labels.
|
|
385
385
|
|
|
@@ -494,18 +494,18 @@ Rules enforced at submit:
|
|
|
494
494
|
|
|
495
495
|
### Jira transition defaults
|
|
496
496
|
|
|
497
|
-
Omitted transitions
|
|
497
|
+
Omitted transitions use the repository-configured Jira lifecycle statuses:
|
|
498
498
|
|
|
499
|
-
- `start`: parent → `
|
|
500
|
-
- work node: mailbox → `
|
|
501
|
-
- `end`: parent → `
|
|
499
|
+
- `start`: parent → `statusDefaults.start`
|
|
500
|
+
- work node: parent and current mailbox → `statusDefaults.work` before the node runtime launches
|
|
501
|
+
- `end`: parent → `statusDefaults.end`
|
|
502
502
|
|
|
503
503
|
### Beads transition defaults
|
|
504
504
|
|
|
505
505
|
Beads follows the same lifecycle shape with Beads-native values:
|
|
506
506
|
|
|
507
507
|
- `start`: parent → `in_progress`
|
|
508
|
-
- work node: mailbox → `in_progress`
|
|
508
|
+
- work node: parent and mailbox → `in_progress`
|
|
509
509
|
- `end`: parent → `closed`
|
|
510
510
|
|
|
511
511
|
A parent moved to `in_progress` stays visible to the claimed-parent poll, which reads `open,in_progress,blocked,deferred`. Entering a node reuses that node's mailbox: a fresh mailbox moves `open → in_progress` and a revisited one moves `closed → in_progress`. Beads reads the issue before every status write, so an already-applied status is a no-op and a status relay-flow did not set (for example a human marking an issue `blocked`) blocks the transition and retries instead of being overwritten.
|
|
@@ -30,7 +30,7 @@ taskConfig:
|
|
|
30
30
|
|
|
31
31
|
# transitionTo describes one lifecycle point, so configure it on a node.
|
|
32
32
|
# Omitting it entirely already gives the Jira-equivalent lifecycle:
|
|
33
|
-
# start parent -> in_progress, work mailbox -> in_progress, end parent -> closed.
|
|
33
|
+
# start parent -> in_progress, work parent and mailbox -> in_progress, end parent -> closed.
|
|
34
34
|
# Values are Beads-native, not Jira values such as "In Progress" or "Done".
|
|
35
35
|
nodes:
|
|
36
36
|
start:
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.3-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -851,9 +851,12 @@ func (s *system) StartDefaults() config.RawValues {
|
|
|
851
851
|
return s.lifecycleDefaults(transitionDefault("parentStatus", statusInProgress))
|
|
852
852
|
}
|
|
853
853
|
|
|
854
|
-
// WorkDefaults starts
|
|
854
|
+
// WorkDefaults starts both the parent and current mailbox in progress.
|
|
855
855
|
func (s *system) WorkDefaults() config.RawValues {
|
|
856
|
-
return s.lifecycleDefaults(
|
|
856
|
+
return s.lifecycleDefaults(config.RawValues{"transitionTo": map[string]any{
|
|
857
|
+
"parentStatus": statusInProgress,
|
|
858
|
+
"taskStatus": statusInProgress,
|
|
859
|
+
}})
|
|
857
860
|
}
|
|
858
861
|
|
|
859
862
|
// EndDefaults closes the parent after workflow completion.
|
|
@@ -60,7 +60,7 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
|
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
t.Run("repo taskStatus reaches a work node", func(t *testing.T) {
|
|
63
|
-
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
63
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
64
64
|
sys := repoScopedSystem(client, config.RawValues{
|
|
65
65
|
"transitionTo": map[string]any{"taskStatus": "blocked"},
|
|
66
66
|
})
|
|
@@ -69,26 +69,30 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
|
|
|
69
69
|
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
70
70
|
t.Fatal(err)
|
|
71
71
|
}
|
|
72
|
-
if len(client.updates) != 1 || client.updates[0].input.Status != "blocked"
|
|
73
|
-
|
|
72
|
+
if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "blocked" ||
|
|
73
|
+
client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
|
|
74
|
+
t.Fatalf("updates = %+v, want inherited mailbox value followed by built-in parent work value", client.updates)
|
|
74
75
|
}
|
|
75
76
|
})
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
func TestLifecycleDefaultsCarryInheritedAssignee(t *testing.T) {
|
|
79
|
-
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
80
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
80
81
|
sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
|
|
81
82
|
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
|
|
82
83
|
|
|
83
84
|
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
84
85
|
t.Fatal(err)
|
|
85
86
|
}
|
|
86
|
-
if len(client.updates) !=
|
|
87
|
-
t.Fatalf("updates = %+v, want
|
|
87
|
+
if len(client.updates) != 2 {
|
|
88
|
+
t.Fatalf("updates = %+v, want mailbox and parent updates", client.updates)
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
-
if
|
|
91
|
-
t.Fatalf("update = %+v, want the built-in status plus the inherited repo assignee",
|
|
90
|
+
mailboxUpdate := client.updates[0].input
|
|
91
|
+
if mailboxUpdate.Status != "in_progress" || mailboxUpdate.Assignee != "repo-bot@example.com" {
|
|
92
|
+
t.Fatalf("mailbox update = %+v, want the built-in status plus the inherited repo assignee", mailboxUpdate)
|
|
93
|
+
}
|
|
94
|
+
if client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
|
|
95
|
+
t.Fatalf("parent update = %+v, want built-in parent work status", client.updates[1])
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -113,7 +117,7 @@ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
|
|
|
113
117
|
},
|
|
114
118
|
} {
|
|
115
119
|
t.Run(tc.name, func(t *testing.T) {
|
|
116
|
-
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
120
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
117
121
|
sys := repoScopedSystem(client, config.RawValues{
|
|
118
122
|
"transitionTo": map[string]any{"taskStatus": "blocked"},
|
|
119
123
|
})
|
|
@@ -122,13 +126,41 @@ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
|
|
|
122
126
|
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
123
127
|
t.Fatal(err)
|
|
124
128
|
}
|
|
125
|
-
if len(client.updates) !=
|
|
126
|
-
|
|
129
|
+
if len(client.updates) != 2 || client.updates[0].input.Status != tc.want ||
|
|
130
|
+
client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
|
|
131
|
+
t.Fatalf("updates = %+v, want mailbox %s followed by parent %s", client.updates, tc.want, statusInProgress)
|
|
127
132
|
}
|
|
128
133
|
})
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
func TestWorkDefaultsUseParentAndMailboxInProgress(t *testing.T) {
|
|
138
|
+
sys := repoScopedSystem(newStatusClient(nil), nil)
|
|
139
|
+
transition, ok := sys.WorkDefaults()["transitionTo"].(map[string]any)
|
|
140
|
+
if !ok {
|
|
141
|
+
t.Fatalf("WorkDefaults transitionTo = %#v, want map", sys.WorkDefaults()["transitionTo"])
|
|
142
|
+
}
|
|
143
|
+
if transition["parentStatus"] != statusInProgress || transition["taskStatus"] != statusInProgress {
|
|
144
|
+
t.Fatalf("WorkDefaults transitionTo = %#v, want both statuses %s", transition, statusInProgress)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func TestWorkNodeParentAndTaskOverridesAreIndependent(t *testing.T) {
|
|
149
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "in_progress"})
|
|
150
|
+
sys := repoScopedSystem(client, nil)
|
|
151
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(),
|
|
152
|
+
config.RawValues{"transitionTo": map[string]any{"parentStatus": "closed"}},
|
|
153
|
+
config.RawValues{"transitionTo": map[string]any{"taskStatus": "closed"}})
|
|
154
|
+
|
|
155
|
+
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
156
|
+
t.Fatal(err)
|
|
157
|
+
}
|
|
158
|
+
if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "closed" ||
|
|
159
|
+
client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != "closed" {
|
|
160
|
+
t.Fatalf("updates = %+v, want independent mailbox and parent overrides", client.updates)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
132
164
|
// With nothing inherited, the built-in lifecycle values still apply: the
|
|
133
165
|
// parent opens the run in_progress, a work mailbox moves to in_progress, and
|
|
134
166
|
// the parent closes at end.
|
|
@@ -13,7 +13,7 @@ import (
|
|
|
13
13
|
// mirrored by the Jira adapter.
|
|
14
14
|
|
|
15
15
|
func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
|
|
16
|
-
client := newStatusClient(map[string]string{"demo-parent.1": "open"})
|
|
16
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
17
17
|
sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
|
|
18
18
|
node := config.RawValues{"assignee": "dev@example.com"}
|
|
19
19
|
|
|
@@ -21,8 +21,9 @@ func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
|
|
|
21
21
|
if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
|
|
22
22
|
t.Fatal(err)
|
|
23
23
|
}
|
|
24
|
-
if len(client.updates) !=
|
|
25
|
-
|
|
24
|
+
if len(client.updates) != 2 || client.updates[0].input.Assignee != "dev@example.com" ||
|
|
25
|
+
client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
|
|
26
|
+
t.Fatalf("updates = %+v, want node assignee on mailbox and work status on parent", client.updates)
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -115,6 +115,40 @@ func TestApplyTaskConfigParentStatusUsesReadBeforeWrite(t *testing.T) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
func TestWorkNodeParentTransitionIsIdempotentAcrossEntries(t *testing.T) {
|
|
119
|
+
client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
|
|
120
|
+
sys := &system{cli: client}
|
|
121
|
+
target := statusTarget("demo-parent.1")
|
|
122
|
+
defaults := sys.WorkDefaults()
|
|
123
|
+
|
|
124
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, defaults); err != nil {
|
|
125
|
+
t.Fatal(err)
|
|
126
|
+
}
|
|
127
|
+
if err := sys.ApplyTaskConfig(context.Background(), target, defaults); err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != statusInProgress ||
|
|
131
|
+
client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
|
|
132
|
+
t.Fatalf("updates = %+v, want only first mailbox and parent transitions", client.updates)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func TestWorkNodeIncompatibleParentStateReturnsConflict(t *testing.T) {
|
|
137
|
+
client := newStatusClient(map[string]string{"demo-parent": "blocked", "demo-parent.1": "open"})
|
|
138
|
+
sys := &system{cli: client}
|
|
139
|
+
|
|
140
|
+
err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), sys.WorkDefaults())
|
|
141
|
+
if err == nil {
|
|
142
|
+
t.Fatal("incompatible parent status was accepted")
|
|
143
|
+
}
|
|
144
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
145
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
146
|
+
}
|
|
147
|
+
if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" {
|
|
148
|
+
t.Fatalf("updates = %+v, want only the mailbox update before parent conflict", client.updates)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
118
152
|
func TestCompleteMailboxUsesReadBeforeWriteAndIsIdempotent(t *testing.T) {
|
|
119
153
|
client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
|
|
120
154
|
sys := &system{cli: client}
|
|
@@ -643,10 +643,14 @@ func (s *system) StartDefaults() config.RawValues {
|
|
|
643
643
|
return s.lifecycleDefaults(transitionDefault("parentStatus", s.startStatus()))
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
-
// WorkDefaults uses the repository-selected active
|
|
647
|
-
//
|
|
646
|
+
// WorkDefaults uses the repository-selected active status for both the parent
|
|
647
|
+
// and the current mailbox.
|
|
648
648
|
func (s *system) WorkDefaults() config.RawValues {
|
|
649
|
-
|
|
649
|
+
status := s.workStatus()
|
|
650
|
+
return s.lifecycleDefaults(config.RawValues{"transitionTo": map[string]any{
|
|
651
|
+
"parentStatus": status,
|
|
652
|
+
"taskStatus": status,
|
|
653
|
+
}})
|
|
650
654
|
}
|
|
651
655
|
|
|
652
656
|
// EndDefaults uses the repository-selected parent completion status.
|
|
@@ -740,9 +744,9 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
|
|
|
740
744
|
|
|
741
745
|
// ApplyTaskConfig applies the adapter-owned taskConfig to the parent and
|
|
742
746
|
// optional mailbox. An omitted work-node taskStatus uses the repository's
|
|
743
|
-
// selected work status
|
|
744
|
-
//
|
|
745
|
-
//
|
|
747
|
+
// selected work status. Run orchestration merges WorkDefaults or EndDefaults
|
|
748
|
+
// into the node config before that call, so omitted work-node defaults apply
|
|
749
|
+
// the selected work status to both the parent and mailbox.
|
|
746
750
|
func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
|
|
747
751
|
cfg, err := decodeConfig(taskConfig)
|
|
748
752
|
if err != nil {
|
|
@@ -85,6 +85,9 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
|
|
|
85
85
|
t.Fatalf("mailbox transitions = %v, want the inherited repo value to beat the registered lifecycle value",
|
|
86
86
|
fake.taskTransitions)
|
|
87
87
|
}
|
|
88
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != defaultWorkTaskStatus {
|
|
89
|
+
t.Fatalf("parent transitions = %v, want the built-in work value", fake.parentTransitions)
|
|
90
|
+
}
|
|
88
91
|
})
|
|
89
92
|
}
|
|
90
93
|
|
|
@@ -160,8 +163,8 @@ func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
|
|
|
160
163
|
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.EndDefaults(), nil, nil)); err != nil {
|
|
161
164
|
t.Fatal(err)
|
|
162
165
|
}
|
|
163
|
-
if len(fake.parentTransitions) !=
|
|
164
|
-
t.Fatalf("parent transitions = %v, want [%s %s]", fake.parentTransitions, defaultStartParentStatus, defaultEndParentStatus)
|
|
166
|
+
if len(fake.parentTransitions) != 3 || fake.parentTransitions[0] != defaultStartParentStatus || fake.parentTransitions[1] != defaultWorkTaskStatus || fake.parentTransitions[2] != defaultEndParentStatus {
|
|
167
|
+
t.Fatalf("parent transitions = %v, want [%s %s %s]", fake.parentTransitions, defaultStartParentStatus, defaultWorkTaskStatus, defaultEndParentStatus)
|
|
165
168
|
}
|
|
166
169
|
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != defaultWorkTaskStatus {
|
|
167
170
|
t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, defaultWorkTaskStatus)
|
|
@@ -170,3 +173,23 @@ func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
|
|
|
170
173
|
t.Fatalf("assignments = %v, want none when no assignee is configured", fake.assignments)
|
|
171
174
|
}
|
|
172
175
|
}
|
|
176
|
+
|
|
177
|
+
func TestWorkNodeParentAndTaskOverridesAreIndependent(t *testing.T) {
|
|
178
|
+
fake := &fakeJira{}
|
|
179
|
+
sys := repoScopedSystem(t, fake, nil)
|
|
180
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
181
|
+
mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
|
|
182
|
+
cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(),
|
|
183
|
+
config.RawValues{"transitionTo": map[string]any{"parentStatus": "Workflow Parent"}},
|
|
184
|
+
config.RawValues{"transitionTo": map[string]any{"taskStatus": "Node Task"}})
|
|
185
|
+
|
|
186
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Workflow Parent" {
|
|
190
|
+
t.Fatalf("parent transitions = %v, want workflow parent override", fake.parentTransitions)
|
|
191
|
+
}
|
|
192
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "Node Task" {
|
|
193
|
+
t.Fatalf("mailbox transitions = %v, want node task override", fake.taskTransitions)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -97,10 +97,8 @@ func inlineContent(parent ast.Node, source []byte, marks []any) []any {
|
|
|
97
97
|
if value := string(node.Text(source)); value != "" {
|
|
98
98
|
content = append(content, textNode(value, marks))
|
|
99
99
|
}
|
|
100
|
-
if node.HardLineBreak() {
|
|
100
|
+
if node.HardLineBreak() || node.SoftLineBreak() {
|
|
101
101
|
content = append(content, map[string]any{"type": "hardBreak"})
|
|
102
|
-
} else if node.SoftLineBreak() {
|
|
103
|
-
content = append(content, textNode(" ", marks))
|
|
104
102
|
}
|
|
105
103
|
case *ast.String:
|
|
106
104
|
if value := string(node.Value); value != "" {
|
|
@@ -146,20 +144,31 @@ func textNode(value string, marks []any) map[string]any {
|
|
|
146
144
|
|
|
147
145
|
func ADFText(raw json.RawMessage) string {
|
|
148
146
|
var node struct {
|
|
147
|
+
Type string `json:"type"`
|
|
149
148
|
Text string `json:"text"`
|
|
150
149
|
Content []json.RawMessage `json:"content"`
|
|
151
150
|
}
|
|
152
151
|
if json.Unmarshal(raw, &node) != nil {
|
|
153
152
|
return ""
|
|
154
153
|
}
|
|
154
|
+
if node.Type == "hardBreak" {
|
|
155
|
+
return "\n"
|
|
156
|
+
}
|
|
157
|
+
if len(node.Content) == 0 {
|
|
158
|
+
return node.Text
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
separator := ""
|
|
162
|
+
switch node.Type {
|
|
163
|
+
case "doc", "blockquote", "bulletList", "orderedList", "listItem":
|
|
164
|
+
separator = "\n"
|
|
165
|
+
}
|
|
155
166
|
parts := make([]string, 0, len(node.Content)+1)
|
|
156
167
|
if node.Text != "" {
|
|
157
168
|
parts = append(parts, node.Text)
|
|
158
169
|
}
|
|
159
170
|
for _, child := range node.Content {
|
|
160
|
-
|
|
161
|
-
parts = append(parts, text)
|
|
162
|
-
}
|
|
171
|
+
parts = append(parts, ADFText(child))
|
|
163
172
|
}
|
|
164
|
-
return strings.Join(parts,
|
|
173
|
+
return strings.Join(parts, separator)
|
|
165
174
|
}
|
|
@@ -48,13 +48,93 @@ func TestADFDoesNotInferFormatting(t *testing.T) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
func TestADFPreservesTaskTextLineBreaks(t *testing.T) {
|
|
52
|
+
value := strings.Join([]string{
|
|
53
|
+
"Parent ticket: PAY-1",
|
|
54
|
+
"Workflow: relayFlow",
|
|
55
|
+
"Node: coding",
|
|
56
|
+
"Node type: agent",
|
|
57
|
+
"Agent: coder",
|
|
58
|
+
"Mailbox: PAY-1:coding",
|
|
59
|
+
"Node work: inspect the change",
|
|
60
|
+
"Keep this instruction on its own line",
|
|
61
|
+
}, "\n")
|
|
62
|
+
doc := ADF(value)
|
|
63
|
+
paragraph := doc["content"].([]any)[0].(map[string]any)
|
|
64
|
+
inline := paragraph["content"].([]any)
|
|
65
|
+
hardBreaks := 0
|
|
66
|
+
for _, item := range inline {
|
|
67
|
+
if item.(map[string]any)["type"] == "hardBreak" {
|
|
68
|
+
hardBreaks++
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if hardBreaks != strings.Count(value, "\n") {
|
|
72
|
+
t.Fatalf("hard break count = %d, want %d: %#v", hardBreaks, strings.Count(value, "\n"), inline)
|
|
73
|
+
}
|
|
74
|
+
raw, err := json.Marshal(doc)
|
|
75
|
+
if err != nil {
|
|
76
|
+
t.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
if got := ADFText(raw); got != value {
|
|
79
|
+
t.Fatalf("ADFText() = %q, want %q", got, value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func TestADFReportLabelsRemainSeparate(t *testing.T) {
|
|
84
|
+
value := strings.Join([]string{
|
|
85
|
+
"STATUS: success",
|
|
86
|
+
"NEXT STEP: reviewer",
|
|
87
|
+
"",
|
|
88
|
+
"SUMMARY:",
|
|
89
|
+
"COMPLETED: implemented",
|
|
90
|
+
"COMMITS: abc123",
|
|
91
|
+
"NOT COMPLETED: None",
|
|
92
|
+
"ISSUES DISCOVERED: None",
|
|
93
|
+
"VERIFICATION: go test ./...",
|
|
94
|
+
"NOTES: None",
|
|
95
|
+
"",
|
|
96
|
+
"FEEDBACK:",
|
|
97
|
+
"REASON FOR NEXT STEP: ready",
|
|
98
|
+
"REQUIRED ACTIONS: None",
|
|
99
|
+
"RELEVANT CONTEXT: None",
|
|
100
|
+
"EXPECTED RESULT: review",
|
|
101
|
+
}, "\n")
|
|
102
|
+
raw, err := json.Marshal(ADF(value))
|
|
103
|
+
if err != nil {
|
|
104
|
+
t.Fatal(err)
|
|
105
|
+
}
|
|
106
|
+
got := ADFText(raw)
|
|
107
|
+
for _, want := range []string{
|
|
108
|
+
"STATUS: success", "NEXT STEP: reviewer", "SUMMARY:",
|
|
109
|
+
"COMPLETED: implemented", "COMMITS: abc123", "NOT COMPLETED: None",
|
|
110
|
+
"ISSUES DISCOVERED: None", "VERIFICATION: go test ./...", "NOTES: None",
|
|
111
|
+
"FEEDBACK:", "REASON FOR NEXT STEP: ready", "REQUIRED ACTIONS: None",
|
|
112
|
+
"RELEVANT CONTEXT: None", "EXPECTED RESULT: review",
|
|
113
|
+
} {
|
|
114
|
+
found := false
|
|
115
|
+
for _, line := range strings.Split(got, "\n") {
|
|
116
|
+
if line == want {
|
|
117
|
+
found = true
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if !found {
|
|
122
|
+
t.Errorf("ADFText() = %q, missing separate report line %q", got, want)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
51
127
|
func TestADFMarkerRoundTrip(t *testing.T) {
|
|
52
128
|
const marker = "<!-- run-1:summary -->"
|
|
53
|
-
raw, err := json.Marshal(ADF("Done
|
|
129
|
+
raw, err := json.Marshal(ADF("Done on line one\nDone on line two\n\n" + marker))
|
|
54
130
|
if err != nil {
|
|
55
131
|
t.Fatal(err)
|
|
56
132
|
}
|
|
57
|
-
|
|
133
|
+
got := ADFText(raw)
|
|
134
|
+
if !strings.Contains(got, "Done on line one\nDone on line two") {
|
|
135
|
+
t.Fatalf("ADFText() lost hard break: %q", got)
|
|
136
|
+
}
|
|
137
|
+
if !strings.Contains(got, marker) {
|
|
58
138
|
t.Fatalf("ADFText() = %q, want marker %q", got, marker)
|
|
59
139
|
}
|
|
60
140
|
}
|
|
@@ -183,6 +183,9 @@ func TestCreateSubtasksBatchesAndIncludesADFParentAndLabel(t *testing.T) {
|
|
|
183
183
|
if !strings.Contains(string(update.Fields["description"]), `"type":"doc"`) {
|
|
184
184
|
t.Fatalf("description is not ADF: %s", update.Fields["description"])
|
|
185
185
|
}
|
|
186
|
+
if got := ADFText(update.Fields["description"]); got != "Work:\nDo it" {
|
|
187
|
+
t.Fatalf("description lost its line break: %q", got)
|
|
188
|
+
}
|
|
186
189
|
created[i] = map[string]any{"id": fmt.Sprint(i + 1), "key": fmt.Sprintf("PAY-%d", i+2)}
|
|
187
190
|
}
|
|
188
191
|
writeJSON(w, map[string]any{"issues": created, "errors": []any{}})
|
|
@@ -274,6 +277,17 @@ func TestUpdateMailboxIsOneCall(t *testing.T) {
|
|
|
274
277
|
if !strings.Contains(string(body), `"description"`) || !strings.Contains(string(body), `"labels"`) {
|
|
275
278
|
t.Fatalf("combined update missing fields: %s", body)
|
|
276
279
|
}
|
|
280
|
+
var request struct {
|
|
281
|
+
Fields struct {
|
|
282
|
+
Description json.RawMessage `json:"description"`
|
|
283
|
+
} `json:"fields"`
|
|
284
|
+
}
|
|
285
|
+
if err := json.Unmarshal(body, &request); err != nil {
|
|
286
|
+
t.Fatal(err)
|
|
287
|
+
}
|
|
288
|
+
if got := ADFText(request.Fields.Description); got != "Work:\nDo it" {
|
|
289
|
+
t.Fatalf("mailbox description lost its line break: %q", got)
|
|
290
|
+
}
|
|
277
291
|
w.WriteHeader(http.StatusNoContent)
|
|
278
292
|
})
|
|
279
293
|
if err := s.client(t).UpdateMailbox(context.Background(), "PAY-2", "Work:\nDo it", "wf:flow"); err != nil {
|
|
@@ -338,6 +352,15 @@ func TestCommentsUseADFAndParseMarker(t *testing.T) {
|
|
|
338
352
|
if !strings.Contains(string(body), `"type":"doc"`) {
|
|
339
353
|
t.Fatalf("comment is not ADF: %s", body)
|
|
340
354
|
}
|
|
355
|
+
var request struct {
|
|
356
|
+
Body json.RawMessage `json:"body"`
|
|
357
|
+
}
|
|
358
|
+
if err := json.Unmarshal(body, &request); err != nil {
|
|
359
|
+
t.Fatal(err)
|
|
360
|
+
}
|
|
361
|
+
if got := ADFText(request.Body); got != "SUMMARY:\nDone" {
|
|
362
|
+
t.Fatalf("comment lost its line break: %q", got)
|
|
363
|
+
}
|
|
341
364
|
w.WriteHeader(http.StatusCreated)
|
|
342
365
|
})
|
|
343
366
|
c := s.client(t)
|
|
@@ -350,6 +373,37 @@ func TestCommentsUseADFAndParseMarker(t *testing.T) {
|
|
|
350
373
|
}
|
|
351
374
|
}
|
|
352
375
|
|
|
376
|
+
func TestCommentsPreserveMultilineReportsAndMarkers(t *testing.T) {
|
|
377
|
+
want := []string{
|
|
378
|
+
"SUMMARY:\nCOMPLETED: implemented\nCOMMITS: abc123",
|
|
379
|
+
"FEEDBACK:\nREASON FOR NEXT STEP: ready\nREQUIRED ACTIONS: fix it\n<!-- visit:feedback -->",
|
|
380
|
+
}
|
|
381
|
+
got := make([]string, 0, len(want))
|
|
382
|
+
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
383
|
+
if r.Method != http.MethodPost || r.URL.Path != "/rest/api/3/issue/PAY-2/comment" {
|
|
384
|
+
http.NotFound(w, r)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
var request struct {
|
|
388
|
+
Body json.RawMessage `json:"body"`
|
|
389
|
+
}
|
|
390
|
+
if err := json.Unmarshal(body, &request); err != nil {
|
|
391
|
+
t.Fatal(err)
|
|
392
|
+
}
|
|
393
|
+
got = append(got, ADFText(request.Body))
|
|
394
|
+
w.WriteHeader(http.StatusCreated)
|
|
395
|
+
})
|
|
396
|
+
client := s.client(t)
|
|
397
|
+
for _, body := range want {
|
|
398
|
+
if err := client.AddComment(context.Background(), "PAY-2", body); err != nil {
|
|
399
|
+
t.Fatal(err)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if !reflect.DeepEqual(got, want) {
|
|
403
|
+
t.Fatalf("comments = %#v, want %#v", got, want)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
353
407
|
func TestRetries429WithoutExposingCredentials(t *testing.T) {
|
|
354
408
|
calls := 0
|
|
355
409
|
s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|
@@ -54,8 +54,8 @@ func TestRepositoryStatusDefaultsDriveLifecycleAndMailboxCompletion(t *testing.T
|
|
|
54
54
|
if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
|
|
55
55
|
t.Fatal(err)
|
|
56
56
|
}
|
|
57
|
-
if len(fake.parentTransitions) !=
|
|
58
|
-
t.Fatalf("parent transitions = %v, want [Open]", fake.parentTransitions)
|
|
57
|
+
if len(fake.parentTransitions) != 2 || fake.parentTransitions[0] != "Open" || fake.parentTransitions[1] != "Working" {
|
|
58
|
+
t.Fatalf("parent transitions = %v, want [Open Working]", fake.parentTransitions)
|
|
59
59
|
}
|
|
60
60
|
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "Working" || fake.taskTransitions[1] != "Closed" {
|
|
61
61
|
t.Fatalf("mailbox transitions = %v, want [Working Closed]", fake.taskTransitions)
|
|
@@ -11,8 +11,8 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
// Jira repository status defaults are deterministic: omitted transitions use
|
|
14
|
-
// the registered start/work/end values, and
|
|
15
|
-
//
|
|
14
|
+
// the registered start/work/end values, and omitted work-node defaults apply
|
|
15
|
+
// the work status to both the parent and mailbox.
|
|
16
16
|
//
|
|
17
17
|
// This test is package-local (package jira) so it can drive the adapter's
|
|
18
18
|
// ApplyTaskConfig against a test-local fake client without inventing an
|
|
@@ -67,20 +67,21 @@ func TestStartDefaultParentInProgress(t *testing.T) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
func
|
|
70
|
+
func TestWorkNodeDefaultParentAndMailboxInProgress(t *testing.T) {
|
|
71
71
|
fake := &fakeJira{}
|
|
72
72
|
sys := newSystemWithFake(t, fake)
|
|
73
73
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
74
74
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
defaults := sys.(task.LifecycleDefaults).WorkDefaults()
|
|
77
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, defaults); err != nil {
|
|
77
78
|
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
78
79
|
}
|
|
79
80
|
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Progress" {
|
|
80
81
|
t.Fatalf("mailbox transitions = %v, want [In Progress]", fake.taskTransitions)
|
|
81
82
|
}
|
|
82
|
-
if len(fake.parentTransitions) != 0 {
|
|
83
|
-
t.Fatalf("parent transitions = %v, want
|
|
83
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
|
|
84
|
+
t.Fatalf("parent transitions = %v, want [In Progress]", fake.parentTransitions)
|
|
84
85
|
}
|
|
85
86
|
if len(fake.assignments) != 0 {
|
|
86
87
|
t.Fatalf("mailbox assignments = %v, want none when assignee omitted", fake.assignments)
|
|
@@ -93,17 +94,21 @@ func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
|
|
|
93
94
|
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
94
95
|
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
95
96
|
|
|
96
|
-
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.Merge(
|
|
98
|
+
sys.(task.LifecycleDefaults).WorkDefaults(),
|
|
99
|
+
config.RawValues{"assignee": "reviewer@example.com"},
|
|
100
|
+
))
|
|
99
101
|
if err != nil {
|
|
100
102
|
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
101
103
|
}
|
|
102
104
|
if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
|
|
103
105
|
t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
|
|
104
106
|
}
|
|
105
|
-
if len(fake.events) !=
|
|
106
|
-
t.Fatalf("
|
|
107
|
+
if len(fake.events) != 2 || fake.events[0] != "transition" || fake.events[1] != "transition" {
|
|
108
|
+
t.Fatalf("transition events = %v, want mailbox and parent transitions", fake.events)
|
|
109
|
+
}
|
|
110
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
|
|
111
|
+
t.Fatalf("parent transitions = %v, want [In Progress]", fake.parentTransitions)
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -178,6 +183,27 @@ func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
|
|
|
178
183
|
}
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
func TestWorkDefaultsUseConfiguredWorkStatusForParentAndMailbox(t *testing.T) {
|
|
187
|
+
fake := &fakeJira{}
|
|
188
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
|
|
189
|
+
Name: "payments",
|
|
190
|
+
RepoConfig: config.RawValues{
|
|
191
|
+
"project": "PAY", "component": "api",
|
|
192
|
+
"statusDefaults": map[string]any{"start": "Open", "work": "Working", "end": "Closed"},
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
if err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
transition, ok := sys.WorkDefaults()["transitionTo"].(map[string]any)
|
|
199
|
+
if !ok {
|
|
200
|
+
t.Fatalf("WorkDefaults transitionTo = %#v, want map", sys.WorkDefaults()["transitionTo"])
|
|
201
|
+
}
|
|
202
|
+
if transition["parentStatus"] != "Working" || transition["taskStatus"] != "Working" {
|
|
203
|
+
t.Fatalf("WorkDefaults transitionTo = %#v, want both statuses Working", transition)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
181
207
|
func TestExplicitTransitionsWin(t *testing.T) {
|
|
182
208
|
fake := &fakeJira{}
|
|
183
209
|
sys := newSystemWithFake(t, fake)
|