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 # 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 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