Files
oci-deal-accelerator/tools/findings_cli.py
root b48d155c1b CLI improvements: --json output, filters, arch-search, ecal-score, deck fixes
6 CLI improvements from MCP integration testing + deck bug fixes:

- kb_cli.py: --json on search, new arch-search and ecal-score subcommands,
  _load_yaml handles multi-document YAML (fixes health command on 32 files)
- findings_cli.py: --json on search/list/stats, filter flags on search
  (--product, --severity, --tag, --client, --status, --category)
- validate-architecture.py: --profile optional, --output defaults to stdout
- common-objections.yaml + ecal-artefacts-catalog.yaml: merge front matter
- oci_deck_gen.py: fix slide 9 cost_notes char iteration, fix slide 12
  migration field mapping (duration/milestones), dynamic phase spacing
- proposal-spec.yaml: replace real name with fictional Carlos Mendoza

All 14 skill options validated with generated deliverables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:02:40 -03:00

750 lines
26 KiB
Python

#!/usr/bin/env python3
"""
OCI Deal Accelerator — Field Findings CLI
Search, add, update, confirm, and report on field findings tracked in
kb/field-findings/tracker.yaml.
Usage:
python findings_cli.py list # List all findings
python findings_cli.py list --severity HIGH # Filter by severity
python findings_cli.py list --product "ADB-S" # Filter by product
python findings_cli.py list --tag dep # Filter by tag
python findings_cli.py list --client "Pepe" # Filter by client
python findings_cli.py list --status open # Filter by status
python findings_cli.py search "maintenance window" # Full-text search
python findings_cli.py add --name "Name" --team "Team" --confidence validated ...
python findings_cli.py add # Interactive mode
python findings_cli.py update FF-202603-001 --status resolved --resolution "Fixed"
python findings_cli.py confirm FF-202603-001 --name "Name" --team "Team" --note "Confirmed"
python findings_cli.py aer # After-Engagement Review (interactive)
python findings_cli.py stats # Summary statistics
"""
import argparse
import difflib
import json
import os
import sys
from collections import Counter
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import yaml
# =============================================================================
# Constants
# =============================================================================
TRACKER_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "kb", "field-findings", "tracker.yaml",
)
TAGS_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "config", "kb-tags.yaml",
)
VALID_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
VALID_STATUSES = {"open", "resolved", "wontfix", "acknowledged", "monitoring"}
VALID_CATEGORIES = {"bug", "limitation", "undocumented", "gotcha", "workaround", "performance"}
VALID_CONFIDENCE = {"validated", "observed", "reported", "inferred"}
# ANSI colors
_SEV_COLORS = {
"CRITICAL": "\033[1;31m", # red bold
"HIGH": "\033[31m", # red
"MEDIUM": "\033[33m", # yellow
"LOW": "\033[36m", # cyan
"INFO": "\033[37m", # white
}
_RESET = "\033[0m"
# =============================================================================
# Data helpers
# =============================================================================
def load_tracker(path: str = TRACKER_PATH) -> Dict[str, Any]:
"""Load the tracker YAML and return the full dict."""
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
print(f"Error: Tracker file not found at {resolved}", file=sys.stderr)
sys.exit(1)
with open(resolved, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not data or "findings" not in data:
print("Error: Tracker file missing 'findings' key.", file=sys.stderr)
sys.exit(1)
return data
def save_tracker(data: Dict[str, Any], path: str = TRACKER_PATH) -> None:
"""Write tracker data back to YAML."""
resolved = os.path.realpath(path)
data["last_updated"] = date.today().isoformat()
with open(resolved, "w", encoding="utf-8") as fh:
yaml.dump(
data,
fh,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=120,
)
def _get_findings(path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return the findings list from the tracker."""
return load_tracker(path).get("findings", [])
def _next_id(findings: List[Dict[str, Any]]) -> str:
"""Generate the next FF-YYYYMM-NNN id."""
prefix = f"FF-{date.today().strftime('%Y%m')}-"
existing = [
int(f["id"].split("-")[-1])
for f in findings
if f["id"].startswith(prefix)
]
next_num = max(existing, default=0) + 1
return f"{prefix}{next_num:03d}"
def _sev_color(severity: str) -> str:
"""Wrap severity with ANSI color if terminal supports it."""
if not (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()):
return severity
code = _SEV_COLORS.get(severity, "")
return f"{code}{severity}{_RESET}" if code else severity
def _get_client(finding: Dict[str, Any]) -> str:
"""Get client from finding, supporting both old and new format."""
contributor = finding.get("contributor")
if isinstance(contributor, dict):
return str(contributor.get("client", ""))
return str(finding.get("client", ""))
def _get_reporter(finding: Dict[str, Any]) -> str:
"""Get reporter name, supporting both old and new format."""
contributor = finding.get("contributor")
if isinstance(contributor, dict):
return str(contributor.get("name", ""))
return str(finding.get("reported_by", ""))
# =============================================================================
# Tag validation
# =============================================================================
def _load_taxonomy() -> List[str]:
"""Load all valid tags from the taxonomy config."""
resolved = os.path.realpath(TAGS_PATH)
if not os.path.isfile(resolved):
return []
with open(resolved, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not data or "taxonomy" not in data:
return []
all_tags = []
for category_tags in data["taxonomy"].values():
if isinstance(category_tags, list):
all_tags.extend(str(t) for t in category_tags)
return all_tags
def validate_tags(tags: List[str], taxonomy: Optional[List[str]] = None) -> List[str]:
"""Validate tags against taxonomy. Returns list of warning messages."""
if taxonomy is None:
taxonomy = _load_taxonomy()
if not taxonomy:
return []
warnings = []
for tag in tags:
if tag not in taxonomy:
close = difflib.get_close_matches(tag, taxonomy, n=1, cutoff=0.5)
if close:
warnings.append(f"WARNING: Unknown tag '{tag}'. Did you mean '{close[0]}'?")
else:
warnings.append(f"WARNING: Unknown tag '{tag}'. Not in taxonomy.")
return warnings
# =============================================================================
# Library API — importable functions for programmatic use and testing
# =============================================================================
def search(query: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Full-text search across summary, detail, workaround, and tags."""
findings = _get_findings(tracker_path)
query_lower = query.lower()
results = []
for f in findings:
searchable = " ".join([
str(f.get("summary", "")),
str(f.get("detail", "")),
str(f.get("workaround", "")),
" ".join(str(t) for t in f.get("tags", [])),
]).lower()
if query_lower in searchable:
results.append(f)
return results
def filter_by_severity(severity: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return findings matching the given severity."""
findings = _get_findings(tracker_path)
return [f for f in findings if f.get("severity") == severity]
def filter_by_client(client: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return findings whose client field contains the query string."""
findings = _get_findings(tracker_path)
client_lower = client.lower()
return [f for f in findings if client_lower in _get_client(f).lower()]
def filter_findings(
findings: List[Dict[str, Any]],
severity: Optional[str] = None,
product: Optional[str] = None,
tag: Optional[str] = None,
client: Optional[str] = None,
status: Optional[str] = None,
category: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Apply multiple filters to a findings list."""
results = findings
if severity:
results = [f for f in results if f.get("severity") == severity.upper()]
if product:
product_lower = product.lower()
results = [f for f in results if product_lower in str(f.get("product", "")).lower()]
if tag:
tag_lower = tag.lower()
results = [f for f in results if tag_lower in [str(t).lower() for t in f.get("tags", [])]]
if client:
client_lower = client.lower()
results = [f for f in results if client_lower in _get_client(f).lower()]
if status:
results = [f for f in results if f.get("status") == status]
if category:
results = [f for f in results if f.get("category") == category]
return results
def add(
tracker_path: str = TRACKER_PATH,
name: str = "",
team: str = "",
confidence: str = "validated",
client: str = "",
context: str = "",
product: str = "",
version: str = "",
severity: str = "MEDIUM",
category: str = "gotcha",
summary: str = "",
detail: str = "",
workaround: str = "",
tags: Optional[List[str]] = None,
status: str = "open",
date_str: Optional[str] = None,
oracle_sr: str = "",
reported_by: str = "",
affects_matrix: Optional[str] = None,
) -> Dict[str, Any]:
"""Add a new finding programmatically. Returns the new entry dict.
Supports both new contributor block (name/team/confidence) and legacy
reported_by for backward compatibility.
"""
data = load_tracker(tracker_path)
findings = data.get("findings", [])
contributor_name = name or reported_by
contributor = {
"name": contributor_name,
"team": team,
"date": date_str or date.today().isoformat(),
"confidence": confidence if confidence in VALID_CONFIDENCE else "validated",
}
if client:
contributor["client"] = client
if context:
contributor["context"] = context
entry = {
"id": _next_id(findings),
"date": date_str or date.today().isoformat(),
"contributor": contributor,
"reported_by": contributor_name,
"product": product,
"version": version,
"severity": severity.upper(),
"category": category,
"summary": summary,
"detail": detail,
"workaround": workaround,
"oracle_sr": oracle_sr,
"status": status,
"resolved_date": None,
"resolution": None,
"affects_matrix": affects_matrix,
"tags": tags or [],
"confirmations": [],
}
# Insert at top (newest first)
findings.insert(0, entry)
data["findings"] = findings
save_tracker(data, tracker_path)
return entry
def confirm(
finding_id: str,
name: str,
team: str,
note: str = "",
tracker_path: str = TRACKER_PATH,
) -> Dict[str, Any]:
"""Add a confirmation to an existing finding. Returns the confirmation dict."""
data = load_tracker(tracker_path)
findings = data.get("findings", [])
target = None
for f in findings:
if f["id"] == finding_id:
target = f
break
if target is None:
raise LookupError(f"Finding '{finding_id}' not found.")
confirmation = {
"name": name,
"team": team,
"date": date.today().isoformat(),
}
if note:
confirmation["note"] = note
if "confirmations" not in target:
target["confirmations"] = []
target["confirmations"].append(confirmation)
save_tracker(data, tracker_path)
return confirmation
def stats(tracker_path: str = TRACKER_PATH) -> Dict[str, Any]:
"""Return summary statistics about findings."""
findings = _get_findings(tracker_path)
return {
"total": len(findings),
"by_severity": dict(Counter(f.get("severity", "UNKNOWN") for f in findings)),
"by_product": dict(Counter(f.get("product", "UNKNOWN") for f in findings)),
"by_status": dict(Counter(f.get("status", "unknown") for f in findings)),
"by_category": dict(Counter(f.get("category", "unknown") for f in findings)),
}
# =============================================================================
# CLI subcommands
# =============================================================================
def cmd_list(args: argparse.Namespace) -> None:
"""List findings with optional filters."""
data = load_tracker(TRACKER_PATH)
findings = data.get("findings", [])
results = filter_findings(
findings,
severity=args.severity,
product=args.product,
tag=args.tag,
client=args.client,
status=args.status,
category=getattr(args, "category", None),
)
if getattr(args, "json_output", False):
print(json.dumps(results, indent=2, default=str))
return
if not results:
print("No findings match the given filters.")
return
# Table output
id_w, date_w, sev_w, prod_w = 16, 12, 10, 30
header = f"{'ID':<{id_w}} {'Date':<{date_w}} {'Severity':<{sev_w}} {'Product':<{prod_w}} Summary"
print(header)
print("-" * len(header))
for f in results:
fid = f.get("id", "?")
fdate = str(f.get("date", "?"))
sev = f.get("severity", "?")
prod = str(f.get("product", "?"))[:prod_w]
summary = str(f.get("summary", ""))[:60]
print(f"{fid:<{id_w}} {fdate:<{date_w}} {_sev_color(sev):<{sev_w}} {prod:<{prod_w}} {summary}")
def cmd_search(args: argparse.Namespace) -> None:
"""Full-text search."""
results = search(args.query)
# Apply optional filters (mirrors list subcommand)
results = filter_findings(
results,
severity=getattr(args, "severity", None),
product=getattr(args, "product", None),
tag=getattr(args, "tag", None),
client=getattr(args, "client", None),
status=getattr(args, "status", None),
category=getattr(args, "category", None),
)
if getattr(args, "json_output", False):
print(json.dumps(results, indent=2, default=str))
return
if not results:
print(f"No findings matching '{args.query}'.")
return
print(f"Found {len(results)} finding(s) matching '{args.query}':\n")
for f in results:
sev = f.get("severity", "?")
print(f" {f['id']} [{_sev_color(sev)}] {f.get('summary', '')}")
def cmd_add(args: argparse.Namespace) -> None:
"""Add a new finding."""
if args.summary:
# Non-interactive mode
tags_list = [t.strip() for t in args.tags.split(",")] if args.tags else []
# Validate tags
tag_warnings = validate_tags(tags_list)
for w in tag_warnings:
print(w, file=sys.stderr)
entry = add(
name=args.name or args.reported_by or "",
team=args.team or "",
confidence=args.confidence or "validated",
client=args.client or "",
context=args.context or "",
product=args.product or "",
version=args.version or "",
severity=args.severity or "MEDIUM",
category=args.category or "gotcha",
summary=args.summary,
detail=args.detail or "",
workaround=args.workaround or "",
tags=tags_list,
date_str=args.date,
oracle_sr=args.oracle_sr or "",
affects_matrix=args.affects_matrix or None,
)
else:
# Interactive mode
print("Add new field finding (press Enter to skip optional fields):\n")
contributor_name = input("Your name: ").strip()
contributor_team = input("Your team: ").strip()
contributor_client = input("Client (optional, press Enter to skip): ").strip()
contributor_context = input("Context (optional): ").strip()
confidence = input("Confidence [validated/observed/reported/inferred]: ").strip() or "validated"
tags_str = input("Tags (comma-separated): ").strip()
tags_list = [t.strip() for t in tags_str.split(",") if t.strip()]
tag_warnings = validate_tags(tags_list)
for w in tag_warnings:
print(w)
entry = add(
date_str=input("Date [today]: ").strip() or None,
name=contributor_name,
team=contributor_team,
client=contributor_client,
context=contributor_context,
confidence=confidence,
product=input("Product: ").strip(),
version=input("Version: ").strip(),
severity=input("Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) [MEDIUM]: ").strip() or "MEDIUM",
category=input("Category (bug/limitation/undocumented/gotcha/workaround/performance) [gotcha]: ").strip() or "gotcha",
summary=input("Summary (one line): ").strip(),
detail=input("Detail: ").strip(),
workaround=input("Workaround: ").strip(),
tags=tags_list,
oracle_sr=input("Oracle SR#: ").strip(),
affects_matrix=input("Affects matrix entry (feature name, or blank): ").strip() or None,
)
print(f"\nAdded: {entry['id']} [{entry['severity']}] {entry['summary']}")
def cmd_confirm(args: argparse.Namespace) -> None:
"""Add a confirmation to an existing finding."""
try:
confirmation = confirm(
finding_id=args.finding_id,
name=args.name,
team=args.team,
note=args.note or "",
)
print(f"Confirmed {args.finding_id}:")
print(f" by: {confirmation['name']} ({confirmation.get('team', '')})")
if confirmation.get("note"):
print(f" note: {confirmation['note']}")
except LookupError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_aer(args: argparse.Namespace) -> None:
"""After-Engagement Review — interactive post-engagement knowledge capture."""
print("After-Engagement Review")
print("=" * 23)
client = input("Client: ").strip()
architect = input("Architect: ").strip()
team = input("Team: ").strip() or "Field Architecture"
print()
q1 = input("1. What did we learn that the KB doesn't cover?\n > ").strip()
print()
q2 = input("2. What went wrong that others should know about?\n > ").strip()
print()
q3 = input("3. What worked well that we should capture as a pattern?\n > ").strip()
print()
print("Saving...")
created = []
# Create finding from Q2 (what went wrong) if provided
if q2:
entry = add(
name=architect,
team=team,
client=client,
context="After-Engagement Review",
confidence="observed",
product=input("Product for issue (Q2): ").strip() or "General",
severity=input("Severity for issue (CRITICAL/HIGH/MEDIUM/LOW/INFO) [MEDIUM]: ").strip() or "MEDIUM",
category="gotcha",
summary=q2[:120],
detail=q2,
)
created.append(f" -> Created finding {entry['id']} ({q2[:60]})")
# Create finding from Q1 (KB gap) if provided
if q1:
entry = add(
name=architect,
team=team,
client=client,
context="After-Engagement Review",
confidence="observed",
product=input("Product for KB gap (Q1): ").strip() or "General",
severity="INFO",
category="undocumented",
summary=f"KB gap: {q1[:110]}",
detail=q1,
)
created.append(f" -> Created finding {entry['id']} (KB gap: {q1[:50]})")
if q3:
created.append(f" -> Pattern suggestion noted: {q3[:60]}...")
created.append(f" (Create manually: kb/patterns/<name>.yaml)")
if created:
for line in created:
print(line)
else:
print(" No items to create. Review complete.")
def cmd_update(args: argparse.Namespace) -> None:
"""Update an existing finding."""
data = load_tracker(TRACKER_PATH)
findings = data.get("findings", [])
target = None
for f in findings:
if f["id"] == args.finding_id:
target = f
break
if target is None:
print(f"Error: Finding '{args.finding_id}' not found.", file=sys.stderr)
sys.exit(1)
updated_fields = []
if args.status:
target["status"] = args.status
updated_fields.append(f"status -> {args.status}")
if args.resolution:
target["resolution"] = args.resolution
updated_fields.append(f"resolution -> {args.resolution}")
if args.resolved_date:
target["resolved_date"] = args.resolved_date
updated_fields.append(f"resolved_date -> {args.resolved_date}")
if args.workaround:
target["workaround"] = args.workaround
updated_fields.append("workaround updated")
if args.oracle_sr:
target["oracle_sr"] = args.oracle_sr
updated_fields.append(f"oracle_sr -> {args.oracle_sr}")
if not updated_fields:
print("No fields to update. Use --status, --resolution, --resolved-date, --workaround, or --oracle-sr.")
return
save_tracker(data, TRACKER_PATH)
print(f"Updated {args.finding_id}:")
for field in updated_fields:
print(f" {field}")
def cmd_stats(args: argparse.Namespace) -> None:
"""Display summary statistics."""
s = stats()
if getattr(args, "json_output", False):
print(json.dumps(s, indent=2))
return
print(f"Total findings: {s['total']}\n")
print("By Severity:")
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
count = s["by_severity"].get(sev, 0)
if count:
print(f" {_sev_color(sev):<10} {count}")
print("\nBy Status:")
for status, count in sorted(s["by_status"].items()):
print(f" {status:<15} {count}")
print("\nBy Product:")
for product, count in sorted(s["by_product"].items(), key=lambda x: -x[1]):
print(f" {product:<35} {count}")
print("\nBy Category:")
for cat, count in sorted(s["by_category"].items(), key=lambda x: -x[1]):
print(f" {cat:<15} {count}")
# =============================================================================
# CLI entry point
# =============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="findings_cli",
description="Manage the Field Findings Tracker.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- list ---
p_list = subparsers.add_parser("list", help="List findings with optional filters.")
p_list.add_argument("--severity", help="Filter by severity.")
p_list.add_argument("--product", help="Filter by product (partial match).")
p_list.add_argument("--tag", help="Filter by tag.")
p_list.add_argument("--client", help="Filter by client (partial match).")
p_list.add_argument("--status", help="Filter by status.")
p_list.add_argument("--category", help="Filter by category.")
p_list.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
# --- search ---
p_search = subparsers.add_parser("search", help="Full-text search.")
p_search.add_argument("query", help="Search term.")
p_search.add_argument("--severity", help="Filter by severity.")
p_search.add_argument("--product", help="Filter by product (partial match).")
p_search.add_argument("--tag", help="Filter by tag.")
p_search.add_argument("--client", help="Filter by client (partial match).")
p_search.add_argument("--status", help="Filter by status.")
p_search.add_argument("--category", help="Filter by category.")
p_search.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
# --- add ---
p_add = subparsers.add_parser("add", help="Add a new finding.")
p_add.add_argument("--date", default=None, help="Date (YYYY-MM-DD). Defaults to today.")
p_add.add_argument("--name", help="Contributor name.")
p_add.add_argument("--team", help="Contributor team.")
p_add.add_argument("--confidence", help="Confidence level: validated/observed/reported/inferred.")
p_add.add_argument("--context", help="Engagement context (PoC, migration, assessment).")
p_add.add_argument("--reported-by", help="(Legacy) Who reported the finding. Use --name instead.")
p_add.add_argument("--client", help="Client name or reference.")
p_add.add_argument("--product", help="OCI product/service affected.")
p_add.add_argument("--version", help="Product version.")
p_add.add_argument("--severity", help="CRITICAL/HIGH/MEDIUM/LOW/INFO.")
p_add.add_argument("--category", help="bug/limitation/undocumented/gotcha/workaround/performance.")
p_add.add_argument("--summary", help="One-line summary.")
p_add.add_argument("--detail", help="Full description.")
p_add.add_argument("--workaround", help="Known workaround.")
p_add.add_argument("--tags", help="Comma-separated tags.")
p_add.add_argument("--oracle-sr", help="Oracle SR number.")
p_add.add_argument("--affects-matrix", help="Feature matrix entry this relates to.")
# --- update ---
p_update = subparsers.add_parser("update", help="Update an existing finding.")
p_update.add_argument("finding_id", help="Finding ID (e.g., FF-202603-001).")
p_update.add_argument("--status", help="New status.")
p_update.add_argument("--resolution", help="Resolution description.")
p_update.add_argument("--resolved-date", help="Resolved date (YYYY-MM-DD).")
p_update.add_argument("--workaround", help="Updated workaround.")
p_update.add_argument("--oracle-sr", help="Oracle SR number.")
# --- confirm ---
p_confirm = subparsers.add_parser("confirm", help="Add a confirmation to an existing finding.")
p_confirm.add_argument("finding_id", help="Finding ID to confirm (e.g., FF-202603-001).")
p_confirm.add_argument("--name", required=True, help="Your name.")
p_confirm.add_argument("--team", required=True, help="Your team.")
p_confirm.add_argument("--note", help="Confirmation note.")
# --- aer ---
subparsers.add_parser("aer", help="After-Engagement Review (interactive).")
# --- stats ---
p_stats = subparsers.add_parser("stats", help="Show summary statistics.")
p_stats.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
dispatch = {
"list": cmd_list,
"search": cmd_search,
"add": cmd_add,
"update": cmd_update,
"confirm": cmd_confirm,
"aer": cmd_aer,
"stats": cmd_stats,
}
handler = dispatch.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
if __name__ == "__main__":
main()