relay-flow 0.2.2-alpha → 0.2.4-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 +154 -13
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +27 -1
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +14 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +34 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +111 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +261 -0
- package/internal/harness/pi/pi_test.go +322 -0
- package/internal/harness/pi/prompt_test.go +121 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +168 -0
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +578 -0
- package/internal/runner/herdr/herdr_test.go +688 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +43 -17
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- package/package.json +1 -1
|
@@ -205,6 +205,17 @@ func (c *Client) RegisterNodeSession(ctx context.Context, registration run.NodeR
|
|
|
205
205
|
return ack, nil
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// RestartRun creates or returns the active fresh attempt for a canceled
|
|
209
|
+
// ticket. The server resolves the current repo/workflow and task-system
|
|
210
|
+
// boundaries; the client only transports the command.
|
|
211
|
+
func (c *Client) RestartRun(ctx context.Context, ticket string) (run.Run, error) {
|
|
212
|
+
var out run.Run
|
|
213
|
+
if err := c.call(ctx, http.MethodPost, "/runs/by-ticket/"+url.PathEscape(ticket)+"/restart", nil, &out); err != nil {
|
|
214
|
+
return run.Run{}, err
|
|
215
|
+
}
|
|
216
|
+
return out, nil
|
|
217
|
+
}
|
|
218
|
+
|
|
208
219
|
// CancelRun cancels the active run for the given ticket with a reason.
|
|
209
220
|
func (c *Client) CancelRun(ctx context.Context, ticket, reason string) error {
|
|
210
221
|
payload, _ := json.Marshal(map[string]string{"reason": reason})
|
|
@@ -34,6 +34,9 @@ type fakeServices struct {
|
|
|
34
34
|
runtimeAck run.NodeRuntimeRegistrationAck
|
|
35
35
|
processedReports map[string]bool
|
|
36
36
|
submittedReports int
|
|
37
|
+
restartRun run.Run
|
|
38
|
+
restartErr error
|
|
39
|
+
restarts []string
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
|
|
@@ -79,6 +82,21 @@ func (f *fakeServices) GetRunByTicket(_ context.Context, ticket string) (run.Run
|
|
|
79
82
|
}
|
|
80
83
|
return run.Run{}, errNotFound{ticket}
|
|
81
84
|
}
|
|
85
|
+
func (f *fakeServices) RestartRun(_ context.Context, ticket string) (run.Run, error) {
|
|
86
|
+
f.restarts = append(f.restarts, ticket)
|
|
87
|
+
if f.restartErr != nil {
|
|
88
|
+
return run.Run{}, f.restartErr
|
|
89
|
+
}
|
|
90
|
+
if f.restartRun.ID != "" {
|
|
91
|
+
return f.restartRun, nil
|
|
92
|
+
}
|
|
93
|
+
for _, r := range f.runs {
|
|
94
|
+
if r.Ticket.Key == ticket {
|
|
95
|
+
return r, nil
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return run.Run{}, errNotFound{ticket}
|
|
99
|
+
}
|
|
82
100
|
func (f *fakeServices) CancelRun(_ context.Context, ticket, _ string) error {
|
|
83
101
|
for i, r := range f.runs {
|
|
84
102
|
if r.Ticket.Key == ticket {
|
|
@@ -31,6 +31,7 @@ type Deps interface {
|
|
|
31
31
|
// Runs
|
|
32
32
|
ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error)
|
|
33
33
|
GetRunByTicket(ctx context.Context, ticket string) (run.Run, error)
|
|
34
|
+
RestartRun(ctx context.Context, ticket string) (run.Run, error)
|
|
34
35
|
CancelRun(ctx context.Context, ticket, reason string) error
|
|
35
36
|
|
|
36
37
|
// Reports
|
|
@@ -117,6 +118,8 @@ func writeEnv(w http.ResponseWriter, status int, env envelope) {
|
|
|
117
118
|
// anything else is an unexpected 500.
|
|
118
119
|
func mapErr(w http.ResponseWriter, err error) {
|
|
119
120
|
switch {
|
|
121
|
+
case errors.Is(err, run.ErrRestartConflict):
|
|
122
|
+
writeErr(w, http.StatusConflict, "conflict", err.Error())
|
|
120
123
|
case errors.Is(err, ErrNotFound):
|
|
121
124
|
writeErr(w, http.StatusNotFound, "notFound", err.Error())
|
|
122
125
|
case errors.Is(err, ErrConflict):
|
|
@@ -475,6 +478,7 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
|
|
475
478
|
|
|
476
479
|
func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
|
|
477
480
|
// /runs/by-ticket/{key} GET
|
|
481
|
+
// /runs/by-ticket/{key}/restart POST
|
|
478
482
|
// /runs/by-ticket/{key}/cancel POST
|
|
479
483
|
rest := strings.TrimPrefix(r.URL.Path, "/runs/by-ticket/")
|
|
480
484
|
parts := strings.Split(rest, "/")
|
|
@@ -490,6 +494,18 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
|
|
|
490
494
|
writeOK(w, http.StatusOK, rn)
|
|
491
495
|
return
|
|
492
496
|
}
|
|
497
|
+
if len(parts) == 2 && parts[0] != "" && parts[1] == "restart" {
|
|
498
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
rn, err := s.deps.RestartRun(r.Context(), parts[0])
|
|
502
|
+
if err != nil {
|
|
503
|
+
mapErr(w, err)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
writeOK(w, http.StatusOK, rn)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
493
509
|
if len(parts) == 2 && parts[0] != "" && parts[1] == "cancel" {
|
|
494
510
|
if !methodOnly(w, r, http.MethodPost) {
|
|
495
511
|
return
|
|
@@ -328,9 +328,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
328
328
|
return nil, err
|
|
329
329
|
}
|
|
330
330
|
f := cfg.Filters
|
|
331
|
-
if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
|
|
332
|
-
f.Assignees = []string{cfg.Assignee}
|
|
333
|
-
}
|
|
334
331
|
return func(ticket task.Ticket) bool {
|
|
335
332
|
if len(f.ParentStatuses) > 0 && !containsExact(f.ParentStatuses, stringField(ticket.Fields, "status")) {
|
|
336
333
|
return false
|
|
@@ -353,19 +350,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
353
350
|
}, nil
|
|
354
351
|
}
|
|
355
352
|
|
|
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
353
|
func containsExact(values []string, want string) bool {
|
|
370
354
|
for _, value := range values {
|
|
371
355
|
if value == want {
|
|
@@ -809,6 +793,21 @@ func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
|
|
|
809
793
|
return config.Merge(builtin, inherited)
|
|
810
794
|
}
|
|
811
795
|
|
|
796
|
+
// PrepareRestart reopens relay-owned mailbox state for a new explicit
|
|
797
|
+
// attempt. The parent is deliberately not changed here; the start task
|
|
798
|
+
// configuration checks the parent's current state and leaves a human-owned
|
|
799
|
+
// Blocked/Deferred/Closed status untouched. Mailbox states outside the
|
|
800
|
+
// relay-owned open/in_progress/closed set return a conflict.
|
|
801
|
+
func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
|
|
802
|
+
for _, mailbox := range mailboxes {
|
|
803
|
+
if err := s.reconcileIssue(ctx, mailbox.Key,
|
|
804
|
+
[]string{statusOpen, statusInProgress, statusClosed}, statusOpen, ""); err != nil {
|
|
805
|
+
return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return nil
|
|
809
|
+
}
|
|
810
|
+
|
|
812
811
|
// ResetForRecovery reopens the parent and every known mailbox, clearing any
|
|
813
812
|
// deferred state while preserving comments, labels, descriptions, history,
|
|
814
813
|
// and issues themselves.
|
|
@@ -837,4 +836,5 @@ func (s *system) ResetForRecovery(ctx context.Context, parent task.TicketRef, ma
|
|
|
837
836
|
var (
|
|
838
837
|
_ task.System = (*system)(nil)
|
|
839
838
|
_ task.LifecycleDefaults = (*system)(nil)
|
|
839
|
+
_ task.RestartPreparer = (*system)(nil)
|
|
840
840
|
)
|
|
@@ -50,7 +50,7 @@ func TestBeadsConfigRejectsJiraOnlyFields(t *testing.T) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
func
|
|
53
|
+
func TestBeadsCompileFilterDoesNotUseTopLevelAssignee(t *testing.T) {
|
|
54
54
|
sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
|
|
55
55
|
"assignee": "Repo.Bot@Example.com",
|
|
56
56
|
})}
|
|
@@ -58,15 +58,14 @@ func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
|
|
|
58
58
|
if err != nil {
|
|
59
59
|
t.Fatalf("CompileFilter failed: %v", err)
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
t.Fatal("ticket assigned to another user matched the default assignee filter")
|
|
61
|
+
for _, assignee := range []string{"repo.bot@example.COM", "other@example.com"} {
|
|
62
|
+
if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
|
|
63
|
+
t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
|
|
64
|
+
}
|
|
66
65
|
}
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
func
|
|
68
|
+
func TestBeadsExplicitAssigneeFilterIsUsed(t *testing.T) {
|
|
70
69
|
sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
|
|
71
70
|
"assignee": "repo@example.com",
|
|
72
71
|
})}
|
|
@@ -84,7 +83,7 @@ func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
|
|
|
84
83
|
}
|
|
85
84
|
for _, assignee := range []string{"repo@example.com", "workflow@example.com"} {
|
|
86
85
|
if match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
|
|
87
|
-
t.Fatalf("
|
|
86
|
+
t.Fatalf("explicit assignee filter accepted %q", assignee)
|
|
88
87
|
}
|
|
89
88
|
}
|
|
90
89
|
}
|
|
@@ -94,6 +94,49 @@ func TestApplyTaskConfigReopensClosedMailboxOnWorkflowRevisit(t *testing.T) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
func TestPrepareRestartReopensRelayOwnedMailboxes(t *testing.T) {
|
|
98
|
+
client := newStatusClient(map[string]string{
|
|
99
|
+
"demo-parent.1": "closed",
|
|
100
|
+
"demo-parent.2": "in_progress",
|
|
101
|
+
})
|
|
102
|
+
sys := &system{cli: client}
|
|
103
|
+
preparer, ok := task.System(sys).(task.RestartPreparer)
|
|
104
|
+
if !ok {
|
|
105
|
+
t.Fatal("Beads system does not implement RestartPreparer")
|
|
106
|
+
}
|
|
107
|
+
mailboxes := []task.Mailbox{
|
|
108
|
+
{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
109
|
+
{ID: "demo-parent.2", Key: "demo-parent.2", Node: "review"},
|
|
110
|
+
}
|
|
111
|
+
if err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "demo-parent"}, mailboxes); err != nil {
|
|
112
|
+
t.Fatalf("PrepareRestart failed: %v", err)
|
|
113
|
+
}
|
|
114
|
+
if len(client.updates) != 2 || client.updates[0].input.Status != "open" || client.updates[1].input.Status != "open" {
|
|
115
|
+
t.Fatalf("updates = %+v, want both relay-owned mailboxes reopened to open", client.updates)
|
|
116
|
+
}
|
|
117
|
+
if client.issues["demo-parent"].Status != "" {
|
|
118
|
+
t.Fatalf("parent was unexpectedly inspected or changed: %+v", client.issues["demo-parent"])
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestPrepareRestartDoesNotOverwriteHumanMailboxState(t *testing.T) {
|
|
123
|
+
client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
|
|
124
|
+
sys := &system{cli: client}
|
|
125
|
+
preparer := task.System(sys).(task.RestartPreparer)
|
|
126
|
+
err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "demo-parent"}, []task.Mailbox{
|
|
127
|
+
{ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
|
|
128
|
+
})
|
|
129
|
+
if err == nil {
|
|
130
|
+
t.Fatal("human-owned blocked mailbox state was overwritten")
|
|
131
|
+
}
|
|
132
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
133
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
134
|
+
}
|
|
135
|
+
if len(client.updates) != 0 {
|
|
136
|
+
t.Fatalf("human-owned mailbox state issued an update: %+v", client.updates)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
func TestApplyTaskConfigRejectsIncompatibleManualMailboxState(t *testing.T) {
|
|
98
141
|
client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
|
|
99
142
|
sys := &system{cli: client}
|
|
@@ -232,6 +232,33 @@ func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
func TestCompileFilterCurrentUserMatchesAuthenticatedJiraEmail(t *testing.T) {
|
|
236
|
+
sys := newSystemWithFake(t, &fakeJira{}).(*system)
|
|
237
|
+
sys.currentUser = "me@example.com"
|
|
238
|
+
match, err := sys.CompileFilter(config.RawValues{
|
|
239
|
+
"filters": map[string]any{"assignees": []any{"currentUser()"}},
|
|
240
|
+
})
|
|
241
|
+
if err != nil {
|
|
242
|
+
t.Fatalf("CompileFilter failed: %v", err)
|
|
243
|
+
}
|
|
244
|
+
if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "ME@EXAMPLE.COM"}}) {
|
|
245
|
+
t.Fatal("currentUser() did not resolve to the authenticated Jira email")
|
|
246
|
+
}
|
|
247
|
+
if match(task.Ticket{Key: "PAY-2", Fields: map[string]any{"assignee": "other@example.com"}}) {
|
|
248
|
+
t.Fatal("currentUser() matched another Jira assignee")
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func TestCompileFilterCurrentUserRequiresCredentials(t *testing.T) {
|
|
253
|
+
sys := newSystemWithFake(t, &fakeJira{})
|
|
254
|
+
_, err := sys.CompileFilter(config.RawValues{
|
|
255
|
+
"filters": map[string]any{"assignees": []any{"currentUser()"}},
|
|
256
|
+
})
|
|
257
|
+
if err == nil || !strings.Contains(err.Error(), "authenticated Jira credentials") {
|
|
258
|
+
t.Fatalf("CompileFilter error = %v, want authenticated-credentials error", err)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
235
262
|
func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
|
|
236
263
|
// Assignee is a supported structured filter dimension
|
|
237
264
|
// (repo-workflow-routing: "parent statuses, issue types, labels, and
|
|
@@ -253,7 +280,7 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
|
|
|
253
280
|
}
|
|
254
281
|
}
|
|
255
282
|
|
|
256
|
-
func
|
|
283
|
+
func TestCompileFilterDoesNotUseEffectiveAssigneeAsFilter(t *testing.T) {
|
|
257
284
|
sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
|
|
258
285
|
Name: "payments",
|
|
259
286
|
RootConfig: config.RawValues{"assignee": "root@example.com"},
|
|
@@ -266,11 +293,10 @@ func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
|
|
|
266
293
|
if err != nil {
|
|
267
294
|
t.Fatal(err)
|
|
268
295
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
t.Fatal("ticket assigned to another user matched effective assignee")
|
|
296
|
+
for _, assignee := range []string{"root@example.com", "repo.bot@example.COM", "other@example.com"} {
|
|
297
|
+
if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
|
|
298
|
+
t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
|
|
299
|
+
}
|
|
274
300
|
}
|
|
275
301
|
}
|
|
276
302
|
|
|
@@ -37,6 +37,9 @@ func (f *fakeClient) Transition(_ context.Context, key, status, assignee string)
|
|
|
37
37
|
return f.fake.assignErr
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
if f.fake.transitionErr != nil {
|
|
41
|
+
return f.fake.transitionErr
|
|
42
|
+
}
|
|
40
43
|
f.fake.events = append(f.fake.events, "transition")
|
|
41
44
|
f.fake.transition(key, status)
|
|
42
45
|
return nil
|
|
@@ -53,6 +53,7 @@ const (
|
|
|
53
53
|
defaultStartParentStatus = "In Progress"
|
|
54
54
|
defaultWorkTaskStatus = "In Progress"
|
|
55
55
|
defaultEndParentStatus = "Done"
|
|
56
|
+
currentUserAssigneeFilter = "currentUser()"
|
|
56
57
|
defaultMailboxDescription = `Parent ticket: {{ticket}}
|
|
57
58
|
Workflow: {{workflow}}
|
|
58
59
|
Node: {{node}}
|
|
@@ -147,7 +148,12 @@ func init() {
|
|
|
147
148
|
if err != nil {
|
|
148
149
|
return nil, err
|
|
149
150
|
}
|
|
150
|
-
|
|
151
|
+
sys, err := newSystem(ctx, client, spec)
|
|
152
|
+
if err != nil {
|
|
153
|
+
return nil, err
|
|
154
|
+
}
|
|
155
|
+
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
156
|
+
return sys, nil
|
|
151
157
|
},
|
|
152
158
|
})
|
|
153
159
|
}
|
|
@@ -173,10 +179,11 @@ func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
|
|
|
173
179
|
// system is the repo-bound Jira task.System. It is safe for concurrent use;
|
|
174
180
|
// the REST client owns connection reuse, caches, and request limiting.
|
|
175
181
|
type system struct {
|
|
176
|
-
cli
|
|
177
|
-
repoName
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
cli jirarest.Client
|
|
183
|
+
repoName string
|
|
184
|
+
currentUser string
|
|
185
|
+
base config.RawValues
|
|
186
|
+
effective Config // root+repo merged
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
|
|
@@ -323,9 +330,11 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
323
330
|
return nil, err
|
|
324
331
|
}
|
|
325
332
|
f := cfg.Filters
|
|
326
|
-
|
|
327
|
-
|
|
333
|
+
resolvedAssignees, err := s.resolveAssigneeFilters(f.Assignees)
|
|
334
|
+
if err != nil {
|
|
335
|
+
return nil, err
|
|
328
336
|
}
|
|
337
|
+
f.Assignees = resolvedAssignees
|
|
329
338
|
return func(t task.Ticket) bool {
|
|
330
339
|
if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
|
|
331
340
|
return false
|
|
@@ -348,17 +357,18 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
|
|
|
348
357
|
}, nil
|
|
349
358
|
}
|
|
350
359
|
|
|
351
|
-
func
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
func (s *system) resolveAssigneeFilters(values []string) ([]string, error) {
|
|
361
|
+
resolved := make([]string, 0, len(values))
|
|
362
|
+
for _, value := range values {
|
|
363
|
+
if value == currentUserAssigneeFilter {
|
|
364
|
+
if s.currentUser == "" {
|
|
365
|
+
return nil, fmt.Errorf("jira: filters.assignees value %q requires authenticated Jira credentials", currentUserAssigneeFilter)
|
|
366
|
+
}
|
|
367
|
+
value = s.currentUser
|
|
368
|
+
}
|
|
369
|
+
resolved = append(resolved, value)
|
|
361
370
|
}
|
|
371
|
+
return resolved, nil
|
|
362
372
|
}
|
|
363
373
|
|
|
364
374
|
// --- Claim ---
|
|
@@ -633,6 +643,21 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
|
|
|
633
643
|
return s.cli.AddComment(ctx, key, body+"\n\n<!-- "+marker+" -->")
|
|
634
644
|
}
|
|
635
645
|
|
|
646
|
+
// PrepareRestart reopens existing relay-owned mailboxes before a fresh
|
|
647
|
+
// explicit attempt. It does not change the parent ticket: the normal start
|
|
648
|
+
// ApplyTaskConfig operation owns the parent status check, so a human-owned
|
|
649
|
+
// parent status can put the new attempt in blocked state without being
|
|
650
|
+
// overwritten. Jira transition failures are classified as conflicts by
|
|
651
|
+
// transition and therefore retry without blind status writes.
|
|
652
|
+
func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
|
|
653
|
+
for _, mailbox := range mailboxes {
|
|
654
|
+
if err := s.transition(ctx, mailbox.Key, "To Do", ""); err != nil {
|
|
655
|
+
return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return nil
|
|
659
|
+
}
|
|
660
|
+
|
|
636
661
|
// --- Recovery ---
|
|
637
662
|
|
|
638
663
|
// ResetForRecovery resets mailbox subtasks to To Do while preserving
|
|
@@ -677,4 +702,5 @@ func strSliceField(fields map[string]any, key string) []string {
|
|
|
677
702
|
var (
|
|
678
703
|
_ task.System = (*system)(nil)
|
|
679
704
|
_ task.LifecycleDefaults = (*system)(nil)
|
|
705
|
+
_ task.RestartPreparer = (*system)(nil)
|
|
680
706
|
)
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"testing"
|
|
7
7
|
|
|
8
8
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
9
10
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
11
|
)
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ type fakeJira struct {
|
|
|
27
28
|
taskTransitions []string
|
|
28
29
|
assignments []string
|
|
29
30
|
assignErr error
|
|
31
|
+
transitionErr error
|
|
30
32
|
events []string
|
|
31
33
|
// searchJSON is the raw Jira search response Poll serves.
|
|
32
34
|
searchJSON []byte
|
|
@@ -139,6 +141,47 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
|
|
145
|
+
fake := &fakeJira{}
|
|
146
|
+
sys := newSystemWithFake(t, fake)
|
|
147
|
+
preparer, ok := sys.(task.RestartPreparer)
|
|
148
|
+
if !ok {
|
|
149
|
+
t.Fatal("Jira system does not implement RestartPreparer")
|
|
150
|
+
}
|
|
151
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
152
|
+
mailboxes := []task.Mailbox{
|
|
153
|
+
{ID: "2", Key: "PAY-102", Node: "coding"},
|
|
154
|
+
{ID: "3", Key: "PAY-103", Node: "review"},
|
|
155
|
+
}
|
|
156
|
+
if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
|
|
157
|
+
t.Fatalf("PrepareRestart failed: %v", err)
|
|
158
|
+
}
|
|
159
|
+
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "To Do" || fake.taskTransitions[1] != "To Do" {
|
|
160
|
+
t.Fatalf("mailbox transitions = %v, want two To Do transitions", fake.taskTransitions)
|
|
161
|
+
}
|
|
162
|
+
if len(fake.parentTransitions) != 0 {
|
|
163
|
+
t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
|
|
168
|
+
fake := &fakeJira{transitionErr: errors.New(`transition to "To Do" is not available for PAY-102`)}
|
|
169
|
+
sys := newSystemWithFake(t, fake)
|
|
170
|
+
preparer := sys.(task.RestartPreparer)
|
|
171
|
+
err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
|
|
172
|
+
{ID: "2", Key: "PAY-102", Node: "coding"},
|
|
173
|
+
})
|
|
174
|
+
if err == nil {
|
|
175
|
+
t.Fatal("incompatible Jira mailbox status was accepted")
|
|
176
|
+
}
|
|
177
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
178
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
179
|
+
}
|
|
180
|
+
if len(fake.parentTransitions) != 0 {
|
|
181
|
+
t.Fatalf("parent transitions = %v, want none", fake.parentTransitions)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
142
185
|
func TestExplicitTransitionsWin(t *testing.T) {
|
|
143
186
|
fake := &fakeJira{}
|
|
144
187
|
sys := newSystemWithFake(t, fake)
|
package/internal/task/task.go
CHANGED
|
@@ -99,6 +99,14 @@ type System interface {
|
|
|
99
99
|
ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// RestartPreparer is an optional adapter capability used only by explicit
|
|
103
|
+
// run restarts. It reopens relay-owned mailbox state while preserving all
|
|
104
|
+
// comments, labels, and descriptions. A human-owned incompatible state must
|
|
105
|
+
// be returned as retry.ConflictError; core never names provider statuses.
|
|
106
|
+
type RestartPreparer interface {
|
|
107
|
+
PrepareRestart(ctx context.Context, parent TicketRef, mailboxes []Mailbox) error
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
// LifecycleDefaults is an optional adapter capability: a System whose
|
|
103
111
|
// taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
|
|
104
112
|
// transitionTo defaults) exposes them here. The lifecycle-aware caller
|