2026-07-12 19:54:08 +00:00
|
|
|
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
|
|
|
|
|
|
2026-07-13 14:32:58 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-07-12 19:54:08 +00:00
|
|
|
# @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
|