trello_tool 0.3.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6285e864995998fa87994fef2bc12d24aa75b9e041a021c694a441ef66f2ee6f
4
- data.tar.gz: 0f4513bd8adb2690cc7b3fa6f8f3e18bbd3646e1f0cdad4ffdecf686953e2007
3
+ metadata.gz: 6ed0b9ce508c845c11e20e9832e4b0bdce3540b50eb726eb39f2e2b0a92dd1da
4
+ data.tar.gz: 72155610d0070653878eb3f705ece349d23358e97b105fb5d2bf75d5090b5d58
5
5
  SHA512:
6
- metadata.gz: d69e46335a0ce78842525b55bf821e3af32ffc72eaaee6c4de4da144827b91fd5f21df905d5ffa52ebc21e8b5ec972f5bb62ecd8c5f179138ffe427ad4174143
7
- data.tar.gz: 5fe44e4a578ea926056e8617a8e1080dd30df28fca91c03733ce175e80066a3881f23e466a3c77700e9452c43d6b43f20850d7eb73c0099bdd2a3256ae7f8628
6
+ metadata.gz: 6811ec433a67808fb9b7d457127a59ac39c9e6e2e96717b51df6ef7f6f4669760f19e82c50b57928f3e41a8ee0e6261734765ebfe40ca65d2747df1332ae8942
7
+ data.tar.gz: 8345785d3149a3322c1850cd1ea89d48a2cf38eaf4249714835c047271c4ccd799b16e601a24ac2231e84a01626740ac0ab37ac30629e2185ea1d7e0b70327b7
data/CHANGELOG.md CHANGED
@@ -1,4 +1,9 @@
1
- ## [Unreleased]
1
+ ## [0.4.0] - 2026-10-02
2
+
3
+ * added `search` and `cards` (find cards in a board; markdown or `--json`)
4
+ * added `comment`, `create`, `checklist`, `check` and `move` (change cards), and the
5
+ `default_list_name_for_new_cards` configuration key for `create`
6
+ * commands that fail now exit non-zero
2
7
 
3
8
  ## [0.3.0] - 2026-09-04
4
9
 
data/README.md CHANGED
@@ -56,6 +56,7 @@ You can also configure some defaults
56
56
  * `divider_template` = "[%s]"
57
57
  * `too_many_doing` = 2
58
58
  * `too_many_todo` = 10
59
+ * `default_list_name_for_new_cards` = (none) -- the list `create` puts a card in when no list is named, e.g. "Triage"
59
60
 
60
61
 
61
62
  ### Trello Authorisation
@@ -69,8 +70,42 @@ You can also read more at https://github.com/jeremytregunna/ruby-trello
69
70
 
70
71
  ## Usage
71
72
 
72
- * bin/trello/health
73
- trello/health
73
+ `bin/trello_tool help` lists every command and `bin/trello_tool help COMMAND` its options. Boards default to
74
+ `main_board_url`; cards are given by id or url; lists, labels and checklists by name.
75
+
76
+ The board:
77
+
78
+ * `bin/trello_tool health` -- checks whether the main board is "healthy"
79
+ * `bin/trello_tool lists (BOARD_URL)` -- the lists of a board
80
+ * `bin/trello_tool release (VERSION)`, `archive`, `archive_last (N)` -- rename "next version" on release, and
81
+ move old version lists to the archive board
82
+
83
+ Reading cards:
84
+
85
+ * `bin/trello_tool card CARD` -- one card as json (title, description, url, checklists, attachment urls)
86
+ * `bin/trello_tool search QUERY (BOARD_URL) (--json)` -- the unarchived cards matching a trello search, grouped by
87
+ list. The query is trello's own, so `label:bug`, `list:"TO DO"` and the like work, and the last word matches as
88
+ a prefix
89
+ * `bin/trello_tool cards (BOARD_URL) (--json)` -- every unarchived card in the board, grouped by list
90
+ * `bin/trello_tool summarize_as_md`, `summarize_as_md_long`, `summarize_as_urls (LIST_NAME (BOARD_URL))` -- the
91
+ cards of one list
92
+
93
+ Changing cards:
94
+
95
+ * `bin/trello_tool comment CARD "text"` or `--file notes.md` -- adds a comment
96
+ * `bin/trello_tool create (LIST_NAME (BOARD_URL)) --title "…" (--desc "…" | --desc-file card.md) (--label bug …)
97
+ (--top) (--force)` -- creates a card and prints it as json. Without LIST_NAME it goes in
98
+ `default_list_name_for_new_cards`. It first searches for an unarchived card with the same title and stops with
99
+ that card's url if there is one (`--force` creates another; trello's search can lag a few seconds behind a card
100
+ that was only just created)
101
+ * `bin/trello_tool checklist CARD NAME --items "first" "second" (--checked)` -- adds the items to the card's
102
+ checklist of that name, creating the checklist if need be and skipping items already there
103
+ * `bin/trello_tool check CARD "item text" (--checklist NAME) (--uncheck)` -- ticks (or unticks) the item with that
104
+ text, or the only item containing it
105
+ * `bin/trello_tool move CARD (LIST_NAME (BOARD_URL)) (--top | --bottom)` -- to the bottom (or top) of a list; without
106
+ LIST_NAME, to the top or bottom of the list the card is in
107
+
108
+ A command that can't do what it was asked says why and exits non-zero.
74
109
 
