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.
- package/README.md +30 -34
- package/cmd/relay-flow/commands_test.go +449 -23
- package/cmd/relay-flow/main.go +207 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine_test.go +17 -3
- package/internal/execution/goworkflows/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +25 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +16 -3
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +23 -10
- package/internal/harness/opencode/opencode.go +22 -4
- package/internal/harness/opencode/opencode_test.go +41 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +21 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +18 -2
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +244 -15
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +67 -0
- package/internal/workflow/report.go +10 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- 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}
|
|
584
|
-
// /runs/by-ticket/{key}/restart
|
|
585
|
-
// /runs/by-ticket/{key}/cancel
|
|
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
|
|
964
|
-
_ task.
|
|
965
|
-
_ task.
|
|
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
|
+
}
|