cc-os/plugins/os-backlog/lib/backlog/issues.rb

220 lines
8.3 KiB
Ruby
Raw Normal View History

require "json"
require "time"
require "fileutils"
module Backlog
# Issue-side capture/list helpers (ADR-0042: one tracker holds both state
# and specs, git issues are the single tracker). Dispatches on the
# tracker's kind: `tea` for forgejo:, `gh` for github:, in-repo markdown
# files for repo:<path>.
#
# The git-host CLIs are shelled out to via an injected #capture(*cmd)
# runner — [stdout, success?], mirroring the tail of Open3.capture3 — so
# tests stub the runner object directly instead of hacking PATH
# (ShellRunner below is the only piece that actually shells out). The
# repo: file mode does real filesystem IO directly (same style as
# Inspector), exercised in tests via tmpdir.
class Issues
# Priority labels capture may attach. Deliberately excludes every state
# label (`next`/`waiting`/`review`) — there is no parameter through
# which a state label can reach #create, so it can never apply `next`
# (issue-state-labels spec: next is human-only).
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
STATE_LABELS = %w[next waiting review].freeze
CommandFailed = Class.new(StandardError)
# Default runner: actually shells out via Open3. The only non-pure
# piece of this file; production CLI wiring uses it, tests never do.
class ShellRunner
def capture(*cmd)
require "open3"
out, _err, status = Open3.capture3(*cmd)
[out, status.success?]
end
end
# @param runner [#capture] responds to #capture(*cmd) -> [stdout, success?]
# @param repo_root [String] base directory a repo:<path> tracker's path is relative to
# @param clock [#call] returns the current Time; injected for repo: issue dating
def initialize(runner:, repo_root: Dir.pwd, clock: -> { Time.now })
@runner = runner
@repo_root = repo_root
@clock = clock
end
# Create an issue with a title, body, and optional priority label
# (P0-P3). Never applies a state label.
#
# @return [Hash] {number:, url:} for forgejo:/github:, {number:, path:} for repo:
def create(tracker:, title:, body:, priority: nil)
validate_priority!(priority)
case Tracker.kind(tracker)
when :forgejo then create_tea(slug(tracker), title: title, body: body, priority: priority)
when :github then create_gh(slug(tracker), title: title, body: body, priority: priority)
when :repo then create_file(rel_path(tracker), title: title, body: body, priority: priority)
else raise ArgumentError, "cannot create an issue on unroutable tracker #{tracker.inspect}"
end
end
# Open issues, grouped by state label.
#
# @return [Hash{Symbol=>Array<Hash>}] :next / :waiting / :review / :other
# => arrays of {number:, title:, labels:, assignee:}; :next first is
# the caller's job to render, not this method's (it returns a plain
# Hash, insertion-ordered next/waiting/review/other).
def list(tracker:)
issues =
case Tracker.kind(tracker)
when :forgejo then list_tea(slug(tracker))
when :github then list_gh(slug(tracker))
when :repo then list_files(rel_path(tracker))
else raise ArgumentError, "cannot list issues on unroutable tracker #{tracker.inspect}"
end
group_by_state(issues)
end
private
def validate_priority!(priority)
return if priority.nil? || PRIORITY_LABELS.include?(priority)
raise ArgumentError, "invalid priority #{priority.inspect}; expected one of #{PRIORITY_LABELS.join(', ')}"
end
def slug(tracker) = tracker.split(":", 2).last
def rel_path(tracker) = tracker.split(":", 2).last
def group_by_state(issues)
buckets = { next: [], waiting: [], review: [], other: [] }
issues.each do |issue|
labels = Array(issue[:labels])
key = STATE_LABELS.find { |label| labels.include?(label) }&.to_sym || :other
buckets[key] << issue
end
buckets
end
# --- forgejo (tea) ---------------------------------------------------
def create_tea(repo_slug, title:, body:, priority:)
cmd = ["tea", "issue", "create", "--repo", repo_slug, "--title", title, "--description", body]
cmd += ["--labels", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_tea(repo_slug)
cmd = ["tea", "issues", "list", "--repo", repo_slug, "--state", "open",
"--fields", "index,title,labels,assignees", "--output", "json"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issues list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
assignee = issue["assignees"].to_s.split(",").map(&:strip).reject(&:empty?).first
{ number: issue["index"].to_i, title: issue["title"],
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: assignee }
end
end
# --- github (gh) ------------------------------------------------------
def create_gh(repo_slug, title:, body:, priority:)
cmd = ["gh", "issue", "create", "--repo", repo_slug, "--title", title, "--body", body]
cmd += ["--label", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_gh(repo_slug)
cmd = ["gh", "issue", "list", "--repo", repo_slug, "--state", "open",
"--json", "number,title,labels,assignees"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
{ number: issue["number"].to_i, title: issue["title"],
labels: Array(issue["labels"]).map { |label| label["name"] },
assignee: Array(issue["assignees"]).map { |a| a["login"] }.first }
end
end
def extract_number(text)
match = text[/#(\d+)/, 1] || text[%r{/issues/(\d+)}, 1]
match&.to_i
end
def extract_url(text)
text.to_s[%r{https?://\S+}] || text.to_s.strip
end
# --- repo: (in-repo markdown issue files) ------------------------------
#
# `<path>/NNN-<slug>.md`, front matter + body. No existing in-repo
# convention for individual issue files predates this (Inspector only
# detects the presence of a docs/issues dir or ISSUES.md, not a file
# shape) — this is a new, deliberately simple convention.
def create_file(rel_dir, title:, body:, priority:)
dir = File.join(@repo_root, rel_dir)
FileUtils.mkdir_p(dir)
number = next_file_number(dir)
path = File.join(dir, format("%03d-%s.md", number, slugify(title)))
File.write(path, file_contents(number: number, title: title, body: body, priority: priority))
{ number: number, path: path }
end
def slugify(title)
title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
end
def file_contents(number:, title:, body:, priority:)
front = {
"number" => number.to_s,
"title" => title.to_s,
"labels" => [priority].compact.join(","),
"state" => "open",
"created" => @clock.call.strftime("%Y-%m-%d")
}
lines = ["---"] + front.map { |k, v| "#{k}: #{v}" } + ["---", ""]
"#{lines.join("\n")}\n#{body}\n"
end
def next_file_number(dir)
existing = Dir.glob(File.join(dir, "[0-9][0-9][0-9]-*.md"))
existing.map { |f| File.basename(f)[/\A(\d+)-/, 1].to_i }.max.to_i + 1
end
def list_files(rel_dir)
dir = File.join(@repo_root, rel_dir)
return [] unless Dir.exist?(dir)
Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse_issue_file(path) }
end
def parse_issue_file(path)
contents = File.read(path)
return nil unless contents.start_with?("---\n")
_, front_matter, = contents.split("---\n", 3)
data = {}
front_matter.to_s.each_line do |line|
key, value = line.split(":", 2)
next unless key && value
data[key.strip] = value.strip
end
return nil unless data["state"].to_s.empty? || data["state"] == "open"
{ number: data["number"].to_i, title: data["title"],
labels: data["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: data["assignee"] }
end
end
end