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
10 changes: 10 additions & 0 deletions internal/commands/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ Comma-separated IDs add the same comment to multiple items:
basecamp comment 789,012,345 "Looks good!"
basecamp comment https://3.basecamp.com/123/buckets/456/todos/789 "Looks good!"

Content can also be piped from stdin:
printf 'Looks good!' | basecamp comment 789

Content supports Markdown and @mentions (@Name or @First.Last):
basecamp comment 789 "Hey @Jane.Smith, **please review**"`,
Annotations: map[string]string{"agent_notes": "Comments are flat — reply to parent item, not to other comments\nURL fragments (#__recording_456) are comment IDs — comment on the parent recording_id, not the comment_id\nComments are on items (todos, messages, cards, etc.) — not on other comments"},
Expand Down Expand Up @@ -331,6 +334,13 @@ Content supports Markdown and @mentions (@Name or @First.Last):
}
}

if !edit && strings.TrimSpace(content) == "" {
stdinContent, hasPipedStdin := readPipedStdin()
if hasPipedStdin {
content = stdinContent
}
}

// Show help when invoked with no content; keep error if editor was opened
if strings.TrimSpace(content) == "" {
if edit {
Expand Down
156 changes: 156 additions & 0 deletions internal/commands/comment_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package commands

import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -56,3 +62,153 @@ func TestCommentsGroupAcceptsInFlag(t *testing.T) {
assert.NotContains(t, err.Error(), "unknown flag")
assert.NotContains(t, err.Error(), "unknown shorthand")
}

type mockCommentCreateTransport struct {
capturedBody []byte
}

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

if req.Method != http.MethodPost {
return nil, errors.New("unexpected request")
}
if !strings.HasSuffix(req.URL.Path, "/comments.json") {
return nil, errors.New("unexpected path: " + req.URL.Path)
}

if req.Body != nil {
body, _ := io.ReadAll(req.Body)
t.capturedBody = body
req.Body.Close()
}

return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(`{"id": 999, "content": "<p>hello from stdin</p>", "status": "active"}`)),
Header: header,
}, nil
}

func TestCommentCreateReadsContentFromStdin(t *testing.T) {
transport := &mockCommentCreateTransport{}
app, _ := newTestAppWithTransport(t, transport)

r, w, err := os.Pipe()
require.NoError(t, err)
_, err = io.WriteString(w, "hello from stdin")
require.NoError(t, err)
require.NoError(t, w.Close())

origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() {
os.Stdin = origStdin
r.Close()
})

cmd := NewCommentCmd()
err = executeCommand(cmd, app, "123")
require.NoError(t, err)
require.NotEmpty(t, transport.capturedBody)

var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, "<p>hello from stdin</p>", body["content"])
}

func TestCommentCreatePrefersPositionalContentOverStdin(t *testing.T) {
transport := &mockCommentCreateTransport{}
app, _ := newTestAppWithTransport(t, transport)

r, w, err := os.Pipe()
require.NoError(t, err)
_, err = io.WriteString(w, "ignored stdin")
require.NoError(t, err)
require.NoError(t, w.Close())

origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() {
os.Stdin = origStdin
r.Close()
})

cmd := NewCommentCmd()
err = executeCommand(cmd, app, "123", "hello from args")
require.NoError(t, err)
require.NotEmpty(t, transport.capturedBody)

var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, "<p>hello from args</p>", body["content"])
}

func TestCommentsCreateReadsContentFromStdin(t *testing.T) {
transport := &mockCommentCreateTransport{}
app, _ := newTestAppWithTransport(t, transport)

r, w, err := os.Pipe()
require.NoError(t, err)
_, err = io.WriteString(w, "hello from stdin")
require.NoError(t, err)
require.NoError(t, w.Close())

origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() {
os.Stdin = origStdin
r.Close()
})

cmd := NewCommentsCmd()
err = executeCommand(cmd, app, "create", "123")
require.NoError(t, err)
require.NotEmpty(t, transport.capturedBody)

var body map[string]any
require.NoError(t, json.Unmarshal(transport.capturedBody, &body))
assert.Equal(t, "<p>hello from stdin</p>", body["content"])
}

func TestCommentCreateMissingContentReturnsUsageBeforeAccountResolution(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.AccountID = ""
app.Flags.JSON = true

devNull, err := os.Open(os.DevNull)
if err != nil {
t.Skip("dev null not available")
}

origStdin := os.Stdin
os.Stdin = devNull
t.Cleanup(func() {
os.Stdin = origStdin
devNull.Close()
})

cmd := NewCommentCmd()
err = executeCommand(cmd, app, "123")
require.Error(t, err)
assert.Contains(t, err.Error(), "<content> required")
assert.NotContains(t, err.Error(), "account")
}

func TestReadPipedStdinIgnoresUnreadableStdin(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
require.NoError(t, r.Close())
require.NoError(t, w.Close())

origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() {
os.Stdin = origStdin
})

content, hasPipedStdin := readPipedStdin()
assert.Empty(t, content)
assert.False(t, hasPipedStdin)
}
17 changes: 17 additions & 0 deletions internal/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -79,6 +80,22 @@ func isMachineOutput(cmd *cobra.Command) bool {
return false
}

func readPipedStdin() (string, bool) {
fi, err := os.Stdin.Stat()
if err != nil {
return "", false
}
if (fi.Mode() & os.ModeCharDevice) != 0 {
return "", false
}

data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", false
}
return string(data), true
}

// DockTool represents a tool in a project's dock.
type DockTool struct {
Name string `json:"name"`
Expand Down
Loading