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
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# os-backlog CLI: board-ensure, activate, archive.
|
||||
# os-backlog CLI: board-ensure, activate, archive, resolve.
|
||||
#
|
||||
# Usage:
|
||||
# os-backlog board-ensure NAME --project Dev|Clients
|
||||
# os-backlog activate NAME
|
||||
# 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,
|
||||
# credentials from PLANKA_BASE_URL/USERNAME/PASSWORD).
|
||||
# board-ensure/activate/archive talk to Planka over the network (the
|
||||
# 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
|
||||
# clear error and exits nonzero. Never destructive.
|
||||
|
||||
require "json"
|
||||
require_relative "../lib/backlog"
|
||||
|
||||
def fail_soft(message)
|
||||
|
|
@ -67,6 +71,14 @@ when "archive"
|
|||
client = build_client
|
||||
board = Backlog::BoardEnsurer.new(client: client).archive(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"
|
||||
puts <<~USAGE
|
||||
usage: os-backlog <command> [options]
|
||||
|
|
@ -75,6 +87,7 @@ when nil, "-h", "--help"
|
|||
board-ensure NAME --project Dev|Clients
|
||||
activate NAME
|
||||
archive NAME --yes
|
||||
resolve (reads {"repo_path","config","boards"} JSON from stdin)
|
||||
USAGE
|
||||
exit(command.nil? ? 1 : 0)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
require_relative "backlog/board_spec"
|
||||
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