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
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ GEM
zeitwerk (2.7.5)

PLATFORMS
arm64-darwin-24
arm64-darwin-25
x86_64-linux

Expand Down
74 changes: 46 additions & 28 deletions lib/superthread/cli/cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def create

desc "update CARD", "Update a card"
option :title, type: :string, desc: "New title"
option :content, type: :string, desc: "Card content (HTML). Use {{@Name}} to mention users"
option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name)"
option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
option :sprint, type: :string, desc: "Sprint to move card to (ID or name)"
Expand All @@ -215,43 +216,60 @@ def create
option :archived, type: :boolean, desc: "Archive/unarchive"
# Update an existing card's properties.
#
# Content is updated via a separate PUT endpoint since the standard
# PATCH endpoint does not support content changes.
#
# @param card_id [String] the unique identifier of the card to update
# @return [void]
def update(card_id)
handle_error do
require_space_for_sprint!

begin
# WORKAROUND: API ignores title when combined with list_id,
# so we make separate requests when both are provided.
# TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
if options[:list] && (options[:title] || options[:priority] || options[:archived] || options[:epic])
# First update non-move fields
field_opts = symbolized_options(:title, :priority, :archived)
field_opts[:epic_id] = options[:epic] if options[:epic]
client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?

# Then move the card (include sprint context)
move_opts = resolve_list_with_context(options[:list], card_id)
move_opts[:position] = options[:position] if options[:position]
card = client.cards.update(workspace_id, card_id, **move_opts)
else
opts = symbolized_options(:title, :priority, :archived)
opts[:epic_id] = options[:epic] if options[:epic]
opts[:position] = options[:position] if options[:position]

if options[:list]
opts.merge!(resolve_list_with_context(options[:list], card_id))
elsif options[:sprint]
# Moving to a sprint without --list — default to first list
opts[:sprint_id] = sprint_id
opts[:project_id] = space_id
sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
opts[:list_id] = sprint_obj.lists.first&.id
end
# Update content via dedicated PUT endpoint (separate from PATCH)
if options[:content]
client.cards.update_content(workspace_id, card_id, content: options[:content])
end

card = client.cards.update(workspace_id, card_id, **opts)
# Update other fields via PATCH (skip if only content was provided)
has_patch_fields = options[:title] || options[:list] || options[:priority] ||
!options[:archived].nil? || options[:epic] || options[:position] || options[:sprint]

if has_patch_fields
# WORKAROUND: API ignores title when combined with list_id,
# so we make separate requests when both are provided.
# TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
# First update non-move fields
field_opts = symbolized_options(:title, :priority, :archived)
field_opts[:epic_id] = options[:epic] if options[:epic]
client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?

# Then move the card (include sprint context)
move_opts = resolve_list_with_context(options[:list], card_id)
move_opts[:position] = options[:position] if options[:position]
card = client.cards.update(workspace_id, card_id, **move_opts)
else
opts = symbolized_options(:title, :priority, :archived)
opts[:epic_id] = options[:epic] if options[:epic]
opts[:position] = options[:position] if options[:position]

if options[:list]
opts.merge!(resolve_list_with_context(options[:list], card_id))
elsif options[:sprint]
# Moving to a sprint without --list — default to first list
opts[:sprint_id] = sprint_id
opts[:project_id] = space_id
sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
opts[:list_id] = sprint_obj.lists.first&.id
end

card = client.cards.update(workspace_id, card_id, **opts)
end
end

# If only content was updated, fetch the card for output
card ||= client.cards.find(workspace_id, card_id)
rescue Superthread::ForbiddenError, Superthread::NotFoundError
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
end
Expand Down
29 changes: 25 additions & 4 deletions lib/superthread/cli/pages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,40 @@ def create

desc "update PAGE", "Update a page"
option :title, type: :string, desc: "New title"
option :content, type: :string, desc: "Page content (HTML)"
option :is_public, type: :boolean, desc: "Public visibility"
option :parent_page, type: :string, desc: "Parent page ID"
option :archived, type: :boolean, desc: "Archive/unarchive"
# Updates an existing page's properties.
#
# Content is updated via a separate PUT endpoint since the standard
# PATCH endpoint does not support content changes.
#
# @param page_id [String] numeric ID or URL slug of the page to retrieve
# @return [void]
def update(page_id)
handle_error do
opts = symbolized_options(:title, :is_public, :archived)
opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
client.pages.update(workspace_id, page_id, **opts)
# Update content via dedicated PUT endpoint (separate from PATCH)
if options[:content]
with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
client.pages.update_content(workspace_id, page_id, content: options[:content])
end
end

