From 8d4b1c7f4643c732b090dfbb9a17836e690a302b Mon Sep 17 00:00:00 2001 From: jared Date: Fri, 10 Jul 2026 13:36:08 -0400 Subject: [PATCH] os-backlog: routing resolver CLI (refs #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " / "activate " / "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 Claude-Session: https://claude.ai/code/session_01XknQRvihHDpYE47RTmUR4N --- plugins/os-backlog/bin/os-backlog | 19 ++++- plugins/os-backlog/lib/backlog.rb | 2 + plugins/os-backlog/lib/backlog/config.rb | 22 +++++ plugins/os-backlog/lib/backlog/resolver.rb | 76 ++++++++++++++++++ plugins/os-backlog/tests/config_test.rb | 30 +++++++ plugins/os-backlog/tests/resolver_test.rb | 93 ++++++++++++++++++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 plugins/os-backlog/lib/backlog/config.rb create mode 100644 plugins/os-backlog/lib/backlog/resolver.rb create mode 100644 plugins/os-backlog/tests/config_test.rb create mode 100644 plugins/os-backlog/tests/resolver_test.rb diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 3253c51..8196c55 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -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 [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 diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index bfbe912..87db9db 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -1,2 +1,4 @@ require_relative "backlog/board_spec" require_relative "backlog/board_ensurer" +require_relative "backlog/config" +require_relative "backlog/resolver" diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb new file mode 100644 index 0000000..244e52d --- /dev/null +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -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 diff --git a/plugins/os-backlog/lib/backlog/resolver.rb b/plugins/os-backlog/lib/backlog/resolver.rb new file mode 100644 index 0000000..97d13ed --- /dev/null +++ b/plugins/os-backlog/lib/backlog/resolver.rb @@ -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 " - an active board to work against + # "activate " - 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] 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 " / "activate " / "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 diff --git a/plugins/os-backlog/tests/config_test.rb b/plugins/os-backlog/tests/config_test.rb new file mode 100644 index 0000000..15532a5 --- /dev/null +++ b/plugins/os-backlog/tests/config_test.rb @@ -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 diff --git a/plugins/os-backlog/tests/resolver_test.rb b/plugins/os-backlog/tests/resolver_test.rb new file mode 100644 index 0000000..fc11a93 --- /dev/null +++ b/plugins/os-backlog/tests/resolver_test.rb @@ -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