superthread 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE +21 -0
- data/README.md +492 -0
- data/exe/suth +19 -0
- data/lib/superthread/cli/accounts.rb +240 -0
- data/lib/superthread/cli/activity.rb +210 -0
- data/lib/superthread/cli/base.rb +355 -0
- data/lib/superthread/cli/boards.rb +131 -0
- data/lib/superthread/cli/cards.rb +530 -0
- data/lib/superthread/cli/checklists.rb +223 -0
- data/lib/superthread/cli/comments.rb +86 -0
- data/lib/superthread/cli/completion.rb +306 -0
- data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
- data/lib/superthread/cli/concerns/confirmable.rb +55 -0
- data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
- data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
- data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
- data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
- data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
- data/lib/superthread/cli/config.rb +129 -0
- data/lib/superthread/cli/formatter.rb +388 -0
- data/lib/superthread/cli/lists.rb +85 -0
- data/lib/superthread/cli/main.rb +121 -0
- data/lib/superthread/cli/members.rb +19 -0
- data/lib/superthread/cli/notes.rb +64 -0
- data/lib/superthread/cli/pages.rb +128 -0
- data/lib/superthread/cli/projects.rb +124 -0
- data/lib/superthread/cli/replies.rb +94 -0
- data/lib/superthread/cli/search.rb +34 -0
- data/lib/superthread/cli/setup.rb +253 -0
- data/lib/superthread/cli/spaces.rb +141 -0
- data/lib/superthread/cli/sprints.rb +32 -0
- data/lib/superthread/cli/tags.rb +86 -0
- data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
- data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
- data/lib/superthread/cli/ui.rb +263 -0
- data/lib/superthread/cli/workspaces.rb +105 -0
- data/lib/superthread/cli.rb +12 -0
- data/lib/superthread/client.rb +207 -0
- data/lib/superthread/configuration.rb +354 -0
- data/lib/superthread/connection.rb +57 -0
- data/lib/superthread/error.rb +164 -0
- data/lib/superthread/mention_formatter.rb +96 -0
- data/lib/superthread/model.rb +178 -0
- data/lib/superthread/models/board.rb +59 -0
- data/lib/superthread/models/card.rb +321 -0
- data/lib/superthread/models/checklist.rb +91 -0
- data/lib/superthread/models/checklist_item.rb +69 -0
- data/lib/superthread/models/comment.rb +71 -0
- data/lib/superthread/models/concerns/archivable.rb +32 -0
- data/lib/superthread/models/concerns/presentable.rb +113 -0
- data/lib/superthread/models/concerns/timestampable.rb +91 -0
- data/lib/superthread/models/list.rb +67 -0
- data/lib/superthread/models/member.rb +40 -0
- data/lib/superthread/models/note.rb +56 -0
- data/lib/superthread/models/page.rb +70 -0
- data/lib/superthread/models/project.rb +83 -0
- data/lib/superthread/models/space.rb +71 -0
- data/lib/superthread/models/sprint.rb +53 -0
- data/lib/superthread/models/tag.rb +52 -0
- data/lib/superthread/models/team.rb +68 -0
- data/lib/superthread/models/user.rb +76 -0
- data/lib/superthread/models.rb +12 -0
- data/lib/superthread/object.rb +285 -0
- data/lib/superthread/objects/collection.rb +179 -0
- data/lib/superthread/resources/base.rb +204 -0
- data/lib/superthread/resources/boards.rb +150 -0
- data/lib/superthread/resources/cards.rb +363 -0
- data/lib/superthread/resources/comments.rb +163 -0
- data/lib/superthread/resources/notes.rb +61 -0
- data/lib/superthread/resources/pages.rb +110 -0
- data/lib/superthread/resources/projects.rb +117 -0
- data/lib/superthread/resources/search.rb +46 -0
- data/lib/superthread/resources/spaces.rb +104 -0
- data/lib/superthread/resources/sprints.rb +37 -0
- data/lib/superthread/resources/tags.rb +52 -0
- data/lib/superthread/resources/users.rb +29 -0
- data/lib/superthread/version.rb +6 -0
- data/lib/superthread/version_checker.rb +174 -0
- data/lib/superthread.rb +30 -0
- metadata +259 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superthread
|
|
4
|
+
module Cli
|
|
5
|
+
# CLI commands for managing Superthread cards.
|
|
6
|
+
#
|
|
7
|
+
# Provides subcommands for listing, creating, updating, and deleting cards,
|
|
8
|
+
# as well as managing card assignments, relationships, checklists, and tags.
|
|
9
|
+
class Cards < Base
|
|
10
|
+
include Concerns::DateParsable
|
|
11
|
+
|
|
12
|
+
# Valid relationship types for linking cards.
|
|
13
|
+
LINK_RELATION_TYPES = %w[blocks blocked_by related duplicates].freeze
|
|
14
|
+
|
|
15
|
+
desc "list", "List cards on a board or sprint"
|
|
16
|
+
option :board, type: :string, aliases: "-b", desc: "Board (ID or name, required unless --sprint)"
|
|
17
|
+
option :sprint, type: :string, desc: "Sprint (ID or name, required unless --board)"
|
|
18
|
+
option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board name)"
|
|
19
|
+
option :list, type: :string, aliases: "-l", desc: "List to filter by (ID or name)"
|
|
20
|
+
option :include_archived, type: :boolean, desc: "Include archived cards"
|
|
21
|
+
option :since, type: :string, desc: "Filter by created date (e.g., 'friday', '3 days ago', '2026-01-20')"
|
|
22
|
+
option :updated_since, type: :string, desc: "Filter by updated date"
|
|
23
|
+
# List cards on a board or sprint with optional filtering.
|
|
24
|
+
#
|
|
25
|
+
# @return [void]
|
|
26
|
+
# @raise [Thor::Error] if neither --board nor --sprint is provided
|
|
27
|
+
# @raise [Thor::Error] if --sprint is used without --space
|
|
28
|
+
def list
|
|
29
|
+
handle_error do
|
|
30
|
+
raise Thor::Error, "Either --board or --sprint is required" unless options[:board] || options[:sprint]
|
|
31
|
+
require_space_for_sprint!
|
|
32
|
+
|
|
33
|
+
opts = {}
|
|
34
|
+
opts[:archived] = options[:include_archived] if options[:include_archived]
|
|
35
|
+
|
|
36
|
+
if options[:sprint]
|
|
37
|
+
opts[:sprint_id] = sprint_id
|
|
38
|
+
opts[:project_id] = space_id
|
|
39
|
+
else
|
|
40
|
+
opts[:board_id] = board_id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
opts[:list_id] = resolve_list(options[:list]) if options[:list]
|
|
44
|
+
cards = client.cards.list(workspace_id, **opts)
|
|
45
|
+
|
|
46
|
+
# Client-side date filtering (API doesn't support time-based filters)
|
|
47
|
+
cards = apply_date_filters(cards)
|
|
48
|
+
|
|
49
|
+
enrich_members(cards)
|
|
50
|
+
output_list cards, columns: %i[id title priority list_title members],
|
|
51
|
+
headers: {id: "CARD_ID", list_title: "LIST"}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
desc "get CARD", "Get card details"
|
|
56
|
+
option :raw, type: :boolean, desc: "Show raw content without markdown rendering"
|
|
57
|
+
option :no_content, type: :boolean, desc: "Hide content, show only metadata"
|
|
58
|
+
# Display detailed information about a specific card.
|
|
59
|
+
#
|
|
60
|
+
# @param card_id [String] the unique identifier of the card to retrieve
|
|
61
|
+
# @return [void]
|
|
62
|
+
def get(card_id)
|
|
63
|
+
handle_error do
|
|
64
|
+
begin
|
|
65
|
+
card = client.cards.find(workspace_id, card_id)
|
|
66
|
+
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
67
|
+
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
enrich_members(card)
|
|
71
|
+
|
|
72
|
+
if json_output?
|
|
73
|
+
fields = %i[id title status priority list_title board_title
|
|
74
|
+
members start_date due_date time_created time_updated
|
|
75
|
+
parent_card child_cards linked_cards checklists]
|
|
76
|
+
fields.insert(2, :content) unless options[:no_content]
|
|
77
|
+
output_item card, fields: fields, labels: {
|
|
78
|
+
id: "Card ID",
|
|
79
|
+
list_title: "List Title",
|
|
80
|
+
board_title: "Board Title",
|
|
81
|
+
parent_card: "Parent Card",
|
|
82
|
+
child_cards: "Child Cards",
|
|
83
|
+
linked_cards: "Linked Cards",
|
|
84
|
+
start_date: "Start Date",
|
|
85
|
+
due_date: "Due Date",
|
|
86
|
+
time_created: "Time Created",
|
|
87
|
+
time_updated: "Time Updated"
|
|
88
|
+
}
|
|
89
|
+
else
|
|
90
|
+
# Output metadata fields (status used for coloring list, not shown separately)
|
|
91
|
+
output_item card,
|
|
92
|
+
fields: %i[id title priority list_title board_title
|
|
93
|
+
members start_date due_date time_created time_updated],
|
|
94
|
+
labels: {
|
|
95
|
+
id: "Card ID",
|
|
96
|
+
list_title: "List",
|
|
97
|
+
board_title: "Board",
|
|
98
|
+
start_date: "Start Date",
|
|
99
|
+
due_date: "Due Date",
|
|
100
|
+
time_created: "Time Created",
|
|
101
|
+
time_updated: "Time Updated"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Output card relationships
|
|
105
|
+
output_card_relationships(card)
|
|
106
|
+
|
|
107
|
+
# Output checklists
|
|
108
|
+
output_card_checklists(card)
|
|
109
|
+
|
|
110
|
+
# Render content separately with markdown formatting
|
|
111
|
+
if !options[:no_content] && card.content && !card.content.empty?
|
|
112
|
+
puts ""
|
|
113
|
+
Ui.section "Content"
|
|
114
|
+
if options[:raw]
|
|
115
|
+
puts card.content
|
|
116
|
+
else
|
|
117
|
+
puts Ui.render_markdown(card.content)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "create", "Create a new card"
|
|
125
|
+
option :title, type: :string, required: true, desc: "Card title"
|
|
126
|
+
option :list, type: :string, required: true, aliases: "-l", desc: "Destination list (ID or name)"
|
|
127
|
+
option :board, type: :string, aliases: "-b", desc: "Board (ID or name, required unless --sprint)"
|
|
128
|
+
option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board name)"
|
|
129
|
+
option :sprint, type: :string, desc: "Sprint (ID or name, required unless --board)"
|
|
130
|
+
option :content, type: :string, desc: "Card content (HTML)"
|
|
131
|
+
option :project, type: :string, desc: "Project ID"
|
|
132
|
+
option :start_date, type: :numeric, desc: "Start date (Unix timestamp)"
|
|
133
|
+
option :due_date, type: :numeric, desc: "Due date (Unix timestamp)"
|
|
134
|
+
option :priority, type: :numeric, desc: "Priority level (1=low, 4=urgent)"
|
|
135
|
+
option :parent_card, type: :string, desc: "Parent card ID"
|
|
136
|
+
option :epic, type: :string, desc: "Epic ID"
|
|
137
|
+
option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, or email)"
|
|
138
|
+
# Create a new card on a board or sprint.
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
# @raise [Thor::Error] if neither --board nor --sprint is provided
|
|
142
|
+
def create
|
|
143
|
+
handle_error do
|
|
144
|
+
raise Thor::Error, "Either --board or --sprint is required" unless options[:board] || options[:sprint]
|
|
145
|
+
require_space_for_sprint!
|
|
146
|
+
|
|
147
|
+
opts = symbolized_options(:title, :content, :start_date, :due_date, :priority)
|
|
148
|
+
opts[:list_id] = resolve_list(options[:list])
|
|
149
|
+
opts[:board_id] = board_id if options[:board]
|
|
150
|
+
opts[:sprint_id] = sprint_id if options[:sprint]
|
|
151
|
+
opts[:project_id] = options[:project] || (space_id if options[:sprint])
|
|
152
|
+
opts[:parent_card_id] = options[:parent_card] if options[:parent_card]
|
|
153
|
+
opts[:epic_id] = options[:epic] if options[:epic]
|
|
154
|
+
opts[:owner_id] = resolve_user(options[:owner]) if options[:owner]
|
|
155
|
+
card = client.cards.create(workspace_id, **opts)
|
|
156
|
+
output_item card, labels: {id: "Card ID"}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
desc "update CARD", "Update a card"
|
|
161
|
+
option :title, type: :string, desc: "New title"
|
|
162
|
+
option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name)"
|
|
163
|
+
option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
|
|
164
|
+
option :sprint, type: :string, desc: "Sprint to move card to (ID or name)"
|
|
165
|
+
option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board/list name)"
|
|
166
|
+
option :position, type: :numeric, desc: "Position within the list (0 = top)"
|
|
167
|
+
option :priority, type: :numeric, desc: "Priority level (1=low, 4=urgent)"
|
|
168
|
+
option :epic, type: :string, desc: "Epic ID"
|
|
169
|
+
option :archived, type: :boolean, desc: "Archive/unarchive"
|
|
170
|
+
# Update an existing card's properties.
|
|
171
|
+
#
|
|
172
|
+
# @param card_id [String] the unique identifier of the card to update
|
|
173
|
+
# @return [void]
|
|
174
|
+
def update(card_id)
|
|
175
|
+
handle_error do
|
|
176
|
+
require_space_for_sprint!
|
|
177
|
+
|
|
178
|
+
begin
|
|
179
|
+
# WORKAROUND: API ignores title when combined with list_id,
|
|
180
|
+
# so we make separate requests when both are provided.
|
|
181
|
+
# TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
|
|
182
|
+
if options[:list] && (options[:title] || options[:priority] || options[:archived] || options[:epic])
|
|
183
|
+
# First update non-move fields
|
|
184
|
+
field_opts = symbolized_options(:title, :priority, :archived)
|
|
185
|
+
field_opts[:epic_id] = options[:epic] if options[:epic]
|
|
186
|
+
client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
|
|
187
|
+
|
|
188
|
+
# Then move the card (include sprint context)
|
|
189
|
+
move_opts = resolve_list_with_context(options[:list], card_id)
|
|
190
|
+
move_opts[:position] = options[:position] if options[:position]
|
|
191
|
+
card = client.cards.update(workspace_id, card_id, **move_opts)
|
|
192
|
+
else
|
|
193
|
+
opts = symbolized_options(:title, :priority, :archived)
|
|
194
|
+
opts[:epic_id] = options[:epic] if options[:epic]
|
|
195
|
+
opts[:position] = options[:position] if options[:position]
|
|
196
|
+
|
|
197
|
+
if options[:list]
|
|
198
|
+
opts.merge!(resolve_list_with_context(options[:list], card_id))
|
|
199
|
+
elsif options[:sprint]
|
|
200
|
+
# Moving to a sprint without --list — default to first list
|
|
201
|
+
opts[:sprint_id] = sprint_id
|
|
202
|
+
opts[:project_id] = space_id
|
|
203
|
+
sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
|
|
204
|
+
opts[:list_id] = sprint_obj.lists.first&.id
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
card = client.cards.update(workspace_id, card_id, **opts)
|
|
208
|
+
end
|
|
209
|
+
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
210
|
+
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
211
|
+
end
|
|
212
|
+
output_item card, labels: {id: "Card ID"}
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
desc "delete CARD", "Delete a card"
|
|
217
|
+
# Permanently delete a card after confirmation.
|
|
218
|
+
#
|
|
219
|
+
# @param card_ref [String] the card ID or reference to delete
|
|
220
|
+
# @return [void]
|
|
221
|
+
def delete(card_ref)
|
|
222
|
+
handle_error do
|
|
223
|
+
begin
|
|
224
|
+
card = client.cards.find(workspace_id, card_ref)
|
|
225
|
+
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
226
|
+
raise Thor::Error, "Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
227
|
+
end
|
|
228
|
+
confirming("Delete card '#{card.title}' (#{card.id})?") do
|
|
229
|
+
client.cards.destroy(workspace_id, card.id)
|
|
230
|
+
output_success "Card '#{card.title}' deleted"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
desc "duplicate CARD", "Duplicate a card"
|
|
236
|
+
option :title, type: :string, desc: "Title for the duplicated card"
|
|
237
|
+
option :project, type: :string, required: true, desc: "Destination project ID"
|
|
238
|
+
option :board, type: :string, required: true, aliases: "-b", desc: "Destination board (ID or name)"
|
|
239
|
+
option :list, type: :string, required: true, aliases: "-l", desc: "Destination list (ID or name)"
|
|
240
|
+
option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
|
|
241
|
+
# Create a copy of an existing card in a specified location.
|
|
242
|
+
#
|
|
243
|
+
# @param card_id [String] the unique identifier of the card to duplicate
|
|
244
|
+
# @return [void]
|
|
245
|
+
def duplicate(card_id)
|
|
246
|
+
handle_error do
|
|
247
|
+
begin
|
|
248
|
+
opts = symbolized_options(:title)
|
|
249
|
+
opts[:project_id] = options[:project]
|
|
250
|
+
opts[:board_id] = board_id
|
|
251
|
+
opts[:list_id] = resolve_list(options[:list])
|
|
252
|
+
card = client.cards.duplicate(workspace_id, card_id, **opts)
|
|
253
|
+
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
254
|
+
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
255
|
+
end
|
|
256
|
+
output_item card, labels: {id: "Card ID"}
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
desc "assigned USER", "Get cards assigned to a user"
|
|
261
|
+
option :board, type: :string, aliases: "-b", desc: "Board to filter by (ID or name)"
|
|
262
|
+
option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
|
|
263
|
+
option :project, type: :string, desc: "Project to filter by (ID)"
|
|
264
|
+
option :include_archived, type: :boolean, desc: "Include archived cards"
|
|
265
|
+
option :since, type: :string, desc: "Filter by created date (e.g., 'friday', '3 days ago', '2026-01-20')"
|
|
266
|
+
option :updated_since, type: :string, desc: "Filter by updated date"
|
|
267
|
+
# List all cards assigned to a specific user.
|
|
268
|
+
#
|
|
269
|
+
# @param user_ref [String] the user ID, name, or email to look up assignments for
|
|
270
|
+
# @return [void]
|
|
271
|
+
def assigned(user_ref)
|
|
272
|
+
handle_error do
|
|
273
|
+
opts = {}
|
|
274
|
+
opts[:archived] = options[:include_archived] if options[:include_archived]
|
|
275
|
+
opts[:user_id] = resolve_user(user_ref)
|
|
276
|
+
opts[:board_id] = board_id if options[:board]
|
|
277
|
+
opts[:project_id] = options[:project] if options[:project]
|
|
278
|
+
cards = client.cards.assigned(workspace_id, **opts)
|
|
279
|
+
|
|
280
|
+
# Client-side date filtering (API doesn't support time-based filters)
|
|
281
|
+
cards = apply_date_filters(cards)
|
|
282
|
+
|
|
283
|
+
enrich_members(cards)
|
|
284
|
+
output_list cards, columns: %i[id title priority list_title members],
|
|
285
|
+
headers: {id: "CARD_ID", list_title: "LIST"}
|
|
286
|
+
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
287
|
+
raise Thor::Error, "User not found: '#{user_ref}'. Use 'suth members list' to see available users."
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
desc "assign CARD_ID USERS", "Assign user(s) to a card (comma-separated)"
|
|
292
|
+
option :role, type: :string, default: "member", desc: "Member role"
|
|
293
|
+
# Add one or more users as members of a card.
|
|
294
|
+
#
|
|
295
|
+
# @param card_id [String] the unique identifier of the card
|
|
296
|
+
# @param user_refs [String] comma-separated user IDs, names, or emails to assign
|
|
297
|
+
# @return [void]
|
|
298
|
+
def assign(card_id, user_refs)
|
|
299
|
+
handle_error do
|
|
300
|
+
users = user_refs.split(",").map(&:strip)
|
|
301
|
+
users.each do |user_ref|
|
|
302
|
+
user_id = resolve_user(user_ref)
|
|
303
|
+
client.cards.add_member(workspace_id, card_id, user_id: user_id, role: options[:role])
|
|
304
|
+
end
|
|
305
|
+
output_success "Assigned #{users.size} user(s) to card #{card_id}"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
desc "unassign CARD_ID USERS", "Unassign user(s) from a card (comma-separated)"
|
|
310
|
+
# Remove one or more users from a card.
|
|
311
|
+
#
|
|
312
|
+
# @param card_id [String] the unique identifier of the card
|
|
313
|
+
# @param user_refs [String] comma-separated user IDs, names, or emails to unassign
|
|
314
|
+
# @return [void]
|
|
315
|
+
def unassign(card_id, user_refs)
|
|
316
|
+
handle_error do
|
|
317
|
+
users = user_refs.split(",").map(&:strip)
|
|
318
|
+
users.each do |user_ref|
|
|
319
|
+
user_id = resolve_user(user_ref)
|
|
320
|
+
client.cards.remove_member(workspace_id, card_id, user_id)
|
|
321
|
+
end
|
|
322
|
+
output_success "Unassigned #{users.size} user(s) from card #{card_id}"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
desc "link", "Link two cards"
|
|
327
|
+
option :card, type: :string, required: true, aliases: "-c", desc: "Card ID"
|
|
328
|
+
option :related, type: :string, required: true, aliases: "-r", desc: "Related card ID"
|
|
329
|
+
option :type, type: :string, required: true,
|
|
330
|
+
desc: "Relationship type (blocks, blocked_by, related, duplicates)"
|
|
331
|
+
# Create a relationship between two cards.
|
|
332
|
+
#
|
|
333
|
+
# @return [void]
|
|
334
|
+
def link
|
|
335
|
+
handle_error do
|
|
336
|
+
unless LINK_RELATION_TYPES.include?(options[:type])
|
|
337
|
+
if %w[parent child].include?(options[:type])
|
|
338
|
+
raise Thor::Error,
|
|
339
|
+
"Parent/child relationships are set via --parent-card on 'suth cards create' or 'suth cards update'"
|
|
340
|
+
end
|
|
341
|
+
raise Thor::Error,
|
|
342
|
+
"Expected '--type' to be one of #{LINK_RELATION_TYPES.join(", ")}; got #{options[:type]}"
|
|
343
|
+
end
|
|
344
|
+
client.cards.add_related(
|
|
345
|
+
workspace_id, options[:card],
|
|
346
|
+
related_card_id: options[:related],
|
|
347
|
+
relation_type: options[:type]
|
|
348
|
+
)
|
|
349
|
+
output_success "Linked #{options[:card]} -> #{options[:related]} (#{options[:type]})"
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
desc "unlink", "Remove card relationship"
|
|
354
|
+
option :card, type: :string, required: true, aliases: "-c", desc: "Card ID"
|
|
355
|
+
option :related, type: :string, required: true, aliases: "-r", desc: "Related card ID"
|
|
356
|
+
# Remove an existing relationship between two cards.
|
|
357
|
+
#
|
|
358
|
+
# @return [void]
|
|
359
|
+
def unlink
|
|
360
|
+
handle_error do
|
|
361
|
+
client.cards.remove_related(workspace_id, options[:card], options[:related])
|
|
362
|
+
output_success "Unlinked #{options[:card]} from #{options[:related]}"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
desc "tag CARD_ID TAGS", "Add tags to card (comma-separated IDs or names)"
|
|
367
|
+
# Apply one or more tags to a card.
|
|
368
|
+
#
|
|
369
|
+
# @param card_id [String] the unique identifier of the card
|
|
370
|
+
# @param tag_refs [String] comma-separated tag IDs or names to add
|
|
371
|
+
# @return [void]
|
|
372
|
+
def tag(card_id, tag_refs)
|
|
373
|
+
handle_error do
|
|
374
|
+
ids = tag_refs.split(",").map { |ref| resolve_tag(ref.strip) }
|
|
375
|
+
client.cards.add_tags(workspace_id, card_id, tag_ids: ids)
|
|
376
|
+
output_success "Added #{ids.count} tag(s) to card #{card_id}"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
desc "untag CARD_ID TAG", "Remove tag from card"
|
|
381
|
+
# Remove a tag from a card.
|
|
382
|
+
#
|
|
383
|
+
# @param card_id [String] the unique identifier of the card
|
|
384
|
+
# @param tag_ref [String] the tag ID or name to remove
|
|
385
|
+
# @return [void]
|
|
386
|
+
def untag(card_id, tag_ref)
|
|
387
|
+
handle_error do
|
|
388
|
+
tag_id = resolve_tag(tag_ref)
|
|
389
|
+
client.cards.remove_tag(workspace_id, card_id, tag_id)
|
|
390
|
+
output_success "Removed tag #{tag_ref} from card #{card_id}"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
# Enrich card members with display names from workspace users.
|
|
397
|
+
#
|
|
398
|
+
# The API returns members with only user_id. This fetches the workspace
|
|
399
|
+
# member list once and sets display_name on each member object.
|
|
400
|
+
#
|
|
401
|
+
# @param cards [Array<Card>, Card] card(s) whose members to enrich
|
|
402
|
+
# @return [void]
|
|
403
|
+
def enrich_members(cards)
|
|
404
|
+
cards = Array(cards)
|
|
405
|
+
return unless cards.any? { |c| c.members.any? }
|
|
406
|
+
|
|
407
|
+
user_names = workspace_users.each_with_object({}) do |u, h|
|
|
408
|
+
h[u.user_identifier] = u.display_name
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
cards.each do |card|
|
|
412
|
+
card.members.each do |member|
|
|
413
|
+
member.display_name ||= user_names[member.user_id]
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Resolve a list reference and include sprint context when needed.
|
|
419
|
+
#
|
|
420
|
+
# The API requires sprint_id and project_id alongside list_id when moving
|
|
421
|
+
# cards within a sprint. This method handles three scenarios:
|
|
422
|
+
# 1. --board or --sprint given: resolve list name with that context
|
|
423
|
+
# 2. Neither given: fetch the card to discover its sprint/board context
|
|
424
|
+
# 3. Card is on a board (no sprint): resolve normally
|
|
425
|
+
#
|
|
426
|
+
# @param list_ref [String] the list ID or name to resolve
|
|
427
|
+
# @param card_id [String] the card identifier to look up for context
|
|
428
|
+
# @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
|
|
429
|
+
def resolve_list_with_context(list_ref, card_id)
|
|
430
|
+
result = {}
|
|
431
|
+
|
|
432
|
+
if options[:board] || options[:sprint]
|
|
433
|
+
result[:list_id] = resolve_list(list_ref)
|
|
434
|
+
if options[:sprint]
|
|
435
|
+
result[:sprint_id] = sprint_id
|
|
436
|
+
result[:project_id] = space_id
|
|
437
|
+
end
|
|
438
|
+
else
|
|
439
|
+
existing = client.cards.find(workspace_id, card_id)
|
|
440
|
+
if existing.sprint_id
|
|
441
|
+
sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
|
|
442
|
+
space_id: existing.project_id)
|
|
443
|
+
list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
|
|
444
|
+
result[:list_id] = list ? list.id : list_ref
|
|
445
|
+
result[:sprint_id] = existing.sprint_id
|
|
446
|
+
result[:project_id] = existing.project_id
|
|
447
|
+
else
|
|
448
|
+
result[:list_id] = resolve_list(list_ref)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
result
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Apply date filters to a collection of cards.
|
|
456
|
+
# The API doesn't support time-based filtering, so we filter client-side.
|
|
457
|
+
#
|
|
458
|
+
# @param cards [Enumerable] the cards to apply --since and --updated_since filters to
|
|
459
|
+
# @return [Array] Filtered cards
|
|
460
|
+
def apply_date_filters(cards)
|
|
461
|
+
since_ts = parse_date(options[:since]) if options[:since]
|
|
462
|
+
updated_ts = parse_date(options[:updated_since]) if options[:updated_since]
|
|
463
|
+
return cards.to_a unless since_ts || updated_ts
|
|
464
|
+
|
|
465
|
+
cards.to_a.select do |card|
|
|
466
|
+
(since_ts.nil? || meets_date_threshold?(card.time_created, since_ts)) &&
|
|
467
|
+
(updated_ts.nil? || meets_date_threshold?(card.time_updated, updated_ts))
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Check if a timestamp meets a minimum threshold after normalization.
|
|
472
|
+
#
|
|
473
|
+
# @param timestamp [Object] the raw timestamp value from the model
|
|
474
|
+
# @param threshold [Integer] the minimum Unix timestamp
|
|
475
|
+
# @return [Boolean] true if the normalized timestamp meets or exceeds the threshold
|
|
476
|
+
def meets_date_threshold?(timestamp, threshold)
|
|
477
|
+
ts = Formatter.normalize_timestamp(timestamp)
|
|
478
|
+
ts && ts >= threshold
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Output card relationships (parent, children, links) in human-readable format.
|
|
482
|
+
#
|
|
483
|
+
# @param card [Card] the card object containing relationship data
|
|
484
|
+
# @return [void]
|
|
485
|
+
def output_card_relationships(card)
|
|
486
|
+
has_relationships = card.parent || card.children.any? || card.links.any?
|
|
487
|
+
return unless has_relationships
|
|
488
|
+
|
|
489
|
+
puts ""
|
|
490
|
+
Ui.section "Relationships"
|
|
491
|
+
|
|
492
|
+
Ui.kv("Parent", card.parent.to_s) if card.parent
|
|
493
|
+
|
|
494
|
+
if card.children.any?
|
|
495
|
+
Ui.kv("Children", "")
|
|
496
|
+
card.children.each { |child| puts " #{child}" }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
if card.links.any?
|
|
500
|
+
card.links_by_type.each do |type, links|
|
|
501
|
+
label = type.tr("_", " ").capitalize
|
|
502
|
+
Ui.kv(label, "")
|
|
503
|
+
links.each { |link| puts " #{link}" }
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Output card checklists with items in human-readable format.
|
|
509
|
+
#
|
|
510
|
+
# @param card [Card] the card object containing checklist data
|
|
511
|
+
# @return [void]
|
|
512
|
+
def output_card_checklists(card)
|
|
513
|
+
return unless card.checklists&.any?
|
|
514
|
+
|
|
515
|
+
puts ""
|
|
516
|
+
Ui.section "Checklists"
|
|
517
|
+
|
|
518
|
+
card.checklists.each do |checklist|
|
|
519
|
+
progress = "(#{checklist.completed_count}/#{checklist.total_count})"
|
|
520
|
+
Ui.kv(checklist.title, progress)
|
|
521
|
+
checklist.items&.each do |item|
|
|
522
|
+
marker = item.checked? ? "✓" : "○"
|
|
523
|
+
title = item.title.gsub(/<[^>]*>/, "").strip
|
|
524
|
+
puts " #{marker} #{title}"
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|