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
100 changes: 90 additions & 10 deletions cmd/bw/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@ import (
)

type CloseArgs struct {
ID string
Reason string
JSON bool
ID string
Reason string
Recursive bool
JSON bool
}

func parseCloseArgs(raw []string) (CloseArgs, error) {
if len(raw) == 0 {
return CloseArgs{}, fmt.Errorf("usage: bw close <id> [--reason <reason>]")
}
a, err := ParseArgs(raw[1:], []string{"--reason"}, []string{"--json"})
a, err := ParseArgs(raw, []string{"--reason"}, []string{"--recursive", "--json"})
if err != nil {
return CloseArgs{}, err
}
id := a.PosFirst()
if id == "" {
return CloseArgs{}, fmt.Errorf("usage: bw close <id> [--reason <reason>] [--recursive]")
}
return CloseArgs{
ID: raw[0],
Reason: a.String("--reason"),
JSON: a.JSON(),
ID: id,
Reason: a.String("--reason"),
Recursive: a.Bool("--recursive"),
JSON: a.JSON(),
}, nil
}

Expand All @@ -36,6 +39,10 @@ func cmdClose(store *issue.Store, args []string, w Writer, _ *config.Config) (*c
return nil, err
}

if ca.Recursive {
return cmdCloseRecursive(store, ca, w)
}

var iss *issue.Issue
var unblocked []*issue.Issue

Expand Down Expand Up @@ -90,6 +97,79 @@ func cmdClose(store *issue.Store, args []string, w Writer, _ *config.Config) (*c
return nil, nil
}

// cmdCloseRecursive closes an issue and its entire subtree in a single commit.
func cmdCloseRecursive(store *issue.Store, ca CloseArgs, w Writer) (*config.Config, error) {
var result *issue.SubtreeCloseResult

err := commitWithRetry(store, commitMaxRetries, func() (string, error) {
var cerr error
result, cerr = store.CloseSubtree(ca.ID, ca.Reason)
if cerr != nil {
return "", cerr
}
if len(result.Closed) == 0 {
return "", fmt.Errorf("nothing to close: %s and its subtree are already closed", ca.ID)
}

intent := ""
for i, iss := range result.Closed {
if i > 0 {
intent += "\n"
}
intent += fmt.Sprintf("close %s", iss.ID)
if iss.CloseReason != "" {
intent += fmt.Sprintf(" reason=%q", iss.CloseReason)
}
}
for _, u := range result.Unblocked {
intent += fmt.Sprintf("\nunblocked %s", u.ID)
}
return intent, nil
})
if err != nil {
return nil, err
}

if ca.JSON {
if result.Closed == nil {
result.Closed = []*issue.Issue{}
}
if result.Skipped == nil {
result.Skipped = []*issue.Issue{}
}
if result.Unblocked == nil {
result.Unblocked = []*issue.Issue{}
}
fprintJSON(w, result)
return nil, nil
}

fmt.Fprintf(w, "closed %d issue(s) under {id:%s}:\n", len(result.Closed), ca.ID)
w.Push(2)
for _, iss := range result.Closed {
fmt.Fprintf(w, "{id:%s}: ~~%s~~\n", iss.ID, md.Escape(iss.Title))
}
w.Pop()
if len(result.Skipped) > 0 {
fmt.Fprintf(w, "\n%d already closed, skipped.\n", len(result.Skipped))
}
if len(result.Unblocked) > 0 {
fmt.Fprintln(w)
w.Push(2)
for _, u := range result.Unblocked {
fmt.Fprintf(w, "unblocked {id:%s}: %s\n", u.ID, md.Escape(u.Title))
}
w.Pop()
fmt.Fprintln(w)
if len(result.Unblocked) == 1 {
fmt.Fprintf(w, "Next: `bw start %s` to begin, or `bw ready` for all options.\n", result.Unblocked[0].ID)
} else {
fmt.Fprintln(w, "Next: `bw ready` to see available work.")
}
}
return nil, nil
}

type ReopenArgs struct {
ID string
JSON bool
Expand Down
92 changes: 92 additions & 0 deletions cmd/bw/close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,98 @@ func TestCmdCloseNotFound(t *testing.T) {
}
}

func TestCmdCloseRecursive(t *testing.T) {
env := testutil.NewEnv(t)
defer env.Cleanup()

root, _ := env.Store.Create("Epic", issue.CreateOpts{Type: "epic"})
c, _ := env.Store.Create("Child", issue.CreateOpts{Parent: root.ID})
gc, _ := env.Store.Create("Grandchild", issue.CreateOpts{Parent: c.ID})
env.Repo.Commit("setup")

var buf bytes.Buffer
_, err := cmdClose(env.Store, []string{root.ID, "--recursive"}, PlainWriter(&buf), nil)
if err != nil {
t.Fatalf("cmdClose --recursive: %v", err)
}
if !strings.Contains(buf.String(), "closed 3 issue") {
t.Errorf("output = %q", buf.String())
}

for _, id := range []string{root.ID, c.ID, gc.ID} {
got, _ := env.Store.Get(id)
if got.Status != "closed" {
t.Errorf("%s status = %q, want closed", id, got.Status)
}
}
}

