os-backlog: routing resolver CLI (refs #12)

Pure repo -> board decision function (Backlog::Resolver) plus the
`resolve` os-backlog subcommand: given repo path, .cc-os/config contents,
and a board-name inventory (all passed in — no filesystem or network
access in the resolver itself), returns exactly one of "use <board>" /
"activate <archived board>" / "stop-and-discuss". An explicit config
board takes precedence; otherwise the board name is derived from the
repo/client dir basename with project inferred from ~/dev vs ~/clients.
Unmapped paths, and configured-but-unmatched boards, both resolve to
stop-and-discuss rather than silently falling back. Pure Ruby unit tests,
no network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XknQRvihHDpYE47RTmUR4N
This commit is contained in:
jared 2026-07-10 13:36:08 -04:00
parent 9a0e8a79fb
commit 8d4b1c7f46
6 changed files with 239 additions and 3 deletions

View File

@ -1,19 +1,23 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# frozen_string_literal: true # frozen_string_literal: true
# #
# os-backlog CLI: board-ensure, activate, archive. # os-backlog CLI: board-ensure, activate, archive, resolve.
# #
# Usage: # Usage:
# os-backlog board-ensure NAME --project Dev|Clients # os-backlog board-ensure NAME --project Dev|Clients
# os-backlog activate NAME # os-backlog activate NAME
# os-backlog archive NAME --yes # os-backlog archive NAME --yes
# os-backlog resolve < input.json
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]}
# #
# Talks to Planka over the network (the installed planka-api gem, # board-ensure/activate/archive talk to Planka over the network (the
# credentials from PLANKA_BASE_URL/USERNAME/PASSWORD). # installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
# PASSWORD). resolve is pure — no network access at all.
# #
# Fails soft: if the gem or Planka credentials are unavailable, prints one # Fails soft: if the gem or Planka credentials are unavailable, prints one
# clear error and exits nonzero. Never destructive. # clear error and exits nonzero. Never destructive.
require "json"
require_relative "../lib/backlog" require_relative "../lib/backlog"
def fail_soft(message) def fail_soft(message)
@ -67,6 +71,14 @@ when "archive"
client = build_client client = build_client
board = Backlog::BoardEnsurer.new(client: client).archive(name) board = Backlog::BoardEnsurer.new(client: client).archive(name)
puts "archived: #{board['name']}" puts "archived: #{board['name']}"
when "resolve"
input = JSON.parse($stdin.read)
resolver = Backlog::Resolver.new(
repo_path: input.fetch("repo_path"),
config_contents: input["config"],
boards: input.fetch("boards", [])
)
puts resolver.resolve_string
when nil, "-h", "--help" when nil, "-h", "--help"
puts <<~USAGE puts <<~USAGE
usage: os-backlog <command> [options] usage: os-backlog <command> [options]
@ -75,6 +87,7 @@ when nil, "-h", "--help"
board-ensure NAME --project Dev|Clients board-ensure NAME --project Dev|Clients
activate NAME activate NAME
archive NAME --yes archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin)
USAGE USAGE
exit(command.nil? ? 1 : 0) exit(command.nil? ? 1 : 0)
else else

View File

@ -1,2 +1,4 @@
require_relative "backlog/board_spec" require_relative "backlog/board_spec"
require_relative "backlog/board_ensurer" require_relative "backlog/board_ensurer"
require_relative "backlog/config"
require_relative "backlog/resolver"

View File

@ -0,0 +1,22 @@
require "yaml"
module Backlog
# The parsed contents of a repo's `.cc-os/config` (YAML). Only the
# `board` key matters to the resolver: an explicit board-name override.
# Never touches the filesystem itself — callers read the file and pass
# its contents (or nil, if absent) in.
class Config
def initialize(contents)
@data = contents.to_s.strip.empty? ? {} : (YAML.safe_load(contents) || {})
rescue Psych::SyntaxError
@data = {}
end
# @return [String, nil] the explicit board name override, if configured
def board
@data["board"]
end
def configured? = !board.nil?
end
end

View File

