From 46b0584643bd407a1d7f405303a0fc6dc0928651 Mon Sep 17 00:00:00 2001 From: jared Date: Mon, 13 Jul 2026 09:18:45 -0400 Subject: [PATCH] os-backlog: cache resolved Planka board id in .cc-os/config (issue #22) - Backlog::BoardResolver with injected config read/write; Cards does no filesystem I/O - board_id honored only for planka: trackers; id hit validated against returned board name; 404/mismatch/cache failure degrades to name lookup and rewrites the cache - comment-preserving Config.set mirrors os-status's writer (Config.merge not used) - FakePlankaClient boards.get 404 support; 14 new tests; suite 90 runs / 0 failures Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho --- plugins/os-backlog/bin/os-backlog | 27 +++- plugins/os-backlog/lib/backlog.rb | 1 + .../os-backlog/lib/backlog/board_resolver.rb | 86 +++++++++++ plugins/os-backlog/lib/backlog/cards.rb | 14 +- plugins/os-backlog/lib/backlog/config.rb | 37 +++++ .../os-backlog/tests/board_resolver_test.rb | 135 ++++++++++++++++++ plugins/os-backlog/tests/config_test.rb | 29 ++++ plugins/os-backlog/tests/test_helper.rb | 5 + 8 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 plugins/os-backlog/lib/backlog/board_resolver.rb create mode 100644 plugins/os-backlog/tests/board_resolver_test.rb diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 4c7214a..afc4c14 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -47,6 +47,21 @@ rescue StandardError => e fail_soft("could not authenticate to Planka: #{e.message}") end +# Cards wired to the cwd repo's .cc-os/config board_id cache (issue #22): +# the BoardResolver gets injected read/write lambdas; any cache failure +# degrades to the plain name lookup inside the resolver itself. +def build_cards(client) + config_path = File.join(Dir.pwd, ".cc-os", "config") + read_config = -> { File.exist?(config_path) ? File.read(config_path) : nil } + write_config = lambda do |contents| + FileUtils.mkdir_p(File.dirname(config_path)) + File.write(config_path, contents) + end + resolver = Backlog::BoardResolver.new(client: client, read_config: read_config, + write_config: write_config) + Backlog::Cards.new(client: client, board_resolver: resolver) +end + def parse_project_flag(args) idx = args.index("--project") return "Dev" unless idx @@ -157,19 +172,19 @@ when "card-add" title = require_flag(rest, "--title", "card-add") description = parse_flag(rest, "--description") client = build_client - card = Backlog::Cards.new(client: client).add(board_name: board, title: title, description: description) + card = build_cards(client).add(board_name: board, title: title, description: description) puts "created card ##{card.id} at Backlog on #{board}: #{card.name}" when "card-move" board = require_flag(rest, "--board", "card-move") card_id = require_flag(rest, "--card", "card-move") to = require_flag(rest, "--to", "card-move") client = build_client - card = Backlog::Cards.new(client: client).move(board_name: board, card_id: card_id, to: to) + card = build_cards(client).move(board_name: board, card_id: card_id, to: to) puts "moved card ##{card.id} to #{to} on #{board}" when "cards" board = require_flag(rest, "--board", "cards") client = build_client - snapshot = Backlog::Cards.new(client: client).snapshot(board_name: board) + snapshot = build_cards(client).snapshot(board_name: board) snapshot["lists"].each do |list| puts "#{list['name']} (#{list['cards'].size})" list["cards"].each do |card| @@ -180,19 +195,19 @@ when "cards" when "snapshot" board = require_flag(rest, "--board", "snapshot") client = build_client - puts JSON.pretty_generate(Backlog::Cards.new(client: client).snapshot(board_name: board)) + puts JSON.pretty_generate(build_cards(client).snapshot(board_name: board)) when "card-label" board = require_flag(rest, "--board", "card-label") card_id = require_flag(rest, "--card", "card-label") label = require_flag(rest, "--label", "card-label") client = build_client - Backlog::Cards.new(client: client).attach_label(board_name: board, card_id: card_id, label: label) + build_cards(client).attach_label(board_name: board, card_id: card_id, label: label) puts "labeled card ##{card_id} with #{label}" when "card-comment" card_id = require_flag(rest, "--card", "card-comment") text = require_flag(rest, "--text", "card-comment") client = build_client - comment = Backlog::Cards.new(client: client).comment(card_id: card_id, text: text) + comment = build_cards(client).comment(card_id: card_id, text: text) puts "commented on card ##{card_id} (comment ##{comment.id})" when "inspect" repo_path = Dir.pwd diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index fa3bc22..f598669 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -1,5 +1,6 @@ require_relative "backlog/board_spec" require_relative "backlog/board_ensurer" +require_relative "backlog/board_resolver" require_relative "backlog/cards" require_relative "backlog/config" require_relative "backlog/inspector" diff --git a/plugins/os-backlog/lib/backlog/board_resolver.rb b/plugins/os-backlog/lib/backlog/board_resolver.rb new file mode 100644 index 0000000..f7d510e --- /dev/null +++ b/plugins/os-backlog/lib/backlog/board_resolver.rb @@ -0,0 +1,86 @@ +require_relative "config" + +module Backlog + # Resolves a board name to its Planka board record, caching the resolved + # id as `board_id=` in the repo's `.cc-os/config` (issue #22). + # + # The tracker's board *name* stays the source of truth; the id is only a + # cache. The cached id is honored only when the config's tracker is + # exactly `planka:`, and a cached `boards.get(id)` hit counts + # only if the returned board's name still matches — a 404 or a rename + # falls back to the projects+boards name lookup and rewrites the cache. + # Any cache failure (unreadable config, unwritable config, stale id) + # degrades to the name lookup; it never fails the command. + # + # Filesystem access is injected: `read_config` returns the raw + # `.cc-os/config` contents (or nil), `write_config` persists updated + # contents. Cache writes go through Config.set, the comment-preserving + # writer — never Config.merge. + class BoardResolver + # @param client [Planka::Client] an already-authenticated client + # @param read_config [#call] -> String, nil — raw .cc-os/config contents + # @param write_config [#call] (String) -> void — persist updated contents + def initialize(client:, read_config:, write_config:) + @client = client + @read_config = read_config + @write_config = write_config + end + + # A resolver with no config access: always name-lookup, never caches. + def self.null(client:) + new(client: client, read_config: -> {}, write_config: ->(_) {}) + end + + # @param name [String] the board name (source of truth) + # @return [Planka::Types::Board] + # @raise [RuntimeError] if no board with that name exists + def board_for(name) + cached = cached_board(name) + return cached if cached + + board = lookup_by_name!(name) + cache_id(name, board.id) + board + end + + private + + def cached_board(name) + config = safe_config + return nil unless config && config.tracker == "planka:#{name}" + + id = config.board_id + return nil unless id + + board = @client.boards.get(id).board + board && board.name == name ? board : nil + rescue StandardError + nil + end + + def lookup_by_name!(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 cache_id(name, id) + contents = @read_config.call + config = Config.new(contents) + return unless config.tracker == "planka:#{name}" + return if config.board_id == id.to_s + + @write_config.call(Config.set(contents, "board_id", id)) + rescue StandardError + nil # cache write failure never fails the command + end + + def safe_config + Config.new(@read_config.call) + rescue StandardError + nil + end + end +end diff --git a/plugins/os-backlog/lib/backlog/cards.rb b/plugins/os-backlog/lib/backlog/cards.rb index cec5b87..0a0cb01 100644 --- a/plugins/os-backlog/lib/backlog/cards.rb +++ b/plugins/os-backlog/lib/backlog/cards.rb @@ -1,4 +1,5 @@ require_relative "board_spec" +require_relative "board_resolver" module Backlog # Card-level operations over an authenticated Planka client: create a @@ -12,8 +13,13 @@ module Backlog # Never deletes anything. class Cards # @param client [Planka::Client] an already-authenticated client - def initialize(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 @@ -163,11 +169,7 @@ module Backlog 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)" + @board_resolver.board_for(name) end def find_list!(board, list_name) diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb index 9b5b441..0bb46e4 100644 --- a/plugins/os-backlog/lib/backlog/config.rb +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -35,6 +35,13 @@ module Backlog @data["tracker"] end + # @return [String, nil] the cached Planka board id, if present. Only a + # cache — the tracker's board *name* stays the source of truth + # (issue #22); BoardResolver decides whether to honor it. + def board_id + @data["board_id"] + end + def configured? = !board.nil? def tracker_configured? = !tracker.nil? @@ -55,5 +62,35 @@ module Backlog data[key.to_s] = value.to_s data.map { |k, v| "#{k}=#{v}\n" }.join end + + # Like .merge but comment/format-preserving: every non-key line + # (comments, blanks) is left byte-for-byte untouched; existing + # key=value lines are re-emitted in the normalized no-spaces form; the + # key is replaced in place or appended if absent. Mirrors os-status's + # write_config_value (plugins/os-status/hooks/state.py) — the shared + # .cc-os/config contract owner. Pure — no filesystem. + # + # @param contents [String, nil] existing raw `.cc-os/config` contents + # @param key [String, Symbol] key to set + # @param value [Object] value to set it to + # @return [String] the updated contents + def self.set(contents, key, value) + key = key.to_s + found = false + out = contents.to_s.split("\n").map do |line| + stripped = line.strip + next line if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=") + + k, v = stripped.split("=", 2) + if k.strip == key + found = true + "#{key}=#{value}" + else + "#{k.strip}=#{v.strip}" + end + end + out << "#{key}=#{value}" unless found + "#{out.join("\n")}\n" + end end end diff --git a/plugins/os-backlog/tests/board_resolver_test.rb b/plugins/os-backlog/tests/board_resolver_test.rb new file mode 100644 index 0000000..9830800 --- /dev/null +++ b/plugins/os-backlog/tests/board_resolver_test.rb @@ -0,0 +1,135 @@ +require_relative "test_helper" + +class BoardResolverTest < Minitest::Test + include BacklogTestHelpers + + def setup + @client = FakePlankaClient.new + ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1") + @board = ensurer.ensure("llf-schema", project_name: "Dev").board + @writes = [] + end + + def resolver_with(config) + @config = config + Backlog::BoardResolver.new( + client: @client, + read_config: -> { @config }, + write_config: ->(contents) { @writes << contents } + ) + end + + def config_with_cache(id = @board.id) + "# repo config\ntracker=planka:llf-schema\nboard_id=#{id}\nversion=3\n" + end + + def test_cache_hit_uses_boards_get_and_skips_name_listing + @client.projects.define_singleton_method(:list) { raise "projects.list must not be called on a cache hit" } + resolver = resolver_with(config_with_cache) + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_empty @writes + end + + def test_cache_miss_resolves_by_name_and_writes_board_id_preserving_comments + resolver = resolver_with("# repo config\ntracker=planka:llf-schema\nversion=3\n") + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_equal 1, @writes.size + assert_equal "# repo config\ntracker=planka:llf-schema\nversion=3\nboard_id=#{@board.id}\n", + @writes.last + end + + def test_stale_id_404_falls_back_to_name_lookup_and_rewrites_cache + resolver = resolver_with(config_with_cache("no-such-id")) + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_includes @writes.last, "board_id=#{@board.id}\n" + assert_includes @writes.last, "# repo config" + end + + def test_renamed_board_name_mismatch_falls_back_and_rewrites_cache + stale = @board + @client.boards.update(stale.id, name: "something-else") + current = @client.boards.create(stale.project_id, name: "llf-schema", position: 2) + resolver = resolver_with(config_with_cache(stale.id)) + + board = resolver.board_for("llf-schema") + + assert_equal current.id, board.id + assert_includes @writes.last, "board_id=#{current.id}\n" + end + + def test_non_planka_tracker_ignores_board_id_and_never_writes + resolver = resolver_with("tracker=forgejo:jared/cc-os\nboard_id=bogus-id\n") + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_empty @writes + end + + def test_board_id_for_different_planka_board_name_is_ignored + resolver = resolver_with("tracker=planka:other-board\nboard_id=bogus-id\n") + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_empty @writes # tracker doesn't match this board name; no cache write either + end + + def test_unreadable_config_degrades_to_name_lookup + resolver = Backlog::BoardResolver.new( + client: @client, + read_config: -> { raise Errno::EACCES, "config" }, + write_config: ->(contents) { @writes << contents } + ) + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + assert_empty @writes + end + + def test_unwritable_config_never_fails_the_command + resolver = Backlog::BoardResolver.new( + client: @client, + read_config: -> { "tracker=planka:llf-schema\n" }, + write_config: ->(_) { raise Errno::EACCES, "config" } + ) + + board = resolver.board_for("llf-schema") + + assert_equal @board.id, board.id + end + + def test_matching_cached_id_is_not_rewritten + resolver = resolver_with(config_with_cache) + resolver.board_for("llf-schema") + + assert_empty @writes + end + + def test_unknown_board_still_raises + resolver = resolver_with(nil) + error = assert_raises(RuntimeError) { resolver.board_for("nope") } + assert_match(/no board named "nope"/, error.message) + end + + def test_cards_add_through_resolver_writes_cache_once + resolver = resolver_with("tracker=planka:llf-schema\n") + cards = Backlog::Cards.new(client: @client, board_resolver: resolver) + + card = cards.add(board_name: "llf-schema", title: "Cache me") + + assert_equal "Cache me", card.name + assert_equal 1, @writes.size + assert_includes @writes.last, "board_id=#{@board.id}\n" + end +end diff --git a/plugins/os-backlog/tests/config_test.rb b/plugins/os-backlog/tests/config_test.rb index 978cfd8..2b84056 100644 --- a/plugins/os-backlog/tests/config_test.rb +++ b/plugins/os-backlog/tests/config_test.rb @@ -93,4 +93,33 @@ class ConfigTest < Minitest::Test assert_equal "my-hub", updated.to_h["hub"] assert_equal "planka:board", updated.tracker end + + def test_board_id_reader + config = Backlog::Config.new("tracker=planka:board\nboard_id=42\n") + assert_equal "42", config.board_id + assert_nil Backlog::Config.new(nil).board_id + end + + def test_set_preserves_comments_and_blank_lines + existing = "# managed by cc-os\ntracker=planka:board\n\n# stamped by os-status\nversion=3\n" + updated = Backlog::Config.set(existing, "board_id", "42") + assert_equal "# managed by cc-os\ntracker=planka:board\n\n# stamped by os-status\nversion=3\nboard_id=42\n", + updated + end + + def test_set_replaces_existing_key_in_place + existing = "# top comment\nboard_id=old\ntracker=planka:board\n" + updated = Backlog::Config.set(existing, "board_id", "new") + assert_equal "# top comment\nboard_id=new\ntracker=planka:board\n", updated + end + + def test_set_normalizes_spaced_key_value_lines_only + existing = "# note\nversion = 1\n" + updated = Backlog::Config.set(existing, "board_id", "42") + assert_equal "# note\nversion=1\nboard_id=42\n", updated + end + + def test_set_on_nil_contents + assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42") + end end diff --git a/plugins/os-backlog/tests/test_helper.rb b/plugins/os-backlog/tests/test_helper.rb index 7db7a9b..652ef46 100644 --- a/plugins/os-backlog/tests/test_helper.rb +++ b/plugins/os-backlog/tests/test_helper.rb @@ -111,6 +111,11 @@ module BacklogTestHelpers def get(id) board = @client.boards_store.find { |b| b.id == id } + unless board + raise Planka::NotFoundError.new(status: 404, + body: { "message" => "board #{id} not found" }) + end + Planka::Types::BoardDetail.new( board: board, lists: @client.lists.list(id),