337 lines
14 KiB
Ruby
Executable File
337 lines
14 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
#
|
|
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add,
|
|
# cards, snapshot, inspect, config-write.
|
|
#
|
|
# Usage:
|
|
# os-backlog board-ensure NAME [--project PROJECT] (any Planka project name;
|
|
# defaults to Backlog::Resolver.project_for(Dir.pwd) — Dev/Clients/Servers
|
|
# by path, else "Dev"; creates the project if it doesn't exist yet)
|
|
# os-backlog activate NAME
|
|
# os-backlog archive NAME --yes
|
|
# os-backlog resolve < input.json
|
|
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]}
|
|
# os-backlog card-add --board NAME --title "..." [--description "..."]
|
|
# os-backlog card-move --board NAME --card ID --to COLUMN
|
|
# os-backlog cards --board NAME (human-readable per-list card listing)
|
|
# os-backlog snapshot --board NAME (full board state as JSON; read-only)
|
|
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
|
|
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
|
|
# + upsert the global project index)
|
|
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
|
|
#
|
|
# board-ensure/activate/archive talk to Planka over the network (the
|
|
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
|
|
# PASSWORD). resolve, config-write are pure — no network access at all.
|
|
# inspect touches the network/CLIs opportunistically but fails soft
|
|
# field-by-field rather than aborting.
|
|
#
|
|
# Fails soft: if the gem or Planka credentials are unavailable, prints one
|
|
# clear error and exits nonzero. Never destructive.
|
|
|
|
require "json"
|
|
require "fileutils"
|
|
require "shellwords"
|
|
require_relative "../lib/backlog"
|
|
|
|
def fail_soft(message)
|
|
warn "os-backlog: #{message}"
|
|
exit 1
|
|
end
|
|
|
|
def build_client
|
|
require "planka_api"
|
|
client = Planka::Client.new
|
|
client.login
|
|
client
|
|
rescue LoadError
|
|
fail_soft("the planka-api gem is not installed/loadable; cannot reach Planka")
|
|
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
|
|
|
|
# --project accepts any Planka project name (Dev/Clients/Servers, or a
|
|
# fresh one board-ensure will create). When --project is omitted, the
|
|
# default is derived from the repo's path via
|
|
# Backlog::Resolver.project_for(Dir.pwd), falling back to "Dev" for paths
|
|
# outside every known root.
|
|
def parse_project_flag(args)
|
|
idx = args.index("--project")
|
|
return Backlog::Resolver.project_for(Dir.pwd) || "Dev" unless idx
|
|
|
|
args.delete_at(idx)
|
|
project = args.delete_at(idx)
|
|
project || fail_soft("--project requires a value (a Planka project name)")
|
|
end
|
|
|
|
def parse_flag(args, flag)
|
|
idx = args.index(flag)
|
|
return nil unless idx
|
|
|
|
args.delete_at(idx)
|
|
args.delete_at(idx)
|
|
end
|
|
|
|
def require_flag(args, flag, command)
|
|
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
|
|
end
|
|
|
|
# Best-effort Planka check for `inspect`: does an existing board already
|
|
# match this repo per the resolver's own conventions. Never fails the whole
|
|
# `inspect` command — reports unavailability instead.
|
|
def inspect_planka(repo_path)
|
|
require "planka_api"
|
|
client = Planka::Client.new
|
|
client.login
|
|
boards = client.projects.list.flat_map { |project| client.boards.list(project.id).map(&:name) }
|
|
resolver = Backlog::Resolver.new(repo_path: repo_path, config_contents: nil, boards: boards)
|
|
{ "checked" => true, "decision" => resolver.resolve_string }
|
|
rescue LoadError
|
|
{ "checked" => false, "reason" => "planka-api gem not installed" }
|
|
rescue StandardError => e
|
|
{ "checked" => false, "reason" => e.message }
|
|
end
|
|
|
|
# Best-effort open-issue count + lightweight per-issue metadata via tea
|
|
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
|
|
# soft per-field: any fetch/parse failure means metadata nil. Returns
|
|
# [issue_cli_hash, metadata_or_nil] — the metadata feeds the pure
|
|
# Backlog::Inspector.classify_issue_shape and never appears in the JSON.
|
|
def inspect_issue_cli(remote_info)
|
|
unless remote_info && %i[forgejo github].include?(remote_info[:kind])
|
|
return [{ "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }, nil]
|
|
end
|
|
|
|
repo_slug = "#{remote_info[:owner]}/#{remote_info[:repo]}"
|
|
if remote_info[:kind] == :forgejo
|
|
tea_issues(repo_slug)
|
|
else
|
|
gh_issues(repo_slug)
|
|
end
|
|
end
|
|
|
|
def tea_issues(repo_slug)
|
|
return [{ "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" }, nil] unless system("which tea > /dev/null 2>&1")
|
|
|
|
out = `tea issues list --repo #{repo_slug} --state open --fields index,title,labels,body --output json 2>/dev/null`
|
|
parsed = JSON.parse(out)
|
|
issues = parsed.map do |issue|
|
|
{ number: issue["index"].to_i, title: issue["title"],
|
|
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
|
|
body: issue["body"] }
|
|
end
|
|
[{ "tool" => "tea", "open_count" => issues.size }, issues]
|
|
rescue StandardError
|
|
# JSON mode unavailable/unparsable: fall back to the plain line count.
|
|
out = `tea issues list --repo #{repo_slug} --state open 2>/dev/null`
|
|
lines = out.to_s.lines.map(&:strip).reject(&:empty?)
|
|
[{ "tool" => "tea", "open_count" => lines.size }, nil]
|
|
end
|
|
|
|
def gh_issues(repo_slug)
|
|
return [{ "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" }, nil] unless system("which gh > /dev/null 2>&1")
|
|
|
|
out = `gh issue list --repo #{repo_slug} --state open --json number,title,labels,body 2>/dev/null`
|
|
parsed = JSON.parse(out)
|
|
issues = parsed.map do |issue|
|
|
{ number: issue["number"].to_i, title: issue["title"],
|
|
labels: Array(issue["labels"]).map { |label| label["name"] },
|
|
body: issue["body"] }
|
|
end
|
|
[{ "tool" => "gh", "open_count" => issues.size }, issues]
|
|
rescue StandardError
|
|
[{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil]
|
|
end
|
|
|
|
# Canonical path for the global project index: the realpath of cwd — the
|
|
# directory config-write just wrote .cc-os/config into, i.e. the project
|
|
# the row describes. Deliberately NOT the git toplevel: in an umbrella
|
|
# repo a subdirectory's config is its own project, and keying on the
|
|
# toplevel would make every subproject overwrite the umbrella's row.
|
|
# Never raises.
|
|
def canonical_repo_path
|
|
File.realpath(Dir.pwd)
|
|
rescue StandardError
|
|
Dir.pwd
|
|
end
|
|
|
|
# Best-effort upsert of the global project index row after a successful
|
|
# config-write. Index failures must never fail config-write — warn at most.
|
|
def update_project_index(tracker)
|
|
repo_path = canonical_repo_path
|
|
remote = `git remote get-url origin 2>/dev/null`.strip
|
|
remote = nil if remote.empty?
|
|
Backlog::ProjectIndex.new.upsert(path: repo_path, name: File.basename(repo_path),
|
|
tracker: tracker, remote: remote)
|
|
rescue StandardError => e
|
|
warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
|
|
end
|
|
|
|
command, *rest = ARGV
|
|
|
|
begin
|
|
case command
|
|
when "board-ensure"
|
|
name = rest.shift
|
|
fail_soft("board-ensure requires a board name") unless name
|
|
project = parse_project_flag(rest)
|
|
client = build_client
|
|
result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project)
|
|
puts "board #{result.board.name} (#{result.created ? 'created' : 'existing'}) " \
|
|
"in project #{project}; lists created: #{result.lists_created.join(', ')}; " \
|
|
"labels created: #{result.labels_created.join(', ')}"
|
|
when "activate"
|
|
name = rest.shift
|
|
fail_soft("activate requires a board name") unless name
|
|
client = build_client
|
|
board = Backlog::BoardEnsurer.new(client: client).activate(name)
|
|
puts "activated: #{board.name}"
|
|
when "archive"
|
|
name = rest.shift
|
|
fail_soft("archive requires a board name") unless name
|
|
fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes")
|
|
|
|
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 "card-add"
|
|
board = require_flag(rest, "--board", "card-add")
|
|
title = require_flag(rest, "--title", "card-add")
|
|
description = parse_flag(rest, "--description")
|
|
client = build_client
|
|
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 = 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 = build_cards(client).snapshot(board_name: board)
|
|
snapshot["lists"].each do |list|
|
|
puts "#{list['name']} (#{list['cards'].size})"
|
|
list["cards"].each do |card|
|
|
labels = card["labels"].empty? ? "" : " [#{card['labels'].join(', ')}]"
|
|
puts " - #{card['name']}#{labels}"
|
|
end
|
|
end
|
|
when "snapshot"
|
|
board = require_flag(rest, "--board", "snapshot")
|
|
client = build_client
|
|
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
|
|
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 = 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
|
|
config_path = File.join(repo_path, ".cc-os", "config")
|
|
config_contents = File.exist?(config_path) ? File.read(config_path) : nil
|
|
config = Backlog::Config.new(config_contents)
|
|
|
|
remote_output = `git -C #{Shellwords.escape(repo_path)} remote -v 2>/dev/null`
|
|
remote_info = Backlog::Tracker.parse_remote(remote_output)
|
|
|
|
issue_cli, issue_metadata = inspect_issue_cli(remote_info)
|
|
findings = {
|
|
"repo_path" => repo_path,
|
|
"tracker_configured" => config.tracker,
|
|
"planka" => inspect_planka(repo_path),
|
|
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
|
|
"issue_cli" => issue_cli,
|
|
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
|
|
"in_repo_issue_files" => Backlog::Inspector.issue_files(repo_path)
|
|
}
|
|
puts JSON.pretty_generate(findings)
|
|
when "config-write"
|
|
value = rest.shift
|
|
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
|
|
unless Backlog::Tracker.valid?(value)
|
|
fail_soft("invalid tracker value #{value.inspect}; expected one of " \
|
|
"planka:<board> | forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
|
|
end
|
|
|
|
config_dir = File.join(Dir.pwd, ".cc-os")
|
|
config_path = File.join(config_dir, "config")
|
|
existing = File.exist?(config_path) ? File.read(config_path) : nil
|
|
updated = Backlog::Config.merge(existing, "tracker", value)
|
|
FileUtils.mkdir_p(config_dir)
|
|
File.write(config_path, updated)
|
|
update_project_index(value)
|
|
puts "tracker set to #{value} in #{config_path}"
|
|
when "projects"
|
|
filter = rest.shift
|
|
projects = Backlog::ProjectIndex.new.all
|
|
if filter
|
|
projects = projects.select do |path, row|
|
|
[row["name"], row["tracker"], path].any? { |field| field.to_s.include?(filter) }
|
|
end
|
|
end
|
|
puts JSON.pretty_generate({ "projects" => projects })
|
|
when nil, "-h", "--help"
|
|
puts <<~USAGE
|
|
usage: os-backlog <command> [options]
|
|
|
|
commands:
|
|
board-ensure NAME [--project PROJECT] (any project name; creates it if missing;
|
|
default derived from cwd path)
|
|
activate NAME
|
|
archive NAME --yes
|
|
resolve (reads {"repo_path","config","boards"} JSON from stdin)
|
|
card-add --board NAME --title "..." [--description "..."]
|
|
card-move --board NAME --card ID --to COLUMN
|
|
cards --board NAME
|
|
snapshot --board NAME
|
|
card-label --board NAME --card ID --label NAME
|
|
card-comment --card ID --text "..."
|
|
inspect (detect existing tracking for cwd repo, JSON)
|
|
config-write <tracker-value> (write tracker key to .cc-os/config + global project index)
|
|
projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
|
|
USAGE
|
|
exit(command.nil? ? 1 : 0)
|
|
else
|
|
fail_soft("unknown command #{command.inspect}")
|
|
end
|
|
rescue SystemExit
|
|
raise
|
|
rescue StandardError => e
|
|
fail_soft(e.message)
|
|
end
|