Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .issues/.counter
Original file line number Diff line number Diff line change
@@ -1 +1 @@
14
15
53 changes: 53 additions & 0 deletions .issues/closed/014-create-new-issue-when-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
id: "014"
assignee: ""
labels:
- bug
created: 2025-11-20T14:57:43.364376+09:00
updated: 2025-11-20T15:14:10.062556+09:00
---

# Create new issue when close

## Problem

Closing an issue with `gi close 004 -c` is creating a brand new issue file in `./.issues/open/` instead of only moving the existing one to `./.issues/closed/`.

Observed console output from `~/work/cargo-note-backend`:

```
gi close 004 -c
✓ Closed issue #004
[feat/balance-check 9901acb] Close issue #004
8 files changed, 630 insertions(+), 1 deletion(-)
rename .issues/{open => closed}/004-add-transaction-history-endpoint.md (100%)
create mode 100644 .issues/open/004-save-transaction-history-to-database.md
...
```

## Expected Behavior

- The close command should move the existing issue file from `./.issues/open/` to `./.issues/closed/` and stop there.
- No new issue files should be created during close, regardless of the issue title.

## Actual Behavior

- The existing file was moved to `./.issues/closed/004-add-transaction-history-endpoint.md`.
- A new file `./.issues/open/004-save-transaction-history-to-database.md` was created alongside the move, leaving the issue appearing open again with a new slug.

## Steps to Reproduce

1. In a repo with `.issues/open/004-add-transaction-history-endpoint.md`, run `gi close 004 -c`.
2. Inspect `.issues/open/` and `.issues/closed/`.

## Requirements

- Ensure `gi close` only relocates the targeted issue file; it must not create any new `.issues/open/*.md` file as part of the operation.
- Preserve the original filename/slug when closing, even if the issue title has changed.
- Add a regression test that covers closing an issue after its title or slug has been modified.

## Success Criteria

- [ ] Running `gi close 004 -c` results in exactly one file in `./.issues/closed/` for ID 004 and zero files in `./.issues/open/` with ID 004.
- [ ] Closing and reopening flows handle title changes without creating duplicate files.
- [ ] New test(s) fail on current main and pass after the fix.
43 changes: 43 additions & 0 deletions cmd/close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,49 @@ func TestRunClosePreservesFilenameWithKoreanTitle(t *testing.T) {
}
}

func TestRunCloseAfterEditDoesNotCreateNewIssueFile(t *testing.T) {
_, cleanup := setupCommandTestRepo(t)
defer cleanup()

// Create issue
if err := runCreate(nil, []string{"Original Title"}); err != nil {
t.Fatalf("runCreate() failed: %v", err)
}

// Simulate editing the issue to change the title (mimics `gi edit`)
issue, dir, err := pkg.LoadIssue("001")
if err != nil {
t.Fatalf("failed to load issue: %v", err)
}
issue.Title = "Edited Title That Changes The Slug"
if err := pkg.SaveIssue(issue, dir); err != nil {
t.Fatalf("failed to save edited issue: %v", err)
}

// Close the issue
if err := runClose(nil, []string{"001"}); err != nil {
t.Fatalf("runClose() failed: %v", err)
}

// Verify no new files were created in open/
openFiles, _ := pkg.ListIssues(pkg.OpenDir)
if len(openFiles) != 0 {
t.Fatalf("open directory should be empty after closing, found %d issues", len(openFiles))
}

// Verify closed file uses the original filename
closedPath, dirAfter, err := pkg.FindIssueFile("001")
if err != nil {
t.Fatalf("failed to find closed issue: %v", err)
}
if dirAfter != pkg.ClosedDir {
t.Fatalf("issue should be in closed dir, got %s", dirAfter)
}
if !strings.Contains(closedPath, "original-title") {
t.Fatalf("expected filename to retain original slug, got %s", closedPath)
}
}

func TestRunClosePreservesFilenameWhenTitleModified(t *testing.T) {
_, cleanup := setupCommandTestRepo(t)
defer cleanup()
Expand Down
18 changes: 14 additions & 4 deletions pkg/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,20 @@ func GetNextID() (int, error) {

// SaveIssue writes an issue to the specified directory (open or closed)
func SaveIssue(issue *Issue, dir string) error {
// Generate filename
slug := GenerateSlug(issue.Title)
filename := fmt.Sprintf("%s-%s.md", issue.ID, slug)
path := filepath.Join(IssuesDir, dir, filename)
var path string

// If the issue already exists in the target directory, preserve its existing filename
if existingPath, existingDir, err := FindIssueFile(issue.ID); err == nil {
if existingDir != dir {
return fmt.Errorf("issue %s exists in %s directory, cannot save to %s", issue.ID, existingDir, dir)
}
path = existingPath
} else {
// Generate a new filename only when the issue doesn't exist yet
slug := GenerateSlug(issue.Title)
filename := fmt.Sprintf("%s-%s.md", issue.ID, slug)
path = filepath.Join(IssuesDir, dir, filename)
}

// Serialize issue
content, err := SerializeIssue(issue)
Expand Down
45 changes: 45 additions & 0 deletions pkg/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package pkg
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -162,6 +163,50 @@ func TestSaveAndLoadIssue(t *testing.T) {
}
}

func TestSaveIssuePreservesExistingFilename(t *testing.T) {
cleanup := setupTestRepo(t)
defer cleanup()

if err := InitializeRepo(); err != nil {
t.Fatal(err)
}

// Create initial issue
issue := NewIssue(1, "Original Title", "bob", []string{})
if err := SaveIssue(issue, OpenDir); err != nil {
t.Fatalf("SaveIssue() initial save error = %v", err)
}

// Change the title to something that would generate a different slug
issue.Title = "Completely Different Title After Edit"
if err := SaveIssue(issue, OpenDir); err != nil {
t.Fatalf("SaveIssue() second save error = %v", err)
}

// Ensure only one issue file exists and the filename was preserved
openDir := filepath.Join(IssuesDir, OpenDir)
entries, err := os.ReadDir(openDir)
if err != nil {
t.Fatalf("failed to read open dir: %v", err)
}

var issueFiles []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
issueFiles = append(issueFiles, entry.Name())
}

if len(issueFiles) != 1 {
t.Fatalf("expected exactly 1 issue file, found %d: %v", len(issueFiles), issueFiles)
}

if !strings.Contains(issueFiles[0], "original-title") {
t.Fatalf("expected filename to contain original slug, got %s", issueFiles[0])
}
}

func TestMoveIssue(t *testing.T) {
cleanup := setupTestRepo(t)
defer cleanup()
Expand Down