@ -0,0 +1,76 @@
require_relative "board_spec"
require_relative "config"
module Backlog
# Pure repo -> board routing decision. No filesystem, no network: every
# input (repo path, config contents, board inventory) is passed in.
#
# Decision, exactly one of:
# "use <board>" - an active board to work against
# "activate <archived board>" - an archived board matches; rename it back
# "stop-and-discuss" - no confident match; ask the human
class Resolver
Decision = Struct.new(:action, :board) do
def to_s
action == :use || action == :activate ? "#{action} #{board}" : "stop-and-discuss"
end
end
DEV_ROOT = File.expand_path("~/dev")
CLIENTS_ROOT = File.expand_path("~/clients")
# @param repo_path [String] absolute path to the repo
# @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
# @param boards [Array<String>] board names known to exist on the Planka instance
# (both active and archived--prefixed names)
def initialize(repo_path:, config_contents:, boards:)
@repo_path = File.expand_path(repo_path)
@config = Config.new(config_contents)
@boards = boards
end
# @return [Decision]
def resolve
return decision_for_name(@config.board) || stop_and_discuss if @config.configured?
derived_decision || stop_and_discuss
end
# @return [String] one of "use <board>" / "activate <board>" / "stop-and-discuss"
def resolve_string = resolve.to_s
private
def derived_decision
return nil if project.nil?
decision_for_name(board_name)
end
def decision_for_name(name)
return use(name) if @boards.include?(name)
return activate(name) if @boards.include?(BoardSpec.archived_name(name))
nil
end
def stop_and_discuss = Decision.new(:stop_and_discuss, nil)
def use(name) = Decision.new(:use, name)
def activate(name) = Decision.new(:activate, BoardSpec.archived_name(name))
def board_name = File.basename(@repo_path)
# "Dev" for paths under ~/dev/*, "Clients" for paths under ~/clients/*,
# nil (ambiguous) otherwise.
def project
return "Dev" if under?(DEV_ROOT)
return "Clients" if under?(CLIENTS_ROOT)
nil
end
def under?(root)
@repo_path == root || @repo_path.start_with?("#{root}/")
end
end
end

View File

@ -0,0 +1,30 @@
require_relative "test_helper"
class ConfigTest < Minitest::Test
def test_nil_contents_is_unconfigured
config = Backlog::Config.new(nil)
refute config.configured?
assert_nil config.board
end
def test_blank_contents_is_unconfigured
config = Backlog::Config.new(" \n")
refute config.configured?
end
def test_reads_board_key
config = Backlog::Config.new("board: llf-schema\n")
assert config.configured?
assert_equal "llf-schema", config.board
end
def test_malformed_yaml_is_treated_as_unconfigured
config = Backlog::Config.new("board: [unterminated\n")
refute config.configured?
end
def test_yaml_without_board_key_is_unconfigured
config = Backlog::Config.new("other: value\n")
refute config.configured?
end
end

View File

@ -0,0 +1,93 @@
require_relative "test_helper"
class ResolverTest < Minitest::Test
def test_configured_project_uses_configured_board
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "board: llf-schema\n",
boards: %w[llf-schema other-board]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_dev_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: nil,
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_client_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/clients/acme-co",
config_contents: nil,
boards: %w[acme-co]
)
assert_equal "use acme-co", resolver.resolve_string
end
def test_matching_archived_board_activates
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/old-project",
config_contents: nil,
boards: %w[archived--old-project]
)
assert_equal "activate archived--old-project", resolver.resolve_string
end
def test_configured_archived_board_activates
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "board: old-project\n",
boards: %w[archived--old-project]
)
assert_equal "activate archived--old-project", resolver.resolve_string
end
def test_no_match_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/brand-new-repo",
config_contents: nil,
boards: %w[unrelated-board]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_path_outside_dev_and_clients_is_ambiguous
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/scratch/some-repo",
config_contents: nil,
boards: %w[some-repo]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_configured_board_with_no_match_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "board: nonexistent\n",
boards: %w[llf-schema]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_empty_config_contents_is_treated_as_unconfigured
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: "",
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
end