75
110
  ## Development
76
111
 
@@ -18,7 +18,8 @@ module TrelloTool
18
18
  version_template: "v%s",
19
19
  divider_template: "[%s]",
20
20
  too_many_doing: 2,
21
- too_many_todo: 10
21
+ too_many_todo: 10,
22
+ default_list_name_for_new_cards: nil
22
23
  }.freeze
23
24
  end
24
25
  Configuration = Struct.new(*DefaultConfiguration::DEFAULTS.keys, keyword_init: true) do
@@ -33,7 +34,7 @@ module TrelloTool
33
34
  # generates a file based on current settings
34
35
  def generate
35
36
  File.open(config_file, "w") do |f|
36
- Psych.dump(to_h { |k, v| [k.to_s, v] }, f) # rubocop:disable Style/HashTransformKeys doesn't work
37
+ Psych.dump(to_h { |k, v| [k.to_s, v] }, f)
37
38
  end
38
39
  end
39
40
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "trello"
4
5
  require "trello_tool/util"
5
6
 
@@ -7,6 +8,7 @@ module TrelloTool
7
8
  # Wrapped client for trello adapting it to things we need it to do
8
9
  class TrelloClient < SimpleDelegator
9
10
  include TrelloTool::Util
11
+
10
12
  attr_reader :client, :configuration
11
13
 
12
14
  # @param configuration[TrelloTool::Configuration]
@@ -60,6 +62,50 @@ module TrelloTool
60
62
  client.find(:cards, card_id)
61
63
  end
62
64
 
65
+ # @return [Trello::Board]
66
+ def find_board(board_url)
67
+ client.find(:boards, extract_id_from_url(board_url))
68
+ end
69
+
70
+ # the card fields the listing commands need, as trello names them
71
+ CARD_FIELDS = "name,url,idList,labels,closed"
72
+
73
+ # Unarchived cards of a board matching a trello search (operators such as label: and list: work, and the
74
+ # last word matches as a prefix)
75
+ # @return [Array<Hash>] cards as trello returns them: "name", "url", "idList", "labels"
76
+ def search_cards(board, query, limit: 50)
77
+ response = client.get("/search", "query" => query, "idBoards" => board.id, "modelTypes" => "cards",
78
+ "cards_limit" => limit.to_s, "card_fields" => CARD_FIELDS, "partial" => "true")
79
+ JSON.parse(response.body).fetch("cards", []).reject { |card| card["closed"] }
80
+ end
81
+
82
+ # Every unarchived card in an unarchived list of a board, in one request
83
+ # @return [Array<Hash>] as #search_cards
84
+ def open_cards(board)
85
+ JSON.parse(client.get("/boards/#{board.id}/cards/visible", "fields" => CARD_FIELDS).body)
86
+ end
87
+
88
+ # @param list [Trello::List]
89
+ # @return [Hash] the new card as trello returns it ("id", "url"...)
90
+ def create_card(list, title:, description: "", top: false, label_ids: [])
91
+ body = { name: title, desc: description, idList: list.id, pos: top ? "top" : "bottom" }
92
+ body[:idLabels] = label_ids.join(",") if label_ids.any?
93
+ JSON.parse(client.post("/cards", body).body)
94
+ end
95
+
96
+ # @param item [Hash] a checklist's check item ("id", "name", "state")
97
+ def set_check_item_state(card, item, complete:)
98
+ client.put("/cards/#{card.id}/checkItem/#{item['id']}", state: complete ? "complete" : "incomplete")
99
+ end
100
+
101
+ # @param position [String] "top" or "bottom"
102
+ # @param list [Trello::List, nil] nil to reposition the card within its list
103
+ def move_card(card, position:, list: nil)
104
+ body = { pos: position }
105
+ body[:idList] = list.id if list
106
+ client.put("/cards/#{card.id}", body)
107
+ end
108
+
63
109
  def next_version_list
