relay-flow 0.3.9-alpha → 0.3.11-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.
Files changed (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -35,6 +35,7 @@ type Deps interface {
35
35
  GetRunByTicket(ctx context.Context, ticket string) (run.Run, error)
36
36
  RestartRun(ctx context.Context, ticket string) (run.Run, error)
37
37
  CancelRun(ctx context.Context, ticket, reason string) error
38
+ BackfillClaimOwner(ctx context.Context, repo, ticket, workflow string) error
38
39
 
39
40
  // Reports
40
41
  HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error)
@@ -580,9 +581,10 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
580
581
  }
581
582
 
582
583
  func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
583
- // /runs/by-ticket/{key} GET
584
- // /runs/by-ticket/{key}/restart POST
585
- // /runs/by-ticket/{key}/cancel POST
584
+ // /runs/by-ticket/{key} GET
585
+ // /runs/by-ticket/{key}/restart POST
586
+ // /runs/by-ticket/{key}/cancel POST
587
+ // /runs/by-ticket/{key}/backfill-owner POST
586
588
  rest := strings.TrimPrefix(r.URL.Path, "/runs/by-ticket/")
587
589
  parts := strings.Split(rest, "/")
588
590
  if len(parts) == 1 && parts[0] != "" {
@@ -643,5 +645,32 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
643
645
  writeOK(w, http.StatusOK, map[string]string{"canceled": parts[0]})
644
646
  return
645
647
  }
648
+ if len(parts) == 2 && parts[0] != "" && parts[1] == "backfill-owner" {
649
+ if !methodOnly(w, r, http.MethodPost) {
650
+ return
651
+ }
652
+ var payload struct {
653
+ Repo string `json:"repo"`
654
+ Workflow string `json:"workflow"`
655
+ }
656
+ body, err := readBody(r)
657
+ if err != nil {
658
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
659
+ return
660
+ }
661
+ if err := decodeStrict(body, &payload); err != nil || strings.TrimSpace(payload.Repo) == "" || strings.TrimSpace(payload.Workflow) == "" {
662
+ if err == nil {
663
+ err = errors.New("repo and workflow are required")
664
+ }
665
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
666
+ return
667
+ }
668
+ if err := s.deps.BackfillClaimOwner(r.Context(), payload.Repo, parts[0], payload.Workflow); err != nil {
669
+ mapErr(w, err)
670
+ return
671
+ }
672
+ writeOK(w, http.StatusOK, map[string]string{"backfilled": parts[0], "repo": payload.Repo, "workflow": payload.Workflow})
673
+ return
674
+ }
646
675
  writeErr(w, http.StatusNotFound, "notFound", "run not found")
647
676
  }
@@ -5,6 +5,8 @@ package beads
5
5
 
