cc-os/plugins/os-backlog/lib/backlog/cards.rb

117 lines
4.3 KiB
Ruby

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 [Planka::Types::Card] 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" => [{"id","name","labels",...card fields}] }] }
def snapshot(board_name:)
board = find_board!(board_name)
detail = @client.boards.get(board.id)
labels_by_id = detail.labels.to_h { |l| [l.id, l.name] }
card_labels = detail.card_labels.group_by(&:card_id)
cards_by_list = detail.cards.group_by(&:list_id)
lists = detail.lists
.select(&:name) # skip built-in archive/trash lists (name: nil)
.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.label_id] }
card_hash(card, 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 [Planka::Types::CardLabel] 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 [Planka::Types::Comment] the created comment
def comment(card_id:, text:)
@client.comments.create(card_id, text: text)
end
private
# Build the plain-hash card representation the CLI/JSON output emits,
# keeping the output shape byte-compatible with the 0.1.x snapshot
# (string keys, "labels" merged in) even though Types::Card is an
# immutable Data object we can no longer +merge+.
def card_hash(card, label_names)
{
"id" => card.id,
"name" => card.name,
"description" => card.description,
"position" => card.position,
"listId" => card.list_id,
"boardId" => card.board_id,
"dueDate" => card.due_date,
"labels" => label_names
}
end
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)
detail = @client.boards.get(board.id)
positions = detail.cards
.select { |c| c.list_id == list.id }
.filter_map(&:position)
(positions.max || 0) + 65_536
end
end
end