diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 7b7a33b2..530c3275 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -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"}, @@ -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 { diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index 963c26de..d83a4bca 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -1,6 +1,12 @@ package commands import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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": "

hello from stdin

", "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, "

hello from stdin

", 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, "

hello from args

", 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, "

hello from stdin

", 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(), " 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) +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index d355f870..4c873db2 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "strconv" "strings" @@ -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"`