Split internal/rpc/server.go into 8 focused modules (bd-215)
Refactored monolithic 2238-line server.go into 8 files with clear responsibilities: - server_core.go: Server type, NewServer (115 lines) - server_lifecycle_conn.go: Start/Stop/connection handling (248 lines) - server_cache_storage.go: Storage caching and eviction (286 lines) - server_routing_validation_diagnostics.go: Request routing/validation (384 lines) - server_issues_epics.go: Issue CRUD operations (506 lines) - server_labels_deps_comments.go: Labels/deps/comments (199 lines) - server_compact.go: Compaction operations (287 lines) - server_export_import_auto.go: Export/import operations (293 lines) Improvements: - Replaced RWMutex.TryLock with atomic.Bool for portable single-flight guard - Added default storage close in Stop() to prevent FD leaks - All methods remain on *Server receiver (no behavior changes) - Each file <510 LOC for better maintainability - All tests pass, daemon verified working Amp-Thread-ID: https://ampcode.com/threads/T-92d481ad-1bda-4ecd-bcf5-874a1889db30 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
506
internal/rpc/server_issues_epics.go
Normal file
506
internal/rpc/server_issues_epics.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
|
||||
func normalizeLabels(ss []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func strValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func updatesFromArgs(a UpdateArgs) map[string]interface{} {
|
||||
u := map[string]interface{}{}
|
||||
if a.Title != nil {
|
||||
u["title"] = *a.Title
|
||||
}
|
||||
if a.Description != nil {
|
||||
u["description"] = *a.Description
|
||||
}
|
||||
if a.Status != nil {
|
||||
u["status"] = *a.Status
|
||||
}
|
||||
if a.Priority != nil {
|
||||
u["priority"] = *a.Priority
|
||||
}
|
||||
if a.Design != nil {
|
||||
u["design"] = a.Design
|
||||
}
|
||||
if a.AcceptanceCriteria != nil {
|
||||
u["acceptance_criteria"] = a.AcceptanceCriteria
|
||||
}
|
||||
if a.Notes != nil {
|
||||
u["notes"] = a.Notes
|
||||
}
|
||||
if a.Assignee != nil {
|
||||
u["assignee"] = a.Assignee
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) handleCreate(req *Request) Response {
|
||||
var createArgs CreateArgs
|
||||
if err := json.Unmarshal(req.Args, &createArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid create args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
var design, acceptance, assignee *string
|
||||
if createArgs.Design != "" {
|
||||
design = &createArgs.Design
|
||||
}
|
||||
if createArgs.AcceptanceCriteria != "" {
|
||||
acceptance = &createArgs.AcceptanceCriteria
|
||||
}
|
||||
if createArgs.Assignee != "" {
|
||||
assignee = &createArgs.Assignee
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: createArgs.ID,
|
||||
Title: createArgs.Title,
|
||||
Description: createArgs.Description,
|
||||
IssueType: types.IssueType(createArgs.IssueType),
|
||||
Priority: createArgs.Priority,
|
||||
Design: strValue(design),
|
||||
AcceptanceCriteria: strValue(acceptance),
|
||||
Assignee: strValue(assignee),
|
||||
Status: types.StatusOpen,
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := store.CreateIssue(ctx, issue, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels if specified
|
||||
for _, label := range createArgs.Labels {
|
||||
if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add label %s: %v", label, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies if specified
|
||||
for _, depSpec := range createArgs.Dependencies {
|
||||
depSpec = strings.TrimSpace(depSpec)
|
||||
if depSpec == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var depType types.DependencyType
|
||||
var dependsOnID string
|
||||
|
||||
if strings.Contains(depSpec, ":") {
|
||||
parts := strings.SplitN(depSpec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec),
|
||||
}
|
||||
}
|
||||
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
||||
dependsOnID = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
depType = types.DepBlocks
|
||||
dependsOnID = depSpec
|
||||
}
|
||||
|
||||
if !depType.IsValid() {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType),
|
||||
}
|
||||
}
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue.ID,
|
||||
DependsOnID: dependsOnID,
|
||||
Type: depType,
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(req *Request) Response {
|
||||
var updateArgs UpdateArgs
|
||||
if err := json.Unmarshal(req.Args, &updateArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid update args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
updates := updatesFromArgs(updateArgs)
|
||||
if len(updates) == 0 {
|
||||
return Response{Success: true}
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, updateArgs.ID, updates, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to update issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get updated issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClose(req *Request) Response {
|
||||
var closeArgs CloseArgs
|
||||
if err := json.Unmarshal(req.Args, &closeArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid close args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to close issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
issue, _ := store.GetIssue(ctx, closeArgs.ID)
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleList(req *Request) Response {
|
||||
var listArgs ListArgs
|
||||
if err := json.Unmarshal(req.Args, &listArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid list args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
filter := types.IssueFilter{
|
||||
Limit: listArgs.Limit,
|
||||
}
|
||||
if listArgs.Status != "" {
|
||||
status := types.Status(listArgs.Status)
|
||||
filter.Status = &status
|
||||
}
|
||||
if listArgs.IssueType != "" {
|
||||
issueType := types.IssueType(listArgs.IssueType)
|
||||
filter.IssueType = &issueType
|
||||
}
|
||||
if listArgs.Assignee != "" {
|
||||
filter.Assignee = &listArgs.Assignee
|
||||
}
|
||||
if listArgs.Priority != nil {
|
||||
filter.Priority = listArgs.Priority
|
||||
}
|
||||
// Normalize and apply label filters
|
||||
labels := normalizeLabels(listArgs.Labels)
|
||||
labelsAny := normalizeLabels(listArgs.LabelsAny)
|
||||
// Support both old single Label and new Labels array
|
||||
if len(labels) > 0 {
|
||||
filter.Labels = labels
|
||||
} else if listArgs.Label != "" {
|
||||
filter.Labels = []string{strings.TrimSpace(listArgs.Label)}
|
||||
}
|
||||
if len(labelsAny) > 0 {
|
||||
filter.LabelsAny = labelsAny
|
||||
}
|
||||
if len(listArgs.IDs) > 0 {
|
||||
ids := normalizeLabels(listArgs.IDs)
|
||||
if len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against excessive ID lists to avoid SQLite parameter limits
|
||||
const maxIDs = 1000
|
||||
if len(filter.IDs) > maxIDs {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("--id flag supports at most %d issue IDs, got %d", maxIDs, len(filter.IDs)),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issues, err := store.SearchIssues(ctx, listArgs.Query, filter)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to list issues: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate labels for each issue
|
||||
for _, issue := range issues {
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issues)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleShow(req *Request) Response {
|
||||
var showArgs ShowArgs
|
||||
if err := json.Unmarshal(req.Args, &showArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid show args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issue, err := store.GetIssue(ctx, showArgs.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate labels, dependencies, and dependents
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
deps, _ := store.GetDependencies(ctx, issue.ID)
|
||||
dependents, _ := store.GetDependents(ctx, issue.ID)
|
||||
|
||||
// Create detailed response with related data
|
||||
type IssueDetails struct {
|
||||
*types.Issue
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Dependencies []*types.Issue `json:"dependencies,omitempty"`
|
||||
Dependents []*types.Issue `json:"dependents,omitempty"`
|
||||
}
|
||||
|
||||
details := &IssueDetails{
|
||||
Issue: issue,
|
||||
Labels: labels,
|
||||
Dependencies: deps,
|
||||
Dependents: dependents,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(details)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(req *Request) Response {
|
||||
var readyArgs ReadyArgs
|
||||
if err := json.Unmarshal(req.Args, &readyArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid ready args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
wf := types.WorkFilter{
|
||||
Status: types.StatusOpen,
|
||||
Priority: readyArgs.Priority,
|
||||
Limit: readyArgs.Limit,
|
||||
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
|
||||
}
|
||||
if readyArgs.Assignee != "" {
|
||||
wf.Assignee = &readyArgs.Assignee
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issues, err := store.GetReadyWork(ctx, wf)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get ready work: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issues)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStats(req *Request) Response {
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get statistics: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(stats)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEpicStatus(req *Request) Response {
|
||||
var epicArgs EpicStatusArgs
|
||||
if err := json.Unmarshal(req.Args, &epicArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid epic status args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
epics, err := store.GetEpicsEligibleForClosure(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get epic status: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if epicArgs.EligibleOnly {
|
||||
filtered := []*types.EpicStatus{}
|
||||
for _, epic := range epics {
|
||||
if epic.EligibleForClose {
|
||||
filtered = append(filtered, epic)
|
||||
}
|
||||
}
|
||||
epics = filtered
|
||||
}
|
||||
|
||||
data, err := json.Marshal(epics)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to marshal epics: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user