package store
import (
"fmt"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
type MRStatus string
const (
MROpen MRStatus = "open"
MRMerged MRStatus = "merged"
MRClosed MRStatus = "closed"
)
// MergeRequest proposes merging SourceBranch into TargetBranch within a
// single repo — there is no fork model in gitfed (collaborators share
// write access to one repo, per DESIGN.md), so unlike GitHub this never
// crosses a repo boundary. The diff itself is never stored: it's always
// computed live from the two branches' current tips, so a new push to
// SourceBranch is reflected without any extra bookkeeping here.
type MergeRequest struct {
Repo string `json:"repo"`
Number int `json:"number"` // sequential per repo, like "#3"
Title string `json:"title"`
Description string `json:"description"`
Author string `json:"author"` // principal who opened it
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
Status MRStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedBy string `json:"merged_by,omitempty"`
MergeCommit string `json:"merge_commit,omitempty"`
}
// MRComment is one message in a merge request's discussion thread.
// Comments are general, not attached to a specific diff line — see the
// package doc for why inline per-line comments aren't in scope yet.
type MRComment struct {
ID string `json:"id"`
Repo string `json:"repo"`
Number int `json:"number"`
Author string `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
// Zero-padded so key order matches numeric order — purely cosmetic, since
// every caller re-sorts in Go anyway.
func mrKey(repo string, number int) string {
return fmt.Sprintf("%s\x00%08d", repo, number)
}
func mrCommentPrefix(repo string, number int) string {
return mrKey(repo, number) + "\x00"
}
// CreateMergeRequest assigns mr the next sequential number for its repo
// and stores it as MROpen. Numbering and the write happen in the same
// bbolt transaction, so concurrent creates for the same repo can't race to
// the same number — bbolt allows only one Update transaction at a time.
func (s *Store) CreateMergeRequest(mr MergeRequest) (MergeRequest, error) {
err := s.db.Update(func(tx *bolt.Tx) error {
existing, err := listJSONPrefix[MergeRequest](tx, bucketMergeRequests, mr.Repo+"\x00")
if err != nil {
return err
}
next := 1
for _, e := range existing {
if e.Number >= next {
next = e.Number + 1
}
}
mr.Number = next
mr.Status = MROpen
mr.CreatedAt = time.Now().UTC()
mr.UpdatedAt = mr.CreatedAt
return putJSON(tx, bucketMergeRequests, mrKey(mr.Repo, mr.Number), mr)
})
return mr, err
}
// ListMergeRequests returns repo's merge requests, newest (highest number)
// first.
func (s *Store) ListMergeRequests(repo string) ([]MergeRequest, error) {
var out []MergeRequest
err := s.db.View(func(tx *bolt.Tx) error {
var err error
out, err = listJSONPrefix[MergeRequest](tx, bucketMergeRequests, repo+"\x00")
return err
})
sort.Slice(out, func(i, j int) bool { return out[i].Number > out[j].Number })
return out, err
}
func (s *Store) GetMergeRequest(repo string, number int) (MergeRequest, error) {
var mr MergeRequest
err := s.db.View(func(tx *bolt.Tx) error {
return getJSON(tx, bucketMergeRequests, mrKey(repo, number), &mr)
})
return mr, err
}
// UpdateMergeRequest overwrites the stored record for mr's (Repo, Number)
// — used for status transitions (merged/closed) and edits. UpdatedAt is
// always refreshed here rather than trusted from the caller.
func (s *Store) UpdateMergeRequest(mr MergeRequest) error {
return s.db.Update(func(tx *bolt.Tx) error {
mr.UpdatedAt = time.Now().UTC()
return putJSON(tx, bucketMergeRequests, mrKey(mr.Repo, mr.Number), mr)
})
}
// AddMRComment appends a comment, generating an ID if it doesn't have one.
func (s *Store) AddMRComment(c MRComment) (MRComment, error) {
err := s.db.Update(func(tx *bolt.Tx) error {
if c.ID == "" {
id, err := randomToken()
if err != nil {
return err
}
c.ID = id
}
c.CreatedAt = time.Now().UTC()
return putJSON(tx, bucketMRComments, mrCommentPrefix(c.Repo, c.Number)+c.ID, c)
})
return c, err
}
// ListMRComments returns a merge request's comments, oldest first.
func (s *Store) ListMRComments(repo string, number int) ([]MRComment, error) {
var out []MRComment
err := s.db.View(func(tx *bolt.Tx) error {
var err error
out, err = listJSONPrefix[MRComment](tx, bucketMRComments, mrCommentPrefix(repo, number))
return err
})
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
return out, err
}