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 @@ -862,6 +862,7 @@ FLAG basecamp boost show --todolist type=string
FLAG basecamp boost show --verbose type=count
FLAG basecamp card --account type=string
FLAG basecamp card --agent type=bool
FLAG basecamp card --assignee type=string
FLAG basecamp card --cache-dir type=string
FLAG basecamp card --card-table type=string
FLAG basecamp card --column type=string
Expand All @@ -880,6 +881,7 @@ FLAG basecamp card --project type=string
FLAG basecamp card --quiet type=bool
FLAG basecamp card --stats type=bool
FLAG basecamp card --styled type=bool
FLAG basecamp card --to type=string
FLAG basecamp card --todolist type=string
FLAG basecamp card --verbose type=count
FLAG basecamp card move --account type=string
Expand Down Expand Up @@ -1212,6 +1214,7 @@ FLAG basecamp cards columns --todolist type=string
FLAG basecamp cards columns --verbose type=count
FLAG basecamp cards create --account type=string
FLAG basecamp cards create --agent type=bool
FLAG basecamp cards create --assignee type=string
FLAG basecamp cards create --cache-dir type=string
FLAG basecamp cards create --card-table type=string
FLAG basecamp cards create --column type=string
Expand All @@ -1230,6 +1233,7 @@ FLAG basecamp cards create --project type=string
FLAG basecamp cards create --quiet type=bool
FLAG basecamp cards create --stats type=bool
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 list --account type=string
Expand Down
115 changes: 98 additions & 17 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"context"
"errors"
"fmt"
"strconv"
"strings"
Expand Down Expand Up @@ -298,8 +299,35 @@ You can pass either a card ID or a Basecamp URL:
return cmd
}

func resolveAssigneeID(ctx context.Context, app *appctx.App, input string) (int64, error) {
input = strings.TrimSpace(input)
if input == "" {
return 0, output.ErrUsage("Assignee cannot be empty")
}

if id, err := strconv.ParseInt(input, 10, 64); err == nil {
if id <= 0 {
return 0, output.ErrUsage("Assignee ID must be a positive number")
}
return id, nil
}
resolvedID, _, err := app.Names.ResolvePerson(ctx, input)
if err != nil {
return 0, fmt.Errorf("failed to resolve assignee '%s': %w", input, err)
}
id, err := strconv.ParseInt(resolvedID, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid resolved ID '%s': %w", resolvedID, err)
}
if id <= 0 {
return 0, fmt.Errorf("resolved assignee ID for '%s' is not valid: %d", input, id)
}
return id, nil
}

