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
11 changes: 9 additions & 2 deletions internal/commands/checkins.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
"strings"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"
Expand All @@ -13,6 +14,8 @@ import (
"github.com/basecamp/basecamp-cli/internal/richtext"
)

var checkinsNow = time.Now

// NewCheckinsCmd creates the checkins command group.
func NewCheckinsCmd() *cobra.Command {
var project string
Expand Down Expand Up @@ -774,6 +777,10 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command {
if err != nil {
return output.ErrUsage("Invalid question ID")
}
effectiveGroupOn := groupOn
if effectiveGroupOn == "" {
effectiveGroupOn = checkinsNow().Format("2006-01-02")
}

html := richtext.MarkdownToHTML(content)

Expand All @@ -794,7 +801,7 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command {

req := &basecamp.CreateAnswerRequest{
Content: html,
GroupOn: groupOn,
GroupOn: effectiveGroupOn,
}

answer, err := app.Account().Checkins().CreateAnswer(cmd.Context(), qID, req)
Expand Down Expand Up @@ -825,7 +832,7 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command {
},
}

cmd.Flags().StringVar(&groupOn, "date", "", "Date to group answer (ISO 8601, e.g., 2024-01-22)")
cmd.Flags().StringVar(&groupOn, "date", "", "Date to group answer (ISO 8601, e.g., 2024-01-22; defaults to today)")
cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)")

return cmd
Expand Down
103 changes: 103 additions & 0 deletions internal/commands/checkins_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package commands

import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type mockCheckinsAnswerCreateTransport struct {
recordedPath string
recordedBody map[string]any
}

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

switch {
case req.Method == "GET" && strings.Contains(req.URL.Path, "/projects.json"):
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`[{"id":123,"name":"Test Project"}]`)),
Header: header,
}, nil
case req.Method == "POST" && strings.Contains(req.URL.Path, "/questions/456/answers.json"):
m.recordedPath = req.URL.Path
if req.Body != nil {
defer req.Body.Close()
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &m.recordedBody); err != nil {
return nil, err
}
Comment thread
robzolkos marked this conversation as resolved.
return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(`{
"id": 789,
"content": "<p>hello world</p>",
"group_on": "2026-03-25",
"creator": {"name": "Rob Zolkos"},
"parent": {"id": 456, "title": "What did you work on today?", "type": "Question", "url": "https://example.test/questions/456", "app_url": "https://example.test/questions/456"},
"bucket": {"id": 123, "name": "Test Project", "type": "Project"},
"status": "active",
"type": "Question::Answer",
"title": "Answer"
}`)),
Header: header,
}, nil
default:
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(strings.NewReader(`{"error":"Not Found"}`)),
Header: header,
}, nil
}
}

func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) {
originalNow := checkinsNow
checkinsNow = func() time.Time {
return time.Date(2026, 3, 25, 9, 30, 0, 0, time.Local)
}
t.Cleanup(func() {
checkinsNow = originalNow
})

transport := &mockCheckinsAnswerCreateTransport{}
app, _ := newTestAppWithTransport(t, transport)
app.Config.ProjectID = "123"

project := ""
cmd := newCheckinsAnswerCreateCmd(&project)

err := executeCommand(cmd, app, "456", "hello world")
require.NoError(t, err)
require.NotNil(t, transport.recordedBody)
assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath)
assert.Equal(t, "<p>hello world</p>", transport.recordedBody["content"])
assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"])
}
Comment thread
robzolkos marked this conversation as resolved.
Comment thread
robzolkos marked this conversation as resolved.

func TestCheckinsAnswerCreatePreservesExplicitDate(t *testing.T) {
transport := &mockCheckinsAnswerCreateTransport{}
app, _ := newTestAppWithTransport(t, transport)
app.Config.ProjectID = "123"

project := ""
cmd := newCheckinsAnswerCreateCmd(&project)

err := executeCommand(cmd, app, "456", "hello world", "--date", "2026-03-25")
require.NoError(t, err)
require.NotNil(t, transport.recordedBody)
assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"])
}
7 changes: 6 additions & 1 deletion internal/tui/workspace/data/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

var hubNow = time.Now

