diff --git a/.surface b/.surface index 372852d3..1e83baf3 100644 --- a/.surface +++ b/.surface @@ -48,6 +48,7 @@ ARG basecamp cards column update 00 ARG basecamp cards column watch 00 ARG basecamp cards create 00 ARG basecamp cards create 01 [body] +ARG basecamp cards done 00 <id|url> ARG basecamp cards move 00 <id|url> ARG basecamp cards mv 00 <id|url> ARG basecamp cards restore 00 <id|url> @@ -498,6 +499,7 @@ CMD basecamp cards column update CMD basecamp cards column watch CMD basecamp cards columns CMD basecamp cards create +CMD basecamp cards done CMD basecamp cards list CMD basecamp cards move CMD basecamp cards mv @@ -2808,6 +2810,28 @@ FLAG basecamp cards create --styled type=bool FLAG basecamp cards create --to type=string FLAG basecamp cards create --todolist type=string FLAG basecamp cards create --verbose type=count +FLAG basecamp cards done --account type=string +FLAG basecamp cards done --agent type=bool +FLAG basecamp cards done --cache-dir type=string +FLAG basecamp cards done --card-table type=string +FLAG basecamp cards done --count type=bool +FLAG basecamp cards done --help type=bool +FLAG basecamp cards done --hints type=bool +FLAG basecamp cards done --ids-only type=bool +FLAG basecamp cards done --in type=string +FLAG basecamp cards done --jq type=string +FLAG basecamp cards done --json type=bool +FLAG basecamp cards done --markdown type=bool +FLAG basecamp cards done --md type=bool +FLAG basecamp cards done --no-hints type=bool +FLAG basecamp cards done --no-stats type=bool +FLAG basecamp cards done --profile type=string +FLAG basecamp cards done --project type=string +FLAG basecamp cards done --quiet type=bool +FLAG basecamp cards done --stats type=bool +FLAG basecamp cards done --styled type=bool +FLAG basecamp cards done --todolist type=string +FLAG basecamp cards done --verbose type=count FLAG basecamp cards list --account type=string FLAG basecamp cards list --agent type=bool FLAG basecamp cards list --all type=bool @@ -16285,6 +16309,7 @@ SUB basecamp cards column update SUB basecamp cards column watch SUB basecamp cards columns SUB basecamp cards create +SUB basecamp cards done SUB basecamp cards list SUB basecamp cards move SUB basecamp cards mv diff --git a/README.md b/README.md index 6a6418a9..ec9c7b61 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ basecamp projects list # List projects basecamp todos list --in 12345 # Todos in a project basecamp todo "Fix bug" --in 12345 # Create todo basecamp done 67890 # Complete todo +basecamp cards done 67890 --in 12345 # Complete card (move to Done) basecamp search "authentication" # Search across projects basecamp files list --in 12345 # List docs & files basecamp cards list --in 12345 # List cards (Kanban) diff --git a/e2e/smoke/smoke_cards_write.bats b/e2e/smoke/smoke_cards_write.bats index baa643bd..286952e1 100644 --- a/e2e/smoke/smoke_cards_write.bats +++ b/e2e/smoke/smoke_cards_write.bats @@ -86,6 +86,18 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "cards done moves a card to done" { + local card_file="$BATS_FILE_TMPDIR/direct_card_id" + [[ -f "$card_file" ]] || mark_unverifiable "No card created in prior test" + local card_id + card_id=$(<"$card_file") + + run_smoke basecamp cards done "$card_id" \ + --card-table "$QA_CARDTABLE" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' +} + @test "cards step create creates a step on a card" { local id_file="$BATS_FILE_TMPDIR/card_id" [[ -f "$id_file" ]] || mark_unverifiable "No card created in prior test" diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 49f52e4a..a42b227e 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -40,6 +40,7 @@ func NewCardsCmd() *cobra.Command { newCardsCreateCmd(&project, &cardTable), newCardsUpdateCmd(), newCardsMoveCmd(&project, &cardTable), + newCardsDoneCmd(&project, &cardTable), newCardsColumnsCmd(&project, &cardTable), newCardsColumnCmd(&project, &cardTable), newCardsStepsCmd(&project), @@ -294,6 +295,11 @@ You can pass either a card ID or a Basecamp URL: opts := []output.ResponseOption{ output.WithSummary(fmt.Sprintf("Card #%s: %s", cardIDStr, card.Title)), output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "done", + Cmd: fmt.Sprintf("basecamp cards done %s", cardIDStr), + Description: "Move card to Done", + }, output.Breadcrumb{ Action: "comment", Cmd: fmt.Sprintf("basecamp comment %s <text>", cardIDStr), @@ -872,6 +878,134 @@ You can pass either a card ID or a Basecamp URL: return cmd } +func newCardsDoneCmd(project, cardTable *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "done <id|url>", + Short: "Move a card to the Done column", + Long: `Move a card to the Done column in its card table. + +You can pass either a card ID or a Basecamp URL: + basecamp cards done 789 --in my-project + basecamp cards done https://3.basecamp.com/123/buckets/456/card_tables/cards/789`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + + cardIDStr, urlProjectID := extractWithProject(args[0]) + cardID, err := strconv.ParseInt(cardIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid card ID") + } + + card, err := app.Account().Cards().Get(cmd.Context(), cardID) + if err != nil { + return convertSDKError(err) + } + + alreadyInDone := card.Parent != nil && card.Parent.Type == "Kanban::DoneColumn" + if alreadyInDone || card.Completed { + summary := fmt.Sprintf("Card #%s is already in 'Done'", cardIDStr) + if !alreadyInDone && card.Completed { + summary = fmt.Sprintf("Card #%s is already completed", cardIDStr) + } + return app.OK(card, + output.WithSummary(summary), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "view", + Cmd: fmt.Sprintf("basecamp cards show %s", cardIDStr), + Description: "View card", + }), + ) + } + + projectID := *project + if projectID == "" && urlProjectID != "" { + projectID = urlProjectID + } + if projectID == "" && card.Bucket != nil && card.Bucket.ID != 0 { + projectID = fmt.Sprintf("%d", card.Bucket.ID) + } + if projectID == "" { + projectID = app.Flags.Project + } + if projectID == "" { + projectID = app.Config.ProjectID + } + if projectID == "" { + if err := ensureProject(cmd, app); err != nil { + return err + } + projectID = app.Config.ProjectID + } + + resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) + if err != nil { + return err + } + + cardTableIDVal, cardTableData, err := resolveCardTableForCard(cmd, app, resolvedProjectID, *cardTable, card) + if err != nil { + return err + } + + doneColumn := findDoneColumn(cardTableData.Lists) + if doneColumn == nil { + return output.ErrUsageHint( + fmt.Sprintf("Card table '%s' does not have a Done column", cardTableData.Title), + fmt.Sprintf("Inspect available columns: basecamp cards columns --card-table %s --in %s", cardTableIDVal, resolvedProjectID), + ) + } + + if err := app.Account().Cards().Move(cmd.Context(), cardID, doneColumn.ID, nil); err != nil { + return convertSDKError(err) + } + + updatedCard, err := app.Account().Cards().Get(cmd.Context(), cardID) + if err != nil { + result := map[string]any{ + "id": cardIDStr, + "status": "completed", + "column": doneColumn.Title, + "column_id": doneColumn.ID, + } + return app.OK(result, + output.WithSummary(fmt.Sprintf("Moved card #%s to '%s'", cardIDStr, doneColumn.Title)), + output.WithBreadcrumbs(cardDoneBreadcrumbs(cardIDStr, resolvedProjectID, cardTableIDVal, doneColumn.Title)...), + ) + } + + return app.OK(updatedCard, + output.WithSummary(fmt.Sprintf("Moved card #%s to '%s'", cardIDStr, doneColumn.Title)), + output.WithBreadcrumbs(cardDoneBreadcrumbs(cardIDStr, resolvedProjectID, cardTableIDVal, doneColumn.Title)...), + ) + }, + } + + return cmd +} + +func cardDoneBreadcrumbs(cardIDStr, projectID, cardTableID, doneColumn string) []output.Breadcrumb { + breadcrumbs := []output.Breadcrumb{ + { + Action: "view", + Cmd: fmt.Sprintf("basecamp cards show %s --in %s", cardIDStr, projectID), + Description: "View card", + }, + } + if cardTableID != "" { + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: "list", + Cmd: fmt.Sprintf("basecamp cards list --in %s --card-table %s --column %q", projectID, cardTableID, doneColumn), + Description: "List cards in Done", + }) + } + return breadcrumbs +} + func moveCardOnHold(cmd *cobra.Command, app *appctx.App, cardID int64, cardIDStr, projectID, targetColumn, cardTableFlag string) error { var column *basecamp.CardColumn @@ -2210,14 +2344,50 @@ You can pass either a step ID or a Basecamp URL: return cmd } +type projectCardTable struct { + ID int64 + Title string +} + // getCardTableID retrieves the card table ID from a project's dock. // If the project has multiple card tables and no explicit cardTableID is provided, // an error is returned with the available card table IDs. func getCardTableID(cmd *cobra.Command, app *appctx.App, projectID, explicitCardTableID string) (string, error) { + cardTables, err := listProjectCardTables(cmd, app, projectID) + if err != nil { + return "", err + } + if len(cardTables) == 0 { + return "", output.ErrNotFound("card table", projectID) + } + + if explicitCardTableID != "" { + idInt, parseErr := strconv.ParseInt(explicitCardTableID, 10, 64) + if parseErr == nil { + for _, ct := range cardTables { + if ct.ID == idInt { + return explicitCardTableID, nil + } + } + } + return "", output.ErrUsageHint( + fmt.Sprintf("Card table '%s' not found", explicitCardTableID), + fmt.Sprintf("Available card tables: %s", formatCardTableIDs(cardTables)), + ) + } + + if len(cardTables) == 1 { + return fmt.Sprintf("%d", cardTables[0].ID), nil + } + + return "", ambiguousCardTablesError(cardTables) +} + +func listProjectCardTables(cmd *cobra.Command, app *appctx.App, projectID string) ([]projectCardTable, error) { path := fmt.Sprintf("/projects/%s.json", projectID) resp, err := app.Account().Get(cmd.Context(), path) if err != nil { - return "", convertSDKError(err) + return nil, convertSDKError(err) } var project struct { @@ -2228,49 +2398,127 @@ func getCardTableID(cmd *cobra.Command, app *appctx.App, projectID, explicitCard } `json:"dock"` } if err := resp.UnmarshalData(&project); err != nil { - return "", fmt.Errorf("failed to parse project: %w", err) + return nil, fmt.Errorf("failed to parse project: %w", err) } - // Collect all card tables from dock - var cardTables []struct { - ID int64 - Title string - } + var cardTables []projectCardTable for _, item := range project.Dock { if item.Name == "kanban_board" { - cardTables = append(cardTables, struct { - ID int64 - Title string - }{ID: item.ID, Title: item.Title}) + cardTables = append(cardTables, projectCardTable{ID: item.ID, Title: item.Title}) } } + return cardTables, nil +} + +func resolveCardTableForCard(cmd *cobra.Command, app *appctx.App, projectID, explicitCardTableID string, card *basecamp.Card) (string, *basecamp.CardTable, error) { + cardTables, err := listProjectCardTables(cmd, app, projectID) + if err != nil { + return "", nil, err + } if len(cardTables) == 0 { - return "", output.ErrNotFound("card table", projectID) + return "", nil, output.ErrNotFound("card table", projectID) } - // If explicit card table ID provided, validate it exists if explicitCardTableID != "" { - var idInt int64 - if _, err := fmt.Sscanf(explicitCardTableID, "%d", &idInt); err == nil { - for _, ct := range cardTables { - if ct.ID == idInt { - return explicitCardTableID, nil - } + for _, ct := range cardTables { + if fmt.Sprintf("%d", ct.ID) != explicitCardTableID { + continue + } + cardTableData, err := app.Account().CardTables().Get(cmd.Context(), ct.ID) + if err != nil { + return "", nil, convertSDKError(err) } + return explicitCardTableID, cardTableData, nil } - return "", output.ErrUsageHint( + return "", nil, output.ErrUsageHint( fmt.Sprintf("Card table '%s' not found", explicitCardTableID), fmt.Sprintf("Available card tables: %s", formatCardTableIDs(cardTables)), ) } - // Single card table - return it if len(cardTables) == 1 { - return fmt.Sprintf("%d", cardTables[0].ID), nil + cardTableData, err := app.Account().CardTables().Get(cmd.Context(), cardTables[0].ID) + if err != nil { + return "", nil, convertSDKError(err) + } + return fmt.Sprintf("%d", cardTables[0].ID), cardTableData, nil + } + + if card == nil || card.Parent == nil || card.Parent.ID == 0 { + return "", nil, ambiguousCardTablesError(cardTables) + } + + if cardTableIDVal, ok := resolveCardTableIDFromParentColumn(cmd, app, cardTables, card.Parent.ID); ok { + cardTableIDInt, parseErr := strconv.ParseInt(cardTableIDVal, 10, 64) + if parseErr != nil { + return "", nil, output.ErrUsage("Invalid card table ID") + } + cardTableData, err := app.Account().CardTables().Get(cmd.Context(), cardTableIDInt) + if err != nil { + return "", nil, convertSDKError(err) + } + return cardTableIDVal, cardTableData, nil + } + + var matchedID string + var matchedTable *basecamp.CardTable + for _, ct := range cardTables { + cardTableData, err := app.Account().CardTables().Get(cmd.Context(), ct.ID) + if err != nil { + return "", nil, convertSDKError(err) + } + if !cardTableContainsColumn(cardTableData.Lists, card.Parent.ID) { + continue + } + if matchedTable != nil { + return "", nil, ambiguousCardTablesError(cardTables) + } + matchedID = fmt.Sprintf("%d", ct.ID) + matchedTable = cardTableData } + if matchedTable == nil { + return "", nil, ambiguousCardTablesError(cardTables) + } + + return matchedID, matchedTable, nil +} + +func resolveCardTableIDFromParentColumn(cmd *cobra.Command, app *appctx.App, cardTables []projectCardTable, parentColumnID int64) (string, bool) { + column, err := app.Account().CardColumns().Get(cmd.Context(), parentColumnID) + if err != nil || column == nil || column.Parent == nil || column.Parent.ID == 0 { + return "", false + } + for _, ct := range cardTables { + if ct.ID == column.Parent.ID { + return fmt.Sprintf("%d", ct.ID), true + } + } + return "", false +} + +func cardTableContainsColumn(columns []basecamp.CardColumn, columnID int64) bool { + for _, column := range columns { + if column.ID == columnID { + return true + } + if column.OnHold != nil && column.OnHold.ID == columnID { + return true + } + } + return false +} + +func findDoneColumn(columns []basecamp.CardColumn) *basecamp.CardColumn { + for i := range columns { + if columns[i].Type == "Kanban::DoneColumn" { + return &columns[i] + } + } + return nil +} - // Multiple card tables - error with available IDs +func ambiguousCardTablesError(cardTables []projectCardTable) error { lines := make([]string, 0, len(cardTables)) for _, ct := range cardTables { title := ct.Title @@ -2279,7 +2527,7 @@ func getCardTableID(cmd *cobra.Command, app *appctx.App, projectID, explicitCard } lines = append(lines, fmt.Sprintf(" %d %s", ct.ID, title)) } - return "", &output.Error{ + return &output.Error{ Code: output.CodeAmbiguous, Message: "Multiple card tables found", Hint: fmt.Sprintf("Specify one with --card-table <id>:\n%s", strings.Join(lines, "\n")), @@ -2287,10 +2535,7 @@ func getCardTableID(cmd *cobra.Command, app *appctx.App, projectID, explicitCard } // formatCardTableIDs formats card table IDs for error messages. -func formatCardTableIDs(cardTables []struct { - ID int64 - Title string -}) string { +func formatCardTableIDs(cardTables []projectCardTable) string { ids := make([]string, len(cardTables)) for i, ct := range cardTables { if ct.Title != "" { diff --git a/internal/commands/cards_test.go b/internal/commands/cards_test.go index e1e621b0..8f05d842 100644 --- a/internal/commands/cards_test.go +++ b/internal/commands/cards_test.go @@ -724,38 +724,26 @@ func TestCardsColumnNameVariations(t *testing.T) { func TestFormatCardTableIDs(t *testing.T) { tests := []struct { name string - cardTables []struct { - ID int64 - Title string - } - expected string + cardTables []projectCardTable + expected string }{ { name: "single with title", - cardTables: []struct { - ID int64 - Title string - }{ + cardTables: []projectCardTable{ {ID: 123, Title: "Sprint Board"}, }, expected: "[123 (Sprint Board)]", }, { name: "single without title", - cardTables: []struct { - ID int64 - Title string - }{ + cardTables: []projectCardTable{ {ID: 456, Title: ""}, }, expected: "[456]", }, { name: "multiple with titles", - cardTables: []struct { - ID int64 - Title string - }{ + cardTables: []projectCardTable{ {ID: 123, Title: "Sprint Board"}, {ID: 456, Title: "Backlog"}, }, @@ -763,10 +751,7 @@ func TestFormatCardTableIDs(t *testing.T) { }, { name: "mixed titles", - cardTables: []struct { - ID int64 - Title string - }{ + cardTables: []projectCardTable{ {ID: 123, Title: "Sprint Board"}, {ID: 456, Title: ""}, {ID: 789, Title: "Archive"}, @@ -1134,6 +1119,223 @@ func TestCardsMovePositionNumericToMultiTableAmbiguous(t *testing.T) { } } +func TestGetCardTableIDRejectsPartialNumericExplicitID(t *testing.T) { + transport := &mockCardMoveTransport{} + app, _ := newTestAppWithTransport(t, transport) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + _, err := getCardTableID(cmd, app, "123", "555abc") + require.Error(t, err) + + var e *output.Error + if assert.True(t, errors.As(err, &e)) { + assert.Equal(t, "Card table '555abc' not found", e.Message) + } +} + +type mockCardsDoneTransport struct { + projectDock string + initialCard string + updatedCard string + columns map[string]string + tables map[string]string + cardGetCount int + cardTableGetCount int + moveCalls int + capturedMovePath string + capturedMoveBody []byte +} + +func (t *mockCardsDoneTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + if req.Method == "POST" && strings.Contains(req.URL.Path, "/moves.json") { + t.moveCalls++ + t.capturedMovePath = req.URL.Path + if req.Body != nil { + body, _ := io.ReadAll(req.Body) + t.capturedMoveBody = body + req.Body.Close() + } + return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Header: header}, nil + } + + if req.Method != "GET" { + return nil, fmt.Errorf("unexpected request: %s %s", req.Method, req.URL.Path) + } + + switch { + case strings.HasSuffix(req.URL.Path, "/projects.json"): + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`[{"id": 123, "name": "Test Project"}]`)), Header: header}, nil + case strings.Contains(req.URL.Path, "/projects/123"): + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(t.projectDock)), Header: header}, nil + case strings.Contains(req.URL.Path, "/card_tables/cards/456"): + t.cardGetCount++ + body := t.initialCard + if t.cardGetCount > 1 && t.updatedCard != "" { + body = t.updatedCard + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + case strings.Contains(req.URL.Path, "/card_tables/columns/"): + for id, body := range t.columns { + if strings.Contains(req.URL.Path, "/card_tables/columns/"+id) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + } + case strings.Contains(req.URL.Path, "/card_tables/"): + for id, body := range t.tables { + if strings.Contains(req.URL.Path, "/card_tables/"+id) { + t.cardTableGetCount++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + } + } + + return nil, fmt.Errorf("unexpected GET request: %s", req.URL.Path) +} + +func TestCardsDoneMovesCardToDoneColumn(t *testing.T) { + transport := &mockCardsDoneTransport{ + projectDock: `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board"}]}`, + initialCard: `{"id": 456, "title": "Test Card", "completed": false, "parent": {"id": 777, "title": "Doing", "type": "Kanban::Column"}, "bucket": {"id": 123, "name": "Test Project"}}`, + updatedCard: `{"id": 456, "title": "Test Card", "completed": true, "parent": {"id": 888, "title": "Done", "type": "Kanban::DoneColumn"}, "bucket": {"id": 123, "name": "Test Project"}}`, + tables: map[string]string{ + "555": `{"id": 555, "title": "Board", "lists": [{"id": 777, "title": "Doing", "type": "Kanban::Column"}, {"id": 888, "title": "Done", "type": "Kanban::DoneColumn"}]}`, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.NoError(t, err) + assert.Equal(t, 1, transport.moveCalls) + assert.Contains(t, transport.capturedMovePath, "/card_tables/cards/456/moves.json") + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedMoveBody, &body)) + assert.Equal(t, float64(888), body["column_id"]) +} + +func TestCardsDoneUsesParentColumnToResolveTable(t *testing.T) { + transport := &mockCardsDoneTransport{ + projectDock: `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board A"}, {"name": "kanban_board", "id": 666, "title": "Board B"}]}`, + initialCard: `{"id": 456, "title": "Test Card", "completed": false, "parent": {"id": 990, "title": "Doing", "type": "Kanban::Column"}, "bucket": {"id": 123, "name": "Test Project"}}`, + updatedCard: `{"id": 456, "title": "Test Card", "completed": true, "parent": {"id": 991, "title": "Done", "type": "Kanban::DoneColumn"}, "bucket": {"id": 123, "name": "Test Project"}}`, + columns: map[string]string{ + "990": `{"id": 990, "title": "Doing", "type": "Kanban::Column", "parent": {"id": 666, "title": "Board B", "type": "Kanban::Board"}}`, + }, + tables: map[string]string{ + "555": `{"id": 555, "title": "Board A", "lists": [{"id": 777, "title": "Doing", "type": "Kanban::Column"}, {"id": 888, "title": "Done", "type": "Kanban::DoneColumn"}]}`, + "666": `{"id": 666, "title": "Board B", "lists": [{"id": 990, "title": "Doing", "type": "Kanban::Column"}, {"id": 991, "title": "Done", "type": "Kanban::DoneColumn"}]}`, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedMoveBody, &body)) + assert.Equal(t, float64(991), body["column_id"]) + assert.Equal(t, 1, transport.cardTableGetCount) +} + +func TestCardsDoneUsesOnHoldParentToResolveTable(t *testing.T) { + transport := &mockCardsDoneTransport{ + projectDock: `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board A"}, {"name": "kanban_board", "id": 666, "title": "Board B"}]}`, + initialCard: `{"id": 456, "title": "Test Card", "completed": false, "parent": {"id": 1990, "title": "On hold", "type": "Kanban::Column"}, "bucket": {"id": 123, "name": "Test Project"}}`, + updatedCard: `{"id": 456, "title": "Test Card", "completed": true, "parent": {"id": 991, "title": "Done", "type": "Kanban::DoneColumn"}, "bucket": {"id": 123, "name": "Test Project"}}`, + tables: map[string]string{ + "555": `{"id": 555, "title": "Board A", "lists": [{"id": 777, "title": "Doing", "type": "Kanban::Column", "on_hold": {"id": 1777, "title": "On hold"}}, {"id": 888, "title": "Done", "type": "Kanban::DoneColumn"}]}`, + "666": `{"id": 666, "title": "Board B", "lists": [{"id": 990, "title": "Doing", "type": "Kanban::Column", "on_hold": {"id": 1990, "title": "On hold"}}, {"id": 991, "title": "Done", "type": "Kanban::DoneColumn"}]}`, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedMoveBody, &body)) + assert.Equal(t, float64(991), body["column_id"]) + assert.Equal(t, 2, transport.cardTableGetCount) +} + +func TestCardsDoneAlreadyCompletedSkipsMove(t *testing.T) { + transport := &mockCardsDoneTransport{ + initialCard: `{"id": 456, "title": "Test Card", "completed": true, "parent": {"id": 888, "title": "Done", "type": "Kanban::DoneColumn"}}`, + } + app, buf := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.NoError(t, err) + assert.Equal(t, 0, transport.moveCalls) + + var out map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) + assert.Equal(t, "Card #456 is already in 'Done'", out["summary"]) +} + +func TestCardsDoneCompletedOutsideDoneUsesAccurateSummary(t *testing.T) { + transport := &mockCardsDoneTransport{ + initialCard: `{"id": 456, "title": "Test Card", "completed": true, "parent": {"id": 777, "title": "Doing", "type": "Kanban::Column"}}`, + } + app, buf := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.NoError(t, err) + assert.Equal(t, 0, transport.moveCalls) + + var out map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) + assert.Equal(t, "Card #456 is already completed", out["summary"]) +} + +func TestCardsDoneWithoutDoneColumnErrors(t *testing.T) { + transport := &mockCardsDoneTransport{ + projectDock: `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board"}]}`, + initialCard: `{"id": 456, "title": "Test Card", "completed": false, "parent": {"id": 777, "title": "Doing", "type": "Kanban::Column"}, "bucket": {"id": 123, "name": "Test Project"}}`, + tables: map[string]string{ + "555": `{"id": 555, "title": "Board", "lists": [{"id": 777, "title": "Doing", "type": "Kanban::Column"}]}`, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + project := "" + cardTable := "" + cmd := newCardsDoneCmd(&project, &cardTable) + + err := executeCommand(cmd, app, "456") + require.Error(t, err) + + var e *output.Error + if assert.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) { + assert.Equal(t, "Card table 'Board' does not have a Done column", e.Message) + assert.Contains(t, e.Hint, "basecamp cards columns --card-table 555 --in 123") + } +} + // ============================================================================= // Dash-separator title tests // ============================================================================= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 242b3131..ab3368fa 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -43,7 +43,7 @@ func CommandCategories() []CommandCategory { {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position"}}, {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "trash", "archive", "restore"}}, {Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "delete"}}, - {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "columns", "steps", "trash", "archive", "restore"}}, + {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "done", "columns", "steps", "trash", "archive", "restore"}}, {Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "download", "update", "trash", "archive", "restore"}}, {Name: "checkins", Category: "core", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer"}}, {Name: "schedule", Category: "core", Description: "Manage schedule entries", Actions: []string{"show", "entries", "create", "update"}}, diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index b29ab263..383bac63 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -163,6 +163,7 @@ basecamp <cmd> --page 1 # First page only, no auto-pagination | Complete todo | `basecamp done <id> --json` | | List cards | `basecamp cards list --in <project> --json` | | Create card | `basecamp card "Title" --in <project> --json` | +| Complete card | `basecamp cards done <id|url> --in <project> --json` | | Move card | `basecamp cards move <id> --to <column> [--position N] --in <project> --json` | | Move card to on-hold | `basecamp cards move <id> --on-hold --in <project> --json` | | Post message | `basecamp message "Title" "Body" --in <project> --json` | @@ -248,7 +249,8 @@ Want to change something? ├── Have URL? → basecamp url parse "<url>" → use extracted IDs ├── Have ID? → basecamp <resource> update <id> --field value ├── Change status? → basecamp recordings trash|archive|restore <id> -└── Complete todo? → basecamp done <id> +├── Complete todo? → basecamp done <id> +└── Complete card? → basecamp cards done <id|url> --in <project> ``` ## Common Workflows @@ -318,6 +320,9 @@ basecamp chat post "@Jane, done!" --in <project> # List columns to get IDs basecamp cards columns --in <project> --json +# Complete a card (moves it to the Done column automatically) +basecamp cards done <card_id> --in <project> + # Move card to column basecamp cards move <card_id> --to <column_id> --in <project> @@ -454,6 +459,7 @@ basecamp cards columns --in <project> --json # List columns (needs --ca basecamp cards show <id> --in <project> # Card details basecamp card "Title" "<p>Body</p>" --in <project> --column <id> basecamp cards update <id> --title "New" --due tomorrow --assignee me +basecamp cards done <id|url> --in <project> # Move to the Done column automatically basecamp cards move <id> --to <column_id> # Move to column (numeric ID) basecamp cards move <id> --to "Done" --card-table <table_id> # Move by name (needs table) basecamp cards move <id> --to "Done" --position 1 --card-table <table_id> # Move to position