64
110
  @next_version_list ||= find_list_by_list_name(main_board, configuration.next_version_list_name)
65
111
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TrelloTool
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -8,7 +8,7 @@ require "trello_tool/trello_client"
8
8
  require "trello_tool/util"
9
9
 
10
10
  # The thor class
11
- # rubocop:disable Metrics/ClassLength
11
+ # rubocop:disable-next Metrics/ClassLength
12
12
  class TrelloToolThor < Thor
13
13
  include TrelloTool::Util
14
14
 
@@ -16,6 +16,11 @@ class TrelloToolThor < Thor
16
16
  @configuration ||= TrelloTool::Configuration.new
17
17
  end
18
18
 
19
+ # a command that can't do what it was asked (raises Thor::Error) exits non-zero
20
+ def self.exit_on_failure?
21
+ true
22
+ end
23
+
19
24
  no_commands do
20
25
  delegate :configuration, to: :class
21
26
  end
@@ -171,12 +176,216 @@ class TrelloToolThor < Thor
171
176
  say "\n"
172
177
  end
173
178
 
179
+ desc "search QUERY (BOARD_URL)",
180
+ "prints the unarchived cards matching a trello search, grouped by list " \
181
+ "(operators like label: and list: work) -- markdown, or --json"
182
+ method_option :json, type: :boolean, default: false, desc: "print json instead of markdown"
183
+
184
+ def search(query, url = configuration.main_board_url)
185
+ board = client.find_board(url)
186
+ print_cards(board, client.search_cards(board, query))
187
+ end
188
+
189
+ desc "cards (BOARD_URL)", "prints every unarchived card in a board, grouped by list -- markdown, or --json"
190
+ method_option :json, type: :boolean, default: false, desc: "print json instead of markdown"
191
+
192
+ def cards(url = configuration.main_board_url)
193
+ board = client.find_board(url)
194
+ print_cards(board, client.open_cards(board))
195
+ end
196
+
197
+ desc "comment CARD_ID_OR_URL (TEXT)", "adds a comment to a card (the text inline, or from --file)"
198
+ method_option :file, type: :string, desc: "read the comment from this file"
199
+
200
+ def comment(card_id_or_url, text = nil)
201
+ raise Thor::Error, "give the comment either inline or with --file, not both" if text && options[:file]
202
+
203
+ text = File.read(options[:file]) if options[:file]
204
+ raise Thor::Error, "the comment is empty" if text.to_s.strip.empty?
205
+
206
+ card = client.find_card(extract_card_id(card_id_or_url))
207
+ card.add_comment(text)
208
+ say "commented on #{card.url}"
209
+ end
210
+
211
+ desc "create (LIST_NAME (BOARD_URL)) --title TITLE (--desc TEXT | --desc-file PATH) (--label NAME ...) (--top) (--force)",
212
+ "creates a card at the bottom (or --top) of LIST_NAME (default: default_list_name_for_new_cards) and " \
213
+ "prints it as json; stops if an unarchived card already has that title, unless --force"
214
+ method_option :title, type: :string, required: true
215
+ method_option :desc, type: :string, desc: "the description"
216
+ method_option :desc_file, type: :string, desc: "read the description (markdown) from this file"
217
+ method_option :label, type: :array, default: [], desc: "names of labels of the board"
218
+ method_option :top, type: :boolean, default: false, desc: "at the top of the list rather than the bottom"
219
+ method_option :force, type: :boolean, default: false, desc: "create even if a card with this title exists"
220
+
221
+ def create(list_name = configuration.default_list_name_for_new_cards, url = configuration.main_board_url)
222
+ title = title_option!
223
+ description = description_option!
224
+ no_list = "give a LIST_NAME, or set default_list_name_for_new_cards in #{configuration.config_file}"
225
+ raise Thor::Error, no_list unless list_name
226
+
227
+ board = client.find_board(url)
228
+ list = find_list!(board, list_name)
229
+ label_ids = label_ids!(board, Array(options[:label]))
230
+ refuse_duplicate!(board, title) unless options[:force]
231
+ created = client.create_card(list, title: title, description: description, top: options[:top] ? true : false,
232
+ label_ids: label_ids)
233
+ say "created #{created['url']} in #{list.name.inspect}"
234
+ card(created["id"])
235
+ end
236
+
237
+ desc "checklist CARD_ID_OR_URL NAME --items ITEM ...",
238
+ "adds items to the card's checklist of that name (creating it if need be), skipping items already there; " \
239
+ "prints the checklist as json"
240
+ method_option :items, type: :array, default: [], desc: "the items to add"
241
+ method_option :checked, type: :boolean, default: false, desc: "add the items already ticked"
242
+
243
+ def checklist(card_id_or_url, name)
244
+ card_id = extract_card_id(card_id_or_url)
245
+ list = find_checklist(card_id, name) || create_checklist(card_id, name)
246
+ existing = list.check_items.map { |item| item["name"] }
247
+ (Array(options[:items]).uniq - existing).each { |item| list.add_item(item, options[:checked] ? true : false, "bottom") }
248
+ say JSON.pretty_generate(checklist_as_hash(find_checklist(card_id, name)))
249
+ end
250
+
251
+ desc "check CARD_ID_OR_URL ITEM_TEXT (--checklist NAME) (--uncheck)",
252
+ "ticks (or with --uncheck unticks) the checklist item with that text (the whole text, or a part only it has)"
253
+ method_option :checklist, type: :string, desc: "only look in the checklist of this name"
254
+ method_option :uncheck, type: :boolean, default: false, desc: "untick instead"
255
+
256
+ def check(card_id_or_url, item_text)
257
+ card = client.find_card(extract_card_id(card_id_or_url))
258
+ item = find_check_item!(card, item_text)
259
+ client.set_check_item_state(card, item, complete: !options[:uncheck])
260
+ say "#{options[:uncheck] ? 'unticked' : 'ticked'} #{item['name'].inspect} on #{card.url}"
261
+ end
262
+
263
+ desc "move CARD_ID_OR_URL (LIST_NAME (BOARD_URL)) (--top | --bottom)",
264
+ "moves a card to the bottom (or --top) of a list; without LIST_NAME, to the top or bottom of the list it is in"
265
+ method_option :top, type: :boolean, default: false
266
+ method_option :bottom, type: :boolean, default: false
267
+
268
+ def move(card_id_or_url, list_name = nil, url = configuration.main_board_url)
269
+ position = position_option!(list_name)
270
+ card = client.find_card(extract_card_id(card_id_or_url))
271
+ list = find_list!(client.find_board(url), list_name) if list_name
272
+ client.move_card(card, position: position, list: list)
273
+ say "moved #{card.url} to the #{position} of #{list ? list.name.inspect : 'its list'}"
274
+ end
275
+
174
276
  private
