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:
parent
90db409721
commit
46b0584643
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue