Add 'last touched' issue tracking for update/close without ID
When no issue ID is provided to `bd update` or `bd close`, use the last touched issue from the most recent create, update, show, or close operation. This addresses the common workflow where you create an issue and then immediately want to add more details (like changing priority from P2 to P4) without re-typing the issue ID. Implementation: - New file last_touched.go with Get/Set/Clear functions - Store last touched ID in .beads/last-touched (gitignored) - Track on create, update, show, and close operations - Allow update/close with zero args to use last touched (bd-s2t) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,22 @@ var closeCmd = &cobra.Command{
|
|||||||
Use: "close [id...]",
|
Use: "close [id...]",
|
||||||
GroupID: "issues",
|
GroupID: "issues",
|
||||||
Short: "Close one or more issues",
|
Short: "Close one or more issues",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Long: `Close one or more issues.
|
||||||
|
|
||||||
|
If no issue ID is provided, closes the last touched issue (from most recent
|
||||||
|
create, update, show, or close operation).`,
|
||||||
|
Args: cobra.MinimumNArgs(0),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
CheckReadonly("close")
|
CheckReadonly("close")
|
||||||
|
|
||||||
|
// If no IDs provided, use last touched issue
|
||||||
|
if len(args) == 0 {
|
||||||
|
lastTouched := GetLastTouchedID()
|
||||||
|
if lastTouched == "" {
|
||||||
|
FatalErrorRespectJSON("no issue ID provided and no last touched issue")
|
||||||
|
}
|
||||||
|
args = []string{lastTouched}
|
||||||
|
}
|
||||||
reason, _ := cmd.Flags().GetString("reason")
|
reason, _ := cmd.Flags().GetString("reason")
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
// Check --resolution alias (Jira CLI convention)
|
// Check --resolution alias (Jira CLI convention)
|
||||||
|
|||||||
@@ -316,6 +316,9 @@ var createCmd = &cobra.Command{
|
|||||||
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
||||||
fmt.Printf(" Status: %s\n", issue.Status)
|
fmt.Printf(" Status: %s\n", issue.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track as last touched issue
|
||||||
|
SetLastTouchedID(issue.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +517,9 @@ var createCmd = &cobra.Command{
|
|||||||
// Show tip after successful create (direct mode only)
|
// Show tip after successful create (direct mode only)
|
||||||
maybeShowTip(store)
|
maybeShowTip(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track as last touched issue
|
||||||
|
SetLastTouchedID(issue.ID)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ daemon.log
|
|||||||
daemon.pid
|
daemon.pid
|
||||||
bd.sock
|
bd.sock
|
||||||
sync-state.json
|
sync-state.json
|
||||||
|
last-touched
|
||||||
|
|
||||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
|
|||||||
57
cmd/bd/last_touched.go
Normal file
57
cmd/bd/last_touched.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastTouchedFile = "last-touched"
|
||||||
|
|
||||||
|
// GetLastTouchedID returns the ID of the last touched issue.
|
||||||
|
// Returns empty string if no last touched issue exists or the file is unreadable.
|
||||||
|
func GetLastTouchedID() string {
|
||||||
|
beadsDir := beads.FindBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchedPath := filepath.Join(beadsDir, lastTouchedFile)
|
||||||
|
data, err := os.ReadFile(lastTouchedPath) // #nosec G304 -- path constructed from beadsDir
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastTouchedID saves the ID of the last touched issue.
|
||||||
|
// Silently ignores errors (best-effort tracking).
|
||||||
|
func SetLastTouchedID(issueID string) {
|
||||||
|
if issueID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := beads.FindBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchedPath := filepath.Join(beadsDir, lastTouchedFile)
|
||||||
|
// Write with restrictive permissions (local-only state)
|
||||||
|
_ = os.WriteFile(lastTouchedPath, []byte(issueID+"\n"), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLastTouched removes the last touched file.
|
||||||
|
// Silently ignores errors.
|
||||||
|
func ClearLastTouched() {
|
||||||
|
beadsDir := beads.FindBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchedPath := filepath.Join(beadsDir, lastTouchedFile)
|
||||||
|
_ = os.Remove(lastTouchedPath)
|
||||||
|
}
|
||||||
105
cmd/bd/last_touched_test.go
Normal file
105
cmd/bd/last_touched_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLastTouchedBasic(t *testing.T) {
|
||||||
|
// Create a temp directory to simulate .beads
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a marker file so FindBeadsDir recognizes this as a valid beads directory
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte{}, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the original working directory
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = os.Chdir(origDir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Change to temp directory so FindBeadsDir finds our .beads
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that no last touched returns empty
|
||||||
|
got := GetLastTouchedID()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("GetLastTouchedID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set and retrieve
|
||||||
|
testID := "bd-test123"
|
||||||
|
SetLastTouchedID(testID)
|
||||||
|
got = GetLastTouchedID()
|
||||||
|
if got != testID {
|
||||||
|
t.Errorf("GetLastTouchedID() = %q, want %q", got, testID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with new ID
|
||||||
|
testID2 := "bd-test456"
|
||||||
|
SetLastTouchedID(testID2)
|
||||||
|
got = GetLastTouchedID()
|
||||||
|
if got != testID2 {
|
||||||
|
t.Errorf("GetLastTouchedID() = %q, want %q", got, testID2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear and verify
|
||||||
|
ClearLastTouched()
|
||||||
|
got = GetLastTouchedID()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("After ClearLastTouched(), GetLastTouchedID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetLastTouchedIDIgnoresEmpty(t *testing.T) {
|
||||||
|
// Create a temp directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a marker file so FindBeadsDir recognizes this as a valid beads directory
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte{}, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the original working directory
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = os.Chdir(origDir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Change to temp directory
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First set a value
|
||||||
|
testID := "bd-original"
|
||||||
|
SetLastTouchedID(testID)
|
||||||
|
|
||||||
|
// Try to set empty - should be ignored
|
||||||
|
SetLastTouchedID("")
|
||||||
|
|
||||||
|
// Should still have original value
|
||||||
|
got := GetLastTouchedID()
|
||||||
|
if got != testID {
|
||||||
|
t.Errorf("After SetLastTouchedID(\"\"), GetLastTouchedID() = %q, want %q", got, testID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,13 @@ var showCmd = &cobra.Command{
|
|||||||
if jsonOutput && len(allDetails) > 0 {
|
if jsonOutput && len(allDetails) > 0 {
|
||||||
outputJSON(allDetails)
|
outputJSON(allDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track first shown issue as last touched
|
||||||
|
if len(resolvedIDs) > 0 {
|
||||||
|
SetLastTouchedID(resolvedIDs[0])
|
||||||
|
} else if len(routedArgs) > 0 {
|
||||||
|
SetLastTouchedID(routedArgs[0])
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,6 +571,11 @@ var showCmd = &cobra.Command{
|
|||||||
// Show tip after successful show (non-JSON mode)
|
// Show tip after successful show (non-JSON mode)
|
||||||
maybeShowTip(store)
|
maybeShowTip(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track first shown issue as last touched
|
||||||
|
if len(args) > 0 {
|
||||||
|
SetLastTouchedID(args[0])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,23 @@ var updateCmd = &cobra.Command{
|
|||||||
Use: "update [id...]",
|
Use: "update [id...]",
|
||||||
GroupID: "issues",
|
GroupID: "issues",
|
||||||
Short: "Update one or more issues",
|
Short: "Update one or more issues",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Long: `Update one or more issues.
|
||||||
|
|
||||||
|
If no issue ID is provided, updates the last touched issue (from most recent
|
||||||
|
create, update, show, or close operation).`,
|
||||||
|
Args: cobra.MinimumNArgs(0),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
CheckReadonly("update")
|
CheckReadonly("update")
|
||||||
|
|
||||||
|
// If no IDs provided, use last touched issue
|
||||||
|
if len(args) == 0 {
|
||||||
|
lastTouched := GetLastTouchedID()
|
||||||
|
if lastTouched == "" {
|
||||||
|
FatalErrorRespectJSON("no issue ID provided and no last touched issue")
|
||||||
|
}
|
||||||
|
args = []string{lastTouched}
|
||||||
|
}
|
||||||
|
|
||||||
updates := make(map[string]interface{})
|
updates := make(map[string]interface{})
|
||||||
|
|
||||||
if cmd.Flags().Changed("status") {
|
if cmd.Flags().Changed("status") {
|
||||||
@@ -141,6 +155,7 @@ var updateCmd = &cobra.Command{
|
|||||||
// If daemon is running, use RPC
|
// If daemon is running, use RPC
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
updatedIssues := []*types.Issue{}
|
updatedIssues := []*types.Issue{}
|
||||||
|
var firstUpdatedID string // Track first successful update for last-touched
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
updateArgs := &rpc.UpdateArgs{ID: id}
|
updateArgs := &rpc.UpdateArgs{ID: id}
|
||||||
|
|
||||||
@@ -213,16 +228,27 @@ var updateCmd = &cobra.Command{
|
|||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
|
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track first successful update for last-touched
|
||||||
|
if firstUpdatedID == "" {
|
||||||
|
firstUpdatedID = id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput && len(updatedIssues) > 0 {
|
if jsonOutput && len(updatedIssues) > 0 {
|
||||||
outputJSON(updatedIssues)
|
outputJSON(updatedIssues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set last touched after all updates complete
|
||||||
|
if firstUpdatedID != "" {
|
||||||
|
SetLastTouchedID(firstUpdatedID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct mode
|
// Direct mode
|
||||||
updatedIssues := []*types.Issue{}
|
updatedIssues := []*types.Issue{}
|
||||||
|
var firstUpdatedID string // Track first successful update for last-touched
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
// Check if issue is a template: templates are read-only
|
// Check if issue is a template: templates are read-only
|
||||||
issue, err := store.GetIssue(ctx, id)
|
issue, err := store.GetIssue(ctx, id)
|
||||||
@@ -324,6 +350,16 @@ var updateCmd = &cobra.Command{
|
|||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
|
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track first successful update for last-touched
|
||||||
|
if firstUpdatedID == "" {
|
||||||
|
firstUpdatedID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set last touched after all updates complete
|
||||||
|
if firstUpdatedID != "" {
|
||||||
|
SetLastTouchedID(firstUpdatedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule auto-flush if any issues were updated
|
// Schedule auto-flush if any issues were updated
|
||||||
|
|||||||
Reference in New Issue
Block a user