175
277
 
176
278
  def client
177
279
  TrelloTool::TrelloClient.new(configuration)
178
280
  end
179
281
 
282
+ # @param cards [Array<Hash>] as trello returns them ("name", "url", "idList", "labels"); cards whose list is
283
+ # archived (not among the board's lists) are left out
284
+ def print_cards(board, cards)
285
+ # in the board's list order
286
+ rows = board.lists.flat_map { |list| cards.filter_map { |found| card_row(found, [list]) } }
287
+ return say(JSON.pretty_generate(rows)) if options[:json]
288
+ return say("no cards found") if rows.empty?
289
+
290
+ print_cards_as_markdown(rows)
291
+ end
292
+
293
+ def print_cards_as_markdown(rows)
294
+ rows.group_by { |row| row[:list] }.each do |list_name, in_list|
295
+ say "\n# #{list_name} (#{in_list.length} cards)\n\n"
296
+ in_list.each { |row| say "* [#{row[:title]}](#{row[:url]})#{row[:labels].map { |label| " [#{label}]" }.join}" }
297
+ end
298
+ say "\n"
299
+ end
300
+
301
+ def title_option!
302
+ title = options[:title].to_s.strip
303
+ raise Thor::Error, "--title is empty" if title.empty?
304
+
305
+ title
306
+ end
307
+
308
+ def description_option!
309
+ both = "give the description either with --desc or with --desc-file, not both"
310
+ raise Thor::Error, both if options[:desc] && options[:desc_file]
311
+
312
+ options[:desc_file] ? File.read(options[:desc_file]) : options[:desc].to_s
313
+ end
314
+
315
+ # @return [String] "top" or "bottom" (the default when the card is going to another list)
316
+ def position_option!(list_name)
317
+ raise Thor::Error, "--top or --bottom, not both" if options[:top] && options[:bottom]
318
+ raise Thor::Error, "give a LIST_NAME, --top or --bottom" unless list_name || options[:top] || options[:bottom]
319
+
320
+ options[:top] ? "top" : "bottom"
321
+ end
322
+
323
+ # @return [Hash, nil] nil when the card's list isn't one of lists
324
+ def card_row(found, lists)
325
+ list = lists.detect { |candidate| candidate.id == found["idList"] }
326
+ return nil unless list
327
+
328
+ { title: found["name"], url: found["url"], list: list.name,
329
+ labels: (found["labels"] || []).map { |label| label["name"].to_s }.reject(&:empty?) }
330
+ end
331
+
332
+ # the list of that name (exactly, or failing that the only one that differs just by case)
333
+ def find_list!(board, list_name)
334
+ lists = board.lists
335
+ list = lists.detect { |candidate| candidate.name == list_name }
336
+ list ||= lists.select { |candidate| candidate.name.casecmp?(list_name) }.then { |found| found.first if found.size == 1 }
337
+ list || raise(Thor::Error, "no list called #{list_name.inspect} in #{board.name}. Lists: #{lists.map(&:name).join(', ')}")
338
+ end
339
+
340
+ def label_ids!(board, names)
341
+ return [] if names.empty?
342
+
343
+ labels = board.labels
344
+ names.map do |name|
345
+ label = labels.detect { |candidate| candidate.name.to_s.casecmp?(name) }
346
+ label&.id || raise(Thor::Error, "no label called #{name.inspect} in #{board.name}. " \
347
+ "Labels: #{labels.map { |candidate| candidate.name.to_s }.reject(&:empty?).join(', ')}")
348
+ end
349
+ end
350
+
351
+ def refuse_duplicate!(board, title)
352
+ duplicate = client.search_cards(board, %("#{title.delete('"')}")).detect { |found| found["name"].to_s.strip.casecmp?(title) }
353
+ return unless duplicate
354
+
355
+ raise Thor::Error, "a card called #{title.inspect} already exists: #{duplicate['url']} (--force to create another)"
356
+ end
357
+
358
+ # always from a fresh read of the card, so it sees a checklist or item that was just added
359
+ def find_checklist(card_id, name)
360
+ client.find_card(card_id).checklists.detect { |candidate| candidate.name == name }
361
+ end
362
+
363
+ def create_checklist(card_id, name)
364
+ client.find_card(card_id).create_new_checklist(name)
365
+ find_checklist(card_id, name) || raise(Thor::Error, "couldn't create the checklist #{name.inspect}")
366
+ end
367
+
368
+ # @return [Hash] the one check item whose name is item_text, or failing that the one containing it
369
+ def find_check_item!(card, item_text)
370
+ items = check_items!(card)
371
+ matches = items.select { |item| item["name"].casecmp?(item_text) }
372
+ matches = items.select { |item| item["name"].downcase.include?(item_text.downcase) } if matches.empty?
373
+ return matches.first if matches.size == 1
374
+
375
+ problem = matches.empty? ? "no item matching" : "more than one item matching"
376
+ candidates = (matches.empty? ? items : matches).map { |item| item["name"].inspect }.join(", ")
377
+ raise Thor::Error, "#{problem} #{item_text.inspect} on #{card.url}. Items: #{candidates}"
378
+ end
379
+
380
+ # @return [Array<Hash>] the check items of the card's checklists (of the --checklist one, if given)
381
+ def check_items!(card)
382
+ name = options[:checklist]
383
+ checklists = card.checklists.select { |candidate| name.nil? || candidate.name == name }
384
+ raise Thor::Error, "no checklist#{" called #{name.inspect}" if name} on #{card.url}" if checklists.empty?
385
+
386
+ checklists.flat_map(&:check_items)
387
+ end
388
+
180
389
  # @param card [Trello::Card]
181
390
  # @return [Hash]
182
391
  def card_as_hash(card)
@@ -200,4 +409,3 @@ class TrelloToolThor < Thor
200
409
  }
201
410
  end
202
411
  end
203
- # rubocop:enable Metrics/ClassLength
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trello_tool
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Diggins
@@ -85,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
85
  - !ruby/object:Gem::Version
86
86
  version: '0'
87
87
  requirements: []
88
- rubygems_version: 4.0.14
88
+ rubygems_version: 3.6.9
89
89
  specification_version: 4
90
90
  summary: Tool for doing basic things to a dev trello using the api.
91
91
  test_files: []