From 09d3e1a1807b546075fbb19e9574a0b37173c607 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Sun, 8 Mar 2026 22:16:11 +0200 Subject: [PATCH] feat: implement card assign/unassign commands and edit flags Complete the stubbed card assign and unassign commands, and add --assign/--unassign flags to card edit's non-interactive mode. Also fix parseSince "this week"/"last week" to truncate to midnight. Co-Authored-By: Claude Opus 4.6 --- README.md | 15 ++++++-- cmd/activity/list.go | 8 ++-- cmd/card/assign.go | 79 ++++++++++++++++++++++++++++++++------- cmd/card/edit.go | 69 +++++++++++++++++++++++++++++++--- cmd/card/unassign.go | 89 ++++++++++++++++++++++++++++++++++++-------- 5 files changed, 218 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4dd1da2..14e3d12 100644 --- a/README.md +++ b/README.md @@ -397,11 +397,18 @@ bc4 card edit https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/ bc4 card move 12345 --column "In Progress" bc4 card move https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/12345 --column "Done" -# Assign users to a card (by ID or URL) -bc4 card assign 12345 +# Assign users to a card (by @mention, email, or name) +bc4 card assign 12345 @jane +bc4 card assign 12345 @jane @john +bc4 card assign 12345 --assign user@example.com # Remove assignees from a card -bc4 card unassign 12345 +bc4 card unassign 12345 @jane +bc4 card unassign 12345 --unassign user@example.com + +# Edit a card's assignees (non-interactive) +bc4 card edit 12345 --assign @jane +bc4 card edit 12345 --unassign old@example.com # Archive a card bc4 card archive 12345 @@ -659,7 +666,7 @@ bc4 card move 45678 --column "Review" bc4 card move 45678 --column "Done" # Assign team members to cards -bc4 card assign 45678 # Interactive assignee selector +bc4 card assign 45678 @jane @john ``` #### Working with URLs diff --git a/cmd/activity/list.go b/cmd/activity/list.go index bdaad61..5501bbf 100644 --- a/cmd/activity/list.go +++ b/cmd/activity/list.go @@ -200,12 +200,14 @@ func parseSince(s string) (time.Time, error) { y, m, d := now.AddDate(0, 0, -1).Date() return time.Date(y, m, d, 0, 0, 0, 0, now.Location()), nil case "this week": - // Go to start of this week (Sunday) + // Go to start of this week (Sunday) at midnight daysToSunday := int(now.Weekday()) - return now.AddDate(0, 0, -daysToSunday), nil + y, m, d := now.AddDate(0, 0, -daysToSunday).Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()), nil case "last week": daysToSunday := int(now.Weekday()) - return now.AddDate(0, 0, -daysToSunday-7), nil + y, m, d := now.AddDate(0, 0, -daysToSunday-7).Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()), nil } return time.Time{}, fmt.Errorf("unable to parse time: %s", s) diff --git a/cmd/card/assign.go b/cmd/card/assign.go index 9103333..261d914 100644 --- a/cmd/card/assign.go +++ b/cmd/card/assign.go @@ -4,24 +4,41 @@ import ( "fmt" "strconv" + "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/parser" + "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" ) func newAssignCmd(f *factory.Factory) *cobra.Command { var accountID string var projectID string + var assign []string cmd := &cobra.Command{ - Use: "assign [ID or URL]", + Use: "assign [ID or URL] [@user ...]", Short: "Assign people to card", Long: `Assign one or more people to a card. You can specify the card using either: - A numeric ID (e.g., "12345") -- A Basecamp URL (e.g., "https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/12345")`, - Args: cobra.ExactArgs(1), +- A Basecamp URL (e.g., "https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/12345") + +Users can be specified as positional arguments or with the --assign flag. +Supports email addresses, @mentions, and names.`, + Example: ` # Assign by @mention + bc4 card assign 12345 @jane + + # Assign multiple users + bc4 card assign 12345 @jane @john + + # Assign by email + bc4 card assign 12345 --assign user@example.com + + # Assign using a URL + bc4 card assign https://3.basecamp.com/.../cards/12345 @jane`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Parse card ID (could be numeric ID or URL) cardID, parsedURL, err := parser.ParseArgument(args[0]) @@ -50,6 +67,12 @@ You can specify the card using either: } } + // Collect user identifiers from positional args and --assign flag + userIdentifiers := append(args[1:], assign...) + if len(userIdentifiers) == 0 { + return fmt.Errorf("no users specified. Provide users as arguments or with --assign") + } + // Get resolved project ID resolvedProjectID, err := f.ProjectID() if err != nil { @@ -63,30 +86,58 @@ You can specify the card using either: } cardOps := client.Cards() - // Get the card first to display current assignees + // Get the card to merge with existing assignees card, err := cardOps.GetCard(f.Context(), resolvedProjectID, cardID) if err != nil { return fmt.Errorf("failed to fetch card: %w", err) } - // TODO: Implement interactive person selection - fmt.Printf("Card: %s\n", card.Title) - if len(card.Assignees) > 0 { - fmt.Print("Current assignees: ") - for i, assignee := range card.Assignees { - if i > 0 { - fmt.Print(", ") + // Resolve user identifiers to person IDs + userResolver := utils.NewUserResolver(client.Client, resolvedProjectID) + newAssigneeIDs, err := userResolver.ResolveUsers(f.Context(), userIdentifiers) + if err != nil { + return fmt.Errorf("failed to resolve assignees: %w", err) + } + + // Start with existing assignees + currentAssigneeIDs := make([]int64, 0, len(card.Assignees)) + for _, assignee := range card.Assignees { + currentAssigneeIDs = append(currentAssigneeIDs, assignee.ID) + } + + // Merge without duplicates + for _, newID := range newAssigneeIDs { + found := false + for _, existingID := range currentAssigneeIDs { + if existingID == newID { + found = true + break } - fmt.Print(assignee.Name) } - fmt.Println() + if !found { + currentAssigneeIDs = append(currentAssigneeIDs, newID) + } + } + + // Update the card + req := api.CardUpdateRequest{ + Title: card.Title, + Content: card.Content, + AssigneeIDs: currentAssigneeIDs, + } + _, err = cardOps.UpdateCard(f.Context(), resolvedProjectID, cardID, req) + if err != nil { + return fmt.Errorf("failed to assign users: %w", err) } - return fmt.Errorf("card assignment not yet implemented") + + fmt.Printf("Assigned %d user(s) to card #%d\n", len(newAssigneeIDs), card.ID) + return nil }, } cmd.Flags().StringVarP(&accountID, "account", "a", "", "Specify account ID") cmd.Flags().StringVarP(&projectID, "project", "p", "", "Specify project ID") + cmd.Flags().StringSliceVar(&assign, "assign", nil, "Add assignees (by email or @mention)") return cmd } diff --git a/cmd/card/edit.go b/cmd/card/edit.go index fd29710..5c6fb59 100644 --- a/cmd/card/edit.go +++ b/cmd/card/edit.go @@ -19,6 +19,7 @@ import ( "github.com/needmore/bc4/internal/markdown" "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" + "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" ) @@ -365,7 +366,7 @@ func (m editModel) View() string { return content } -func updateCardNonInteractive(f *factory.Factory, client *api.Client, projectID string, cardID int64, title, content string, attach []string) error { +func updateCardNonInteractive(f *factory.Factory, client *api.Client, projectID string, cardID int64, title, content string, attach, assign, unassign []string) error { // Get current card card, err := client.GetCard(f.Context(), projectID, cardID) if err != nil { @@ -417,11 +418,59 @@ func updateCardNonInteractive(f *factory.Factory, client *api.Client, projectID } } - // Preserve assignees + // Start with existing assignees assigneeIDs := make([]int64, 0, len(card.Assignees)) for _, a := range card.Assignees { assigneeIDs = append(assigneeIDs, a.ID) } + + // Handle assignee changes + if len(assign) > 0 || len(unassign) > 0 { + userResolver := utils.NewUserResolver(client, projectID) + + // Add new assignees + if len(assign) > 0 { + newIDs, err := userResolver.ResolveUsers(f.Context(), assign) + if err != nil { + return fmt.Errorf("failed to resolve assignees to add: %w", err) + } + for _, newID := range newIDs { + found := false + for _, existingID := range assigneeIDs { + if existingID == newID { + found = true + break + } + } + if !found { + assigneeIDs = append(assigneeIDs, newID) + } + } + } + + // Remove assignees + if len(unassign) > 0 { + removeIDs, err := userResolver.ResolveUsers(f.Context(), unassign) + if err != nil { + return fmt.Errorf("failed to resolve assignees to remove: %w", err) + } + filteredIDs := make([]int64, 0) + for _, existingID := range assigneeIDs { + shouldRemove := false + for _, removeID := range removeIDs { + if existingID == removeID { + shouldRemove = true + break + } + } + if !shouldRemove { + filteredIDs = append(filteredIDs, existingID) + } + } + assigneeIDs = filteredIDs + } + } + req.AssigneeIDs = assigneeIDs // Update the card @@ -438,6 +487,8 @@ func newEditCmd(f *factory.Factory) *cobra.Command { var accountID string var projectID string var attach []string + var assign []string + var unassign []string cmd := &cobra.Command{ Use: "edit [ID or URL]", @@ -461,7 +512,13 @@ the flag multiple times.`, bc4 card edit 12345 --attach ./screenshot.png # Add multiple attachments - bc4 card edit 12345 --attach ./photo1.jpg --attach ./photo2.jpg`, + bc4 card edit 12345 --attach ./photo1.jpg --attach ./photo2.jpg + + # Assign someone to a card + bc4 card edit 12345 --assign @jane + + # Remove someone from a card + bc4 card edit 12345 --unassign old@email.com`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Parse card ID (could be numeric ID or URL) @@ -509,8 +566,8 @@ the flag multiple times.`, interactive, _ := cmd.Flags().GetBool("interactive") // If non-interactive mode with flags - if !interactive && (title != "" || content != "" || len(attach) > 0) { - return updateCardNonInteractive(f, client.Client, resolvedProjectID, cardID, title, content, attach) + if !interactive && (title != "" || content != "" || len(attach) > 0 || len(assign) > 0 || len(unassign) > 0) { + return updateCardNonInteractive(f, client.Client, resolvedProjectID, cardID, title, content, attach, assign, unassign) } // Interactive mode @@ -564,6 +621,8 @@ the flag multiple times.`, cmd.Flags().String("content", "", "New content for the card (Markdown supported)") cmd.Flags().Bool("interactive", false, "Use interactive mode (default when no flags)") cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attach file(s) to the card (can be used multiple times)") + cmd.Flags().StringSliceVar(&assign, "assign", nil, "Add assignees (by email or @mention)") + cmd.Flags().StringSliceVar(&unassign, "unassign", nil, "Remove assignees (by email or @mention)") return cmd } diff --git a/cmd/card/unassign.go b/cmd/card/unassign.go index b4ddbbc..00ad939 100644 --- a/cmd/card/unassign.go +++ b/cmd/card/unassign.go @@ -4,24 +4,41 @@ import ( "fmt" "strconv" + "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/parser" + "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" ) func newUnassignCmd(f *factory.Factory) *cobra.Command { var accountID string var projectID string + var unassign []string cmd := &cobra.Command{ - Use: "unassign [ID or URL]", + Use: "unassign [ID or URL] [@user ...]", Short: "Remove assignees from card", Long: `Remove one or more assignees from a card. You can specify the card using either: - A numeric ID (e.g., "12345") -- A Basecamp URL (e.g., "https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/12345")`, - Args: cobra.ExactArgs(1), +- A Basecamp URL (e.g., "https://3.basecamp.com/1234567/buckets/89012345/card_tables/cards/12345") + +Users can be specified as positional arguments or with the --unassign flag. +Supports email addresses, @mentions, and names.`, + Example: ` # Remove by @mention + bc4 card unassign 12345 @jane + + # Remove multiple users + bc4 card unassign 12345 @jane @john + + # Remove by email + bc4 card unassign 12345 --unassign user@example.com + + # Remove using a URL + bc4 card unassign https://3.basecamp.com/.../cards/12345 @jane`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Parse card ID (could be numeric ID or URL) cardID, parsedURL, err := parser.ParseArgument(args[0]) @@ -50,6 +67,12 @@ You can specify the card using either: } } + // Collect user identifiers from positional args and --unassign flag + userIdentifiers := append(args[1:], unassign...) + if len(userIdentifiers) == 0 { + return fmt.Errorf("no users specified. Provide users as arguments or with --unassign") + } + // Get resolved project ID resolvedProjectID, err := f.ProjectID() if err != nil { @@ -63,32 +86,66 @@ You can specify the card using either: } cardOps := client.Cards() - // Get the card first to display current assignees + // Get the card to check current assignees card, err := cardOps.GetCard(f.Context(), resolvedProjectID, cardID) if err != nil { return fmt.Errorf("failed to fetch card: %w", err) } - // TODO: Implement interactive assignee removal - fmt.Printf("Card: %s\n", card.Title) - if len(card.Assignees) > 0 { - fmt.Print("Current assignees: ") - for i, assignee := range card.Assignees { - if i > 0 { - fmt.Print(", ") + if len(card.Assignees) == 0 { + fmt.Println("Card has no assignees") + return nil + } + + // Resolve user identifiers to person IDs + userResolver := utils.NewUserResolver(client.Client, resolvedProjectID) + removeIDs, err := userResolver.ResolveUsers(f.Context(), userIdentifiers) + if err != nil { + return fmt.Errorf("failed to resolve assignees: %w", err) + } + + // Filter out removed assignees + filteredIDs := make([]int64, 0) + removedCount := 0 + for _, assignee := range card.Assignees { + shouldRemove := false + for _, removeID := range removeIDs { + if assignee.ID == removeID { + shouldRemove = true + break } - fmt.Print(assignee.Name) } - fmt.Println() - } else { - fmt.Println("No current assignees") + if shouldRemove { + removedCount++ + } else { + filteredIDs = append(filteredIDs, assignee.ID) + } } - return fmt.Errorf("card unassignment not yet implemented") + + if removedCount == 0 { + fmt.Println("None of the specified users are currently assigned to this card") + return nil + } + + // Update the card + req := api.CardUpdateRequest{ + Title: card.Title, + Content: card.Content, + AssigneeIDs: filteredIDs, + } + _, err = cardOps.UpdateCard(f.Context(), resolvedProjectID, cardID, req) + if err != nil { + return fmt.Errorf("failed to unassign users: %w", err) + } + + fmt.Printf("Removed %d user(s) from card #%d\n", removedCount, card.ID) + return nil }, } cmd.Flags().StringVarP(&accountID, "account", "a", "", "Specify account ID") cmd.Flags().StringVarP(&projectID, "project", "p", "", "Specify project ID") + cmd.Flags().StringSliceVar(&unassign, "unassign", nil, "Remove assignees (by email or @mention)") return cmd }