Skip to content
Open
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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions cmd/activity/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 65 additions & 14 deletions cmd/card/assign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
69 changes: 64 additions & 5 deletions cmd/card/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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]",
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading