2026-07-10 17:41:44 +00:00
|
|
|
require_relative "board_spec"
|
|
|
|
|
|
|
|
|
|
module Backlog
|
|
|
|
|
# Card-level operations over an authenticated Planka client: create a
|
|
|
|
|
# card at Backlog (the only column the AI ever creates into), and build
|
|
|
|
|
# a read-only board snapshot (lists with their cards, labels, card-label
|
|
|
|
|
# assignments) for the list skill and the triage/audit agents.
|
|
|
|
|
#
|
|
|
|
|
# Never moves cards between columns and never deletes anything.
|
|
|
|
|
class Cards
|
|
|
|
|
# @param client [Planka::Client] an already-authenticated client
|
|
|
|
|
def initialize(client:)
|
|
|
|
|
@client = client
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Create a card at the board's Backlog list. Raises if the board or
|
|
|
|
|
# its Backlog list doesn't exist (callers run board-ensure first).
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash] the created card
|
|
|
|
|
def add(board_name:, title:, description: nil)
|
|
|
|
|
board = find_board!(board_name)
|
|
|
|
|
backlog = find_list!(board, "Backlog")
|
|
|
|
|
attrs = { name: title, type: "project", position: next_position(board, backlog) }
|
|
|
|
|
attrs[:description] = description if description
|
|
|
|
|
@client.cards.create(backlog["id"], attrs)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Read-only snapshot of a whole board: every list in BoardSpec order
|
|
|
|
|
# (plus any extras), each with its cards and their label names.
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash] { "board" => name, "labels" => [names],
|
|
|
|
|
# "lists" => [{ "name", "cards" => [{card fields + "labels"}] }] }
|
|
|
|
|
def snapshot(board_name:)
|
|
|
|
|
board = find_board!(board_name)
|
|
|
|
|
included = @client.boards.get(board["id"])["included"]
|
|
|
|
|
labels_by_id = (included["labels"] || []).to_h { |l| [l["id"], l["name"]] }
|
|
|
|
|
card_labels = (included["cardLabels"] || []).group_by { |cl| cl["cardId"] }
|
|
|
|
|
cards_by_list = (included["cards"] || []).group_by { |c| c["listId"] }
|
|
|
|
|
|
|
|
|
|
lists = (included["lists"] || [])
|
|
|
|
|
.select { |l| l["name"] } # skip built-in archive/trash lists (name: null)
|
|
|
|
|
.sort_by { |l| l["position"] || Float::INFINITY }
|
|
|
|
|
.map do |list|
|
|
|
|
|
cards = (cards_by_list[list["id"]] || []).map do |card|
|
|
|
|
|
label_names = (card_labels[card["id"]] || []).filter_map { |cl| labels_by_id[cl["labelId"]] }
|
|
|
|
|
card.merge("labels" => label_names)
|
|
|
|
|
end
|
|
|
|
|
{ "name" => list["name"], "cards" => cards }
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
{ "board" => board["name"], "labels" => labels_by_id.values, "lists" => lists }
|
|
|
|
|
end
|
|
|
|
|
|
2026-07-10 17:44:13 +00:00
|
|
|
# Attach a board label (by name) to a card. The only card mutation the
|
|
|
|
|
# triage agent is allowed — label changes, never column moves.
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash] the card-label join record
|
|
|
|
|
def attach_label(board_name:, card_id:, label:)
|
|
|
|
|
board = find_board!(board_name)
|
|
|
|
|
record = @client.labels.list(board["id"]).find { |l| l["name"] == label }
|
|
|
|
|
raise "board #{board_name.inspect} has no label #{label.inspect} (run board-ensure)" unless record
|
|
|
|
|
|
|
|
|
|
@client.labels.attach_to_card(card_id, record["id"])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Add a comment to a card. The only card mutation the audit agent is
|
|
|
|
|
# allowed.
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash] the created comment
|
|
|
|
|
def comment(card_id:, text:)
|
|
|
|
|
@client.comments.create(card_id, text: text)
|
|
|
|
|
end
|
|
|
|
|
|
2026-07-10 17:41:44 +00:00
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def find_board!(name)
|
|
|
|
|
@client.projects.list.each do |project|
|
|
|
|
|
board = @client.boards.list(project["id"]).find { |b| b["name"] == name }
|
|
|
|
|
return board if board
|
|
|
|
|
end
|
|
|
|
|
raise "no board named #{name.inspect} found (run board-ensure first)"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def find_list!(board, list_name)
|
|
|
|
|
list = @client.lists.list(board["id"]).find { |l| l["name"] == list_name }
|
|
|
|
|
raise "board #{board['name'].inspect} has no #{list_name.inspect} list (run board-ensure)" unless list
|
|
|
|
|
|
|
|
|
|
list
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def next_position(board, list)
|
|
|
|
|
included = @client.boards.get(board["id"])["included"]
|
|
|
|
|
positions = (included["cards"] || [])
|
|
|
|
|
.select { |c| c["listId"] == list["id"] }
|
|
|
|
|
.filter_map { |c| c["position"] }
|
|
|
|
|
(positions.max || 0) + 65_536
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|