# Update other fields via PATCH (skip if only content was provided)
has_patch_fields = options[:title] || !options[:is_public].nil? || !options[:archived].nil? || options[:parent_page]

if has_patch_fields
opts = symbolized_options(:title, :is_public, :archived)
opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
client.pages.update(workspace_id, page_id, **opts)
end
end

# If only content was updated, fetch the page for output
page ||= with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
client.pages.find(workspace_id, page_id)
end
output_item page, labels: {id: "Page ID"}
end
Expand Down
14 changes: 14 additions & 0 deletions lib/superthread/resources/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ def patch_object(path, body: nil, object_class: nil, unwrap_key: nil)
)
end

# Performs a PUT request and returns a typed object.
#
# @param path [String] the API endpoint path
# @param body [Hash{Symbol => Object}, nil] optional request body
# @param object_class [Class, nil] the model class to instantiate
# @param unwrap_key [Symbol, nil] key to extract from response before wrapping
# @return [Superthread::Object] the response wrapped in an object
def put_object(path, body: nil, object_class: nil, unwrap_key: nil)
@client.request_object(
method: :put, path: path, body: body,
object_class: object_class, unwrap_key: unwrap_key
)
end

# Performs a DELETE request and returns a typed object.
#
# @param path [String] the API endpoint path
Expand Down
19 changes: 18 additions & 1 deletion lib/superthread/resources/cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def create(workspace_id, **params)
# @option params [String] :epic_id the new epic identifier
# @option params [Boolean] :archived whether the card is archived
# @return [Superthread::Models::Card] the updated card
# @note Content cannot be updated via this API; it uses WebSocket collaboration.
# @note Content updates use a separate PUT endpoint; use {#update_content} instead.
# @note parent_card_id is not supported on update; the API silently ignores it.
def update(workspace_id, card_id, **params)
ws = safe_id("workspace_id", workspace_id)
Expand All @@ -61,6 +61,23 @@ def update(workspace_id, card_id, **params)
object_class: Models::Card, unwrap_key: :card)
end

# Updates a card's content (description) via the dedicated PUT endpoint.
#
# Card content cannot be updated through the standard PATCH endpoint
# because the web app uses WebSocket collaboration for real-time editing.
# This method uses the separate PUT endpoint for content updates.
#
# @param workspace_id [String] the workspace identifier
# @param card_id [String] the card identifier
# @param content [String] the new content as HTML
# @return [Superthread::Object] a response object with success: true
def update_content(workspace_id, card_id, content:)
ws = safe_id("workspace_id", workspace_id)
card = safe_id("card_id", card_id)
content = format_mentions(workspace_id, content)
put_object("/#{ws}/cards/#{card}/content", body: {content: content, is_html: true})
end

# Gets a specific card with full details.
#
# @param workspace_id [String] the workspace identifier
Expand Down
17 changes: 16 additions & 1 deletion lib/superthread/resources/pages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,32 @@ def create(workspace_id, space_id:, **params)
# @param page_id [String] the page identifier
# @param params [Hash{Symbol => Object}] the attributes to update
# @option params [String] :title the new page title
# @option params [String] :content the new page content as HTML
# @option params [Boolean] :archived whether the page is archived
# @option params [String] :parent_page_id the new parent page identifier
# @return [Superthread::Models::Page] the updated page
# @note Content updates use a separate PUT endpoint; use {#update_content} instead.
def update(workspace_id, page_id, **params)
ws = safe_id("workspace_id", workspace_id)
page = safe_id("page_id", page_id)
patch_object("/#{ws}/pages/#{page}", body: compact_params(**params),
object_class: Models::Page, unwrap_key: :page)
end

# Updates a page's content via the dedicated PUT endpoint.
#
# Page content cannot be updated through the standard PATCH endpoint.
# This method uses the separate PUT endpoint for content updates.
#
# @param workspace_id [String] the workspace identifier
# @param page_id [String] the page identifier
# @param content [String] the new content as HTML
# @return [Superthread::Object] a response object with success: true
def update_content(workspace_id, page_id, content:)
ws = safe_id("workspace_id", workspace_id)
page = safe_id("page_id", page_id)
put_object("/#{ws}/pages/#{page}/content", body: {content: content, is_html: true})
end

