diff --git a/Gemfile.lock b/Gemfile.lock index c9ba5b6..e372f36 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,6 +180,7 @@ GEM zeitwerk (2.7.5) PLATFORMS + arm64-darwin-24 arm64-darwin-25 x86_64-linux diff --git a/lib/superthread/cli/cards.rb b/lib/superthread/cli/cards.rb index cf5a3e8..f28a5d4 100644 --- a/lib/superthread/cli/cards.rb +++ b/lib/superthread/cli/cards.rb @@ -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)" @@ -215,6 +216,9 @@ 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) @@ -222,36 +226,50 @@ def update(card_id) 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 diff --git a/lib/superthread/cli/pages.rb b/lib/superthread/cli/pages.rb index 6f68cb5..4213c23 100644 --- a/lib/superthread/cli/pages.rb +++ b/lib/superthread/cli/pages.rb @@ -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 diff --git a/lib/superthread/resources/base.rb b/lib/superthread/resources/base.rb index ce13cc7..9116fd4 100644 --- a/lib/superthread/resources/base.rb +++ b/lib/superthread/resources/base.rb @@ -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 diff --git a/lib/superthread/resources/cards.rb b/lib/superthread/resources/cards.rb index 38971bf..84399e5 100644 --- a/lib/superthread/resources/cards.rb +++ b/lib/superthread/resources/cards.rb @@ -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) @@ -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 diff --git a/lib/superthread/resources/pages.rb b/lib/superthread/resources/pages.rb index d93c9a1..2180b11 100644 --- a/lib/superthread/resources/pages.rb +++ b/lib/superthread/resources/pages.rb @@ -56,10 +56,10 @@ 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) @@ -67,6 +67,21 @@ def update(workspace_id, page_id, **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 diff --git a/skills/superthread/SKILL.md b/skills/superthread/SKILL.md index a308008..a35d98f 100644 --- a/skills/superthread/SKILL.md +++ b/skills/superthread/SKILL.md @@ -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 @@ -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 @@ -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 diff --git a/spec/superthread/resources/cards_spec.rb b/spec/superthread/resources/cards_spec.rb index 1aff72d..ce1e2bd 100644 --- a/spec/superthread/resources/cards_spec.rb +++ b/spec/superthread/resources/cards_spec.rb @@ -25,6 +25,25 @@ end end + describe "#update_content" do + 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: "

Updated description

" + ) + + 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" => "

Updated description

", "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", diff --git a/spec/superthread/resources/pages_spec.rb b/spec/superthread/resources/pages_spec.rb new file mode 100644 index 0000000..c2e0b70 --- /dev/null +++ b/spec/superthread/resources/pages_spec.rb @@ -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: "

Updated page

" + ) + + 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" => "

Updated page

", "is_html" => true)) + end + end +end diff --git a/spec/support/stub_helpers.rb b/spec/support/stub_helpers.rb index 9cd1b04..1e95604 100644 --- a/spec/support/stub_helpers.rb +++ b/spec/support/stub_helpers.rb @@ -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}")