feat(molecules): add bd molecule commands and template protection (beads-1ra)
- Add `bd molecule list` to list template molecules - Add `bd molecule show` to show molecule details - Add `bd molecule instantiate` to create work items from templates - Exclude templates from `bd list` by default (use --include-templates) - Reject mutations (update/close/delete) to template issues - Add IncludeTemplates to RPC ListArgs for daemon mode Templates are marked with is_template=true and are read-only. Use `bd molecule instantiate` to create editable work items. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,9 @@ var listCmd = &cobra.Command{
|
|||||||
pinnedFlag, _ := cmd.Flags().GetBool("pinned")
|
pinnedFlag, _ := cmd.Flags().GetBool("pinned")
|
||||||
noPinnedFlag, _ := cmd.Flags().GetBool("no-pinned")
|
noPinnedFlag, _ := cmd.Flags().GetBool("no-pinned")
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra)
|
||||||
|
includeTemplates, _ := cmd.Flags().GetBool("include-templates")
|
||||||
|
|
||||||
// Use global jsonOutput set by PersistentPreRun
|
// Use global jsonOutput set by PersistentPreRun
|
||||||
|
|
||||||
// Normalize labels: trim, dedupe, remove empty
|
// Normalize labels: trim, dedupe, remove empty
|
||||||
@@ -290,6 +293,13 @@ var listCmd = &cobra.Command{
|
|||||||
filter.Pinned = &pinned
|
filter.Pinned = &pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra): exclude templates by default
|
||||||
|
// Use --include-templates to show all issues including templates
|
||||||
|
if !includeTemplates {
|
||||||
|
isTemplate := false
|
||||||
|
filter.IsTemplate = &isTemplate
|
||||||
|
}
|
||||||
|
|
||||||
// Check database freshness before reading (bd-2q6d, bd-c4rq)
|
// Check database freshness before reading (bd-2q6d, bd-c4rq)
|
||||||
// Skip check when using daemon (daemon auto-imports on staleness)
|
// Skip check when using daemon (daemon auto-imports on staleness)
|
||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
@@ -368,6 +378,9 @@ var listCmd = &cobra.Command{
|
|||||||
// Pinned filtering (bd-p8e)
|
// Pinned filtering (bd-p8e)
|
||||||
listArgs.Pinned = filter.Pinned
|
listArgs.Pinned = filter.Pinned
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra)
|
||||||
|
listArgs.IncludeTemplates = includeTemplates
|
||||||
|
|
||||||
resp, err := daemonClient.List(listArgs)
|
resp, err := daemonClient.List(listArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -585,6 +598,9 @@ func init() {
|
|||||||
listCmd.Flags().Bool("pinned", false, "Show only pinned issues")
|
listCmd.Flags().Bool("pinned", false, "Show only pinned issues")
|
||||||
listCmd.Flags().Bool("no-pinned", false, "Exclude pinned issues")
|
listCmd.Flags().Bool("no-pinned", false, "Exclude pinned issues")
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra): exclude templates by default
|
||||||
|
listCmd.Flags().Bool("include-templates", false, "Include template molecules in output")
|
||||||
|
|
||||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||||
rootCmd.AddCommand(listCmd)
|
rootCmd.AddCommand(listCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
355
cmd/bd/molecule.go
Normal file
355
cmd/bd/molecule.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/hooks"
|
||||||
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var moleculeCmd = &cobra.Command{
|
||||||
|
Use: "molecule",
|
||||||
|
Short: "Manage template molecules",
|
||||||
|
Long: `Manage template molecules for issue instantiation.
|
||||||
|
|
||||||
|
Molecules are template issues that can be instantiated to create work items.
|
||||||
|
They are stored in molecules.jsonl and marked with is_template=true.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd molecule list # List all available molecules
|
||||||
|
bd molecule show mol-123 # Show details of a molecule
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var moleculeListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List available molecules",
|
||||||
|
Long: `List all available template molecules.
|
||||||
|
|
||||||
|
Templates are read-only issues that can be instantiated to create work items.
|
||||||
|
Use --all to include closed molecules.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd molecule list # List open molecules
|
||||||
|
bd molecule list --all # List all molecules including closed
|
||||||
|
`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
showAll, _ := cmd.Flags().GetBool("all")
|
||||||
|
|
||||||
|
ctx := rootCtx
|
||||||
|
if daemonClient == nil {
|
||||||
|
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter for template molecules
|
||||||
|
isTemplate := true
|
||||||
|
filter := types.IssueFilter{
|
||||||
|
IsTemplate: &isTemplate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !showAll {
|
||||||
|
// Default to non-closed
|
||||||
|
status := types.StatusOpen
|
||||||
|
filter.Status = &status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct mode only for now
|
||||||
|
if store == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: database not available\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
issues, err := store.SearchIssues(ctx, "", filter)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
fmt.Println("No molecules found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(issues)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print molecule list
|
||||||
|
for _, issue := range issues {
|
||||||
|
priorityStr := fmt.Sprintf("P%d", issue.Priority)
|
||||||
|
fmt.Printf("%s [%s] %s\n", issue.ID, priorityStr, issue.Title)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d molecule(s)\n", len(issues))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var moleculeShowCmd = &cobra.Command{
|
||||||
|
Use: "show <molecule-id>",
|
||||||
|
Short: "Show molecule details",
|
||||||
|
Long: `Show detailed information about a template molecule.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd molecule show mol-123
|
||||||
|
`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
moleculeID := args[0]
|
||||||
|
|
||||||
|
ctx := rootCtx
|
||||||
|
if daemonClient == nil {
|
||||||
|
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if store == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: database not available\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := store.GetIssue(ctx, moleculeID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: molecule %s not found\n", moleculeID)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !issue.IsTemplate {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: %s is not a template molecule\n", moleculeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(issue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print molecule details
|
||||||
|
fmt.Printf("%s: %s\n", issue.ID, issue.Title)
|
||||||
|
fmt.Printf("Type: %s\n", issue.IssueType)
|
||||||
|
fmt.Printf("Priority: P%d\n", issue.Priority)
|
||||||
|
fmt.Printf("Status: %s\n", issue.Status)
|
||||||
|
fmt.Printf("Template: %v\n", issue.IsTemplate)
|
||||||
|
|
||||||
|
if issue.Description != "" {
|
||||||
|
fmt.Printf("\nDescription:\n%s\n", issue.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.Design != "" {
|
||||||
|
fmt.Printf("\nDesign:\n%s\n", issue.Design)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.AcceptanceCriteria != "" {
|
||||||
|
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var moleculeInstantiateCmd = &cobra.Command{
|
||||||
|
Use: "instantiate <molecule-id>",
|
||||||
|
Short: "Create a work item from a template molecule",
|
||||||
|
Long: `Create a new work item based on a template molecule.
|
||||||
|
|
||||||
|
The new issue will inherit the template's title, description, design,
|
||||||
|
acceptance criteria, priority, and issue type. The new issue will have
|
||||||
|
is_template=false and will be linked to the template via a discovered-from
|
||||||
|
dependency.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd molecule instantiate mol-123 # Create work item from template
|
||||||
|
bd molecule instantiate mol-123 --title "Custom title" # Override title
|
||||||
|
`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
CheckReadonly("instantiate")
|
||||||
|
moleculeID := args[0]
|
||||||
|
|
||||||
|
// Get flag overrides
|
||||||
|
titleOverride, _ := cmd.Flags().GetString("title")
|
||||||
|
assignee, _ := cmd.Flags().GetString("assignee")
|
||||||
|
|
||||||
|
ctx := rootCtx
|
||||||
|
if daemonClient == nil {
|
||||||
|
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if store == nil && daemonClient == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: database not available\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template molecule
|
||||||
|
var template *types.Issue
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if daemonClient != nil {
|
||||||
|
showArgs := &rpc.ShowArgs{ID: moleculeID}
|
||||||
|
resp, showErr := daemonClient.Show(showArgs)
|
||||||
|
if showErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", showErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
template = &types.Issue{}
|
||||||
|
if jsonErr := json.Unmarshal(resp.Data, template); jsonErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", jsonErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
template, err = store.GetIssue(ctx, moleculeID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if template == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: molecule %s not found\n", moleculeID)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !template.IsTemplate {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: %s is not a template molecule (is_template=false)\n", moleculeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build title (use override or template title)
|
||||||
|
title := template.Title
|
||||||
|
if titleOverride != "" {
|
||||||
|
title = titleOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new work item
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Daemon mode
|
||||||
|
createArgs := &rpc.CreateArgs{
|
||||||
|
Title: title,
|
||||||
|
Description: template.Description,
|
||||||
|
IssueType: string(template.IssueType),
|
||||||
|
Priority: template.Priority,
|
||||||
|
Design: template.Design,
|
||||||
|
AcceptanceCriteria: template.AcceptanceCriteria,
|
||||||
|
Assignee: assignee,
|
||||||
|
Dependencies: []string{"discovered-from:" + moleculeID},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, createErr := daemonClient.Create(createArgs)
|
||||||
|
if createErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", createErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue types.Issue
|
||||||
|
if jsonErr := json.Unmarshal(resp.Data, &issue); jsonErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", jsonErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run create hook
|
||||||
|
if hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventCreate, &issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(&issue)
|
||||||
|
} else {
|
||||||
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
fmt.Printf("%s Created work item: %s (from template %s)\n", green("✓"), issue.ID, moleculeID)
|
||||||
|
fmt.Printf(" Title: %s\n", issue.Title)
|
||||||
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
||||||
|
fmt.Printf(" Status: %s\n", issue.Status)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct mode
|
||||||
|
now := time.Now()
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: title,
|
||||||
|
Description: template.Description,
|
||||||
|
Design: template.Design,
|
||||||
|
AcceptanceCriteria: template.AcceptanceCriteria,
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: template.Priority,
|
||||||
|
IssueType: template.IssueType,
|
||||||
|
Assignee: assignee,
|
||||||
|
IsTemplate: false, // Work items are not templates
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, issue, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the created issue (ID is set by CreateIssue)
|
||||||
|
createdIssue, err := store.GetIssue(ctx, issue.ID)
|
||||||
|
if err != nil || createdIssue == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting created issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add discovered-from dependency to template
|
||||||
|
dep := &types.Dependency{
|
||||||
|
IssueID: createdIssue.ID,
|
||||||
|
DependsOnID: moleculeID,
|
||||||
|
Type: types.DepDiscoveredFrom,
|
||||||
|
CreatedAt: now,
|
||||||
|
CreatedBy: actor,
|
||||||
|
}
|
||||||
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to add dependency to template: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run create hook
|
||||||
|
if hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventCreate, createdIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule auto-flush
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(createdIssue)
|
||||||
|
} else {
|
||||||
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
fmt.Printf("%s Created work item: %s (from template %s)\n", green("✓"), createdIssue.ID, moleculeID)
|
||||||
|
fmt.Printf(" Title: %s\n", createdIssue.Title)
|
||||||
|
fmt.Printf(" Priority: P%d\n", createdIssue.Priority)
|
||||||
|
fmt.Printf(" Status: %s\n", createdIssue.Status)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Add subcommands
|
||||||
|
moleculeCmd.AddCommand(moleculeListCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeShowCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeInstantiateCmd)
|
||||||
|
|
||||||
|
// Flags for list command
|
||||||
|
moleculeListCmd.Flags().Bool("all", false, "Include closed molecules")
|
||||||
|
|
||||||
|
// Flags for instantiate command
|
||||||
|
moleculeInstantiateCmd.Flags().String("title", "", "Override the template title")
|
||||||
|
moleculeInstantiateCmd.Flags().StringP("assignee", "a", "", "Assign the new work item")
|
||||||
|
|
||||||
|
// Add molecule command to root
|
||||||
|
rootCmd.AddCommand(moleculeCmd)
|
||||||
|
}
|
||||||
@@ -673,6 +673,17 @@ var updateCmd = &cobra.Command{
|
|||||||
// Direct mode
|
// Direct mode
|
||||||
updatedIssues := []*types.Issue{}
|
updatedIssues := []*types.Issue{}
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
|
issue, err := store.GetIssue(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting %s: %v\n", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if issue != nil && issue.IsTemplate {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cannot update template %s: templates are read-only; use 'bd molecule instantiate' to create a work item\n", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Apply regular field updates if any
|
// Apply regular field updates if any
|
||||||
regularUpdates := make(map[string]interface{})
|
regularUpdates := make(map[string]interface{})
|
||||||
for k, v := range updates {
|
for k, v := range updates {
|
||||||
@@ -733,14 +744,14 @@ var updateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run update hook (bd-kwro.8)
|
// Run update hook (bd-kwro.8)
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
updatedIssue, _ := store.GetIssue(ctx, id)
|
||||||
if issue != nil && hookRunner != nil {
|
if updatedIssue != nil && hookRunner != nil {
|
||||||
hookRunner.Run(hooks.EventUpdate, issue)
|
hookRunner.Run(hooks.EventUpdate, updatedIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
if issue != nil {
|
if updatedIssue != nil {
|
||||||
updatedIssues = append(updatedIssues, issue)
|
updatedIssues = append(updatedIssues, updatedIssue)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
@@ -1002,17 +1013,21 @@ var closeCmd = &cobra.Command{
|
|||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
closedIssues := []*types.Issue{}
|
closedIssues := []*types.Issue{}
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
// Check if issue is pinned (bd-6v2)
|
// Get issue for template and pinned checks
|
||||||
if !force {
|
|
||||||
showArgs := &rpc.ShowArgs{ID: id}
|
showArgs := &rpc.ShowArgs{ID: id}
|
||||||
showResp, showErr := daemonClient.Show(showArgs)
|
showResp, showErr := daemonClient.Show(showArgs)
|
||||||
if showErr == nil {
|
if showErr == nil {
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
if json.Unmarshal(showResp.Data, &issue) == nil {
|
if json.Unmarshal(showResp.Data, &issue) == nil {
|
||||||
if issue.Status == types.StatusPinned {
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id)
|
if issue.IsTemplate {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cannot close template %s: templates are read-only\n", id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Check if issue is pinned (bd-6v2)
|
||||||
|
if !force && issue.Status == types.StatusPinned {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1052,9 +1067,17 @@ var closeCmd = &cobra.Command{
|
|||||||
// Direct mode
|
// Direct mode
|
||||||
closedIssues := []*types.Issue{}
|
closedIssues := []*types.Issue{}
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
|
// Get issue for checks
|
||||||
|
issue, _ := store.GetIssue(ctx, id)
|
||||||
|
|
||||||
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
|
if issue != nil && issue.IsTemplate {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cannot close template %s: templates are read-only\n", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check if issue is pinned (bd-6v2)
|
// Check if issue is pinned (bd-6v2)
|
||||||
if !force {
|
if !force {
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
|
||||||
if issue != nil && issue.Status == types.StatusPinned {
|
if issue != nil && issue.Status == types.StatusPinned {
|
||||||
fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id)
|
fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id)
|
||||||
continue
|
continue
|
||||||
@@ -1067,14 +1090,14 @@ var closeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run close hook (bd-kwro.8)
|
// Run close hook (bd-kwro.8)
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
closedIssue, _ := store.GetIssue(ctx, id)
|
||||||
if issue != nil && hookRunner != nil {
|
if closedIssue != nil && hookRunner != nil {
|
||||||
hookRunner.Run(hooks.EventClose, issue)
|
hookRunner.Run(hooks.EventClose, closedIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
if issue != nil {
|
if closedIssue != nil {
|
||||||
closedIssues = append(closedIssues, issue)
|
closedIssues = append(closedIssues, closedIssue)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ type ListArgs struct {
|
|||||||
|
|
||||||
// Pinned filtering (bd-p8e)
|
// Pinned filtering (bd-p8e)
|
||||||
Pinned *bool `json:"pinned,omitempty"`
|
Pinned *bool `json:"pinned,omitempty"`
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra)
|
||||||
|
IncludeTemplates bool `json:"include_templates,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountArgs represents arguments for the count operation
|
// CountArgs represents arguments for the count operation
|
||||||
|
|||||||
@@ -337,6 +337,28 @@ func (s *Server) handleUpdate(req *Request) Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := s.reqCtx(req)
|
ctx := s.reqCtx(req)
|
||||||
|
|
||||||
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
|
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to get issue: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("issue %s not found", updateArgs.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issue.IsTemplate {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("cannot update template %s: templates are read-only; use 'bd molecule instantiate' to create a work item", updateArgs.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updates := updatesFromArgs(updateArgs)
|
updates := updatesFromArgs(updateArgs)
|
||||||
actor := s.reqActor(req)
|
actor := s.reqActor(req)
|
||||||
|
|
||||||
@@ -406,15 +428,15 @@ func (s *Server) handleUpdate(req *Request) Response {
|
|||||||
s.emitMutation(MutationUpdate, updateArgs.ID)
|
s.emitMutation(MutationUpdate, updateArgs.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
updatedIssue, getErr := store.GetIssue(ctx, updateArgs.ID)
|
||||||
if err != nil {
|
if getErr != nil {
|
||||||
return Response{
|
return Response{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("failed to get updated issue: %v", err),
|
Error: fmt.Sprintf("failed to get updated issue: %v", getErr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := json.Marshal(issue)
|
data, _ := json.Marshal(updatedIssue)
|
||||||
return Response{
|
return Response{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -439,6 +461,22 @@ func (s *Server) handleClose(req *Request) Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := s.reqCtx(req)
|
ctx := s.reqCtx(req)
|
||||||
|
|
||||||
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
|
issue, err := store.GetIssue(ctx, closeArgs.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to get issue: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issue != nil && issue.IsTemplate {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("cannot close template %s: templates are read-only", closeArgs.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
|
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
|
||||||
return Response{
|
return Response{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -449,8 +487,8 @@ func (s *Server) handleClose(req *Request) Response {
|
|||||||
// Emit mutation event for event-driven daemon
|
// Emit mutation event for event-driven daemon
|
||||||
s.emitMutation(MutationUpdate, closeArgs.ID)
|
s.emitMutation(MutationUpdate, closeArgs.ID)
|
||||||
|
|
||||||
issue, _ := store.GetIssue(ctx, closeArgs.ID)
|
closedIssue, _ := store.GetIssue(ctx, closeArgs.ID)
|
||||||
data, _ := json.Marshal(issue)
|
data, _ := json.Marshal(closedIssue)
|
||||||
return Response{
|
return Response{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -512,6 +550,12 @@ func (s *Server) handleDelete(req *Request) Response {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if issue is a template (beads-1ra): templates are read-only
|
||||||
|
if issue.IsTemplate {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: cannot delete template (templates are read-only)", issueID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Create tombstone instead of hard delete (bd-rp4o fix)
|
// Create tombstone instead of hard delete (bd-rp4o fix)
|
||||||
// This preserves deletion history and prevents resurrection during sync
|
// This preserves deletion history and prevents resurrection during sync
|
||||||
type tombstoner interface {
|
type tombstoner interface {
|
||||||
@@ -701,6 +745,12 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
// Pinned filtering (bd-p8e)
|
// Pinned filtering (bd-p8e)
|
||||||
filter.Pinned = listArgs.Pinned
|
filter.Pinned = listArgs.Pinned
|
||||||
|
|
||||||
|
// Template filtering (beads-1ra): exclude templates by default
|
||||||
|
if !listArgs.IncludeTemplates {
|
||||||
|
isTemplate := false
|
||||||
|
filter.IsTemplate = &isTemplate
|
||||||
|
}
|
||||||
|
|
||||||
// Guard against excessive ID lists to avoid SQLite parameter limits
|
// Guard against excessive ID lists to avoid SQLite parameter limits
|
||||||
const maxIDs = 1000
|
const maxIDs = 1000
|
||||||
if len(filter.IDs) > maxIDs {
|
if len(filter.IDs) > maxIDs {
|
||||||
|
|||||||
Reference in New Issue
Block a user