# Duplicates a page to a destination space.
#
# @param workspace_id [String] the workspace identifier
Expand Down
9 changes: 5 additions & 4 deletions skills/superthread/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Most commands accept **names or IDs** for spaces, boards, lists, sprints, users,

## Mentions

Use `{{@Name}}` to mention workspace members in comments, replies, and checklist items. The name is matched case-insensitively against workspace member display names.
Use `{{@Name}}` to mention workspace members in comments, replies, checklist items, and card/page content. The name is matched case-insensitively against workspace member display names.

```bash
# Mention in a comment
Expand Down Expand Up @@ -189,9 +189,10 @@ suth cards create --title "Task" -l LIST -b BOARD
# --sprint SPRINT, -s SPACE, --start-date TIMESTAMP,
# --due-date TIMESTAMP, --priority N, --owner/-o USER
suth cards update CARD
# Options: --title, --list LIST, --board BOARD, --sprint SPRINT, -s SPACE,
# --position N, --priority N, --epic ID, --archived/--no-archived
# Options: --title, --content HTML, --list LIST, --board BOARD, --sprint SPRINT,
# -s SPACE, --position N, --priority N, --epic ID, --archived/--no-archived
# Note: list names auto-resolve. Moving to sprint requires --sprint and -s.
# Note: --content uses a separate PUT endpoint from other fields.
suth cards delete CARD
suth cards duplicate CARD --project ID -b BOARD -l LIST
# Required: --project, --board/-b, --list/-l
Expand Down Expand Up @@ -235,7 +236,7 @@ suth pages get PAGE
suth pages create -s SPACE [--title "Doc"]
# Options: --content, --parent-page ID, --is-public
suth pages update PAGE --title "New title"
# Options: --is-public, --parent-page ID, --archived
# Options: --content HTML, --is-public, --parent-page ID, --archived
suth pages duplicate PAGE -s SPACE
# Options: --title, --parent-page ID
suth pages archive PAGE
Expand Down
19 changes: 19 additions & 0 deletions spec/superthread/resources/cards_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@
end
end

describe "#update_content" do

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a matching spec for Pages#update_content too? Doesn't need to be elaborate — just verifying the PUT goes to the right endpoint with is_html: true.

before do
stub_api_put("test_workspace/cards/card-123/content",
response: {success: true})
end

it "updates card content via PUT endpoint" do
result = client.cards.update_content(
"test_workspace",
"card-123",
content: "<p>Updated description</p>"
)

expect(result[:success]).to be true
expect(WebMock).to have_requested(:put, "https://api.superthread.com/v1/test_workspace/cards/card-123/content")
.with(body: hash_including("content" => "<p>Updated description</p>", "is_html" => true))
end
end

describe "#update_checklist_item" do
before do
stub_api_patch("test_workspace/cards/card-123/checklists/checklist-1/items/item-1",
Expand Down
28 changes: 28 additions & 0 deletions spec/superthread/resources/pages_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Superthread::Resources::Pages do
include StubHelpers

let(:client) { Superthread::Client.new(api_key: "test_key") }

describe "#update_content" do
before do
stub_api_put("test_workspace/pages/page-42/content",
response: {success: true})
end

it "updates page content via PUT endpoint" do
result = client.pages.update_content(
"test_workspace",
"page-42",
content: "<h1>Updated page</h1>"
)

expect(result[:success]).to be true
expect(WebMock).to have_requested(:put, "https://api.superthread.com/v1/test_workspace/pages/page-42/content")
.with(body: hash_including("content" => "<h1>Updated page</h1>", "is_html" => true))
end
end
end
10 changes: 10 additions & 0 deletions spec/support/stub_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ def stub_api_patch(path, response:, status: 200)
)
end

# Stub a PUT request returning JSON
def stub_api_put(path, response:, status: 200)
stub_request(:put, "#{BASE_URL}/#{path}")
.to_return(
status: status,
body: response.to_json,
headers: {"Content-Type" => "application/json"}
)
end

# Stub a DELETE request returning success
def stub_api_delete(path, response: {success: true}, status: 200)
stub_request(:delete, "#{BASE_URL}/#{path}")
Expand Down