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
4 changes: 4 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,8 @@ FLAG basecamp card move --markdown type=bool
FLAG basecamp card move --md type=bool
FLAG basecamp card move --no-hints type=bool
FLAG basecamp card move --no-stats type=bool
FLAG basecamp card move --pos type=int
FLAG basecamp card move --position type=int
FLAG basecamp card move --profile type=string
FLAG basecamp card move --project type=string
FLAG basecamp card move --quiet type=bool
Expand Down Expand Up @@ -1269,6 +1271,8 @@ FLAG basecamp cards move --markdown type=bool
FLAG basecamp cards move --md type=bool
FLAG basecamp cards move --no-hints type=bool
FLAG basecamp cards move --no-stats type=bool
FLAG basecamp cards move --pos type=int
FLAG basecamp cards move --position type=int
FLAG basecamp cards move --profile type=string
FLAG basecamp cards move --project type=string
FLAG basecamp cards move --quiet type=bool
Expand Down
3 changes: 3 additions & 0 deletions e2e/smoke/.qa-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ test_lineup_create_creates_a_lineup_marker # API not available on some servers

# SDK bug: TodolistOrGroup0 union type expects wrapped JSON, API returns flat
test_todolists_show_returns_todolist_detail # SDK deserialization mismatch — smoke

# Positioned card move depends on card table dock tool
test_cards_move_with_position_moves_card_to_position # depends on card table + column — smoke
16 changes: 16 additions & 0 deletions e2e/smoke/smoke_cards_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ setup_file() {
assert_json_value '.ok' 'true'
}

