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)
}
}