func TestCmdCloseRecursiveAlias(t *testing.T) {
env := testutil.NewEnv(t)
defer env.Cleanup()

root, _ := env.Store.Create("Epic", issue.CreateOpts{Type: "epic"})
c, _ := env.Store.Create("Child", issue.CreateOpts{Parent: root.ID})
env.Repo.Commit("setup")

var buf bytes.Buffer
if _, err := cmdClose(env.Store, []string{root.ID, "-r"}, PlainWriter(&buf), nil); err != nil {
t.Fatalf("cmdClose -r: %v", err)
}
got, _ := env.Store.Get(c.ID)
if got.Status != "closed" {
t.Errorf("child status = %q, want closed", got.Status)
}
}

func TestCmdCloseRecursiveSkipsAlreadyClosed(t *testing.T) {
env := testutil.NewEnv(t)
defer env.Cleanup()

root, _ := env.Store.Create("Epic", issue.CreateOpts{Type: "epic"})
c, _ := env.Store.Create("Child", issue.CreateOpts{Parent: root.ID})
env.Store.Close(c.ID, "")
env.Repo.Commit("setup")

var buf bytes.Buffer
if _, err := cmdClose(env.Store, []string{root.ID, "-r"}, PlainWriter(&buf), nil); err != nil {
t.Fatalf("cmdClose -r: %v", err)
}
if !strings.Contains(buf.String(), "already closed, skipped") {
t.Errorf("output = %q", buf.String())
}
rootGot, _ := env.Store.Get(root.ID)
if rootGot.Status != "closed" {
t.Errorf("root status = %q, want closed", rootGot.Status)
}
}

func TestCmdCloseRecursiveJSON(t *testing.T) {
env := testutil.NewEnv(t)
defer env.Cleanup()

root, _ := env.Store.Create("Epic", issue.CreateOpts{Type: "epic"})
env.Store.Create("Child", issue.CreateOpts{Parent: root.ID})
env.Repo.Commit("setup")

var buf bytes.Buffer
_, err := cmdClose(env.Store, []string{root.ID, "-r", "--json"}, PlainWriter(&buf), nil)
if err != nil {
t.Fatalf("cmdClose -r --json: %v", err)
}

var got issue.SubtreeCloseResult
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("JSON parse: %v", err)
}
if len(got.Closed) != 2 {
t.Errorf("closed = %d, want 2", len(got.Closed))
}
if got.Skipped == nil || got.Unblocked == nil {
t.Error("skipped/unblocked should be [] not null")
}
}

func TestCmdReopenBasic(t *testing.T) {
env := testutil.NewEnv(t)
defer env.Cleanup()
Expand Down
8 changes: 6 additions & 2 deletions cmd/bw/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,19 @@ var commands = []Command{
{
Name: "close",
Summary: "Close an issue",
Description: "Close an issue. Optionally provide a reason.",
Description: "Close an issue. Optionally provide a reason.\nWith --recursive, also close the issue's entire subtree (all descendants).",
Positionals: []Positional{
{Name: "<id>", Required: true, Help: "Issue ID"},
},
Flags: []Flag{
{Long: "--reason", Value: "REASON", Help: "Closing reason"},
{Long: "--recursive", Short: "-r", Help: "Also close all descendants (the whole subtree)"},
{Long: "--json", Help: "Output as JSON"},
},
Examples: []Example{
{Cmd: "bw close bw-a3f8"},
{Cmd: "bw close bw-a3f8 --reason duplicate"},
{Cmd: "bw close bw-a3f8 -r"},
},
NeedsStore: true,
Run: cmdClose,
Expand Down Expand Up @@ -258,17 +260,19 @@ var commands = []Command{
{
Name: "delete",
Summary: "Delete an issue",
Description: "Permanently delete an issue and clean up references.\nWithout --force, shows a preview of what would be affected.",
Description: "Permanently delete an issue and clean up references.\nWithout --force, shows a preview of what would be affected.\nWith --recursive, also delete the issue's entire subtree (all descendants).",
Positionals: []Positional{
{Name: "<id>", Required: true, Help: "Issue ID"},
},
Flags: []Flag{
{Long: "--recursive", Short: "-r", Help: "Also delete all descendants (the whole subtree)"},
{Long: "--force", Help: "Actually delete (default: preview only)"},
{Long: "--json", Help: "Output as JSON"},
},
Examples: []Example{
{Cmd: "bw delete bw-a3f8", Help: "Preview deletion"},
{Cmd: "bw delete bw-a3f8 --force", Help: "Delete permanently"},
{Cmd: "bw delete bw-a3f8 -r --force", Help: "Delete the whole subtree"},
},
NeedsStore: true,
Run: cmdDelete,
Expand Down
Loading
Loading