Gitfed
bastien-mrq/gitfed / internal / store / merge_requests.go
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
}