forked from diegoecab/oci-deal-accelerator
initial version
This commit is contained in:
716
tools/findings_cli.py
Normal file
716
tools/findings_cli.py
Normal file
@@ -0,0 +1,716 @@
|
||||
#!/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 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 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)
|
||||
|
||||
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()
|
||||
|
||||
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.")
|
||||
|
||||
# --- search ---
|
||||
p_search = subparsers.add_parser("search", help="Full-text search.")
|
||||
p_search.add_argument("query", help="Search term.")
|
||||
|
||||
# --- 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 ---
|
||||
subparsers.add_parser("stats", help="Show summary statistics.")
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user