6
6
  import (
7
7
  "context"
8
+ "crypto/sha256"
9
+ "encoding/hex"
8
10
  "errors"
9
11
  "fmt"
10
12
  "io"
@@ -442,6 +444,71 @@ func claimLabel(workflow string) string {
442
444
  return "wf:" + workflow
443
445
  }
444
446
 
447
+ // claimOwnerLabel is a durable provider-owned provenance label. Beads stores
448
+ // the normalized owner value in the adapter's filter space, while the label
449
+ // carries only a hash so the marker is stable without exposing that value.
450
+ // Reassignment never replaces the marker; an explicit provider-side handoff
451
+ // is required to transfer ownership.
452
+ const claimOwnerLabelPrefix = "wf-owner:"
453
+
454
+ func claimOwnerToken(owner string) string {
455
+ owner = strings.ToLower(strings.TrimSpace(owner))
456
+ if owner == "" {
457
+ return ""
458
+ }
459
+ sum := sha256.Sum256([]byte(owner))
460
+ return hex.EncodeToString(sum[:])
461
+ }
462
+
463
+ func claimOwnerLabel(workflow, owner string) string {
464
+ token := claimOwnerToken(owner)
465
+ if token == "" {
466
+ return ""
467
+ }
468
+ return claimOwnerLabelPrefix + workflow + ":" + token
469
+ }
470
+
471
+ func claimOwnerMarkers(labels []string, workflow string) []string {
472
+ prefix := claimOwnerLabelPrefix + workflow + ":"
473
+ markers := make([]string, 0, 1)
474
+ for _, label := range labels {
475
+ if strings.HasPrefix(label, prefix) {
476
+ markers = append(markers, label)
477
+ }
478
+ }
479
+ return markers
480
+ }
481
+
482
+ func hasForeignClaimOwnerMarker(labels []string, workflow string) bool {
483
+ currentPrefix := claimOwnerLabelPrefix + workflow + ":"
484
+ for _, label := range labels {
485
+ if strings.HasPrefix(label, claimOwnerLabelPrefix) && !strings.HasPrefix(label, currentPrefix) {
486
+ return true
487
+ }
488
+ }
489
+ return false
490
+ }
491
+
492
+ func claimOwnerMatches(labels []string, workflow string, assignees []string) bool {
493
+ markers := claimOwnerMarkers(labels, workflow)
494
+ if len(markers) != 1 {
495
+ return false
496
+ }
497
+ for _, assignee := range assignees {
498
+ if markers[0] == claimOwnerLabel(workflow, assignee) {
499
+ return true
500
+ }
501
+ }
502
+ return false
503
+ }
504
+
505
+ func claimedWorkflow(ticket task.Ticket) string {
506
+ if len(ticket.WorkflowClaims) != 1 || !strings.HasPrefix(ticket.WorkflowClaims[0], "wf:") {
507
+ return ""
508
+ }
509
+ return strings.TrimPrefix(ticket.WorkflowClaims[0], "wf:")
510
+ }
511
+
445
512
  // CompileFilter compiles Beads-owned structured filters into a local matcher.
446
513
  // No Beads query language is accepted or sent to the CLI.
447
514
  func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
@@ -473,6 +540,132 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
473
540
  }, nil
474
541
  }
475
542
 
