package service import ( "context" "errors" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) // autoMergeSetup opens a proposal that does not auto-merge yet — there is no // policy at the time it is opened — and then installs one that covers it. The // agent's next revision is therefore the first moment auto-merge can fire, // which is exactly the window an open review thread has to hold shut. func autoMergeSetup(t *testing.T) (*Service, context.Context, *Space, ProposeResult, string) { t.Helper() svc, _ := newTestService(t) ctx := context.Background() sp, err := svc.CreateSpace(ctx, fxSpace) if err != nil { t.Fatalf("CreateSpace: %v", err) } base, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatalf("ApprovedHead: %v", err) } res, err := svc.Propose(ctx, ProposeRequest{ Space: fxSpace, Principal: agentPrincipal(), Title: "firehose note", IfMatch: base.String(), Message: "add notes/a.md", Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "first body")}}, }) if err != nil { t.Fatalf("Propose: %v", err) } if res.Merged { t.Fatalf("proposal auto-merged with no policy installed") } // Widen the policy so the proposal's paths now qualify. commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ ".spec.yml": []byte("review:\n auto_merge: [notes/**]\n"), }) return svc, ctx, sp, res, base.String() } // revise is the agent's next push onto an open proposal, which is where // auto-merge is re-evaluated. func revise(t *testing.T, svc *Service, ctx context.Context, res ProposeResult, base, body string) ProposeResult { t.Helper() out, err := svc.Propose(ctx, ProposeRequest{ Space: fxSpace, Principal: agentPrincipal(), ProposalID: res.Proposal.ID, IfMatch: base, Message: "revise notes/a.md", Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", body)}}, }) if err != nil { t.Fatalf("revise: %v", err) } return out } func commentOn(t *testing.T, svc *Service, ctx context.Context, proposalID int) Thread { t.Helper() th, err := svc.CommentOn(ctx, CommentRequest{ Principal: ownerPrincipal(), Space: fxSpace, ProposalID: proposalID, DocPath: "notes/a.md", Anchor: core.CommentAnchor{ DocID: "N-1", HeadingPath: nil, Index: 0, BlockHash: "whatever", Side: core.SideNew, }, Body: "This contradicts SPEC-0003.", }) if err != nil { t.Fatalf("CommentOn: %v", err) } return th } // The gate. Without a comment the agent's revision lands under policy; with an // open thread it must not, because the owner has engaged with this proposal and // it must not slip past them unattended on the agent's next push. func TestOpenThreadSuppressesPolicyAutoMerge(t *testing.T) { svc, ctx, _, res, base := autoMergeSetup(t) commentOn(t, svc, ctx, res.Proposal.ID) revised := revise(t, svc, ctx, res, base, "second body") if revised.Merged { t.Fatal("policy auto-merged a proposal with an open review thread") } if revised.Proposal.State != core.StateOpen { t.Errorf("state = %s, want open", revised.Proposal.State) } } // The control for the test above: the same revision, with nothing to hold it // back, does land. Without this, a broken auto-merge would make the gate test // pass for the wrong reason. func TestPolicyAutoMergeStillFiresWithoutAThread(t *testing.T) { svc, ctx, _, res, base := autoMergeSetup(t) revised := revise(t, svc, ctx, res, base, "second body") if !revised.Merged { t.Fatal("policy did not auto-merge an uncommented proposal; the gate test above proves nothing") } if revised.Proposal.Approval != core.ApprovalPolicy { t.Errorf("approval = %q, want policy", revised.Proposal.Approval) } } // Resolving the thread lifts the gate: the owner has had their say, so the // proposal returns to the firehose rather than needing a manual click. func TestResolvingTheThreadRestoresAutoMerge(t *testing.T) { svc, ctx, _, res, base := autoMergeSetup(t) th := commentOn(t, svc, ctx, res.Proposal.ID) if err := svc.ResolveThread(ctx, ownerPrincipal(), th.Root.ID, true); err != nil { t.Fatalf("ResolveThread: %v", err) } revised := revise(t, svc, ctx, res, base, "second body") if !revised.Merged { t.Fatal("a resolved thread still suppressed auto-merge") } } // An agent reply does not lift the gate. The agent answering a critique is not // the owner accepting the answer, and if it were, the gate would be under the // control of the thing it exists to hold back. func TestAgentReplyDoesNotLiftTheGate(t *testing.T) { svc, ctx, _, res, base := autoMergeSetup(t) th := commentOn(t, svc, ctx, res.Proposal.ID) if _, err := svc.ReplyTo(ctx, agentPrincipal(), th.Root.ID, "Fixed in this revision."); err != nil { t.Fatalf("ReplyTo: %v", err) } revised := revise(t, svc, ctx, res, base, "second body") if revised.Merged { t.Fatal("an agent reply cleared the auto-merge gate") } } // The gate is on policy auto-merge only. A comment nobody got round to // resolving must not be able to wedge a proposal shut — the owner clicking // approve is the judgement the thread was asking for. func TestManualApproveIgnoresOpenThreads(t *testing.T) { svc, ctx, _, res, _ := autoMergeSetup(t) commentOn(t, svc, ctx, res.Proposal.ID) merged, err := svc.MergeHuman(ctx, fxSpace, res.Proposal.ID) if err != nil { t.Fatalf("MergeHuman with an open thread: %v", err) } if merged.State != core.StateMerged { t.Errorf("state = %s, want merged", merged.State) } if merged.Approval != core.ApprovalHuman { t.Errorf("approval = %q, want human", merged.Approval) } } // Who may do what. An agent may join a conversation but may neither start one // nor declare it finished: both would hand it control of the gate. func TestThreadAuthority(t *testing.T) { svc, ctx, _, res, _ := autoMergeSetup(t) if _, err := svc.CommentOn(ctx, CommentRequest{ Principal: agentPrincipal(), Space: fxSpace, ProposalID: res.Proposal.ID, DocPath: "notes/a.md", Anchor: core.CommentAnchor{DocID: "N-1", Side: core.SideNew}, Body: "self-review", }); !errors.Is(err, ErrForbidden) { t.Errorf("agent opening a thread = %v, want ErrForbidden", err) } th := commentOn(t, svc, ctx, res.Proposal.ID) reply, err := svc.ReplyTo(ctx, agentPrincipal(), th.Root.ID, "Acknowledged.") if err != nil { t.Fatalf("agent reply: %v", err) } if !reply.Agent { t.Error("agent reply did not come back marked as an agent's") } if err := svc.ResolveThread(ctx, agentPrincipal(), th.Root.ID, true); !errors.Is(err, ErrForbidden) { t.Errorf("agent resolving a thread = %v, want ErrForbidden", err) } threads, err := svc.Threads(ctx, ownerPrincipal(), res.Proposal.ID) if err != nil { t.Fatalf("Threads: %v", err) } if len(threads) != 1 { t.Fatalf("Threads returned %d threads, want 1 (replies belong to their root)", len(threads)) } if len(threads[0].Replies) != 1 || !threads[0].Replies[0].Agent { t.Errorf("replies = %+v, want one agent reply", threads[0].Replies) } if !threads[0].Open() { t.Error("thread reports closed; the agent must not have been able to resolve it") } } // --- anchoring against a revision (no database) --- const anchorDoc = `# Storage The first paragraph of the storage section. The second paragraph, which will be edited. ## Trade-offs A trade-off paragraph. ` func anchorFor(t *testing.T, src string, ordinal int) core.CommentAnchor { t.Helper() a, err := AnchorOf("SPEC-0007", []byte(src), ordinal, core.SideNew) if err != nil { t.Fatalf("AnchorOf(%d): %v", ordinal, err) } return a } // AnchorOf and AnchorBlocks must number blocks identically — one builds an // anchor, the other resolves it, and a disagreement would put every comment on // the wrong block of its own section. func TestAnchorOfAgreesWithResolution(t *testing.T) { src := []byte(anchorDoc) blocks := anchorBlocksOf(src) for ordinal := range blocks { a := anchorFor(t, anchorDoc, ordinal) got, state := core.ResolveAnchor(a, blocks) if got != ordinal || state != core.AnchorExact { t.Errorf("block %d: resolved to (%d, %s), want (%d, %s)", ordinal, got, state, ordinal, core.AnchorExact) } } if _, err := AnchorOf("SPEC-0007", src, len(blocks), core.SideNew); !errors.Is(err, ErrInvalid) { t.Errorf("AnchorOf past the end = %v, want ErrInvalid", err) } } // The three outcomes, against a revision the agent has since pushed: untouched // text stays anchored, edited text keeps its comment and says it was edited, // and text that is gone is reported outdated rather than moved onto a // neighbouring paragraph. func TestAnchorThreadsReportsFitAgainstARevision(t *testing.T) { const revised = `# Storage The first paragraph of the storage section. The second paragraph, completely rewritten in the agent's revision. ## Trade-offs A trade-off paragraph. ` untouched := anchorFor(t, anchorDoc, 1) // "The first paragraph..." edited := anchorFor(t, anchorDoc, 2) // "The second paragraph, which will be edited." // An anchor to a block that the revision drops entirely. const withExtra = anchorDoc + "\nA paragraph that the revision removes.\n" removed := anchorFor(t, withExtra, 5) threads := []Thread{ {DocPath: "specs/a.md", Anchor: untouched}, {DocPath: "specs/a.md", Anchor: edited}, {DocPath: "specs/a.md", Anchor: removed}, {DocPath: "specs/gone.md", Anchor: untouched}, } docs := []ProposalDoc{{Path: "specs/a.md", Base: []byte(anchorDoc), Proposed: []byte(revised)}} got := AnchorThreads(threads, docs) want := []core.AnchorState{ core.AnchorExact, core.AnchorEdited, core.AnchorOutdated, core.AnchorOutdated, // its document is not among the proposal's changes } for i := range want { if got[i].State != want[i] { t.Errorf("thread %d: state = %s, want %s", i, got[i].State, want[i]) } } if got[0].Block < 0 || got[1].Block < 0 { t.Errorf("a resolved anchor must name a block: %d, %d", got[0].Block, got[1].Block) } if got[2].Block != -1 || got[3].Block != -1 { t.Errorf("an outdated anchor must name no block: %d, %d", got[2].Block, got[3].Block) } // The input must not be mutated: a caller rendering two revisions would // otherwise see the first one's answers on the second. if threads[0].State != "" || threads[0].Block != 0 { t.Error("AnchorThreads mutated its input") } } // A thread whose document the proposal no longer changes — the agent reverted // it — is outdated, not dropped. A comment that silently vanished would look // like one that was never made. func TestRevertedDocumentOutdatesItsThreadsRatherThanLosingThem(t *testing.T) { threads := []Thread{{DocPath: "specs/a.md", Anchor: anchorFor(t, anchorDoc, 1)}} got := AnchorThreads(threads, nil) if len(got) != 1 { t.Fatalf("AnchorThreads returned %d threads, want 1 kept", len(got)) } if got[0].State != core.AnchorOutdated || got[0].Block != -1 { t.Errorf("state/block = %s/%d, want outdated/-1", got[0].State, got[0].Block) } } // A comment on the old side of a deleted block reads the base, not the proposed // text — there is no proposed text for a block the change removes. func TestOldSideAnchorsAgainstTheBase(t *testing.T) { a := anchorFor(t, anchorDoc, 2) a.Side = core.SideOld got := AnchorThreads([]Thread{{DocPath: "specs/a.md", Anchor: a}}, []ProposalDoc{{Path: "specs/a.md", Base: []byte(anchorDoc), Proposed: []byte("# Storage\n")}}) if got[0].State != core.AnchorExact { t.Errorf("state = %s, want %s: an old-side anchor resolves against the base", got[0].State, core.AnchorExact) } }