#!/usr/bin/env ruby # frozen_string_literal: true # # os-backlog CLI: resolve, inspect, config-write, projects, issue-create, # issues. Post-ADR-0042 (Planka retired): git issues (forgejo/github/repo) # are the single tracker for both state and specs. # # Usage: # os-backlog resolve < input.json # input.json: {"repo_path": "...", "config": "..."|null} # os-backlog inspect (detect existing tracking for cwd repo, JSON) # os-backlog config-write (write tracker key to .cc-os/config # + upsert the global project index) # os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON) # os-backlog issue-create --title "..." --body "..." [--priority P0-P3] # (creates an issue on the cwd repo's configured tracker; never applies # a state label) # os-backlog issues (open issues on the cwd repo's # configured tracker, grouped by # state label, JSON) # # resolve, config-write are pure — no network access at all. inspect, # issue-create, and issues touch the network/CLIs (tea/gh) but fail soft # field-by-field (inspect) or with a one-line error (issue-create/issues) # rather than leaving a stack trace. # # Fails soft: never destructive. require "json" require "fileutils" require "shellwords" require_relative "../lib/backlog" def fail_soft(message) warn "os-backlog: #{message}" exit 1 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} ") 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 # The cwd repo's configured tracker, or nil (no .cc-os/config, or no # tracker key set). Shared by issue-create/issues. def cwd_tracker config_path = File.join(Dir.pwd, ".cc-os", "config") contents = File.exist?(config_path) ? File.read(config_path) : nil Backlog::Config.new(contents).tracker end def require_cwd_tracker(command) tracker = cwd_tracker return tracker if tracker && Backlog::Tracker.valid?(tracker) fail_soft("#{command}: no valid tracker configured for #{Dir.pwd} — run /os-backlog:route first") end command, *rest = ARGV begin case command when "resolve" input = JSON.parse($stdin.read) resolver = Backlog::Resolver.new( repo_path: input.fetch("repo_path"), config_contents: input["config"] ) puts resolver.resolve_string 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, "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 if value.start_with?("planka:") fail_soft("planka: trackers are retired (ADR-0042) — run /os-backlog:route to " \ "choose forgejo:/github:/repo: instead") end unless Backlog::Tracker.valid?(value) fail_soft("invalid tracker value #{value.inspect}; expected one of " \ "forgejo:/ | github:/ | repo:") 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 "issue-create" title = require_flag(rest, "--title", "issue-create") body = require_flag(rest, "--body", "issue-create") || "" priority = parse_flag(rest, "--priority") tracker = require_cwd_tracker("issue-create") issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd) begin result = issues.create(tracker: tracker, title: title, body: body, priority: priority) rescue ArgumentError, Backlog::Issues::CommandFailed => e fail_soft("issue-create: #{e.message}") end puts JSON.pretty_generate(result.transform_keys(&:to_s)) when "issues" tracker = require_cwd_tracker("issues") issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd) begin grouped = issues.list(tracker: tracker) rescue ArgumentError, Backlog::Issues::CommandFailed => e fail_soft("issues: #{e.message}") end puts JSON.pretty_generate(grouped.transform_keys(&:to_s).transform_values { |v| v.map { |i| i.transform_keys(&:to_s) } }) when nil, "-h", "--help" puts <<~USAGE usage: os-backlog [options] commands: resolve (reads {"repo_path","config"} JSON from stdin) inspect (detect existing tracking for cwd repo, JSON) config-write (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) issue-create --title "..." --body "..." [--priority P0-P3] (create an issue on the cwd repo's configured tracker) issues (list open issues on the cwd repo's configured tracker, grouped by state label, 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