@test "cards move with position moves card to position" {
local card_file="$BATS_FILE_TMPDIR/direct_card_id"
local col_file="$BATS_FILE_TMPDIR/column_id"
[[ -f "$card_file" ]] || mark_unverifiable "No card created in prior test"
[[ -f "$col_file" ]] || mark_unverifiable "No column discovered in prior test"
local card_id col_id
card_id=$(<"$card_file")
col_id=$(<"$col_file")
[[ -n "$col_id" ]] || mark_unverifiable "Column ID is empty"

run_smoke basecamp cards move "$card_id" --to "$col_id" --position 1 \
--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"
Expand Down
47 changes: 42 additions & 5 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ You can pass either a card ID or a Basecamp URL:

func newCardsMoveCmd(project, cardTable *string) *cobra.Command {
var targetColumn string
var position int

cmd := &cobra.Command{
Use: "move <id|url>",
Expand All @@ -524,14 +525,21 @@ func newCardsMoveCmd(project, cardTable *string) *cobra.Command {

You can pass either a card ID or a Basecamp URL:
basecamp cards move 789 --to "Done" --in my-project
basecamp cards move https://3.basecamp.com/123/buckets/456/card_tables/cards/789 --to "Done"`,
basecamp cards move https://3.basecamp.com/123/buckets/456/card_tables/cards/789 --to "Done"
basecamp cards move 789 --to "Done" --position 1 --in my-project`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Show help when invoked with no target column
if targetColumn == "" {
return missingArg(cmd, "--to")
}

// Detect --position/--pos presence and validate
positionSet := cmd.Flags().Changed("position") || cmd.Flags().Changed("pos")
if positionSet && position <= 0 {
return output.ErrUsage("--position must be a positive integer (1-indexed)")
}

app := appctx.FromContext(cmd.Context())

if err := ensureAccount(cmd, app); err != nil {
Expand Down Expand Up @@ -615,8 +623,28 @@ You can pass either a card ID or a Basecamp URL:
}
}

// Positioned moves need card table ID for the moves endpoint
if positionSet && position > 0 && cardTableIDVal == "" {
cardTableIDVal, err = getCardTableID(cmd, app, resolvedProjectID, *cardTable)
if err != nil {
return err
}
}

// Move card to column
err = app.Account().Cards().Move(cmd.Context(), cardID, columnID)
if positionSet && position > 0 {
cardTableIDInt, parseErr := strconv.ParseInt(cardTableIDVal, 10, 64)
if parseErr != nil {
return output.ErrUsage("Invalid card table ID")
}
err = app.Account().CardColumns().Move(cmd.Context(), cardTableIDInt, &basecamp.MoveColumnRequest{
SourceID: cardID,
TargetID: columnID,
Position: position,
})
} else {
err = app.Account().Cards().Move(cmd.Context(), cardID, columnID)
}
if err != nil {
return convertSDKError(err)
}
Expand All @@ -637,18 +665,27 @@ You can pass either a card ID or a Basecamp URL:
})
}

return app.OK(map[string]string{
result := map[string]any{
"id": cardIDStr,
"status": "moved",
"column": targetColumn,
},
output.WithSummary(fmt.Sprintf("Moved card #%s to '%s'", cardIDStr, targetColumn)),
}
summary := fmt.Sprintf("Moved card #%s to '%s'", cardIDStr, targetColumn)
if positionSet && position > 0 {
result["position"] = position
summary = fmt.Sprintf("Moved card #%s to '%s' at position %d", cardIDStr, targetColumn, position)
}

return app.OK(result,
output.WithSummary(summary),
output.WithBreadcrumbs(breadcrumbs...),
)
},
}

cmd.Flags().StringVarP(&targetColumn, "to", "t", "", "Target column ID or name (required)")
cmd.Flags().IntVar(&position, "position", 0, "Position in column (1-indexed)")
cmd.Flags().IntVar(&position, "pos", 0, "Position in column (alias for --position)")

return cmd
}
Expand Down
237 changes: 237 additions & 0 deletions internal/commands/cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package commands
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -738,3 +741,237 @@ func TestCardsStepDeleteRequiresStepID(t *testing.T) {
// Cobra validates args count first
assert.Equal(t, "accepts 1 arg(s), received 0", err.Error())
}

// =============================================================================
// Cards Move --position Tests
// =============================================================================

// mockCardMoveTransport handles resolver and card table API calls, captures the move POST.
type mockCardMoveTransport struct {
capturedPath string
capturedBody []byte
}

func (t *mockCardMoveTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("Content-Type", "application/json")

if req.Method == "GET" {
var body string
switch {
case strings.HasSuffix(req.URL.Path, "/projects.json"):
body = `[{"id": 123, "name": "Test Project"}]`
case strings.Contains(req.URL.Path, "/projects/123"):
body = `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board"}]}`
case strings.Contains(req.URL.Path, "/card_tables/555"):
body = `{"id": 555, "lists": [{"id": 777, "title": "Done", "position": 1}]}`
default:
body = `{}`
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

if req.Method == "POST" {
t.capturedPath = req.URL.Path
if req.Body != nil {
body, _ := io.ReadAll(req.Body)
t.capturedBody = body
req.Body.Close()
}
return &http.Response{
StatusCode: 204,
Body: io.NopCloser(strings.NewReader("")),
Header: header,
}, nil
}

return nil, errors.New("unexpected request")
}

// TestCardsMovePositionPayload verifies the CLI sends the intended request contract
// (source_id=card, target_id=column, position) to /card_tables/{id}/moves.json.
// This proves the CLI wiring is correct; it does not prove the BC3 API accepts
// card-as-source on this endpoint — that requires manual/integration validation.
func TestCardsMovePositionPayload(t *testing.T) {
transport := &mockCardMoveTransport{}
app, _ := newTestAppWithTransport(t, transport)

project := ""
cardTable := "555"
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "Done", "--position", "1")
require.NoError(t, err)

// Verify URL path hits the card_tables moves endpoint
assert.Contains(t, transport.capturedPath, "/card_tables/555/moves.json")

// Verify payload shape
var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, float64(456), body["source_id"])
assert.Equal(t, float64(777), body["target_id"])
assert.Equal(t, float64(1), body["position"])
}

// TestCardsMovePositionPosAlias verifies that --pos triggers the same
// positioned-move contract as --position.
func TestCardsMovePositionPosAlias(t *testing.T) {
transport := &mockCardMoveTransport{}
app, _ := newTestAppWithTransport(t, transport)

project := ""
cardTable := "555"
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "Done", "--pos", "2")
require.NoError(t, err)

assert.Contains(t, transport.capturedPath, "/card_tables/555/moves.json")

var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, float64(456), body["source_id"])
assert.Equal(t, float64(777), body["target_id"])
assert.Equal(t, float64(2), body["position"])
}

