diff --git a/plugins/os-backlog/.claude-plugin/plugin.json b/plugins/os-backlog/.claude-plugin/plugin.json new file mode 100644 index 0000000..b9f9538 --- /dev/null +++ b/plugins/os-backlog/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "os-backlog", + "version": "0.1.0", + "description": "Uniform Planka backlog boards across projects: idempotent board-ensure (lists, labels, archive convention, Planka 2.1.1 quirk handling) and a deterministic repo-to-board routing resolver." +} diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog new file mode 100755 index 0000000..3253c51 --- /dev/null +++ b/plugins/os-backlog/bin/os-backlog @@ -0,0 +1,82 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# os-backlog CLI: board-ensure, activate, archive. +# +# Usage: +# os-backlog board-ensure NAME --project Dev|Clients +# os-backlog activate NAME +# os-backlog archive NAME --yes +# +# Talks to Planka over the network (the installed planka-api gem, +# credentials from PLANKA_BASE_URL/USERNAME/PASSWORD). +# +# Fails soft: if the gem or Planka credentials are unavailable, prints one +# clear error and exits nonzero. Never destructive. + +require_relative "../lib/backlog" + +def fail_soft(message) + warn "os-backlog: #{message}" + exit 1 +end + +def build_client + require "planka_api" + client = Planka::Client.new + client.login + client +rescue LoadError + fail_soft("the planka-api gem is not installed/loadable; cannot reach Planka") +rescue StandardError => e + fail_soft("could not authenticate to Planka: #{e.message}") +end + +def parse_project_flag(args) + idx = args.index("--project") + return "Dev" unless idx + + args.delete_at(idx) + project = args.delete_at(idx) + project || fail_soft("--project requires a value (Dev or Clients)") +end + +command, *rest = ARGV + +case command +when "board-ensure" + name = rest.shift + fail_soft("board-ensure requires a board name") unless name + project = parse_project_flag(rest) + client = build_client + result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project) + puts "board #{result.board['name']} (#{result.created ? 'created' : 'existing'}) " \ + "in project #{project}; lists created: #{result.lists_created.join(', ')}; " \ + "labels created: #{result.labels_created.join(', ')}" +when "activate" + name = rest.shift + fail_soft("activate requires a board name") unless name + client = build_client + board = Backlog::BoardEnsurer.new(client: client).activate(name) + puts "activated: #{board['name']}" +when "archive" + name = rest.shift + fail_soft("archive requires a board name") unless name + fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes") + + client = build_client + board = Backlog::BoardEnsurer.new(client: client).archive(name) + puts "archived: #{board['name']}" +when nil, "-h", "--help" + puts <<~USAGE + usage: os-backlog [options] + + commands: + board-ensure NAME --project Dev|Clients + activate NAME + archive NAME --yes + USAGE + exit(command.nil? ? 1 : 0) +else + fail_soft("unknown command #{command.inspect}") +end diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb new file mode 100644 index 0000000..bfbe912 --- /dev/null +++ b/plugins/os-backlog/lib/backlog.rb @@ -0,0 +1,2 @@ +require_relative "backlog/board_spec" +require_relative "backlog/board_ensurer" diff --git a/plugins/os-backlog/lib/backlog/board_ensurer.rb b/plugins/os-backlog/lib/backlog/board_ensurer.rb new file mode 100644 index 0000000..92f3fd8 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/board_ensurer.rb @@ -0,0 +1,150 @@ +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 [Hash] 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 [Hash] 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 { |l| l["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 { |l| l["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 => e + last_error = e + next + end + raise last_error || RuntimeError, "could not create label #{label_name.inspect}: no candidate color accepted" + end + end +end diff --git a/plugins/os-backlog/lib/backlog/board_spec.rb b/plugins/os-backlog/lib/backlog/board_spec.rb new file mode 100644 index 0000000..c1f5b98 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/board_spec.rb @@ -0,0 +1,34 @@ +module Backlog + # The uniform board contract every os-backlog board must have: list order + # and the enforced label set. Values only — no client/network code. + module BoardSpec + # Lists in required left-to-right order. + LISTS = %w[Backlog Next Doing Waiting Review Done].freeze + + # Label name => ordered candidate colors, tried in turn until the server + # accepts one (Planka's palette-name whitelist is not documented, so we + # can't assert it in advance — cf. planka-api gem's Priority::COLOR_CANDIDATES + # pattern). The first candidate in each list is the verified-good default. + LABEL_COLOR_CANDIDATES = { + "P0" => %w[berry-red red-burgundy salsa-red], + "P1" => %w[pumpkin-orange orange-peel sunset-orange], + "P2" => %w[egg-yellow sunny-grass bright-moss], + "P3" => %w[steel-grey morning-sky light-cocoa], + "hitl" => %w[tank-green bright-moss lagoon-blue], + "semi" => %w[lagoon-blue summer-sky navy-blue], + "afk-ready" => %w[antique-blue bright-moss slate-blue] + }.freeze + + LABEL_NAMES = LABEL_COLOR_CANDIDATES.keys.freeze + + ARCHIVE_PREFIX = "archived--" + + class << self + def archived_name(name) = "#{ARCHIVE_PREFIX}#{name}" + + def archived?(name) = name.to_s.start_with?(ARCHIVE_PREFIX) + + def active_name(name) = archived?(name) ? name.sub(ARCHIVE_PREFIX, "") : name + end + end +end diff --git a/plugins/os-backlog/tests/all.rb b/plugins/os-backlog/tests/all.rb new file mode 100644 index 0000000..8336904 --- /dev/null +++ b/plugins/os-backlog/tests/all.rb @@ -0,0 +1,2 @@ +# Run the whole suite: ruby tests/all.rb +Dir.glob(File.join(__dir__, "*_test.rb")).sort.each { |f| require f } diff --git a/plugins/os-backlog/tests/board_ensurer_test.rb b/plugins/os-backlog/tests/board_ensurer_test.rb new file mode 100644 index 0000000..46794f3 --- /dev/null +++ b/plugins/os-backlog/tests/board_ensurer_test.rb @@ -0,0 +1,118 @@ +require_relative "test_helper" + +class BoardEnsurerTest < Minitest::Test + include BacklogTestHelpers + + def test_creates_missing_project_board_lists_and_labels + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + + result = ensurer.ensure("llf-schema", project_name: "Dev") + + assert result.created + assert_equal "llf-schema", result.board["name"] + assert_equal Backlog::BoardSpec::LISTS, result.lists_created + assert_equal Backlog::BoardSpec::LABEL_NAMES, result.labels_created + + project = client.projects_store.first + assert_equal "Dev", project.name + assert_equal "shared", project.type + end + + def test_rerun_is_a_noop + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + ensurer.ensure("llf-schema", project_name: "Dev") + + result = ensurer.ensure("llf-schema", project_name: "Dev") + + refute result.created + assert_empty result.lists_created + assert_empty result.labels_created + assert_equal 1, client.boards_store.size + assert_equal Backlog::BoardSpec::LISTS.size, client.lists_store.size + assert_equal Backlog::BoardSpec::LABEL_NAMES.size, client.labels_store.size + end + + def test_reuses_existing_project + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + ensurer.ensure("board-a", project_name: "Dev") + + ensurer.ensure("board-b", project_name: "Dev") + + assert_equal 1, client.projects_store.size + assert_equal 2, client.boards_store.size + end + + def test_applies_owner_manager_visibility_fix_on_new_project + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + + ensurer.ensure("llf-schema", project_name: "Dev") + + project = client.projects_store.first + assert_includes project.managers, "human-1" + assert_nil project.ownerProjectManagerId + end + + def test_semi_label_is_part_of_enforced_set + assert_includes Backlog::BoardSpec::LABEL_NAMES, "semi" + assert_includes Backlog::BoardSpec::LABEL_NAMES, "hitl" + assert_includes Backlog::BoardSpec::LABEL_NAMES, "afk-ready" + end + + def test_label_color_falls_back_to_next_candidate_on_rejection + first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P0").first + client = FakePlankaClient.new(reject_colors: [first_choice]) + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + + ensurer.ensure("llf-schema", project_name: "Dev") + + p0 = client.labels_store.find { |l| l.name == "P0" } + refute_equal first_choice, p0.color + end + + def test_activate_renames_archived_board_back + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + ensurer.ensure("llf-schema", project_name: "Dev") + board = client.boards_store.first + board.name = "archived--llf-schema" + + result = ensurer.activate("llf-schema") + + assert_equal "llf-schema", result["name"] + end + + def test_archive_renames_active_board + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + ensurer.ensure("llf-schema", project_name: "Dev") + + result = ensurer.archive("llf-schema") + + assert_equal "archived--llf-schema", result["name"] + end + + def test_ensure_never_touches_archived_boards + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + ensurer.ensure("llf-schema", project_name: "Dev") + ensurer.archive("llf-schema") + + result = ensurer.ensure("llf-schema", project_name: "Dev") + + assert result.created + assert_equal 2, client.boards_store.size + archived = client.boards_store.find { |b| b.name == "archived--llf-schema" } + refute_nil archived + end + + def test_ensure_rejects_archived_prefixed_name + client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1") + + assert_raises(ArgumentError) { ensurer.ensure("archived--x", project_name: "Dev") } + end +end diff --git a/plugins/os-backlog/tests/test_helper.rb b/plugins/os-backlog/tests/test_helper.rb new file mode 100644 index 0000000..b4ae1bc --- /dev/null +++ b/plugins/os-backlog/tests/test_helper.rb @@ -0,0 +1,139 @@ +require "minitest/autorun" +require_relative "../lib/backlog" + +# Minimal stand-ins for the planka-api gem's error hierarchy, so tests never +# require (or need) the real gem installed — only the shape our code +# rescues against. +module Planka + class Error < StandardError; end + class ValidationError < Error; end +end + +module BacklogTestHelpers + # A hand-rolled fake of the subset of Planka::Client's resource surface + # BoardEnsurer touches: projects, boards, lists, labels. In-memory only, + # no network. Mirrors the gem's real return shapes (string-keyed hashes). + class FakePlankaClient + Project = Struct.new(:id, :name, :type, :ownerProjectManagerId, :managers, keyword_init: true) + Board = Struct.new(:id, :project_id, :name, keyword_init: true) + List = Struct.new(:id, :board_id, :name, :type, keyword_init: true) + Label = Struct.new(:id, :board_id, :name, :color, keyword_init: true) + + attr_reader :projects, :boards, :lists, :labels + + def initialize(reject_colors: []) + @next_id = 0 + @projects_store = [] + @boards_store = [] + @lists_store = [] + @labels_store = [] + + @projects = ProjectsResource.new(self) + @boards = BoardsResource.new(self) + @lists = ListsResource.new(self) + @labels = LabelsResource.new(self, reject_colors: reject_colors) + end + + def next_id = (@next_id += 1).to_s + + attr_reader :projects_store, :boards_store, :lists_store, :labels_store + + class ProjectsResource + def initialize(client) = @client = client + + def list = @client.projects_store.map { |p| stringify(p) } + + def create(attrs) + project = FakePlankaClient::Project.new( + id: @client.next_id, name: attrs[:name], type: attrs[:type], + ownerProjectManagerId: @client.next_id, managers: [] + ) + @client.projects_store << project + stringify(project) + end + + def update(id, attrs) + project = @client.projects_store.find { |p| p.id == id } + project.ownerProjectManagerId = attrs[:ownerProjectManagerId] if attrs.key?(:ownerProjectManagerId) + stringify(project) + end + + def add_manager(project_id, user_id) + project = @client.projects_store.find { |p| p.id == project_id } + project.managers << user_id + { "id" => @client.next_id, "projectId" => project_id, "userId" => user_id } + end + + private + + def stringify(p) = { "id" => p.id, "name" => p.name, "type" => p.type } + end + + class BoardsResource + def initialize(client) = @client = client + + def list(project_id) + @client.boards_store.select { |b| b.project_id == project_id }.map { |b| stringify(b) } + end + + def create(project_id, attrs) + board = FakePlankaClient::Board.new(id: @client.next_id, project_id: project_id, name: attrs[:name]) + @client.boards_store << board + stringify(board) + end + + def update(id, attrs) + board = @client.boards_store.find { |b| b.id == id } + board.name = attrs[:name] if attrs.key?(:name) + stringify(board) + end + + private + + def stringify(b) = { "id" => b.id, "projectId" => b.project_id, "name" => b.name } + end + + class ListsResource + def initialize(client) = @client = client + + def list(board_id) + @client.lists_store.select { |l| l.board_id == board_id }.map { |l| stringify(l) } + end + + def create(board_id, attrs) + raise ArgumentError, "list create requires type" unless attrs[:type] + + list = FakePlankaClient::List.new(id: @client.next_id, board_id: board_id, name: attrs[:name], type: attrs[:type]) + @client.lists_store << list + stringify(list) + end + + private + + def stringify(l) = { "id" => l.id, "boardId" => l.board_id, "name" => l.name, "type" => l.type } + end + + class LabelsResource + def initialize(client, reject_colors: []) + @client = client + @reject_colors = reject_colors + end + + def list(board_id) + @client.labels_store.select { |l| l.board_id == board_id }.map { |l| stringify(l) } + end + + def create(board_id, attrs) + raise Planka::ValidationError, "color #{attrs[:color]} rejected" if @reject_colors.include?(attrs[:color]) + + label = FakePlankaClient::Label.new(id: @client.next_id, board_id: board_id, name: attrs[:name], color: attrs[:color]) + @client.labels_store << label + stringify(label) + end + + private + + def stringify(l) = { "id" => l.id, "boardId" => l.board_id, "name" => l.name, "color" => l.color } + end + end +end