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:
Steve Yegge
2025-12-30 16:57:01 -08:00
parent eda74e62ea
commit a34f189153
7 changed files with 232 additions and 2 deletions

View File

@@ -17,9 +17,22 @@ var closeCmd = &cobra.Command{
Use: "close [id...]",
GroupID: "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) {
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")
if reason == "" {
// Check --resolution alias (Jira CLI convention)

View File

@@ -316,6 +316,9 @@ var createCmd = &cobra.Command{
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
}
// Track as last touched issue
SetLastTouchedID(issue.ID)
return
}
@@ -514,6 +517,9 @@ var createCmd = &cobra.Command{
// Show tip after successful create (direct mode only)
maybeShowTip(store)
}
// Track as last touched issue
SetLastTouchedID(issue.ID)
},
}

View File

@@ -21,6 +21,7 @@ daemon.log
daemon.pid
bd.sock
sync-state.json
last-touched
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version

57
cmd/bd/last_touched.go Normal file
View 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
View 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)
}
}

View File

@@ -332,6 +332,13 @@ var showCmd = &cobra.Command{
if jsonOutput && len(allDetails) > 0 {
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
}
@@ -564,6 +571,11 @@ var showCmd = &cobra.Command{
// Show tip after successful show (non-JSON mode)
maybeShowTip(store)
}
// Track first shown issue as last touched
if len(args) > 0 {
SetLastTouchedID(args[0])
}
},
}

View File

@@ -18,9 +18,23 @@ var updateCmd = &cobra.Command{
Use: "update [id...]",
GroupID: "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) {
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{})
if cmd.Flags().Changed("status") {
@@ -141,6 +155,7 @@ var updateCmd = &cobra.Command{
// If daemon is running, use RPC
if daemonClient != nil {
updatedIssues := []*types.Issue{}
var firstUpdatedID string // Track first successful update for last-touched
for _, id := range resolvedIDs {
updateArgs := &rpc.UpdateArgs{ID: id}
@@ -213,16 +228,27 @@ var updateCmd = &cobra.Command{
if !jsonOutput {
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 {
outputJSON(updatedIssues)
}
// Set last touched after all updates complete
if firstUpdatedID != "" {
SetLastTouchedID(firstUpdatedID)
}
return
}
// Direct mode
updatedIssues := []*types.Issue{}
var firstUpdatedID string // Track first successful update for last-touched
for _, id := range resolvedIDs {
// Check if issue is a template: templates are read-only
issue, err := store.GetIssue(ctx, id)
@@ -324,6 +350,16 @@ var updateCmd = &cobra.Command{
} else {
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