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