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

82 lines
3.3 KiB
Ruby
Raw Permalink Normal View History

module Backlog
# Filesystem-only detection helpers for the `inspect` subcommand: does the
# repo already have in-repo issue files. Deliberately narrow — network
# checks (Planka, tea, gh) stay in bin/os-backlog where the rest of the
# CLI's IO lives; this class exists so that piece is unit-testable without
# touching the network or shelling out.
class Inspector
CANDIDATE_DIRS = ["docs/issues"].freeze
CANDIDATE_FILES = ["ISSUES.md"].freeze
# Labels that mark an issue as part of a dependency chain. Matched as
# whole (hyphen-preserving) tokens, case-insensitively — "not-blocked"
# does NOT match "blocked".
CHAIN_LABELS = %w[blocked blocked-by depends depends-on prereq
prerequisite waiting needs].freeze
# Dependency language that must appear near a cross-reference for it to
# count as chain evidence — a plain "fixed in #12" does not.
DEPENDENCY_LANGUAGE = /\b(blocked|depends|after|requires|prereq|follows)/i
CROSS_REF_WINDOW = 40 # chars either side of the "#N" reference
# Pure classifier for the shape of a repo's open git issues — no shell,
# no network. Feeds the route skill's split-vs-migrate recommendation
# (ADR-0033: git issues are specs; a chain of interdependent issues is
# spec-shaped and should stay put).
#
# @param issues [Array<Hash>, nil] one hash per open issue:
# {number:, title:, labels: [String], body:}
# @return [String, nil] "sequential-chain" | "flat-adhoc" | "mixed", or
# nil when there's no metadata or fewer than 3 issues (no evidence,
# which is distinct from evidence of flatness)
def self.classify_issue_shape(issues)
return nil if issues.nil? || issues.size < 3
numbers = issues.map { |issue| issue[:number].to_i }
with_evidence = issues.count { |issue| chain_evidence?(issue, numbers) }
return "flat-adhoc" if with_evidence.zero?
with_evidence * 3 >= issues.size * 2 ? "sequential-chain" : "mixed"
end
def self.chain_evidence?(issue, numbers)
chain_label?(issue[:labels]) ||
chain_cross_reference?(issue[:body], own_number: issue[:number].to_i, numbers: numbers)
end
def self.chain_label?(labels)
Array(labels).any? do |label|
tokens = label.to_s.downcase.scan(/[a-z][a-z-]*[a-z]|[a-z]/)
tokens.any? { |token| CHAIN_LABELS.include?(token) }
end
end
def self.chain_cross_reference?(body, own_number:, numbers:)
text = body.to_s
text.enum_for(:scan, /#(\d+)/).any? do
match = Regexp.last_match
referenced = match[1].to_i
next false if referenced == own_number || !numbers.include?(referenced)
window_start = [match.begin(0) - CROSS_REF_WINDOW, 0].max
window = text[window_start, (match.begin(0) - window_start) + match[0].length + CROSS_REF_WINDOW]
window.match?(DEPENDENCY_LANGUAGE)
end
end
# @param repo_path [String] absolute path to the repo
# @return [Array<String>] repo-relative paths of in-repo issue tracking
# found (empty if none)
def self.issue_files(repo_path)
found = []
CANDIDATE_DIRS.each do |rel|
found << rel if Dir.exist?(File.join(repo_path, rel))
end
CANDIDATE_FILES.each do |rel|
found << rel if File.exist?(File.join(repo_path, rel))
end
found
end
end
end