package operations import ( "context" "errors" "strings" "testing" "orchestra/internal/domain" "orchestra/internal/registry" "orchestra/internal/review" "orchestra/internal/store" ) type fakePublisher struct { pushes int prCalls int remoteSHA string pushErr error prErr error pr domain.ExternalRef lastBody string } func (f *fakePublisher) Push(_ context.Context, _, _, sha string) (string, error) { f.pushes++ if f.pushErr != nil { return "", f.pushErr } if f.remoteSHA != "" { return f.remoteSHA, nil } return sha, nil } func (f *fakePublisher) EnsurePR(_ context.Context, plan SubmissionPlan) (domain.ExternalRef, error) { f.prCalls++ f.lastBody = plan.PRBody if f.prErr != nil { return domain.ExternalRef{}, f.prErr } if f.pr.ID == "" { f.pr = domain.ExternalRef{Provider: "gitea:p", ID: "142", URL: "https://git/pulls/142"} } return f.pr, nil } func gate(sha string) domain.GateResult { return domain.GateResult{Command: "go test ./...", ExitCode: 0, SHA: sha, Output: "ok"} } // reviewed walks a task to a reviewed state at one commit. func reviewed(t *testing.T, findings ...review.Finding) (*store.Store, string, registry.Project) { t.Helper() project := registry.Project{ID: "p", QualityGate: "go test ./..."} s, id := atImplement(t, project) if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil { t.Fatal(err) } if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA, Findings: findings}); err != nil { t.Fatal(err) } return s, id, project } func head(sha string) HeadResolver { return func(context.Context) (string, error) { return sha, nil } } func TestSubmissionEligibility(t *testing.T) { // Reviewed A, head A, gate A: allowed. s, id, project := reviewed(t) task, _ := s.Task(id) if check := domain.CheckSubmission(task, shaA, gate(shaA)); !check.Eligible { t.Fatalf("want eligible, got %v", check.Reasons) } // The three shas must agree. cases := map[string]struct { head string g domain.GateResult }{ "head moved past the review": {shaB, gate(shaB)}, "gate ran on another commit": {shaB, gate(shaA)}, "failing gate": {shaA, domain.GateResult{Command: "go test ./...", ExitCode: 1, SHA: shaA}}, "unanchored head": {"short", gate("short")}, } for name, c := range cases { if check := domain.CheckSubmission(task, c.head, c.g); check.Eligible { t.Fatalf("%s: want refusal", name) } } if _, err := PrepareSubmission(s, project, id, shaB, gate(shaB), Notes{}); !errors.Is(err, ErrNotSubmittable) { t.Fatalf("want ErrNotSubmittable, got %v", err) } // Minor findings do not block. Important findings do. s, id, project = reviewed(t, finding("f1", review.Minor)) task, _ = s.Task(id) if check := domain.CheckSubmission(task, shaA, gate(shaA)); !check.Eligible { t.Fatalf("minor-only: want eligible, got %v", check.Reasons) } s, id, project = reviewed(t, finding("f1", review.Important)) task, _ = s.Task(id) check := domain.CheckSubmission(task, shaA, gate(shaA)) if check.Eligible { t.Fatal("an important finding must refuse submission") } if !strings.Contains(strings.Join(check.Reasons, " "), "unresolved blocker or important") { t.Fatalf("reasons = %v", check.Reasons) } // A pending human decision refuses. s, id, project = reviewed(t) lease(t, s, id) if _, err := RequestHumanDecision(s, project, id, request("which behaviour?")); err != nil { t.Fatal(err) } task, _ = s.Task(id) if domain.CheckSubmission(task, shaA, gate(shaA)).Eligible { t.Fatal("an outstanding question must refuse submission") } // A project whose path includes plan but sealed none refuses. bare, bareID := phaseStore(t) if got, _ := bare.Task(bareID); len(got.RequirePhaseArtifacts(project.Phases())) != 2 { t.Fatal("missing research and plan should both be reported") } } func TestSubmissionRecordsExactIdentityAndIsIdempotent(t *testing.T) { s, id, project := reviewed(t, finding("f1", review.Minor)) pub := &fakePublisher{} plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{ BehaviouralChanges: []string{"lookups now use the index"}, Risks: []string{"index rebuild on first boot"}, Hotspots: []string{"internal/attr/attr.go:81-140 concurrency semantics changed"}, }) if err != nil { t.Fatal(err) } e, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)) if err != nil { t.Fatal(err) } if e.Type != domain.EventTaskSubmitted { t.Fatalf("event = %+v", e) } got, _ := s.Task(id) if got.State != domain.StateInReview { t.Fatalf("state = %s, want in_review", got.State) } if got.Submission == nil { t.Fatal("no submission recorded") } if got.Submission.ResultSHA != shaA || got.Submission.PR.ID != "142" || got.Submission.ReviewRef == "" || got.Submission.GateRef == "" { t.Fatalf("submission = %+v", got.Submission) } if got.Submission.RemoteRef != "origin/orchestra/"+id { t.Fatalf("remote ref = %q", got.Submission.RemoteRef) } // Submission is not completion. if got.State == domain.StateCompleted { t.Fatal("submission must not complete the task") } // The packet is derived, and labels the agent's account as unverified. packet, err := s.Artifact(got.Submission.PacketRef) if err != nil { t.Fatal(err) } for _, want := range []string{ "## Goal", "## Acceptance", "## Verification", "`go test ./...` exited 0", "independent review of " + shaA, "minor findings, not fixed: 1", "lookups now use the index", "index rebuild on first boot", "internal/attr/attr.go:81-140", "are not verified", } { if !strings.Contains(string(packet), want) { t.Fatalf("packet missing %q:\n%s", want, packet) } } // Running it again with unchanged state is the same submission. plan2, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatalf("a repeated submission must not be refused: %v", err) } if plan2.Existing == nil || plan2.Existing.PR.ID != "142" { t.Fatalf("the existing submission was not carried into the plan: %+v", plan2.Existing) } e2, err := ExecuteSubmission(context.Background(), s, plan2, pub, head(shaA)) if err != nil { t.Fatal(err) } if e2.ID != "" { t.Fatal("a second TaskSubmitted was appended for the same commit") } if pub.prCalls != 2 || pub.pr.ID != "142" { t.Fatalf("pr calls=%d id=%s: a retry must refresh one pull request", pub.prCalls, pub.pr.ID) } } // A forge failure after a successful push must stay retryable. func TestPRFailureLeavesTaskRetryable(t *testing.T) { s, id, project := reviewed(t) pub := &fakePublisher{prErr: errors.New("gitea 502")} plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatal(err) } if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); err == nil { t.Fatal("expected the forge failure to surface") } got, _ := s.Task(id) if got.State == domain.StateFailed || got.State == domain.StateInReview { t.Fatalf("a transport failure changed the lifecycle: %s", got.State) } if got.Submission != nil { t.Fatal("a failed submission was recorded") } // The retry re-pushes, verifies, and succeeds. pub.prErr = nil plan, err = PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatal(err) } if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); err != nil { t.Fatal(err) } if got, _ := s.Task(id); got.Submission == nil || got.Submission.ResultSHA != shaA { t.Fatalf("retry did not record the submission") } if pub.pushes != 2 { t.Fatalf("pushes = %d, the retry must re-verify the remote", pub.pushes) } } // The remote holding a different commit is a hard refusal. func TestRemoteMismatchRecordsNothing(t *testing.T) { s, id, project := reviewed(t) pub := &fakePublisher{remoteSHA: shaB} plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatal(err) } if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); !errors.Is(err, ErrRemoteMismatch) { t.Fatalf("want ErrRemoteMismatch, got %v", err) } if pub.prCalls != 0 { t.Fatal("a pull request was opened for an unverified push") } if got, _ := s.Task(id); got.Submission != nil { t.Fatal("a mismatched submission was recorded") } } // The commit is re-read immediately before the push and again before the // event, so a tree that moves mid-submission cannot be submitted. func TestHeadMovingDuringSubmissionRefuses(t *testing.T) { s, id, project := reviewed(t) plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatal(err) } pub := &fakePublisher{} if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaB)); !errors.Is(err, ErrNotSubmittable) { t.Fatalf("want refusal before push, got %v", err) } if pub.pushes != 0 { t.Fatal("pushed a commit that was no longer head") } // Moves after the push, before the record. calls := 0 moving := func(context.Context) (string, error) { calls++ if calls == 1 { return shaA, nil } return shaB, nil } if _, err := ExecuteSubmission(context.Background(), s, plan, pub, moving); !errors.Is(err, ErrNotSubmittable) { t.Fatalf("want refusal before recording, got %v", err) } if got, _ := s.Task(id); got.Submission != nil { t.Fatal("recorded a submission for a stale commit") } } // A change after submission means the recorded submission no longer represents // the current head. func TestLaterChangeInvalidatesTheSubmission(t *testing.T) { s, id, project := reviewed(t) plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{}) if err != nil { t.Fatal(err) } if _, err := ExecuteSubmission(context.Background(), s, plan, &fakePublisher{}, head(shaA)); err != nil { t.Fatal(err) } got, _ := s.Task(id) if !got.Submitted(shaA) { t.Fatal("submission missing for its own commit") } if got.Submitted(shaB) { t.Fatal("a submission of one commit must not cover another") } if domain.CheckSubmission(got, shaB, gate(shaB)).Eligible { t.Fatal("a new commit must not inherit the old review and submission") } } func TestNotesAreBounded(t *testing.T) { s, id, project := reviewed(t) long := strings.Repeat("x", 501) bad := []Notes{ {Risks: []string{""}}, {Risks: []string{long}}, {Risks: []string{"one\ntwo"}}, {Hotspots: make([]string, 13)}, } for i, n := range bad { if _, err := PrepareSubmission(s, project, id, shaA, gate(shaA), n); !errors.Is(err, domain.ErrInvalid) { t.Fatalf("notes %d: want ErrInvalid, got %v", i, err) } } }