191 lines
7.9 KiB
Ruby
191 lines
7.9 KiB
Ruby
require_relative "board_spec"
|
|
require_relative "board_resolver"
|
|
|
|
module Backlog
|
|
# Card-level operations over an authenticated Planka client: create a
|
|
# card at Backlog (the only column the AI ever creates into), build a
|
|
# read-only board snapshot (lists with their cards, labels, card-label
|
|
# assignments) for the list skill and the triage/audit agents, and move
|
|
# cards between columns under the deterministic policy guardrails
|
|
# enforced by #move (Next is human-curated, hitl cards are human-owned,
|
|
# Done is a one-way autonomous door gated on the afk-ready label).
|
|
#
|
|
# Never deletes anything.
|
|
class Cards
|
|
# @param client [Planka::Client] an already-authenticated client
|
|
# @param board_resolver [BoardResolver, nil] resolves board names to
|
|
# board records (with the .cc-os/config board_id cache — issue #22);
|
|
# defaults to a cache-less name-only resolver. Cards never touches
|
|
# the filesystem itself — all config I/O lives in the resolver.
|
|
def initialize(client:, board_resolver: nil)
|
|
@client = client
|
|
@board_resolver = board_resolver || BoardResolver.null(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
|
|
|
|
# Move a card to another list on the same board, enforcing the
|
|
# deterministic column-ownership policy (this is code, not skill
|
|
# prose, precisely so it can't be talked around):
|
|
#
|
|
# 1. Any move into or out of Next is refused — Next is human-curated.
|
|
# 2. Any move of a card labeled hitl is refused — hitl cards are
|
|
# human-owned.
|
|
# 3. Moving a card out of Done is refused.
|
|
# 4. Moving a card to Done is allowed only if it carries the
|
|
# afk-ready label (afk-ready work that's shipped+verified goes
|
|
# straight to Done; semi work stops at Review for human sign-off).
|
|
# 5. Everything else among Backlog/Doing/Waiting/Review is allowed.
|
|
#
|
|
# Lands the card at the end of the destination list (max existing
|
|
# position + 65536, or 65536 if the list is empty).
|
|
#
|
|
# @param board_name [String]
|
|
# @param card_id [String, Integer]
|
|
# @param to [String] destination list name (one of BoardSpec::LISTS)
|
|
# @return [Planka::Types::Card] the moved card
|
|
def move(board_name:, card_id:, to:)
|
|
unless BoardSpec::LISTS.include?(to)
|
|
raise "unknown list #{to.inspect} (expected one of #{BoardSpec::LISTS.join(', ')})"
|
|
end
|
|
|
|
board = find_board!(board_name)
|
|
detail = @client.boards.get(board.id)
|
|
card = detail.cards.find { |c| c.id == card_id }
|
|
raise "no card ##{card_id} found on board #{board_name.inspect}" unless card
|
|
|
|
from_name = detail.lists.find { |l| l.id == card.list_id }&.name
|
|
to_list = detail.lists.find { |l| l.name == to }
|
|
raise "board #{board_name.inspect} has no #{to.inspect} list (run board-ensure)" unless to_list
|
|
|
|
enforce_move_policy!(from_name: from_name, to_name: to, card: card, detail: detail)
|
|
|
|
position = next_position(board, to_list)
|
|
@client.cards.move(card_id, list_id: to_list.id, position: position)
|
|
end
|
|
|
|
private
|
|
|
|
def enforce_move_policy!(from_name:, to_name:, card:, detail:)
|
|
raise "Next is human-curated" if from_name == "Next" || to_name == "Next"
|
|
|
|
label_names = detail.card_labels
|
|
.select { |cl| cl.card_id == card.id }
|
|
.filter_map { |cl| detail.labels.find { |l| l.id == cl.label_id }&.name }
|
|
raise "hitl cards are human-owned" if label_names.include?("hitl")
|
|
|
|
raise "cannot move a card out of Done" if from_name == "Done" && to_name != "Done"
|
|
|
|
return unless to_name == "Done" && !label_names.include?("afk-ready")
|
|
|
|
raise "move to Done requires the afk-ready label"
|
|
end
|
|
|
|
# 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+. Emits every
|
|
# Types::Card field — dropping fields here silently breaks consumers
|
|
# like board-audit, which reads "listChangedAt".
|
|
def card_hash(card, label_names)
|
|
{
|
|
"id" => card.id,
|
|
"createdAt" => card.created_at,
|
|
"updatedAt" => card.updated_at,
|
|
"type" => card.type,
|
|
"position" => card.position,
|
|
"name" => card.name,
|
|
"description" => card.description,
|
|
"dueDate" => card.due_date,
|
|
"isDueCompleted" => card.is_due_completed,
|
|
"stopwatch" => card.stopwatch,
|
|
"commentsTotal" => card.comments_total,
|
|
"isClosed" => card.is_closed,
|
|
"listChangedAt" => card.list_changed_at,
|
|
"boardId" => card.board_id,
|
|
"listId" => card.list_id,
|
|
"creatorUserId" => card.creator_user_id,
|
|
"prevListId" => card.prev_list_id,
|
|
"coverAttachmentId" => card.cover_attachment_id,
|
|
"isSubscribed" => card.is_subscribed,
|
|
"labels" => label_names
|
|
}
|
|
end
|
|
|
|
def find_board!(name)
|
|
@board_resolver.board_for(name)
|
|
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
|