543
+ // CompileOwnershipFilter compiles only the effective Beads owner/assignee
544
+ // predicate. Claimed routing must not re-run lifecycle filters.
545
+ func (s *system) CompileOwnershipFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
546
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
547
+ if err != nil {
548
+ return nil, err
549
+ }
550
+ return func(ticket task.Ticket) bool {
551
+ if len(assignees) == 0 {
552
+ return true
553
+ }
554
+ workflow := claimedWorkflow(ticket)
555
+ owner := stringField(ticket.Fields, "assignee")
556
+ return workflow != "" && containsFold(assignees, owner) &&
557
+ claimOwnerMatches(stringSliceField(ticket.Fields, "labels"), workflow, []string{owner})
558
+ }, nil
559
+ }
560
+
561
+ func (s *system) ownershipAssignees(workflowTaskConfig config.RawValues) ([]string, error) {
562
+ merged := config.Merge(s.base, workflowTaskConfig)
563
+ cfg, err := decodeConfig(merged)
564
+ if err != nil {
565
+ return nil, err
566
+ }
567
+ return append([]string(nil), cfg.Filters.Assignees...), nil
568
+ }
569
+
570
+ func (s *system) ValidateOwnership(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
571
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
572
+ if err != nil {
573
+ return err
574
+ }
575
+ if len(assignees) == 0 {
576
+ return nil
577
+ }
578
+ key := ticket.Key
579
+ if strings.TrimSpace(key) == "" {
580
+ key = ticket.ID
581
+ }
582
+ issue, err := s.cli.Show(ctx, key)
583
+ if err != nil {
584
+ return fmt.Errorf("beads: read ticket %s ownership: %w", key, err)
585
+ }
586
+ if !containsFold(assignees, issue.Assignee) || !claimOwnerMatches(issue.Labels, workflow, []string{issue.Assignee}) {
587
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
588
+ }
589
+ return nil
590
+ }
591
+
592
+ // ClaimIfOwned performs the final adapter-owned owner read before adding the
593
+ // idempotent workflow label. The owner marker is added in the same bd update
594
+ // as wf:, so retries can finish an interrupted claim without transferring it.
595
+ func (s *system) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
596
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
597
+ if err != nil {
598
+ return err
599
+ }
600
+ if len(assignees) == 0 {
601
+ return s.Claim(ctx, ticket, workflow)
602
+ }
603
+ key := ticket.Key
604
+ if strings.TrimSpace(key) == "" {
605
+ key = ticket.ID
606
+ }
607
+ issue, err := s.cli.Show(ctx, key)
608
+ if err != nil {
609
+ return fmt.Errorf("beads: read ticket %s before claim: %w", key, err)
610
+ }
611
+ markers := claimOwnerMarkers(issue.Labels, workflow)
612
+ if !containsFold(assignees, issue.Assignee) {
613
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
614
+ }
615
+ if hasForeignClaimOwnerMarker(issue.Labels, workflow) || len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(issue.Labels, workflow, []string{issue.Assignee})) {
616
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
617
+ }
618
+ claims := extractWorkflowClaims(issue.Labels)
619
+ if len(claims) > 0 {
620
+ if len(claims) != 1 || claims[0] != claimLabel(workflow) || len(markers) != 1 {
621
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
622
+ }
623
+ return s.cli.Update(ctx, key, bdcli.UpdateInput{AddLabels: []string{markers[0], claimLabel(workflow)}})
624
+ }
625
+ if strings.TrimSpace(issue.Assignee) == "" {
626
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
627
+ }
628
+ ownerLabel := claimOwnerLabel(workflow, issue.Assignee)
629
+ if ownerLabel == "" {
630
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
631
+ }
632
+ return s.cli.Update(ctx, key, bdcli.UpdateInput{AddLabels: []string{ownerLabel, claimLabel(workflow)}})
633
+ }
634
+
635
+ // BackfillClaimOwner is an explicit repair operation for a legacy claim that
636
+ // has no provenance marker. It only adds the durable marker after checking the
637
+ // live owner; automatic routing never invokes it and never removes labels.
638
+ func (s *system) BackfillClaimOwner(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
639
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
640
+ if err != nil {
641
+ return err
642
+ }
643
+ if len(assignees) == 0 {
644
+ return nil
645
+ }
646
+ key := ticket.Key
647
+ if strings.TrimSpace(key) == "" {
648
+ key = ticket.ID
649
+ }
650
+ issue, err := s.cli.Show(ctx, key)
651
+ if err != nil {
652
+ return fmt.Errorf("beads: read ticket %s for ownership backfill: %w", key, err)
653
+ }
654
+ if !containsFold(assignees, issue.Assignee) ||
655
+ len(extractWorkflowClaims(issue.Labels)) != 1 ||
656
+ extractWorkflowClaims(issue.Labels)[0] != claimLabel(workflow) {
657
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
658
+ }
659
+ markers := claimOwnerMarkers(issue.Labels, workflow)
660
+ if len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(issue.Labels, workflow, []string{issue.Assignee})) {
661
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
662
+ }
663
+ if len(markers) == 1 {
664
+ return nil
665
+ }
666
+ return s.cli.Update(ctx, key, bdcli.UpdateInput{AddLabels: []string{claimOwnerLabel(workflow, issue.Assignee)}})
667
+ }
668
+
476
669
  func containsExact(values []string, want string) bool {
477
670
  for _, value := range values {
478
671
  if value == want {
@@ -960,7 +1153,12 @@ func (s *system) ResetForRecovery(ctx context.Context, parent task.TicketRef, ma
960
1153
  }
961
1154
 
962
1155
  var (
963
- _ task.System = (*system)(nil)
964
- _ task.LifecycleDefaults = (*system)(nil)
965
- _ task.RestartPreparer = (*system)(nil)
1156
+ _ task.System = (*system)(nil)
1157
+ _ task.OwnershipCapabilities = (*system)(nil)
1158
+ _ task.OwnershipFilterCompiler = (*system)(nil)
1159
+ _ task.OwnershipValidator = (*system)(nil)
1160
+ _ task.ConditionalClaimer = (*system)(nil)
1161
+ _ task.ClaimOwnerBackfiller = (*system)(nil)
1162
+ _ task.LifecycleDefaults = (*system)(nil)
1163
+ _ task.RestartPreparer = (*system)(nil)
966
1164
  )
@@ -222,6 +222,178 @@ func TestExtractWorkflowClaimsKeepsOnlyWorkflowLabels(t *testing.T) {
222
222
  }
223
223
  }
224
224
 
225
+ func TestBeadsClaimIfOwnedRejectsReassignmentBeforeLabelWrite(t *testing.T) {
226
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "bob"}}
227
+ sys := &system{cli: client}
228
+ err := sys.ClaimIfOwned(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
229
+ "filters": map[string]any{"assignees": []any{"alice"}},
230
+ })
231
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
232
+ t.Fatalf("ClaimIfOwned error = %v, want ownership mismatch", err)
233
+ }
234
+ if len(client.updates) != 0 {
235
+ t.Fatalf("updates = %+v, want none after reassignment", client.updates)
236
+ }
237
+ }
238
+
239
+ func TestBeadsClaimIfOwnedUpdatesForCurrentOwner(t *testing.T) {
240
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "Alice"}}
241
+ sys := &system{cli: client}
242
+ if err := sys.ClaimIfOwned(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
243
+ "filters": map[string]any{"assignees": []any{"alice"}},
244
+ }); err != nil {
245
+ t.Fatalf("ClaimIfOwned failed for current owner: %v", err)
246
+ }
247
+ wantLabels := []string{claimOwnerLabel("flow", "Alice"), "wf:flow"}
248
+ if len(client.updates) != 1 || !reflect.DeepEqual(client.updates[0].AddLabels, wantLabels) {
249
+ t.Fatalf("updates = %+v, want one owner+workflow label update %v", client.updates, wantLabels)
250
+ }
251
+ }
252
+
253
+ func TestBeadsCompileOwnershipFilterWithoutAssigneeDoesNotGateClaim(t *testing.T) {
254
+ sys := &system{}
255
+ matcher, err := sys.CompileOwnershipFilter(nil)
256
+ if err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ if !matcher(task.Ticket{WorkflowClaims: []string{"wf:flow"}, Fields: map[string]any{"assignee": "anyone"}}) {
260
+ t.Fatal("ownership matcher imposed an assignee gate without filters.assignees")
261
+ }
262
+ }
263
+
264
+ func TestBeadsCompileOwnershipFilterUsesEffectiveRootRepoWorkflowConfig(t *testing.T) {
265
+ sys := &system{base: config.RawValues{"filters": map[string]any{
266
+ "assignees": []any{"repo"},
267
+ }}}
268
+ matcher, err := sys.CompileOwnershipFilter(config.RawValues{"filters": map[string]any{
269
+ "assignees": []any{"workflow"},
270
+ }})
271
+ if err != nil {
272
+ t.Fatalf("CompileOwnershipFilter failed: %v", err)
273
+ }
274
+ if !matcher(task.Ticket{
275
+ WorkflowClaims: []string{"wf:flow"},
276
+ Fields: map[string]any{
277
+ "assignee": "workflow",
278
+ "labels": []string{claimOwnerLabel("flow", "workflow")},
279
+ },
280
+ }) {
281
+ t.Fatal("workflow owner marker was rejected")
282
+ }
283
+ if matcher(task.Ticket{
284
+ WorkflowClaims: []string{"wf:flow"},
285
+ Fields: map[string]any{
286
+ "assignee": "reassigned",
287
+ "labels": []string{claimOwnerLabel("flow", "workflow")},
288
+ },
289
+ }) {
290
+ t.Fatal("claimed route accepted a current-assignee mismatch")
291
+ }
292
+ if matcher(task.Ticket{
293
+ WorkflowClaims: []string{"wf:flow"},
294
+ Fields: map[string]any{
295
+ "assignee": "reassigned",
296
+ "labels": []string{claimOwnerLabel("flow", "repo")},
297
+ },
298
+ }) {
299
+ t.Fatal("inherited repo owner marker bypassed workflow override")
300
+ }
301
+ }
302
+
303
+ func TestBeadsValidateOwnershipRejectsLegacyClaimWithoutProvenance(t *testing.T) {
304
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "alice", Labels: []string{"wf:flow"}}}
305
+ sys := &system{cli: client}
306
+ err := sys.ValidateOwnership(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
307
+ "filters": map[string]any{"assignees": []any{"alice"}},
308
+ })
309
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
310
+ t.Fatalf("ValidateOwnership error = %v, want legacy-claim ownership mismatch", err)
311
+ }
312
+ }
313
+
314
+ func TestBeadsCompileOwnershipFilterRejectsProvenanceForAnotherAllowedOwner(t *testing.T) {
315
+ sys := &system{}
316
+ matcher, err := sys.CompileOwnershipFilter(config.RawValues{"filters": map[string]any{
317
+ "assignees": []any{"alice", "bob"},
318
+ }})
319
+ if err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ if matcher(task.Ticket{
323
+ WorkflowClaims: []string{"wf:flow"},
324
+ Fields: map[string]any{
325
+ "assignee": "bob",
326
+ "labels": []string{claimOwnerLabel("flow", "alice")},
327
+ },
328
+ }) {
329
+ t.Fatal("provenance for Alice was accepted for live owner Bob")
330
+ }
331
+ }
332
+
333
+ func TestBeadsClaimIfOwnedAcceptsClaimAppearingAfterPollForSameOwner(t *testing.T) {
334
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "alice", Labels: []string{claimOwnerLabel("flow", "alice"), "wf:flow"}}}
335
+ sys := &system{cli: client}
336
+ if err := sys.ClaimIfOwned(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
337
+ "filters": map[string]any{"assignees": []any{"alice", "bob"}},
338
+ }); err != nil {
339
+ t.Fatalf("ClaimIfOwned rejected existing same-owner claim: %v", err)
340
+ }
341
+ if len(client.updates) != 1 {
342
+ t.Fatalf("idempotent claim updates = %+v, want one label update", client.updates)
343
+ }
344
+ }
345
+
346
+ func TestBeadsClaimIfOwnedRejectsAppearingClaimAfterReassignment(t *testing.T) {
347
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "bob", Labels: []string{claimOwnerLabel("flow", "alice"), "wf:flow"}}}
348
+ sys := &system{cli: client}
349
+ err := sys.ClaimIfOwned(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
350
+ "filters": map[string]any{"assignees": []any{"alice", "bob"}},
351
+ })
352
+ if !errors.Is(err, task.ErrOwnershipMismatch) || len(client.updates) != 0 {
353
+ t.Fatalf("ClaimIfOwned error=%v updates=%+v, want mismatch with no writes", err, client.updates)
354
+ }
355
+ }
356
+
357
+ func TestBeadsValidateOwnershipRejectsCurrentAssigneeMismatch(t *testing.T) {
358
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "bob", Labels: []string{claimOwnerLabel("flow", "alice"), "wf:flow"}}}
359
+ sys := &system{cli: client}
360
+ err := sys.ValidateOwnership(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
361
+ "filters": map[string]any{"assignees": []any{"alice"}},
362
+ })
363
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
364
+ t.Fatalf("ValidateOwnership error = %v, want current-assignee mismatch", err)
365
+ }
366
+ }
367
+
368
+ func TestBeadsBackfillLegacyClaimAddsOnlyProvenance(t *testing.T) {
369
+ client := &ownershipClient{issue: bdcli.Issue{ID: "demo-1", Assignee: "alice", Labels: []string{"wf:flow"}}}
370
+ sys := &system{cli: client}
371
+ if err := sys.BackfillClaimOwner(context.Background(), task.TicketRef{Key: "demo-1"}, "flow", config.RawValues{
372
+ "filters": map[string]any{"assignees": []any{"alice"}},
373
+ }); err != nil {
374
+ t.Fatalf("BackfillClaimOwner failed: %v", err)
375
+ }
376
+ want := []string{claimOwnerLabel("flow", "alice")}
377
+ if len(client.updates) != 1 || !reflect.DeepEqual(client.updates[0].AddLabels, want) {
378
+ t.Fatalf("backfill updates = %+v, want provenance only %v", client.updates, want)
379
+ }
380
+ }
381
+
382
+ type ownershipClient struct {
383
+ bdcli.Client
384
+ issue bdcli.Issue
385
+ updates []bdcli.UpdateInput
386
+ }
387
+
388
+ func (c *ownershipClient) Show(context.Context, string) (bdcli.Issue, error) {
389
+ return c.issue, nil
390
+ }
391
+
392
+ func (c *ownershipClient) Update(_ context.Context, _ string, input bdcli.UpdateInput) error {
393
+ c.updates = append(c.updates, input)
394
+ return nil
395
+ }
396
+
225
397
  func TestRepositoryLabelIsStableAndValidated(t *testing.T) {
226
398
  got, err := RepositoryLabel("Payments_API")
227
399
  if err != nil {
@@ -2,9 +2,13 @@ package jira
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
6
+ "fmt"
7
+ "reflect"
5
8
  "strings"
6
9
  "testing"
7
10
 
11
+ "github.com/rajpopat27/relay-flow/internal/config"
8
12
  "github.com/rajpopat27/relay-flow/internal/task"
9
13
  )
10
14
 
@@ -37,3 +41,85 @@ func TestCommentKeepsMarkerIdempotency(t *testing.T) {
37
41
  t.Fatalf("posted comments = %v", fake.addedComments)
38
42
  }
39
43
  }
