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:
parent
9a0e8a79fb
commit
8d4b1c7f46
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue