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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
This commit is contained in:
jared 2026-07-13 09:18:45 -04:00
parent 90db409721
commit 46b0584643
8 changed files with 322 additions and 12 deletions

View File

@ -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

View File

@ -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"

View File

@ -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=<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:<board_name>`, 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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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),