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:. # # 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: 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}] :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) ------------------------------ # # `/NNN-.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