os-backlog: route inspect issue-shape classification, split-by-kind default (issue #26)
inspect fetches remote-issue metadata (fail-soft) and emits issue_shape: sequential-chain|flat-adhoc|mixed|null via a pure classifier. Route step 2 rewritten as three branches with split as the default for spec-shaped issue sets (one tracker key, no migration); step 3 never-re-trigger exception; boundary rule cites ADR-0033. Suite 103/212/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
This commit is contained in:
parent
49c8f5eace
commit
9415eb0d65
|
|
@ -63,7 +63,8 @@ entry is in the leaf file named.
|
|||
this file distilled to index+PD (issue #25 parts 2–3, ADR-0032). os-backlog slice 7
|
||||
shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id
|
||||
cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric
|
||||
category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17). Detail:
|
||||
category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect
|
||||
issue-shape classification + split-by-kind default (issue #26). Detail:
|
||||
os-context / os-doc-hygiene / os-backlog leaves.
|
||||
|
||||
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ column ownership).
|
|||
0 failures). os-status `/os-status:fix` step d reworded: invoke `/os-backlog:route`
|
||||
directly, never pre-ask "forgejo or planka?" (boundary rule uses both — surfaced by the
|
||||
servers-repo onboarding, cf. issue #26).
|
||||
- **Outstanding:** #14 routing-skill rollout (onboard cc-os itself, then one more project);
|
||||
#26 route-inspect issue-shape classification; #27 cross-project filing index; #28 wakeup
|
||||
design spike.
|
||||
- **Issue-shape classification (2026-07-13, issue #26):** `inspect` now fetches lightweight
|
||||
remote-issue metadata (tea `--output json` with string-index/joined-labels normalization;
|
||||
gh `--json`; fail-soft → nil) and emits an always-present `issue_shape` key
|
||||
(`sequential-chain` / `flat-adhoc` / `mixed` / null). Classification is a pure
|
||||
`Inspector.classify_issue_shape` (label allowlist with whole-token matching; cross-refs
|
||||
must hit the fetched set with dependency language within 40 chars; <3 issues or no
|
||||
metadata → null). Route SKILL.md step 2 rewritten as three branches with **split as the
|
||||
default** for spec-shaped issue sets (still exactly one tracker key — division of labor,
|
||||
not two destinations); step 3 got a never-re-trigger exception; boundary rule now cites
|
||||
ADR-0033 instead of restating it. 12 new tests incl. a CLI-contract test; suite 103 runs /
|
||||
212 assertions / 0 failures.
|
||||
- **Outstanding:** #14 residual (onboard one more project — operational hitl); #27
|
||||
cross-project filing index; #28 wakeup design spike.
|
||||
|
|
|
|||
|
|
@ -99,37 +99,55 @@ rescue StandardError => e
|
|||
{ "checked" => false, "reason" => e.message }
|
||||
end
|
||||
|
||||
# Best-effort open-issue count via tea (Forgejo) or gh (GitHub), whichever
|
||||
# the detected remote implies. Fails soft per-field.
|
||||
# Best-effort open-issue count + lightweight per-issue metadata via tea
|
||||
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
|
||||
# soft per-field: any fetch/parse failure means metadata nil. Returns
|
||||
# [issue_cli_hash, metadata_or_nil] — the metadata feeds the pure
|
||||
# Backlog::Inspector.classify_issue_shape and never appears in the JSON.
|
||||
def inspect_issue_cli(remote_info)
|
||||
unless remote_info && %i[forgejo github].include?(remote_info[:kind])
|
||||
return { "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }
|
||||
return [{ "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }, nil]
|
||||
end
|
||||
|
||||
repo_slug = "#{remote_info[:owner]}/#{remote_info[:repo]}"
|
||||
if remote_info[:kind] == :forgejo
|
||||
tea_issue_count(repo_slug)
|
||||
tea_issues(repo_slug)
|
||||
else
|
||||
gh_issue_count(repo_slug)
|
||||
gh_issues(repo_slug)
|
||||
end
|
||||
end
|
||||
|
||||
def tea_issue_count(repo_slug)
|
||||
return { "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" } unless system("which tea > /dev/null 2>&1")
|
||||
def tea_issues(repo_slug)
|
||||
return [{ "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" }, nil] unless system("which tea > /dev/null 2>&1")
|
||||
|
||||
out = `tea issues list --repo #{repo_slug} --state open --fields index,title,labels,body --output json 2>/dev/null`
|
||||
parsed = JSON.parse(out)
|
||||
issues = parsed.map do |issue|
|
||||
{ number: issue["index"].to_i, title: issue["title"],
|
||||
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
|
||||
body: issue["body"] }
|
||||
end
|
||||
[{ "tool" => "tea", "open_count" => issues.size }, issues]
|
||||
rescue StandardError
|
||||
# JSON mode unavailable/unparsable: fall back to the plain line count.
|
||||
out = `tea issues list --repo #{repo_slug} --state open 2>/dev/null`
|
||||
lines = out.to_s.lines.map(&:strip).reject(&:empty?)
|
||||
{ "tool" => "tea", "open_count" => lines.size }
|
||||
[{ "tool" => "tea", "open_count" => lines.size }, nil]
|
||||
end
|
||||
|
||||
def gh_issue_count(repo_slug)
|
||||
return { "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" } unless system("which gh > /dev/null 2>&1")
|
||||
def gh_issues(repo_slug)
|
||||
return [{ "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" }, nil] unless system("which gh > /dev/null 2>&1")
|
||||
|
||||
out = `gh issue list --repo #{repo_slug} --state open --json number 2>/dev/null`
|
||||
out = `gh issue list --repo #{repo_slug} --state open --json number,title,labels,body 2>/dev/null`
|
||||
parsed = JSON.parse(out)
|
||||
{ "tool" => "gh", "open_count" => parsed.size }
|
||||
rescue JSON::ParserError
|
||||
{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }
|
||||
issues = parsed.map do |issue|
|
||||
{ number: issue["number"].to_i, title: issue["title"],
|
||||
labels: Array(issue["labels"]).map { |label| label["name"] },
|
||||
body: issue["body"] }
|
||||
end
|
||||
[{ "tool" => "gh", "open_count" => issues.size }, issues]
|
||||
rescue StandardError
|
||||
[{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil]
|
||||
end
|
||||
|
||||
command, *rest = ARGV
|
||||
|
|
@ -218,12 +236,14 @@ when "inspect"
|
|||
remote_output = `git -C #{Shellwords.escape(repo_path)} remote -v 2>/dev/null`
|
||||
remote_info = Backlog::Tracker.parse_remote(remote_output)
|
||||
|
||||
issue_cli, issue_metadata = inspect_issue_cli(remote_info)
|
||||
findings = {
|
||||
"repo_path" => repo_path,
|
||||
"tracker_configured" => config.tracker,
|
||||
"planka" => inspect_planka(repo_path),
|
||||
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
|
||||
"issue_cli" => inspect_issue_cli(remote_info),
|
||||
"issue_cli" => issue_cli,
|
||||
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
|
||||
"in_repo_issue_files" => Backlog::Inspector.issue_files(repo_path)
|
||||
}
|
||||
puts JSON.pretty_generate(findings)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,62 @@ module Backlog
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ Register a project's issue tracker: figure out what already exists, propose wher
|
|||
|
||||
## The boundary rule (what you're deciding between)
|
||||
|
||||
- **Planka = state.** Cards track *what's happening now* — backlog/next/doing/review/done. Use `planka:<board>` when the project's work is mostly ephemeral tasks, small fixes, or process/coordination — nothing that needs a durable written spec.
|
||||
- **Git issues = specs only.** Use `forgejo:<owner>/<repo>` or `github:<owner>/<repo>` when work is specified as text that needs to survive and be referenced (features, migrations, architecture-level changes) — tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`.
|
||||
- **`repo:<path>`** is the escape hatch for a project that already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to keep doing so without moving to Planka or a git host's issue tracker.
|
||||
- **Card-as-pointer + issue-chain-as-spec, for code efforts:** when a body of work needs both — ongoing state tracking AND a durable spec — the Planka card is a pointer (title + link) into a chain of git issues that hold the actual spec. Don't duplicate the spec text into the card description.
|
||||
- **Promotion rule:** if a Planka-tracked effort accretes significant code/design decisions in card comments, that's a signal to promote it — open a git issue (or issue chain) holding the durable spec, and turn the Planka card into a pointer per the rule above. Flag this to the user when you see it happening; don't do it silently.
|
||||
Planka = state, git issues = specs (ADR-0033, `docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md`) — the ADR is the canonical statement; the operative routing rules are:
|
||||
|
||||
- **WHEN** the project's work is mostly ephemeral tasks, small fixes, or process/coordination (state: backlog/next/doing/review/done, nothing needing a durable written spec) → `planka:<board>`.
|
||||
- **WHEN** work is specified as text that must survive and be referenced (features, migrations, architecture-level changes, tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`) → `forgejo:<owner>/<repo>` or `github:<owner>/<repo>`.
|
||||
- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:<path>` (the escape hatch).
|
||||
- **WHEN** a code effort needs both state tracking and a durable spec → card-as-pointer + issue-chain-as-spec: the Planka card is a pointer (title + link) into the git issue chain holding the spec; never duplicate spec text into the card description.
|
||||
- **WHEN** a Planka-tracked effort accretes significant code/design decisions in card comments → flag promotion to the user (open a git issue chain for the spec, turn the card into a pointer); never promote silently.
|
||||
|
||||
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
|
||||
|
||||
|
|
@ -26,12 +28,16 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
|
|||
- `tracker_configured` — an existing `tracker` key in `.cc-os/config`, if any. If this is already set, tell the user the project is already routed to it and confirm they want to re-route before continuing (re-routing is itself a decision gate — treat it as step 2).
|
||||
- `planka` — whether a Planka board already resolves for this repo (via the same resolver `/os-backlog:capture` uses), or why that check couldn't run (gem/credentials unavailable — fail-soft, not blocking).
|
||||
- `git_remote` — the parsed `git remote -v`, classified as `forgejo` (the user's self-hosted instance) / `github` / `unknown`, plus the open-issue count via `tea` or `gh` if that CLI is available (`null` with a `reason` if not — fail-soft, not blocking).
|
||||
- `issue_shape` — a classification of the open git issues' shape: `sequential-chain` (most issues carry dependency labels or blocked-by/depends-on cross-references), `flat-adhoc` (no dependency structure), `mixed` (some), or `null` (no metadata, or fewer than 3 open issues — no evidence either way).
|
||||
- `in_repo_issue_files` — `docs/issues/` and/or `ISSUES.md` if present.
|
||||
|
||||
2. **Synthesize and propose (NAMED DECISION GATE).** Given the findings, propose exactly ONE destination tracker key with a short rationale grounded in the boundary rule above — e.g. "this repo has an active Forgejo remote with 11 open issues and no Planka board; issues already carry specs, so `forgejo:jared/cc-os` fits git-issues-as-spec" or "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
|
||||
2. **Synthesize and propose (NAMED DECISION GATE).** Exactly ONE tracker key is ever written to `.cc-os/config` — the branches below differ in *which* key and *what happens to existing issues*, never in how many keys. Branch on the findings:
|
||||
- **(a) No existing tracker found** (no open git issues, no in-repo issue files, no live board): propose one destination key with a short rationale grounded in the boundary rule — e.g. "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
|
||||
- **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:<board>` as the single declared tracker — it governs new/ephemeral capture only. The existing git issues are specs per ADR-0033: they stay exactly where they are, unmigrated, and remain the durable spec surface; skip migration entirely (step 3 does not apply to them). This is still exactly one tracker key — the split is a division of labor (state vs specs), not two destinations.
|
||||
- **(c) Existing git issues that are flat-adhoc** (`issue_shape` is `flat-adhoc`): migration to Planka is on the table — propose the destination key, and if confirmed, existing open issues go through the step-3 migration gate as usual. If `issue_shape` is `null` (no metadata or too few issues to tell), treat shape as unknown: ask the user which shape fits rather than assuming.
|
||||
**Stop here and wait for the user to confirm or override the destination before writing anything or moving anything.** If findings are ambiguous (e.g. both a live Planka board AND an active issue tracker with open items, and it's unclear which is authoritative), say so plainly and ask rather than guessing.
|
||||
|
||||
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). Before touching any live project history:
|
||||
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). **Exception — never re-trigger on the split:** a `planka:` tracker alongside by-design git spec issues (branch 2b, or any project whose git issues are specs per ADR-0033) is the intended end state, not a discrepancy; do not propose migrating those issues, now or on any later re-run of this skill. Before touching any live project history:
|
||||
- Tell the user exactly what will move (list the items) and ask for explicit go-ahead. This gate is separate from the destination gate in step 2 — confirming the destination is not confirming the migration.
|
||||
- Once confirmed, migrate mechanically using **existing machinery, not new code paths**: `card-add`/`cards` (via `/os-backlog:capture`'s CLI calls) for items moving into Planka; `tea`/`gh` issue-create commands for items moving into git issues. Do not invent bespoke migration scripts.
|
||||
- **Back-link both ways so nothing is double-tracked**: the old item (closed Planka card comment, closed in-repo issue entry, or a note in the git-host issue if migrating away from it) gets a pointer to its new home; the new item (card description or issue body) links back to the source. Close/archive the old item once the back-link is in place — don't leave both open.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
require_relative "test_helper"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
|
||||
class InspectorTest < Minitest::Test
|
||||
def test_detects_docs_issues_dir
|
||||
|
|
@ -23,3 +24,104 @@ class InspectorTest < Minitest::Test
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ClassifyIssueShapeTest < Minitest::Test
|
||||
def issue(number, title: "issue #{number}", labels: [], body: "")
|
||||
{ number: number, title: title, labels: labels, body: body }
|
||||
end
|
||||
|
||||
def test_nil_when_metadata_unavailable
|
||||
assert_nil Backlog::Inspector.classify_issue_shape(nil)
|
||||
end
|
||||
|
||||
def test_nil_when_fewer_than_three_issues_even_with_chain_labels
|
||||
issues = [issue(1, labels: ["blocked"]), issue(2, labels: ["blocked"])]
|
||||
assert_nil Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_flat_adhoc_when_no_issue_shows_chain_evidence
|
||||
issues = [issue(1), issue(2), issue(3, body: "fixed in #1 last week")]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_sequential_chain_from_labels
|
||||
issues = [
|
||||
issue(1, labels: ["Blocked-By"]),
|
||||
issue(2, labels: ["depends-on"]),
|
||||
issue(3, labels: ["enhancement"])
|
||||
]
|
||||
assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_negated_label_token_does_not_count
|
||||
issues = [issue(1, labels: ["not-blocked"]), issue(2), issue(3)]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_cross_reference_near_dependency_language_counts
|
||||
issues = [
|
||||
issue(1, body: "This is blocked by #2 until the schema lands."),
|
||||
issue(2, body: "Must land after #3."),
|
||||
issue(3, body: "Standalone groundwork.")
|
||||
]
|
||||
assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_plain_cross_reference_without_dependency_language_does_not_count
|
||||
issues = [
|
||||
issue(1, body: "See also #2 for background."),
|
||||
issue(2, body: "Related: #3."),
|
||||
issue(3)
|
||||
]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_reference_to_number_outside_fetched_set_does_not_count
|
||||
issues = [
|
||||
issue(1, body: "blocked by #99 upstream"),
|
||||
issue(2),
|
||||
issue(3)
|
||||
]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_dependency_language_too_far_from_reference_does_not_count
|
||||
padding = "x" * 60
|
||||
issues = [
|
||||
issue(1, body: "blocked #{padding} see #2"),
|
||||
issue(2),
|
||||
issue(3)
|
||||
]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_mixed_when_some_but_not_most_show_evidence
|
||||
issues = [issue(1, labels: ["blocked"]), issue(2), issue(3)]
|
||||
assert_equal "mixed", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
|
||||
def test_self_reference_does_not_count
|
||||
issues = [
|
||||
issue(1, body: "depends on #1 (this issue)"),
|
||||
issue(2),
|
||||
issue(3)
|
||||
]
|
||||
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
|
||||
end
|
||||
end
|
||||
|
||||
class InspectCliContractTest < Minitest::Test
|
||||
BIN = File.expand_path("../bin/os-backlog", __dir__)
|
||||
|
||||
# `inspect` must always expose the issue_shape key, even when every
|
||||
# network-ish check degrades (tmpdir: no git remote, no config). Value is
|
||||
# null here because no metadata is fetchable — that's the fail-soft path.
|
||||
def test_inspect_json_always_contains_issue_shape_key
|
||||
Dir.mktmpdir do |dir|
|
||||
out = Dir.chdir(dir) { `ruby #{BIN} inspect 2>/dev/null` }
|
||||
findings = JSON.parse(out)
|
||||
assert findings.key?("issue_shape"), "inspect JSON missing issue_shape key"
|
||||
assert_nil findings["issue_shape"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue