Implements convenience commands for the labels-as-state pattern: - `bd state <id> <dimension>` - Query current state value from labels - `bd state list <id>` - List all state dimensions on an issue - `bd set-state <id> <dimension>=<value> --reason "..."` - Atomically: 1. Create event bead (source of truth) 2. Remove old dimension label 3. Add new dimension:value label (cache) Common dimensions: patrol, mode, health, status Convention: <dimension>:<value> (e.g., patrol:active, mode:degraded) Updated docs/CLI_REFERENCE.md with new State section. Updated docs/LABELS.md to reflect implemented helpers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
441 lines
12 KiB
Go
441 lines
12 KiB
Go
// Package main implements the bd CLI state management commands.
|
|
// These commands provide convenient access to the labels-as-state pattern
|
|
// documented in docs/LABELS.md.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var stateCmd = &cobra.Command{
|
|
Use: "state <issue-id> <dimension>",
|
|
GroupID: "issues",
|
|
Short: "Query the current value of a state dimension",
|
|
Long: `Query the current value of a state dimension from an issue's labels.
|
|
|
|
State labels follow the convention <dimension>:<value>, for example:
|
|
patrol:active
|
|
mode:degraded
|
|
health:healthy
|
|
|
|
This command extracts the value for a given dimension.
|
|
|
|
Examples:
|
|
bd state witness-abc patrol # Output: active
|
|
bd state witness-abc mode # Output: normal
|
|
bd state witness-abc health # Output: healthy`,
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
issueID := args[0]
|
|
dimension := args[1]
|
|
|
|
// Resolve partial ID
|
|
var fullID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: issueID}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
|
|
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
fullID, err = utils.ResolvePartialID(ctx, store, issueID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving %s: %v", issueID, err)
|
|
}
|
|
}
|
|
|
|
// Get labels for the issue
|
|
var labels []string
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID})
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
FatalErrorRespectJSON("parsing response: %v", err)
|
|
}
|
|
labels = issue.Labels
|
|
} else {
|
|
var err error
|
|
labels, err = store.GetLabels(ctx, fullID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
}
|
|
|
|
// Find label matching dimension:*
|
|
prefix := dimension + ":"
|
|
var value string
|
|
for _, label := range labels {
|
|
if strings.HasPrefix(label, prefix) {
|
|
value = strings.TrimPrefix(label, prefix)
|
|
break
|
|
}
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"issue_id": fullID,
|
|
"dimension": dimension,
|
|
"value": value,
|
|
}
|
|
if value == "" {
|
|
result["value"] = nil
|
|
}
|
|
outputJSON(result)
|
|
return
|
|
}
|
|
|
|
if value == "" {
|
|
fmt.Printf("(no %s state set)\n", dimension)
|
|
} else {
|
|
fmt.Println(value)
|
|
}
|
|
},
|
|
}
|
|
|
|
var setStateCmd = &cobra.Command{
|
|
Use: "set-state <issue-id> <dimension>=<value>",
|
|
GroupID: "issues",
|
|
Short: "Set operational state (creates event + updates label)",
|
|
Long: `Atomically set operational state on an issue.
|
|
|
|
This command:
|
|
1. Creates an event bead recording the state change (source of truth)
|
|
2. Removes any existing label for the dimension
|
|
3. Adds the new dimension:value label (fast lookup cache)
|
|
|
|
State labels follow the convention <dimension>:<value>, for example:
|
|
patrol:active, patrol:muted
|
|
mode:normal, mode:degraded
|
|
health:healthy, health:failing
|
|
|
|
Examples:
|
|
bd set-state witness-abc patrol=muted --reason "Investigating stuck polecat"
|
|
bd set-state witness-abc mode=degraded --reason "High error rate detected"
|
|
bd set-state witness-abc health=healthy
|
|
|
|
The --reason flag provides context for the event bead (recommended).`,
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("set-state")
|
|
ctx := rootCtx
|
|
issueID := args[0]
|
|
stateSpec := args[1]
|
|
|
|
// Parse dimension=value
|
|
parts := strings.SplitN(stateSpec, "=", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
FatalErrorRespectJSON("invalid state format %q, expected <dimension>=<value>", stateSpec)
|
|
}
|
|
dimension := parts[0]
|
|
newValue := parts[1]
|
|
|
|
reason, _ := cmd.Flags().GetString("reason")
|
|
|
|
// Resolve partial ID
|
|
var fullID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: issueID}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
|
|
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
fullID, err = utils.ResolvePartialID(ctx, store, issueID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving %s: %v", issueID, err)
|
|
}
|
|
}
|
|
|
|
// Get current labels to find existing dimension value
|
|
var labels []string
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID})
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
FatalErrorRespectJSON("parsing response: %v", err)
|
|
}
|
|
labels = issue.Labels
|
|
} else {
|
|
var err error
|
|
labels, err = store.GetLabels(ctx, fullID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
}
|
|
|
|
// Find existing label for this dimension
|
|
prefix := dimension + ":"
|
|
var oldLabel string
|
|
var oldValue string
|
|
for _, label := range labels {
|
|
if strings.HasPrefix(label, prefix) {
|
|
oldLabel = label
|
|
oldValue = strings.TrimPrefix(label, prefix)
|
|
break
|
|
}
|
|
}
|
|
|
|
newLabel := dimension + ":" + newValue
|
|
|
|
// Skip if no change
|
|
if oldLabel == newLabel {
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"issue_id": fullID,
|
|
"dimension": dimension,
|
|
"value": newValue,
|
|
"changed": false,
|
|
})
|
|
} else {
|
|
fmt.Printf("(no change: %s already set to %s)\n", dimension, newValue)
|
|
}
|
|
return
|
|
}
|
|
|
|
// 1. Create event bead recording the state change
|
|
eventTitle := fmt.Sprintf("State change: %s → %s", dimension, newValue)
|
|
eventDesc := ""
|
|
if oldValue != "" {
|
|
eventDesc = fmt.Sprintf("Changed %s from %s to %s", dimension, oldValue, newValue)
|
|
} else {
|
|
eventDesc = fmt.Sprintf("Set %s to %s", dimension, newValue)
|
|
}
|
|
if reason != "" {
|
|
eventDesc += "\n\nReason: " + reason
|
|
}
|
|
|
|
var eventID string
|
|
if daemonClient != nil {
|
|
createArgs := &rpc.CreateArgs{
|
|
Parent: fullID,
|
|
Title: eventTitle,
|
|
Description: eventDesc,
|
|
IssueType: "event",
|
|
Priority: 4, // Low priority for events
|
|
CreatedBy: getActorWithGit(),
|
|
}
|
|
resp, err := daemonClient.Create(createArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("creating event: %v", err)
|
|
}
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
FatalErrorRespectJSON("parsing event response: %v", err)
|
|
}
|
|
eventID = issue.ID
|
|
} else {
|
|
// Get next child ID for the event
|
|
childID, err := store.GetNextChildID(ctx, fullID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("generating child ID: %v", err)
|
|
}
|
|
|
|
event := &types.Issue{
|
|
ID: childID,
|
|
Title: eventTitle,
|
|
Description: eventDesc,
|
|
Status: types.StatusClosed, // Events are immediately closed
|
|
Priority: 4,
|
|
IssueType: types.IssueType("event"),
|
|
CreatedBy: getActorWithGit(),
|
|
}
|
|
if err := store.CreateIssue(ctx, event, actor); err != nil {
|
|
FatalErrorRespectJSON("creating event: %v", err)
|
|
}
|
|
|
|
// Add parent-child dependency
|
|
dep := &types.Dependency{
|
|
IssueID: childID,
|
|
DependsOnID: fullID,
|
|
Type: types.DepParentChild,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
WarnError("failed to add parent-child dependency: %v", err)
|
|
}
|
|
|
|
eventID = childID
|
|
}
|
|
|
|
// 2. Remove old label if exists
|
|
if oldLabel != "" {
|
|
if daemonClient != nil {
|
|
_, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: fullID, Label: oldLabel})
|
|
if err != nil {
|
|
WarnError("failed to remove old label %s: %v", oldLabel, err)
|
|
}
|
|
} else {
|
|
if err := store.RemoveLabel(ctx, fullID, oldLabel, actor); err != nil {
|
|
WarnError("failed to remove old label %s: %v", oldLabel, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Add new label
|
|
if daemonClient != nil {
|
|
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: fullID, Label: newLabel})
|
|
if err != nil {
|
|
FatalErrorRespectJSON("adding label: %v", err)
|
|
}
|
|
} else {
|
|
if err := store.AddLabel(ctx, fullID, newLabel, actor); err != nil {
|
|
FatalErrorRespectJSON("adding label: %v", err)
|
|
}
|
|
}
|
|
|
|
// Schedule auto-flush if in direct mode
|
|
if daemonClient == nil {
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"issue_id": fullID,
|
|
"dimension": dimension,
|
|
"old_value": oldValue,
|
|
"new_value": newValue,
|
|
"event_id": eventID,
|
|
"changed": true,
|
|
}
|
|
if oldValue == "" {
|
|
result["old_value"] = nil
|
|
}
|
|
outputJSON(result)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s Set %s = %s on %s\n", ui.RenderPass("✓"), dimension, newValue, fullID)
|
|
if oldValue != "" {
|
|
fmt.Printf(" Previous: %s\n", oldValue)
|
|
}
|
|
fmt.Printf(" Event: %s\n", eventID)
|
|
},
|
|
}
|
|
|
|
// stateListCmd lists all state dimensions on an issue
|
|
var stateListCmd = &cobra.Command{
|
|
Use: "list <issue-id>",
|
|
Short: "List all state dimensions on an issue",
|
|
Long: `List all state labels (dimension:value format) on an issue.
|
|
|
|
This filters labels to only show those following the state convention.
|
|
|
|
Example:
|
|
bd state list witness-abc
|
|
# Output:
|
|
# patrol: active
|
|
# mode: normal
|
|
# health: healthy`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
issueID := args[0]
|
|
|
|
// Resolve partial ID
|
|
var fullID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: issueID}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
|
|
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
fullID, err = utils.ResolvePartialID(ctx, store, issueID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving %s: %v", issueID, err)
|
|
}
|
|
}
|
|
|
|
// Get labels for the issue
|
|
var labels []string
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID})
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
FatalErrorRespectJSON("parsing response: %v", err)
|
|
}
|
|
labels = issue.Labels
|
|
} else {
|
|
var err error
|
|
labels, err = store.GetLabels(ctx, fullID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("%v", err)
|
|
}
|
|
}
|
|
|
|
// Extract state labels (those with colon)
|
|
states := make(map[string]string)
|
|
for _, label := range labels {
|
|
if idx := strings.Index(label, ":"); idx > 0 {
|
|
dimension := label[:idx]
|
|
value := label[idx+1:]
|
|
states[dimension] = value
|
|
}
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"issue_id": fullID,
|
|
"states": states,
|
|
}
|
|
outputJSON(result)
|
|
return
|
|
}
|
|
|
|
if len(states) == 0 {
|
|
fmt.Printf("\n%s has no state labels\n", fullID)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n%s State for %s:\n", ui.RenderAccent("📊"), fullID)
|
|
for dimension, value := range states {
|
|
fmt.Printf(" %s: %s\n", dimension, value)
|
|
}
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
// set-state flags
|
|
setStateCmd.Flags().String("reason", "", "Reason for the state change (recorded in event)")
|
|
|
|
// Add subcommands
|
|
stateCmd.AddCommand(stateListCmd)
|
|
|
|
rootCmd.AddCommand(stateCmd)
|
|
rootCmd.AddCommand(setStateCmd)
|
|
}
|
|
|
|
// Ensure ctx is available
|
|
var _ context.Context = rootCtx
|