~bigbes/sr-ht-spec

f8a7a1743ad9704ff46f9e9319f8cc22b75bbdef — Eugene Blikh 26 days ago 65eac3c
feat(graph): wire the proposals read to service.ListProposals (Phase 3)

The Proposals port declared a proposal listing and left Options.Proposals
nil, so the `proposals` query failed loudly with "service/ exposes no
proposal listing yet". Phase 3 supplies it: an adapter maps
service.Proposal onto graph.Proposal at the edge — the two structs are
identical, but service/ must not import graph/, so the rename lives here
beside web.NewReader's equivalent — and cmd wires graph.NewProposals(svc)
into the schema. The `proposals` field now answers from service/.
3 files changed, 154 insertions(+), 3 deletions(-)

M cmd/specsrht/main.go
A graph/proposals.go
A graph/proposals_test.go
M cmd/specsrht/main.go => cmd/specsrht/main.go +4 -3
@@ 478,9 478,10 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
	}

	gql, err := graph.New(graph.Options{
		Reader:   svc,
		Searcher: index,
		Resolver: svc.Resolver(),
		Reader:    svc,
		Searcher:  index,
		Proposals: graph.NewProposals(svc),
		Resolver:  svc.Resolver(),
	})
	if err != nil {
		index.Close()

A graph/proposals.go => graph/proposals.go +63 -0
@@ 0,0 1,63 @@
package graph

import (
	"context"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// proposalLister is the service-layer proposal read this package adapts. It is
// the method *service.Service grew in Phase 3, named as an interface here so the
// adapter is testable without a whole Service and so this file states exactly
// what it depends on.
type proposalLister interface {
	ListProposals(ctx context.Context, space core.SpaceRef, state core.ProposalState) ([]service.Proposal, error)
}

// serviceProposals adapts the service layer to the Proposals port.
//
// The port yields graph.Proposal (core types, no db/ leak) while the service
// yields service.Proposal, and the two cannot be one type without a dependency
// cycle — service/ must not import graph/. So the mapping lives here, at the
// edge, exactly as web.NewReader adapts the same service to the web surface's
// read shape. It is a field-for-field copy; the two structs are deliberately
// identical so this stays a rename rather than a translation.
type serviceProposals struct{ svc proposalLister }

// NewProposals wires the service layer into the schema's proposals field,
// closing the gap the Proposals port documented: with this set, Options.Proposals
// is no longer nil and the `proposals` query answers from service/ instead of
// failing with "no proposal listing yet".
func NewProposals(svc proposalLister) Proposals { return serviceProposals{svc: svc} }

func (s serviceProposals) ListProposals(ctx context.Context, space core.SpaceRef, state core.ProposalState) ([]Proposal, error) {
	ps, err := s.svc.ListProposals(ctx, space, state)
	if err != nil {
		return nil, err
	}
	out := make([]Proposal, 0, len(ps))
	for _, p := range ps {
		out = append(out, Proposal{
			ID:           p.ID,
			Space:        p.Space,
			Title:        p.Title,
			Rationale:    p.Rationale,
			BaseRev:      p.BaseRev,
			Branch:       p.Branch,
			State:        p.State,
			Approval:     p.Approval,
			MergedRev:    p.MergedRev,
			Agent:        p.Agent,
			AgentSession: p.AgentSession,
			Created:      p.Created,
			Resolved:     p.Resolved,
		})
	}
	return out, nil
}

// Compile-time assertion that the production service satisfies the read this
// adapter needs. It lives here, beside the assertions in resolver.go, so a
// signature drift in service/ breaks the build rather than a test.
var _ proposalLister = (*service.Service)(nil)

A graph/proposals_test.go => graph/proposals_test.go +87 -0
@@ 0,0 1,87 @@
package graph

import (
	"context"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// stubLister is a proposalLister that returns a fixed row, so the adapter's
// field mapping can be checked without a Service, a database or a repository.
type stubLister struct {
	rows     []service.Proposal
	gotSpace core.SpaceRef
	gotState core.ProposalState
}

func (s *stubLister) ListProposals(_ context.Context, space core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) {
	s.gotSpace = space
	s.gotState = state
	return s.rows, nil
}

// TestNewProposalsMapsEveryField proves the adapter is a faithful rename: every
// field of a service proposal reaches the graph shape unchanged, and the query
// arguments are forwarded verbatim. A dropped field here would silently blank a
// column in the read schema.
func TestNewProposalsMapsEveryField(t *testing.T) {
	ref := core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
	created := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
	resolved := created.Add(time.Hour)
	want := service.Proposal{
		ID:           42,
		Space:        ref,
		Title:        "Add a note",
		Rationale:    "because",
		BaseRev:      "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809",
		Branch:       "proposals/42",
		State:        core.StateMerged,
		Approval:     core.ApprovalPolicy,
		MergedRev:    "2a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d",
		Agent:        "claude-code/spec-writer",
		AgentSession: "sess-1",
		Created:      created,
		Resolved:     &resolved,
	}
	stub := &stubLister{rows: []service.Proposal{want}}

	got, err := NewProposals(stub).ListProposals(context.Background(), ref, core.StateMerged)
	if err != nil {
		t.Fatalf("ListProposals: %v", err)
	}
	if stub.gotSpace != ref || stub.gotState != core.StateMerged {
		t.Fatalf("forwarded (%v, %s), want (%v, merged)", stub.gotSpace, stub.gotState, ref)
	}
	if len(got) != 1 {
		t.Fatalf("got %d proposals, want 1", len(got))
	}
	g := got[0]
	mismatches := []struct {
		field     string
		got, want any
	}{
		{"ID", g.ID, want.ID},
		{"Space", g.Space, want.Space},
		{"Title", g.Title, want.Title},
		{"Rationale", g.Rationale, want.Rationale},
		{"BaseRev", g.BaseRev, want.BaseRev},
		{"Branch", g.Branch, want.Branch},
		{"State", g.State, want.State},
		{"Approval", g.Approval, want.Approval},
		{"MergedRev", g.MergedRev, want.MergedRev},
		{"Agent", g.Agent, want.Agent},
		{"AgentSession", g.AgentSession, want.AgentSession},
		{"Created", g.Created, want.Created},
	}
	for _, m := range mismatches {
		if m.got != m.want {
			t.Errorf("%s = %v, want %v", m.field, m.got, m.want)
		}
	}
	if g.Resolved == nil || !g.Resolved.Equal(resolved) {
		t.Errorf("Resolved = %v, want %v", g.Resolved, resolved)
	}
}