coverage~bigbes/sr-ht-spec64cae3afservice/proposals.go

Coverage
64.0% 16/25 statements
Δ
+0.0
Blob
94bc8a8
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/core"
10 "sourcecraft.dev/bigbes/sr-ht-spec/db"
11 )
12
13 // Proposal is one proposal as the surfaces above this layer need it: core
14 // types, its space named by reference rather than by the row's opaque id, and
15 // nothing from db/ or gitx/ leaking through.
16 //
17 // It is the read shape shared by every surface — GraphQL's `proposals` field,
18 // the MCP and REST write responses, the review page — so that "what a proposal
19 // is" has one spelling above service/. db.Proposal is the storage shape and
20 // stays in db/; the mapping between them is proposalView, in this package,
21 // because the dependency rule keeps db/ types out of every caller.
22 type Proposal struct {
23 ID int
24 Space core.SpaceRef
25 Title string
26 Rationale string
27 BaseRev string
28 Branch string
29 State core.ProposalState
30 Approval core.Approval
31 MergedRev string
32 Agent string
33 AgentSession string
34 Created time.Time
35 Resolved *time.Time
36 }
37
38 // ProposalURL is the stable, shareable link to a proposal: the value every
39 // write response hands back so an agent can surface it in its transcript.
40 //
41 // The form is <origin>/~owner/space/p/<id>, and it is built here rather than in
42 // each surface because the origin is service/'s config and a second surface
43 // spelling the path would be a link that resolves on one door and 404s on
44 // another. It resolves after merge or rejection too — proposal URLs outlive the
45 // branch — so it is the same URL whatever the proposal's state.
46 31 func (s *Service) ProposalURL(ref core.SpaceRef, id int) string {
47 31 return fmt.Sprintf("%s/~%s/%s/p/%d", s.cfg.Origin, ref.Owner, ref.Name, id)
48 31 }
49
50 // proposalView maps a stored proposal onto the surface shape, naming its space
51 // by the reference the caller already resolved rather than re-reading the row's
52 // space_id.
53 74 func proposalView(p *db.Proposal, ref core.SpaceRef) Proposal {
54 74 return Proposal{
55 74 ID: p.ID,
56 74 Space: ref,
57 74 Title: p.Title,
58 74 Rationale: p.Rationale,
59 74 BaseRev: p.BaseRev,
60 74 Branch: p.Branch,
61 74 State: p.State,
62 74 Approval: p.Approval,
63 74 MergedRev: p.MergedRev,
64 74 Agent: p.Agent,
65 74 AgentSession: p.AgentSession,
66 74 Created: p.Created,
67 74 Resolved: p.Resolved,
68 74 }
69 74 }
70
71 // ListProposals returns a space's proposals in one state, newest first.
72 //
73 // This is the read the GraphQL `proposals` field, the inbox and the review UI
74 // all call — the space-scoped listing the design puts in the read schema. It
75 // exists here, in service/, because nothing above this layer may query db/
76 // directly: the port graph/ declared and left nil until Phase 3 is this
77 // method.
78 //
79 // The space is resolved by reference to its row so the listing filters by
80 // space_id, and a space that does not exist is ErrNotFound rather than an empty
81 // list — "no such space" and "this space has an empty queue" are different
82 // answers, and a surface that conflated them would tell a reviewer their queue
83 // is clear when the space name was simply wrong.
84 2 func (s *Service) ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]Proposal, error) {
85 2 if _, err := core.ParseProposalState(string(state)); err != nil {
86 0 return nil, err
87 0 }
88 2 row, err := s.store.GetSpace(ctx, ref)
89 2 if err != nil {
90 0 if errors.Is(err, db.ErrNotFound) {
91 0 return nil, fmt.Errorf("%w: space %s", ErrNotFound, ref)
92 0 }
93 0 return nil, fmt.Errorf("service: look up space %s: %w", ref, err)
94 }
95 2 rows, err := s.store.ListProposalsBySpace(ctx, row.ID, state, 0)
96 2 if err != nil {
97 0 return nil, fmt.Errorf("service: list %s proposals of %s: %w", state, ref, err)
98 0 }
99 2 out := make([]Proposal, 0, len(rows))
100 2 for _, p := range rows {
101 1 out = append(out, proposalView(p, ref))
102 1 }
103 2 return out, nil
104 }
105
106 // GetProposal resolves one proposal by id, naming its space by reference.
107 //
108 // It is the lookup the stable proposal URL resolves through, so it works in
109 // every state: a link to a merged or rejected proposal still shows the outcome.
110 // The space is resolved from the row's space_id back to a reference so no
111 // caller above this layer has to hold the opaque id.
112 17 func (s *Service) GetProposal(ctx context.Context, id int) (Proposal, error) {
113 17 row, err := s.store.GetProposal(ctx, id)
114 17 if err != nil {
115 0 if errors.Is(err, db.ErrNotFound) {
116 0 return Proposal{}, fmt.Errorf("%w: proposal %d", ErrNotFound, id)
117 0 }
118 0 return Proposal{}, fmt.Errorf("service: look up proposal %d: %w", id, err)
119 }
120 17 space, err := s.store.GetSpaceByID(ctx, row.SpaceID)
121 17 if err != nil {
122 0 return Proposal{}, fmt.Errorf("service: resolve space %d of proposal %d: %w", row.SpaceID, id, err)
123 0 }
124 17 return proposalView(row, space.Ref), nil
125 }