220 lines
8.3 KiB
Ruby
220 lines
8.3 KiB
Ruby
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
|