func newCardsCreateCmd(project, cardTable *string) *cobra.Command {
var column string
var assignee string

cmd := &cobra.Command{
Use: "create <title> [body]",
Expand Down Expand Up @@ -399,6 +427,15 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command {
}
}

// Pre-resolve assignee before side-effectful work (fail early on bad input)
var assigneeID int64
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("to") {
assigneeID, err = resolveAssigneeID(cmd.Context(), app, assignee)
if err != nil {
return err
}
}

// Convert content through rich text pipeline
if content != "" {
content = richtext.MarkdownToHTML(content)
Expand All @@ -423,6 +460,22 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command {
return convertSDKError(err)
}

if assigneeID != 0 {
createdCardID := card.ID
card, err = app.Account().Cards().Update(cmd.Context(), createdCardID, &basecamp.UpdateCardRequest{
AssigneeIDs: []int64{assigneeID},
})
if err != nil {
sdkErr := convertSDKError(err)
var e *output.Error
if errors.As(sdkErr, &e) {
e.Message = fmt.Sprintf("card %d created but assignment failed: %s", createdCardID, e.Message)
return e
}
return fmt.Errorf("card %d created but assignment failed: %w", createdCardID, sdkErr)
}
}

// Build breadcrumbs - only include --card-table when known
breadcrumbs := []output.Breadcrumb{
{
Expand Down Expand Up @@ -459,6 +512,12 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command {
}

cmd.Flags().StringVarP(&column, "column", "c", "", "Column ID or name (defaults to first column)")
cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID or name")
cmd.Flags().StringVar(&assignee, "to", "", "Assignee (alias for --assignee)")

completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("assignee", completer.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("to", completer.PeopleNameCompletion())

return cmd
}
Expand All @@ -479,7 +538,7 @@ You can pass either a card ID or a Basecamp URL:
basecamp cards update 789 --body "new body"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(title) == "" && strings.TrimSpace(content) == "" && due == "" && assignee == "" {
if strings.TrimSpace(title) == "" && strings.TrimSpace(content) == "" && due == "" && !cmd.Flags().Changed("assignee") {
return noChanges(cmd)
}

Expand Down Expand Up @@ -516,13 +575,12 @@ You can pass either a card ID or a Basecamp URL:
if due != "" {
req.DueOn = dateparse.Parse(due)
}
if assignee != "" {
assigneeID, _, err := app.Names.ResolvePerson(cmd.Context(), assignee)
if cmd.Flags().Changed("assignee") {
assigneeID, err := resolveAssigneeID(cmd.Context(), app, assignee)
if err != nil {
return fmt.Errorf("failed to resolve assignee '%s': %w", assignee, err)
return err
}
assigneeIDInt, _ := strconv.ParseInt(assigneeID, 10, 64)
req.AssigneeIDs = []int64{assigneeIDInt}
req.AssigneeIDs = []int64{assigneeID}
}

card, err := app.Account().Cards().Update(cmd.Context(), cardID, req)
Expand Down Expand Up @@ -805,6 +863,7 @@ func NewCardCmd() *cobra.Command {
var project string
var column string
var cardTable string
var assignee string

cmd := &cobra.Command{
Use: "card <title> [body]",
Expand Down Expand Up @@ -905,6 +964,15 @@ func NewCardCmd() *cobra.Command {
}
}

// Pre-resolve assignee before side-effectful work (fail early on bad input)
var assigneeID int64
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("to") {
assigneeID, err = resolveAssigneeID(cmd.Context(), app, assignee)
if err != nil {
return err
}
}

// Convert content through rich text pipeline
if content != "" {
content = richtext.MarkdownToHTML(content)
Expand All @@ -929,6 +997,22 @@ func NewCardCmd() *cobra.Command {
return convertSDKError(err)
}

if assigneeID != 0 {
createdCardID := card.ID
card, err = app.Account().Cards().Update(cmd.Context(), createdCardID, &basecamp.UpdateCardRequest{
AssigneeIDs: []int64{assigneeID},
})
if err != nil {
sdkErr := convertSDKError(err)
var e *output.Error
if errors.As(sdkErr, &e) {
e.Message = fmt.Sprintf("card %d created but assignment failed: %s", createdCardID, e.Message)
return e
}
return fmt.Errorf("card %d created but assignment failed: %w", createdCardID, sdkErr)
}
}

// Build breadcrumbs - only include --card-table when known
cardBreadcrumbs := []output.Breadcrumb{
{
Expand Down Expand Up @@ -966,8 +1050,14 @@ func NewCardCmd() *cobra.Command {
cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name")
cmd.PersistentFlags().StringVar(&project, "in", "", "Project ID (alias for --project)")
cmd.Flags().StringVarP(&column, "column", "c", "", "Column ID or name (defaults to first column)")
cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID or name")
cmd.Flags().StringVar(&assignee, "to", "", "Assignee (alias for --assignee)")
cmd.PersistentFlags().StringVar(&cardTable, "card-table", "", "Card table ID (required if project has multiple)")

cardCompleter := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("assignee", cardCompleter.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("to", cardCompleter.PeopleNameCompletion())

cmd.AddCommand(
newCardsUpdateCmd(),
newCardsMoveCmd(&project, &cardTable),
Expand Down Expand Up @@ -2075,18 +2165,9 @@ func resolveAssigneeIDs(ctx context.Context, app *appctx.App, input string) ([]i
continue
}

if id, err := strconv.ParseInt(part, 10, 64); err == nil {
ids = append(ids, id)
continue
}

resolvedID, _, err := app.Names.ResolvePerson(ctx, part)
if err != nil {
return nil, fmt.Errorf("failed to resolve assignee '%s': %w", part, err)
}
id, err := strconv.ParseInt(resolvedID, 10, 64)
id, err := resolveAssigneeID(ctx, app, part)
if err != nil {
return nil, fmt.Errorf("invalid resolved ID '%s': %w", resolvedID, err)
return nil, err
}
ids = append(ids, id)
}
Expand Down
139 changes: 139 additions & 0 deletions internal/commands/cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1241,3 +1241,142 @@ func TestCardsCreateRemoteImagePassesThrough(t *testing.T) {
require.True(t, ok)
assert.Contains(t, content, `<img src="https://example.com/img.png"`)
}

// =============================================================================
// Assignee Flag Tests
// =============================================================================

func TestCardsCreateHasAssigneeFlag(t *testing.T) {
project := ""
cardTable := ""
cmd := newCardsCreateCmd(&project, &cardTable)

flag := cmd.Flags().Lookup("assignee")
require.NotNil(t, flag, "expected --assignee flag on cards create")

toFlag := cmd.Flags().Lookup("to")
require.NotNil(t, toFlag, "expected --to flag on cards create")
}

func TestCardShortcutHasAssigneeFlag(t *testing.T) {
cmd := NewCardCmd()

flag := cmd.Flags().Lookup("assignee")
require.NotNil(t, flag, "expected --assignee flag on card shortcut")

toFlag := cmd.Flags().Lookup("to")
require.NotNil(t, toFlag, "expected --to flag on card shortcut")
}

// mockCardAssignTransport handles resolver API calls with people endpoint,
// card creation, and captures the PUT body for assignment verification.
type mockCardAssignTransport struct {
capturedPutBody []byte
}

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

if req.Method == "GET" {
var body string
if strings.Contains(req.URL.Path, "/projects.json") {
body = `[{"id": 123, "name": "Test Project"}]`
} else if strings.Contains(req.URL.Path, "/projects/") {
body = `{"id": 123, "dock": [{"name": "kanban_board", "id": 789, "title": "Card Table"}]}`
} else if strings.Contains(req.URL.Path, "/card_tables/") {
body = `{"id": 789, "lists": [{"id": 111, "title": "Backlog"}]}`
} else if strings.Contains(req.URL.Path, "/people.json") {
body = `[{"id": 42, "name": "Annie Bryan"}]`
} else {
body = `{}`
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

if req.Method == "POST" {
mockResp := `{"id": 999, "title": "Test Card", "assignees": []}`
return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(mockResp)),
Header: header,
}, nil
}

if req.Method == "PUT" {
if req.Body != nil {
body, _ := io.ReadAll(req.Body)
t.capturedPutBody = body
req.Body.Close()
}
mockResp := `{"id": 999, "title": "Test Card", "assignees": [{"id": 42, "name": "Annie Bryan"}]}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(mockResp)),
Header: header,
}, nil
}

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

func TestCardsCreateWithAssigneeSendsUpdate(t *testing.T) {
transport := &mockCardAssignTransport{}
app := setupCardsMockApp(t, transport)
app.Output = output.New(output.Options{
Format: output.FormatJSON,
Writer: &bytes.Buffer{},
})

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

err := executeCommand(cmd, app, "Test Card", "--assignee", "Annie Bryan")
require.NoError(t, err)

require.NotEmpty(t, transport.capturedPutBody, "expected PUT request for assignment")

var putBody map[string]any
err = json.Unmarshal(transport.capturedPutBody, &putBody)
require.NoError(t, err)

assigneeIDs, ok := putBody["assignee_ids"].([]any)
require.True(t, ok, "expected assignee_ids array in PUT body")
require.Len(t, assigneeIDs, 1)
assert.Equal(t, float64(42), assigneeIDs[0])
}

func TestResolveAssigneeIDRejectsZero(t *testing.T) {
app, _ := setupTestApp(t)

_, err := resolveAssigneeID(context.Background(), app, "0")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, "Assignee ID must be a positive number", e.Message)
}

func TestResolveAssigneeIDRejectsNegative(t *testing.T) {
app, _ := setupTestApp(t)

_, err := resolveAssigneeID(context.Background(), app, "-5")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, "Assignee ID must be a positive number", e.Message)
}

func TestResolveAssigneeIDAcceptsPositive(t *testing.T) {
app, _ := setupTestApp(t)

id, err := resolveAssigneeID(context.Background(), app, "42")
require.NoError(t, err)
assert.Equal(t, int64(42), id)
}
Loading