44
+
45
+ func TestJiraClaimIfOwnedRejectsReassignmentBeforeLabelWrite(t *testing.T) {
46
+ fake := &fakeJira{viewJSON: []byte(`{"id":"1","key":"PAY-1","fields":{"summary":"work","assignee":{"emailAddress":"bob@example.com"},"labels":[]}}`)}
47
+ sys := newSystemWithFake(t, fake)
48
+ err := sys.(task.ConditionalClaimer).ClaimIfOwned(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
49
+ "filters": map[string]any{"assignees": []any{"alice@example.com"}},
50
+ })
51
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
52
+ t.Fatalf("ClaimIfOwned error = %v, want ownership mismatch", err)
53
+ }
54
+ if len(fake.labelCalls) != 0 {
55
+ t.Fatalf("label calls = %v, want none after reassignment", fake.labelCalls)
56
+ }
57
+ }
58
+
59
+ func TestJiraClaimIfOwnedAddsLabelForCurrentOwner(t *testing.T) {
60
+ fake := &fakeJira{viewJSON: []byte(`{"id":"1","key":"PAY-1","fields":{"summary":"work","assignee":{"emailAddress":"ALICE@EXAMPLE.COM"},"labels":[]}}`)}
61
+ sys := newSystemWithFake(t, fake)
62
+ if err := sys.(task.ConditionalClaimer).ClaimIfOwned(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
63
+ "filters": map[string]any{"assignees": []any{"alice@example.com"}},
64
+ }); err != nil {
65
+ t.Fatalf("ClaimIfOwned failed for current owner: %v", err)
66
+ }
67
+ want := []string{
68
+ "PAY-1:" + claimOwnerLabel("flow", "alice@example.com"),
69
+ "PAY-1:wf:flow",
70
+ }
71
+ if !reflect.DeepEqual(fake.labelCalls, want) {
72
+ t.Fatalf("label calls = %v, want one owner+workflow label write %v", fake.labelCalls, want)
73
+ }
74
+ }
75
+
76
+ func TestJiraClaimIfOwnedAcceptsClaimAppearingAfterPollForSameOwner(t *testing.T) {
77
+ view := fmt.Sprintf(`{"id":"1","key":"PAY-1","fields":{"assignee":{"emailAddress":"alice@example.com"},"labels":[%q,"wf:flow"]}}`, claimOwnerLabel("flow", "alice@example.com"))
78
+ fake := &fakeJira{viewJSON: []byte(view)}
79
+ sys := newSystemWithFake(t, fake)
80
+ if err := sys.(task.ConditionalClaimer).ClaimIfOwned(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
81
+ "filters": map[string]any{"assignees": []any{"alice@example.com", "bob@example.com"}},
82
+ }); err != nil {
83
+ t.Fatalf("ClaimIfOwned rejected an existing same-owner claim: %v", err)
84
+ }
85
+ if len(fake.labelCalls) != 2 {
86
+ t.Fatalf("idempotent claim label calls = %v, want owner and wf labels", fake.labelCalls)
87
+ }
88
+ }
89
+
90
+ func TestJiraClaimIfOwnedRejectsAppearingClaimAfterReassignment(t *testing.T) {
91
+ view := fmt.Sprintf(`{"id":"1","key":"PAY-1","fields":{"assignee":{"emailAddress":"bob@example.com"},"labels":[%q,"wf:flow"]}}`, claimOwnerLabel("flow", "alice@example.com"))
92
+ fake := &fakeJira{viewJSON: []byte(view)}
93
+ sys := newSystemWithFake(t, fake)
94
+ err := sys.(task.ConditionalClaimer).ClaimIfOwned(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
95
+ "filters": map[string]any{"assignees": []any{"alice@example.com", "bob@example.com"}},
96
+ })
97
+ if !errors.Is(err, task.ErrOwnershipMismatch) || len(fake.labelCalls) != 0 {
98
+ t.Fatalf("ClaimIfOwned error=%v labels=%v, want mismatch with no writes", err, fake.labelCalls)
99
+ }
100
+ }
101
+
102
+ func TestJiraValidateOwnershipRejectsCurrentAssigneeMismatch(t *testing.T) {
103
+ fake := &fakeJira{viewJSON: []byte(fmt.Sprintf(`{"id":"1","key":"PAY-1","fields":{"assignee":{"emailAddress":"bob@example.com"},"labels":[%q,"wf:flow"]}}`, claimOwnerLabel("flow", "alice@example.com")))}
104
+ sys := newSystemWithFake(t, fake)
105
+ err := sys.(task.OwnershipValidator).ValidateOwnership(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
106
+ "filters": map[string]any{"assignees": []any{"alice@example.com"}},
107
+ })
108
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
109
+ t.Fatalf("ValidateOwnership error = %v, want current-assignee mismatch", err)
110
+ }
111
+ }
112
+
113
+ func TestJiraBackfillLegacyClaimAddsOnlyProvenance(t *testing.T) {
114
+ fake := &fakeJira{viewJSON: []byte(`{"id":"1","key":"PAY-1","fields":{"assignee":{"emailAddress":"alice@example.com"},"labels":["wf:flow"]}}`)}
115
+ sys := newSystemWithFake(t, fake)
116
+ if err := sys.(task.ClaimOwnerBackfiller).BackfillClaimOwner(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
117
+ "filters": map[string]any{"assignees": []any{"alice@example.com"}},
118
+ }); err != nil {
119
+ t.Fatalf("BackfillClaimOwner failed: %v", err)
120
+ }
121
+ want := []string{"PAY-1:" + claimOwnerLabel("flow", "alice@example.com")}
122
+ if !reflect.DeepEqual(fake.labelCalls, want) {
123
+ t.Fatalf("backfill label calls = %v, want provenance only %v", fake.labelCalls, want)
124
+ }
125
+ }
@@ -2,6 +2,7 @@ package jira
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "os"
6
7
  "strings"
