//go:build integration package main import ( "strings" "testing" "github.com/git-bug/git-bug/entities/bug" "github.com/git-bug/git-bug/entities/common" "github.com/git-bug/git-bug/entity" "github.com/git-bug/git-bug/repository" ) // readSnapshot reads a bug by full ID and compiles it func readSnapshot(t *testing.T, repoPath, fullID string) *bug.Snapshot { t.Helper() repo, err := repository.OpenGoGitRepo(repoPath, "", nil) if err != nil { t.Fatalf("failed to open repo: %v", err) } defer repo.Close() b, err := bug.Read(repo, entity.Id(fullID)) if err != nil { t.Fatalf("failed to read bug %s: %v", fullID, err) } return b.Compile() } // findLiveIssueByTitle returns the bug with the given title that has not // been superseded by a clone func findLiveIssueByTitle(t *testing.T, repoPath, title string) BugIssue { t.Helper() issues, err := LoadBugs(repoPath) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } var found []BugIssue for _, issue := range issues { if issue.Title == title && issue.SupersededBy == "" { found = append(found, issue) } } if len(found) != 1 { t.Fatalf("expected exactly one live bug titled %q, found %d", title, len(found)) } return found[0] } // findIssueByID returns the bug with the given full ID func findIssueByID(t *testing.T, repoPath, fullID string) BugIssue { t.Helper() issues, err := LoadBugs(repoPath) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } for _, issue := range issues { if issue.FullID == fullID { return issue } } t.Fatalf("bug %s not found", fullID) return BugIssue{} } // newLinked creates a bug under parent (or root if parent is empty) func newLinked(t *testing.T, repoPath, title, parentID string) BugIssue { t.Helper() if err := runNew(repoPath, title, title+" body", parentID, "", ""); err != nil { t.Fatalf("runNew %q failed: %v", title, err) } return findLiveIssueByTitle(t, repoPath, title) } // TestEditCommand_ChangeParentClonesBug verifies changing an existing parent // re-creates the bug under the new parent, preserving its history, and // closes the original as superseded func TestEditCommand_ChangeParentClonesBug(t *testing.T) { tmpDir := setupParentEditRepo(t) oldParent := newLinked(t, tmpDir, "Old Parent", "") newParent := newLinked(t, tmpDir, "New Parent", "") child := newLinked(t, tmpDir, "Child", oldParent.FullID) if err := runComment(tmpDir, child.FullID, "user comment"); err != nil { t.Fatalf("runComment failed: %v", err) } if err := runAgentComment(tmpDir, child.FullID, "agent comment", "", false); err != nil { t.Fatalf("runAgentComment failed: %v", err) } if err := runEdit(tmpDir, child.FullID, "Child Renamed", "", "", ""); err != nil { t.Fatalf("runEdit title failed: %v", err) } original := readSnapshot(t, tmpDir, child.FullID) if err := runEdit(tmpDir, child.FullID, "", "", newParent.FullID, ""); err != nil { t.Fatalf("runEdit change parent failed: %v", err) } clone := findLiveIssueByTitle(t, tmpDir, "Child Renamed") if clone.FullID == child.FullID { t.Fatal("expected a new bug, got the original") } if clone.ParentID != newParent.FullID { t.Errorf("clone ParentID = %q, want %q", clone.ParentID, newParent.FullID) } if clone.Status != "open" { t.Errorf("clone status = %q, want open", clone.Status) } cloned := readSnapshot(t, tmpDir, clone.FullID) if len(cloned.Comments) != len(original.Comments) { t.Fatalf("clone has %d comments, want %d", len(cloned.Comments), len(original.Comments)) } for i, want := range original.Comments { got := cloned.Comments[i] if got.Message != want.Message { t.Errorf("comment %d message = %q, want %q", i, got.Message, want.Message) } if got.Author.Id() != want.Author.Id() { t.Errorf("comment %d author = %s, want %s", i, got.Author.Name(), want.Author.Name()) } if got.FormatTime() != want.FormatTime() { t.Errorf("comment %d time = %s, want %s", i, got.FormatTime(), want.FormatTime()) } } if !cloned.CreateTime.Equal(original.CreateTime) { t.Errorf("clone CreateTime = %v, want %v", cloned.CreateTime, original.CreateTime) } // Original is closed, still linked to its old parent, and points at the clone old := findIssueByID(t, tmpDir, child.FullID) if old.Status != "closed" { t.Errorf("original status = %q, want closed", old.Status) } if old.ParentID != oldParent.FullID { t.Errorf("original ParentID = %q, want %q", old.ParentID, oldParent.FullID) } if old.SupersededBy != clone.FullID { t.Errorf("original SupersededBy = %q, want %q", old.SupersededBy, clone.FullID) } oldSnap := readSnapshot(t, tmpDir, child.FullID) last := oldSnap.Comments[len(oldSnap.Comments)-1] if !strings.Contains(last.Message, clone.FullID[:7]) { t.Errorf("superseded comment %q should mention new ID %s", last.Message, clone.FullID[:7]) } } // TestEditCommand_ChangeParentPreservesStatusAndLabels verifies closed // status and labels are replayed onto the clone func TestEditCommand_ChangeParentPreservesStatusAndLabels(t *testing.T) { tmpDir := setupParentEditRepo(t) oldParent := newLinked(t, tmpDir, "Old Parent", "") newParent := newLinked(t, tmpDir, "New Parent", "") child := newLinked(t, tmpDir, "Child", oldParent.FullID) repo, err := repository.OpenGoGitRepo(tmpDir, "", nil) if err != nil { t.Fatalf("failed to open repo: %v", err) } b, err := bug.Read(repo, entity.Id(child.FullID)) if err != nil { t.Fatalf("failed to read bug: %v", err) } author := b.Compile().Author if _, _, err := bug.ChangeLabels(b, author, 1700000000, []string{"keep-me"}, nil, nil); err != nil { t.Fatalf("ChangeLabels failed: %v", err) } if err := b.Commit(repo); err != nil { t.Fatalf("commit failed: %v", err) } repo.Close() invalidateGitBugCache(tmpDir) if err := runClose(tmpDir, child.FullID); err != nil { t.Fatalf("runClose failed: %v", err) } if err := runEdit(tmpDir, child.FullID, "", "", newParent.FullID, ""); err != nil { t.Fatalf("runEdit change parent failed: %v", err) } clone := findLiveIssueByTitle(t, tmpDir, "Child") if clone.Status != "closed" { t.Errorf("clone status = %q, want closed", clone.Status) } if len(clone.Labels) != 1 || clone.Labels[0] != "keep-me" { t.Errorf("clone labels = %v, want [keep-me]", clone.Labels) } if clone.ParentID != newParent.FullID { t.Errorf("clone ParentID = %q, want %q", clone.ParentID, newParent.FullID) } snap := readSnapshot(t, tmpDir, clone.FullID) if snap.Status != common.ClosedStatus { t.Errorf("clone snapshot status = %v, want closed", snap.Status) } } // TestEditCommand_ChangeParentClonesDescendants verifies the whole subtree // is re-created so descendants point at the new IDs func TestEditCommand_ChangeParentClonesDescendants(t *testing.T) { tmpDir := setupParentEditRepo(t) oldParent := newLinked(t, tmpDir, "Old Parent", "") newParent := newLinked(t, tmpDir, "New Parent", "") child := newLinked(t, tmpDir, "Child", oldParent.FullID) grandchild := newLinked(t, tmpDir, "Grandchild", child.FullID) if err := runEdit(tmpDir, child.FullID, "", "", newParent.FullID, ""); err != nil { t.Fatalf("runEdit change parent failed: %v", err) } newChild := findLiveIssueByTitle(t, tmpDir, "Child") newGrandchild := findLiveIssueByTitle(t, tmpDir, "Grandchild") if newGrandchild.FullID == grandchild.FullID { t.Fatal("expected grandchild to be cloned") } if newGrandchild.ParentID != newChild.FullID { t.Errorf("grandchild ParentID = %q, want new child %q", newGrandchild.ParentID, newChild.FullID) } oldGrandchild := findIssueByID(t, tmpDir, grandchild.FullID) if oldGrandchild.SupersededBy != newGrandchild.FullID { t.Errorf("old grandchild SupersededBy = %q, want %q", oldGrandchild.SupersededBy, newGrandchild.FullID) } if oldGrandchild.Status != "closed" { t.Errorf("old grandchild status = %q, want closed", oldGrandchild.Status) } } // TestEditCommand_ChangeParentSkipsSupersededChildren verifies a child that // was already moved elsewhere is not cloned again with its former parent func TestEditCommand_ChangeParentSkipsSupersededChildren(t *testing.T) { tmpDir := setupParentEditRepo(t) a := newLinked(t, tmpDir, "A", "") b := newLinked(t, tmpDir, "B", "") c := newLinked(t, tmpDir, "C", "") child := newLinked(t, tmpDir, "Child", a.FullID) parentOfA := newLinked(t, tmpDir, "Parent Of A", c.FullID) // Move Child from A to B: the original Child stays linked to A, superseded if err := runEdit(tmpDir, child.FullID, "", "", b.FullID, ""); err != nil { t.Fatalf("move child failed: %v", err) } // Link A under "Parent Of A", then move it under C if err := runEdit(tmpDir, a.FullID, "", "", parentOfA.FullID, ""); err != nil { t.Fatalf("link A failed: %v", err) } if err := runEdit(tmpDir, a.FullID, "", "", b.FullID, ""); err != nil { t.Fatalf("move A failed: %v", err) } issues, err := LoadBugs(tmpDir) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } count := 0 for _, issue := range issues { if issue.Title == "Child" { count++ } } if count != 2 { t.Errorf("found %d bugs titled Child, want 2 (original + one clone)", count) } liveChild := findLiveIssueByTitle(t, tmpDir, "Child") if liveChild.ParentID != b.FullID { t.Errorf("live child ParentID = %q, want B %q", liveChild.ParentID, b.FullID) } } // TestEditCommand_ChangeParentOfSupersededBug verifies a superseded bug // cannot be moved and the user is pointed at its replacement func TestEditCommand_ChangeParentOfSupersededBug(t *testing.T) { tmpDir := setupParentEditRepo(t) oldParent := newLinked(t, tmpDir, "Old Parent", "") newParent := newLinked(t, tmpDir, "New Parent", "") child := newLinked(t, tmpDir, "Child", oldParent.FullID) if err := runEdit(tmpDir, child.FullID, "", "", newParent.FullID, ""); err != nil { t.Fatalf("runEdit change parent failed: %v", err) } clone := findLiveIssueByTitle(t, tmpDir, "Child") err := runEdit(tmpDir, child.FullID, "", "", oldParent.FullID, "") if err == nil { t.Fatal("expected error when moving a superseded bug, got nil") } if !strings.Contains(err.Error(), "superseded") || !strings.Contains(err.Error(), clone.FullID[:7]) { t.Errorf("expected error mentioning superseded and %s, got: %v", clone.FullID[:7], err) } } // TestEditCommand_ChangeParentCycleError verifies moving a bug under its own // descendant is refused func TestEditCommand_ChangeParentCycleError(t *testing.T) { tmpDir := setupParentEditRepo(t) root := newLinked(t, tmpDir, "Root", "") child := newLinked(t, tmpDir, "Child", root.FullID) grandchild := newLinked(t, tmpDir, "Grandchild", child.FullID) err := runEdit(tmpDir, child.FullID, "", "", grandchild.FullID, "") if err == nil { t.Fatal("expected cycle error, got nil") } if !strings.Contains(err.Error(), "cycle") { t.Errorf("expected 'cycle' error, got: %v", err) } if got := findIssueByID(t, tmpDir, child.FullID); got.Status != "open" || got.SupersededBy != "" { t.Errorf("child should be untouched, got status %q superseded %q", got.Status, got.SupersededBy) } } // TestAgentEditCommand_ChangeParentClonesBug verifies the agent API can // change an existing parent func TestAgentEditCommand_ChangeParentClonesBug(t *testing.T) { tmpDir := setupParentEditRepo(t) oldParent := newLinked(t, tmpDir, "Old Parent", "") newParent := newLinked(t, tmpDir, "New Parent", "") child := newLinked(t, tmpDir, "Child", oldParent.FullID) if err := runAgentEdit(tmpDir, child.FullID, "", "", "", false, newParent.FullID, ""); err != nil { t.Fatalf("runAgentEdit change parent failed: %v", err) } clone := findLiveIssueByTitle(t, tmpDir, "Child") if clone.ParentID != newParent.FullID { t.Errorf("clone ParentID = %q, want %q", clone.ParentID, newParent.FullID) } old := findIssueByID(t, tmpDir, child.FullID) if old.SupersededBy != clone.FullID || old.Status != "closed" { t.Errorf("original should be closed and superseded, got status %q superseded %q", old.Status, old.SupersededBy) } // The superseded note is authored by the agent snap := readSnapshot(t, tmpDir, child.FullID) last := snap.Comments[len(snap.Comments)-1] if last.Author.Name() != "agent" { t.Errorf("superseded comment author = %q, want agent", last.Author.Name()) } }