require_relative "board_spec" module Backlog # Idempotently creates/repairs a uniform Planka board: the project it # lives under, its lists, and its label set — plus the Planka 2.1.1 # quirks the planka-api gem does not handle for us: # # 1. undocumented `type` fields on create (project: "shared" so a # manager can be added later; list: "active"; the gem already # requires callers to pass these — it does not default them) # 2. bot-created projects are invisible to the human: a `"private"` # project auto-assigns its creator as sole ownerProjectManagerId and # rejects additional managers (422). We create projects as # `type: "shared"` instead, then explicitly add the human as a # project manager and null out ownerProjectManagerId. # 3. label colors must come from Planka's (undocumented) color-name # whitelist — we try an ordered candidate list per label, same # pattern the planka-api gem uses for its own priority labels. # # This class never archives a board — archiving only happens on explicit # human request (see BoardSpec / the resolver). It does provide the raw # rename primitives (+activate+ / +archive+) that a caller may invoke. class BoardEnsurer Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true) # @param client [Planka::Client] an already-authenticated client # @param human_user_id [String, Integer, nil] Planka user id of the # human who must be able to see bot-created projects. Falls back to # +ENV["PLANKA_HUMAN_USER_ID"]+. Only needed when a project doesn't # already exist. def initialize(client:, human_user_id: nil) @client = client @human_user_id = human_user_id || ENV["PLANKA_HUMAN_USER_ID"] end # @param name [String] board name (never an archived---prefixed name; # this method only ever targets the active board) # @param project_name [String] "Dev" or "Clients" (or any project name) # @return [Result] def ensure(name, project_name:) raise ArgumentError, "board name must not use the archived-- prefix" if BoardSpec.archived?(name) project = find_or_create_project(project_name) board, created = find_or_create_board(project, name) lists_created = ensure_lists(board) labels_created = ensure_labels(board) Result.new(board: board, created: created, project: project, lists_created: lists_created, labels_created: labels_created) end # Rename an archived board back to its active name. Raises if no # archived board by that name exists. # # @param name [String] the active (non-prefixed) name to activate # @return [Types::Board] the updated board def activate(name) board = find_board_by_name(BoardSpec.archived_name(name)) raise "no archived board named #{BoardSpec.archived_name(name).inspect} found" unless board @client.boards.update(board.id, name: name) end # Rename an active board to its archived form. Only ever called on # explicit human request — never invoked by +ensure+. # # @param name [String] the active (non-prefixed) name to archive # @return [Types::Board] the updated board def archive(name) board = find_board_by_name(name) raise "no active board named #{name.inspect} found" unless board @client.boards.update(board.id, name: BoardSpec.archived_name(name)) end private def find_or_create_project(project_name) existing = @client.projects.list.find { |p| p.name == project_name } return existing if existing project = @client.projects.create(name: project_name, type: "shared") fix_visibility(project) project end # Quirk 2: null the bot's sole ownerProjectManagerId and add the human # as a project manager, so the project shows up for them. def fix_visibility(project) return unless @human_user_id @client.projects.add_manager(project.id, @human_user_id) @client.projects.update(project.id, ownerProjectManagerId: nil) end def find_or_create_board(project, name) board = find_board_by_name(name, project: project) return [board, false] if board created = @client.boards.create(project.id, name: name, position: 65_536) [created, true] end def find_board_by_name(name, project: nil) projects = project ? [project] : @client.projects.list projects.each do |p| board = @client.boards.list(p.id).find { |b| b.name == name } return board if board end nil end def ensure_lists(board) existing = @client.lists.list(board.id).filter_map(&:name) created = [] BoardSpec::LISTS.each_with_index do |list_name, index| next if existing.include?(list_name) @client.lists.create(board.id, name: list_name, type: "active", position: (index + 1) * 65_536) created << list_name end created end def ensure_labels(board) existing = @client.labels.list(board.id).filter_map(&:name) created = [] BoardSpec::LABEL_NAMES.each do |label_name| next if existing.include?(label_name) create_label_with_fallback_color(board, label_name) created << label_name end created end def create_label_with_fallback_color(board, label_name) candidates = BoardSpec::LABEL_COLOR_CANDIDATES.fetch(label_name) last_error = nil candidates.each do |color| return @client.labels.create(board.id, name: label_name, color: color, position: 65_536) rescue Planka::ValidationError, Planka::BadRequestError => e last_error = e next end raise last_error || RuntimeError, "could not create label #{label_name.inspect}: no candidate color accepted" end end end