7
8
  "testing"
@@ -338,3 +339,105 @@ func TestCompileFilterWithoutAssigneeDoesNotFilter(t *testing.T) {
338
339
  t.Fatal("empty explicit assignee filter rejected a ticket")
339
340
  }
340
341
  }
342
+
343
+ func TestCompileOwnershipFilterWithoutAssigneeDoesNotGateClaim(t *testing.T) {
344
+ sys := newSystemWithFake(t, &fakeJira{})
345
+ matcher, err := sys.(task.OwnershipFilterCompiler).CompileOwnershipFilter(nil)
346
+ if err != nil {
347
+ t.Fatal(err)
348
+ }
349
+ if !matcher(task.Ticket{WorkflowClaims: []string{"wf:flow"}, Fields: map[string]any{"assignee": "anyone"}}) {
350
+ t.Fatal("ownership matcher imposed an assignee gate without filters.assignees")
351
+ }
352
+ }
353
+
354
+ func TestCompileOwnershipFilterUsesEffectiveRootRepoWorkflowConfig(t *testing.T) {
355
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
356
+ Name: "payments",
357
+ RootConfig: config.RawValues{"filters": map[string]any{
358
+ "assignees": []any{"root@example.com"},
359
+ }},
360
+ RepoConfig: testRepoConfig(config.RawValues{"filters": map[string]any{
361
+ "assignees": []any{"repo@example.com"},
362
+ }}),
363
+ })
364
+ if err != nil {
365
+ t.Fatal(err)
366
+ }
367
+ matcher, err := sys.CompileOwnershipFilter(config.RawValues{
368
+ "filters": map[string]any{"assignees": []any{"workflow@example.com"}},
369
+ })
370
+ if err != nil {
371
+ t.Fatalf("CompileOwnershipFilter failed: %v", err)
372
+ }
373
+ valid := task.Ticket{
374
+ WorkflowClaims: []string{"wf:flow"},
375
+ Fields: map[string]any{
376
+ "status": "In Progress",
377
+ "assignee": "workflow@example.com",
378
+ "labels": []string{claimOwnerLabel("flow", "workflow@example.com")},
379
+ },
380
+ }
381
+ if !matcher(valid) {
382
+ t.Fatal("effective workflow ownership marker was rejected")
383
+ }
384
+ invalid := valid
385
+ invalid.Fields = map[string]any{
386
+ "status": "In Progress",
387
+ "assignee": "workflow@example.com",
388
+ "labels": []string{claimOwnerLabel("flow", "repo@example.com")},
389
+ }
390
+ if matcher(invalid) {
391
+ t.Fatal("repo/root owner marker bypassed workflow ownership override")
392
+ }
393
+ if !matcher(task.Ticket{
394
+ WorkflowClaims: []string{"wf:flow"},
395
+ Fields: map[string]any{
396
+ "status": "Done",
397
+ "assignee": "workflow@example.com",
398
+ "labels": []string{claimOwnerLabel("flow", "workflow@example.com")},
399
+ },
400
+ }) {
401
+ t.Fatal("valid owner marker was rejected after a lifecycle status change")
402
+ }
403
+ if matcher(task.Ticket{
404
+ WorkflowClaims: []string{"wf:flow"},
405
+ Fields: map[string]any{
406
+ "status": "Done",
407
+ "assignee": "someone-else@example.com",
408
+ "labels": []string{claimOwnerLabel("flow", "workflow@example.com")},
409
+ },
410
+ }) {
411
+ t.Fatal("claimed route accepted a current-assignee mismatch")
412
+ }
413
+ }
414
+
415
+ func TestCompileOwnershipFilterRejectsProvenanceForAnotherAllowedOwner(t *testing.T) {
416
+ sys := newSystemWithFake(t, &fakeJira{})
417
+ matcher, err := sys.(task.OwnershipFilterCompiler).CompileOwnershipFilter(config.RawValues{"filters": map[string]any{
418
+ "assignees": []any{"alice@example.com", "bob@example.com"},
419
+ }})
420
+ if err != nil {
421
+ t.Fatal(err)
422
+ }
423
+ if matcher(task.Ticket{
424
+ WorkflowClaims: []string{"wf:flow"},
425
+ Fields: map[string]any{
426
+ "assignee": "bob@example.com",
427
+ "labels": []string{claimOwnerLabel("flow", "alice@example.com")},
428
+ },
429
+ }) {
430
+ t.Fatal("provenance for Alice was accepted for live owner Bob")
431
+ }
432
+ }
433
+
434
+ func TestValidateOwnershipRejectsLegacyClaimWithoutProvenance(t *testing.T) {
435
+ fake := &fakeJira{viewJSON: []byte(`{"id":"1","key":"PAY-1","fields":{"assignee":{"emailAddress":"alice@example.com"},"labels":["wf:flow"]}}`)}
436
+ sys := newSystemWithFake(t, fake)
437
+ err := sys.(task.OwnershipValidator).ValidateOwnership(context.Background(), task.TicketRef{Key: "PAY-1"}, "flow", config.RawValues{
438
+ "filters": map[string]any{"assignees": []any{"alice@example.com"}},
439
+ })
440
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
441
+ t.Fatalf("ValidateOwnership error = %v, want legacy-claim ownership mismatch", err)
442
+ }
443
+ }