// TestCardsMovePositionNumericToSingleTableAutoResolves verifies that a
// positioned move with numeric --to and no --card-table auto-resolves
// the card table when the project has exactly one.
func TestCardsMovePositionNumericToSingleTableAutoResolves(t *testing.T) {
transport := &mockCardMoveTransport{}
app, _ := newTestAppWithTransport(t, transport)

project := ""
cardTable := "" // no --card-table
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "777", "--position", "1")
require.NoError(t, err)

// Should auto-resolve to card table 555 (single table in mock dock)
assert.Contains(t, transport.capturedPath, "/card_tables/555/moves.json")

var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, float64(456), body["source_id"])
assert.Equal(t, float64(777), body["target_id"])
assert.Equal(t, float64(1), body["position"])
}

// TestCardsMoveWithoutPositionUsesCardsMove verifies the old path
// (POST /card_tables/cards/{id}/moves.json with {column_id}) is taken
// when --position is absent.
func TestCardsMoveWithoutPositionUsesCardsMove(t *testing.T) {
transport := &mockCardMoveTransport{}
app, _ := newTestAppWithTransport(t, transport)

project := ""
cardTable := ""
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "777")
require.NoError(t, err)

// Verify URL path hits the cards move endpoint, not card_tables moves
assert.Contains(t, transport.capturedPath, "/card_tables/cards/456/moves.json")

// Verify payload has column_id, not source_id/target_id
var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, float64(777), body["column_id"])
_, hasSourceID := body["source_id"]
assert.False(t, hasSourceID, "non-positioned move should not send source_id")
}

// TestCardsMovePositionRejectsNonPositive tests that --position -1 is rejected.
func TestCardsMovePositionRejectsNonPositive(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cardTable := "999"
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "777", "--position", "-1")
require.NotNil(t, err)

var e *output.Error
if assert.True(t, errors.As(err, &e)) {
assert.Equal(t, "--position must be a positive integer (1-indexed)", e.Message)
}
}

// TestCardsMovePositionRejectsZero tests that --position 0 is rejected.
func TestCardsMovePositionRejectsZero(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cardTable := "999"
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "777", "--position", "0")
require.NotNil(t, err)

var e *output.Error
if assert.True(t, errors.As(err, &e)) {
assert.Equal(t, "--position must be a positive integer (1-indexed)", e.Message)
}
}

// mockMultiCardTableTransport returns a project with multiple card tables.
type mockMultiCardTableTransport struct{}

func (t *mockMultiCardTableTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("Content-Type", "application/json")

if req.Method == "GET" {
var body string
switch {
case strings.HasSuffix(req.URL.Path, "/projects.json"):
body = `[{"id": 123, "name": "Test Project"}]`
case strings.Contains(req.URL.Path, "/projects/123"):
body = `{"id": 123, "dock": [` +
`{"name": "kanban_board", "id": 555, "title": "Board A"},` +
`{"name": "kanban_board", "id": 666, "title": "Board B"}` +
`]}`
default:
body = `{}`
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

return nil, errors.New("unexpected request")
}

// TestCardsMovePositionNumericToMultiTableAmbiguous verifies that a positioned move
// with a numeric --to and no --card-table returns an ambiguous error when the project
// has multiple card tables.
func TestCardsMovePositionNumericToMultiTableAmbiguous(t *testing.T) {
transport := &mockMultiCardTableTransport{}
app, _ := newTestAppWithTransport(t, transport)

project := ""
cardTable := "" // no --card-table
cmd := newCardsMoveCmd(&project, &cardTable)

err := executeCommand(cmd, app, "456", "--to", "777", "--position", "1")
require.NotNil(t, err)

var e *output.Error
if assert.True(t, errors.As(err, &e)) {
assert.Equal(t, output.CodeAmbiguous, e.Code)
assert.Contains(t, e.Message, "card table")
}
}
Loading
Loading