// Hub is the central data coordinator providing typed, realm-scoped pool access.
//
// Hub manages three realm tiers:
Expand Down Expand Up @@ -1160,7 +1162,10 @@ func (h *Hub) CreateCheckinAnswer(ctx context.Context, accountID string, project
if client == nil {
return fmt.Errorf("no client for account %s", accountID)
}
_, err := client.Checkins().CreateAnswer(ctx, questionID, &basecamp.CreateAnswerRequest{Content: content})
_, err := client.Checkins().CreateAnswer(ctx, questionID, &basecamp.CreateAnswerRequest{
Content: content,
GroupOn: hubNow().Format("2006-01-02"),
})
return err
}

Expand Down
81 changes: 81 additions & 0 deletions internal/tui/workspace/data/hub_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package data

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"

Expand All @@ -9,6 +14,82 @@ import (
"github.com/stretchr/testify/require"
)

type hubCheckinsTestTokenProvider struct{}

func (hubCheckinsTestTokenProvider) AccessToken(_ context.Context) (string, error) {
return "test-token", nil
}

type mockHubCheckinsTransport struct {
recordedPath string
recordedBody map[string]any
}

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

switch {
case req.Method == http.MethodPost && strings.Contains(req.URL.Path, "/questions/456/answers.json"):
m.recordedPath = req.URL.Path
if req.Body != nil {
defer req.Body.Close()
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &m.recordedBody); err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusCreated,
Body: io.NopCloser(strings.NewReader(`{
"id": 789,
"content": "<p>hello world</p>",
"group_on": "2026-03-25",
"creator": {"name": "Rob Zolkos"},
"parent": {"id": 456, "title": "What did you work on today?", "type": "Question", "url": "https://example.test/questions/456", "app_url": "https://example.test/questions/456"},
"bucket": {"id": 123, "name": "Test Project", "type": "Project"},
"status": "active",
"type": "Question::Answer",
"title": "Answer"
}`)),
Header: header,
}, nil
default:
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader(`{"error":"Not Found"}`)),
Header: header,
}, nil
}
}

func TestHubCreateCheckinAnswerDefaultsDateToToday(t *testing.T) {
originalNow := hubNow
hubNow = func() time.Time {
return time.Date(2026, 3, 25, 9, 30, 0, 0, time.Local)
}
t.Cleanup(func() {
hubNow = originalNow
})

transport := &mockHubCheckinsTransport{}
sdk := basecamp.NewClient(&basecamp.Config{}, hubCheckinsTestTokenProvider{},
basecamp.WithTransport(transport),
basecamp.WithMaxRetries(0),
)
h := NewHub(NewMultiStore(sdk), "")

err := h.CreateCheckinAnswer(context.Background(), "99999", 123, 456, "<p>hello world</p>")
require.NoError(t, err)
require.NotNil(t, transport.recordedBody)
assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath)
assert.Equal(t, "<p>hello world</p>", transport.recordedBody["content"])
assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"])
}

func TestHubNewHasGlobalRealm(t *testing.T) {
h := NewHub(nil, "")
require.NotNil(t, h.Global())
Expand Down
3 changes: 2 additions & 1 deletion skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ basecamp checkins answers <question_id> --in <project> # List answers
basecamp checkins answer <id> --in <project> # Answer details
basecamp checkins question create "What did you work on?" --in <project>
basecamp checkins question update <id> "New question" --frequency every_week
basecamp checkins answer create <question-id> "My answer" --in <project>
basecamp checkins answer create <question-id> "My answer" --in <project> # Defaults to today
basecamp checkins answer update <id> "Updated" --in <project>
```

Expand Down Expand Up @@ -758,6 +758,7 @@ cat ~/.config/basecamp/accounts.json # Check available accounts
- `basecamp comment <id> "Text"` (not a flag)
- `basecamp webhooks create "https://..." --in <project>` (not `--url`)
- `basecamp checkins answer create <question-id> "content"` (not `--question`)
- `--date YYYY-MM-DD` is optional for `checkins answer create`; if omitted, it defaults to today

**Missing argument errors (code: "usage"):**
When a required positional argument is missing, the CLI returns a structured error naming
Expand Down
Loading