Import sap-architecture skill
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rank SAP reference templates for a natural-language diagram request.
|
||||
|
||||
This is intentionally simple and dependency-free. The goal is not semantic
|
||||
search; it is to stop the author from guessing which SAP template to start
|
||||
from. The script scores filenames plus visible labels in each .drawio file,
|
||||
adds scenario-family boosts, and prints the best candidates.
|
||||
|
||||
Usage:
|
||||
select_reference.py "CAP app with XSUAA and HANA Cloud"
|
||||
echo "Joule agent calls S/4HANA through MCP" | select_reference.py
|
||||
select_reference.py --top 10 --json "Business Data Cloud with Databricks"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
STOPWORDS = {
|
||||
"a", "an", "and", "app", "apps", "arch", "architecture", "as", "at", "between",
|
||||
"btp", "by", "cloud", "create", "diagram", "draw", "for", "from", "in",
|
||||
"into", "is", "l0", "l1", "l2", "landscape", "make", "my", "of", "on", "or",
|
||||
"ref", "reference", "sap", "show", "solution", "the", "to", "using",
|
||||
"via", "with",
|
||||
# Generic prompt framing words. Keeping these out avoids accidental matches
|
||||
# such as "Architecture Center" -> Task Center.
|
||||
"center", "convention", "conventions", "horizon", "icon", "icons",
|
||||
"label", "labels", "palette", "preserve", "readable", "rhythm",
|
||||
"semantic", "semantics", "style", "template", "visual", "zone",
|
||||
"zones",
|
||||
}
|
||||
TOKEN_CANONICAL = {
|
||||
"adminstrator": "administrator",
|
||||
"admin": "administrator",
|
||||
"plaforms": "platforms",
|
||||
"provisoning": "provisioning",
|
||||
"ressources": "resources",
|
||||
"s": "s4hana",
|
||||
"4hana": "s4hana",
|
||||
}
|
||||
|
||||
SCENARIOS = [
|
||||
{
|
||||
"name": "identity-authentication",
|
||||
"query": {"ias", "identity", "authentication", "authn", "oauth", "oidc", "saml", "single", "sign", "sso", "xsuaa", "jwt", "trust"},
|
||||
"reference": {"identity", "authentication", "authn", "iam", "xsuaa", "ias", "joule_iam"},
|
||||
"boost": 18,
|
||||
},
|
||||
{
|
||||
"name": "identity-authorization",
|
||||
"query": {"authorization", "authz", "role", "roles", "scope", "scopes", "permission", "permissions", "rbac"},
|
||||
"reference": {"authorization", "authz", "iam", "identity"},
|
||||
"boost": 18,
|
||||
},
|
||||
{
|
||||
"name": "private-connectivity",
|
||||
"query": {"private", "privatelink", "link", "connectivity", "connector", "cloudconnector", "scc", "onprem", "premise", "principal", "principalpropagation", "propagation", "odata"},
|
||||
"reference": {"privatelink", "private", "connector", "cloudconnector", "connectivity", "odata", "e2b"},
|
||||
"boost": 17,
|
||||
},
|
||||
{
|
||||
"name": "agentic-ai-mcp",
|
||||
"query": {"agent", "agents", "agentic", "mcp", "a2a", "tool", "tools", "joule", "copilot", "cline", "llm"},
|
||||
"reference": {"agent", "agentic", "mcp", "a2a", "joule", "genai", "generative"},
|
||||
"boost": 17,
|
||||
},
|
||||
{
|
||||
"name": "generative-ai-rag",
|
||||
"query": {"genai", "generative", "rag", "retrieval", "semantic", "embedding", "embeddings", "vector", "prompt"},
|
||||
"reference": {"genai", "generative", "rag", "semantic", "agent2agent"},
|
||||
"boost": 16,
|
||||
},
|
||||
{
|
||||
"name": "business-data-cloud",
|
||||
"query": {"bdc", "business", "data", "databricks", "snowflake", "hana", "datasphere", "analytics", "bw"},
|
||||
"reference": {"bdc", "businessdatacloud", "databricks", "hyperscalerdata", "dataintegration"},
|
||||
"boost": 15,
|
||||
},
|
||||
{
|
||||
"name": "event-driven-integration",
|
||||
"query": {"event", "events", "eventmesh", "eventing", "eda", "queue", "queues", "kafka", "message", "messages"},
|
||||
"reference": {"eventdriven", "eda", "event", "integration", "e2b", "a2aintegration", "b2bintegration"},
|
||||
"boost": 15,
|
||||
},
|
||||
{
|
||||
"name": "resiliency",
|
||||
"query": {"resiliency", "resilience", "multi", "region", "availability", "az", "failover", "load", "balancer", "disaster"},
|
||||
"reference": {"resiliency", "multiregion", "multiaz", "loadbalancer"},
|
||||
"boost": 14,
|
||||
},
|
||||
{
|
||||
"name": "multitenant-saas-cap",
|
||||
"query": {"cap", "saas", "tenant", "tenants", "multitenant", "multitenancy", "subscription"},
|
||||
"reference": {"susaas", "cap", "multitenant"},
|
||||
"boost": 14,
|
||||
},
|
||||
{
|
||||
"name": "task-workflow-workzone",
|
||||
"query": {"task", "tasks", "inbox", "workflow", "workzone", "work", "zone", "launchpad", "process", "automation", "spa"},
|
||||
"reference": {"taskcenter", "buildworkzone", "buildprocessautomation"},
|
||||
"boost": 14,
|
||||
},
|
||||
{
|
||||
"name": "devops",
|
||||
"query": {"devops", "cicd", "ci", "cd", "pipeline", "pipelines", "transport", "deploy", "deployment"},
|
||||
"reference": {"devops"},
|
||||
"boost": 20,
|
||||
},
|
||||
{
|
||||
"name": "security-operations",
|
||||
"query": {"siem", "soar", "threat", "detection", "audit", "security", "etd"},
|
||||
"reference": {"siem", "soar", "etd"},
|
||||
"boost": 20,
|
||||
},
|
||||
{
|
||||
"name": "federated-ml",
|
||||
"query": {"federated", "ml", "machine", "learning", "training", "model", "models", "aicore", "ai"},
|
||||
"reference": {"federated", "ml", "machine", "learning", "aicore", "ai"},
|
||||
"boost": 22,
|
||||
},
|
||||
{
|
||||
"name": "edge-integration-cell",
|
||||
"query": {"edge", "eic", "cell", "pipo", "pi", "po", "runtime", "migration"},
|
||||
"reference": {"edge", "eic", "cell", "pipo", "integration"},
|
||||
"boost": 20,
|
||||
},
|
||||
{
|
||||
"name": "successfactors",
|
||||
"query": {"successfactors", "hxm", "bizx", "employee", "recruiting", "module", "modules", "talent"},
|
||||
"reference": {"successfactors", "hxm", "bizx", "recruiting"},
|
||||
"boost": 22,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
path: str
|
||||
score: float
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
token_hits: list[str] = field(default_factory=list)
|
||||
metadata_title: str | None = None
|
||||
metadata_tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def default_reference_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "assets" / "reference-examples"
|
||||
|
||||
|
||||
def metadata_path_for(reference_dir: Path) -> Path:
|
||||
return reference_dir / "template-metadata.json"
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def load_metadata(reference_dir_text: str) -> dict:
|
||||
path = metadata_path_for(Path(reference_dir_text))
|
||||
if not path.exists():
|
||||
return {"templates": {}}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"templates": {}}
|
||||
|
||||
|
||||
def template_metadata(path: Path) -> dict:
|
||||
metadata = load_metadata(str(path.parent.resolve()))
|
||||
return metadata.get("templates", {}).get(path.name, {})
|
||||
|
||||
|
||||
def metadata_search_text(path: Path, metadata: dict | None = None) -> str:
|
||||
metadata = metadata if metadata is not None else template_metadata(path)
|
||||
if not metadata:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for key in ("title", "summary", "family", "level", "domain"):
|
||||
val = metadata.get(key)
|
||||
if isinstance(val, str):
|
||||
parts.append(val)
|
||||
for key in ("aliases", "tags", "products", "fallback_templates"):
|
||||
vals = metadata.get(key)
|
||||
if isinstance(vals, list):
|
||||
parts.extend(str(v) for v in vals)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def explicit_metadata_title(path: Path) -> str | None:
|
||||
val = template_metadata(path).get("title")
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def split_words(text: str) -> list[str]:
|
||||
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
|
||||
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
|
||||
text = text.replace("_", " ").replace("-", " ").replace("/", " ")
|
||||
return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)]
|
||||
|
||||
|
||||
def tokens(text: str) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for t in split_words(text):
|
||||
t = TOKEN_CANONICAL.get(t, t)
|
||||
if len(t) >= 2 and t not in STOPWORDS:
|
||||
out.add(t)
|
||||
joined = "".join(split_words(text))
|
||||
for compact in (
|
||||
"xsuaa",
|
||||
"privatelink",
|
||||
"workzone",
|
||||
"taskcenter",
|
||||
"eventmesh",
|
||||
"multiaz",
|
||||
"multiregion",
|
||||
"businessdatacloud",
|
||||
"successfactors",
|
||||
"cloudconnector",
|
||||
"principalpropagation",
|
||||
):
|
||||
if compact in joined:
|
||||
out.add(compact)
|
||||
if "businessdatacloud" in out:
|
||||
out.add("bdc")
|
||||
if "aicore" in joined or {"ai", "core"} <= out:
|
||||
out.add("aicore")
|
||||
if "cloudconnector" in joined:
|
||||
out.add("cloudconnector")
|
||||
if "principalpropagation" in joined:
|
||||
out.add("principalpropagation")
|
||||
if "s4hana" in joined or "4hana" in out or {"s4", "hana"} <= out:
|
||||
out.add("s4hana")
|
||||
if {"ci", "cd"} <= out:
|
||||
out.add("cicd")
|
||||
if {"pi", "po"} <= out:
|
||||
out.add("pipo")
|
||||
if {"edge", "integration", "cell"} <= out:
|
||||
out.add("eic")
|
||||
return out
|
||||
|
||||
|
||||
def drawio_text(path: Path) -> str:
|
||||
raw = path.read_text(encoding="utf-8", errors="ignore")
|
||||
parts = [path.stem]
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
for elem in root.iter():
|
||||
for attr in ("name", "label", "value"):
|
||||
val = elem.get(attr)
|
||||
if val:
|
||||
parts.append(val)
|
||||
except ET.ParseError:
|
||||
parts.append(raw[:10000])
|
||||
visible = html.unescape(" ".join(parts))
|
||||
visible = re.sub(r"<br\s*/?>", " ", visible, flags=re.I)
|
||||
visible = re.sub(r"<[^>]+>", " ", visible)
|
||||
return visible
|
||||
|
||||
|
||||
def phrase_hits(query: str, phrases: list[str]) -> list[str]:
|
||||
query_clean = " ".join(split_words(query))
|
||||
hits: list[str] = []
|
||||
for phrase in phrases:
|
||||
phrase_clean = " ".join(split_words(str(phrase)))
|
||||
if len(phrase_clean) >= 4 and phrase_clean in query_clean:
|
||||
hits.append(str(phrase))
|
||||
return hits
|
||||
|
||||
|
||||
def exact_stem_mentioned(path: Path, query: str) -> bool:
|
||||
query_lower = query.lower()
|
||||
if path.name.lower() in query_lower:
|
||||
return True
|
||||
stem_words = " ".join(split_words(path.stem))
|
||||
query_words = " ".join(split_words(query))
|
||||
return len(stem_words) >= 8 and stem_words in query_words
|
||||
|
||||
|
||||
def primary_visual_fallback_mentioned(path: Path, query: str) -> bool:
|
||||
match = re.search(r"Primary SAP visual fallback template:\s*([A-Za-z0-9_.-]+)", query, flags=re.I)
|
||||
if not match:
|
||||
return False
|
||||
return match.group(1).strip().rstrip(".").lower() == path.name.lower()
|
||||
|
||||
|
||||
def explicit_level(query: str) -> str | None:
|
||||
m = re.search(r"\bL([012])\b", query, flags=re.I)
|
||||
return f"l{m.group(1)}" if m else None
|
||||
|
||||
|
||||
def explicit_family(query: str) -> str | None:
|
||||
m = re.search(r"\bRA(\d{4})\b", query, flags=re.I)
|
||||
return f"ra{m.group(1)}" if m else None
|
||||
|
||||
|
||||
def score(path: Path, query: str) -> Candidate:
|
||||
q_tokens = tokens(query)
|
||||
metadata = template_metadata(path)
|
||||
doc_text = drawio_text(path)
|
||||
meta_text = metadata_search_text(path, metadata)
|
||||
d_tokens = tokens(doc_text)
|
||||
m_tokens = tokens(meta_text)
|
||||
filename_tokens = tokens(path.stem)
|
||||
combined_tokens = d_tokens | m_tokens
|
||||
|
||||
token_hits = sorted(q_tokens & combined_tokens)
|
||||
value = len(token_hits) * 2.0
|
||||
reasons: list[str] = []
|
||||
if token_hits:
|
||||
reasons.append("token overlap: " + ", ".join(token_hits[:8]))
|
||||
|
||||
filename_hits = sorted(q_tokens & filename_tokens)
|
||||
if filename_hits:
|
||||
value += len(filename_hits) * 4.0
|
||||
reasons.append("filename match: " + ", ".join(filename_hits[:8]))
|
||||
|
||||
if exact_stem_mentioned(path, query):
|
||||
value += 70
|
||||
reasons.append("exact template filename mentioned (+70)")
|
||||
|
||||
if primary_visual_fallback_mentioned(path, query):
|
||||
value += 90
|
||||
reasons.append("primary visual fallback match (+90)")
|
||||
|
||||
meta_hits = sorted(q_tokens & m_tokens)
|
||||
if meta_hits:
|
||||
boost = min(36.0, len(meta_hits) * 4.0)
|
||||
value += boost
|
||||
reasons.append("metadata match: " + ", ".join(meta_hits[:8]) + f" (+{int(boost)})")
|
||||
|
||||
alias_hits = phrase_hits(query, metadata.get("aliases", []) if isinstance(metadata.get("aliases"), list) else [])
|
||||
if alias_hits:
|
||||
boost = min(30.0, 12.0 + (len(alias_hits) - 1) * 6.0)
|
||||
value += boost
|
||||
reasons.append("alias phrase match: " + ", ".join(alias_hits[:3]) + f" (+{int(boost)})")
|
||||
|
||||
title = metadata.get("title")
|
||||
if isinstance(title, str) and phrase_hits(query, [title]):
|
||||
value += 22
|
||||
reasons.append("metadata title phrase match (+22)")
|
||||
|
||||
level = explicit_level(query)
|
||||
family = explicit_family(query)
|
||||
filename_lower = path.name.lower()
|
||||
path_lower = str(path).lower()
|
||||
metadata_family = str(metadata.get("family", "")).lower()
|
||||
metadata_level = str(metadata.get("level", "")).lower()
|
||||
if family:
|
||||
if family in path_lower or family == metadata_family:
|
||||
value += 24
|
||||
reasons.append(f"explicit {family.upper()} family match (+24)")
|
||||
elif re.search(r"\bra\d{4}\b", path_lower):
|
||||
value -= 8
|
||||
reasons.append("different reference family penalty (-8)")
|
||||
else:
|
||||
value -= 5
|
||||
reasons.append("different reference source penalty (-5)")
|
||||
if level:
|
||||
if level in filename_lower or level == metadata_level:
|
||||
value += 10
|
||||
reasons.append(f"explicit {level.upper()} match")
|
||||
elif re.search(r"_l[012]\b", filename_lower):
|
||||
value -= 3
|
||||
elif "_l2" in filename_lower:
|
||||
value += 3
|
||||
reasons.append("default L2 preference")
|
||||
|
||||
for scenario in SCENARIOS:
|
||||
q_hit = q_tokens & scenario["query"]
|
||||
r_hit = filename_tokens & scenario["reference"]
|
||||
if not r_hit:
|
||||
r_hit = m_tokens & scenario["reference"]
|
||||
if q_hit and r_hit:
|
||||
boost = float(scenario["boost"])
|
||||
value += boost
|
||||
reasons.append(f"{scenario['name']} boost (+{int(boost)})")
|
||||
|
||||
strong_query_tags = {
|
||||
"devops",
|
||||
"federated",
|
||||
"ml",
|
||||
"eic",
|
||||
"pipo",
|
||||
"siem",
|
||||
"soar",
|
||||
"successfactors",
|
||||
"embodied",
|
||||
"agentic",
|
||||
} & q_tokens
|
||||
if strong_query_tags and not (strong_query_tags & (filename_tokens | m_tokens)):
|
||||
value -= 10
|
||||
reasons.append("strong scenario mismatch penalty (-10)")
|
||||
|
||||
# MCP/A2A bonus: only fire when the query is *specifically* about agent-to-
|
||||
# agent or MCP integration (not when it merely mentions an MCP gateway as
|
||||
# one of many components in a broader Agentic AI scenario). The previous
|
||||
# rule unconditionally awarded +20 to A2A_MCP whenever "mcp" appeared,
|
||||
# incorrectly outranking AgenticAI_root for the canonical RA0029 prompt.
|
||||
if q_tokens & {"mcp", "a2a"} and filename_tokens & {"mcp", "a2a"}:
|
||||
# Strong agentic-AI / Joule signals indicate the user wants the
|
||||
# umbrella RA0029 root template, not the A2A/MCP sub-scenario.
|
||||
broader_agentic_signal = bool(
|
||||
q_tokens & {"agentic"}
|
||||
and (q_tokens & {"joule"} or len(q_tokens & {"agent", "agents", "ai"}) >= 2)
|
||||
)
|
||||
if not broader_agentic_signal:
|
||||
value += 20
|
||||
reasons.append("exact MCP/A2A filename match (+20)")
|
||||
else:
|
||||
value += 6
|
||||
reasons.append("MCP/A2A filename match dampened (broader agentic-AI scenario) (+6)")
|
||||
|
||||
# Primary-template preference within a family: when the user query maps
|
||||
# to a well-known family (e.g. agentic-ai) without a specific sub-scenario
|
||||
# signal (no explicit "embodied", "procode", "joule studio", etc.), prefer
|
||||
# the metadata-flagged "primary" template. This correctly routes the
|
||||
# canonical "Agentic AI on SAP BTP" prompt to AgenticAI_root.
|
||||
if metadata.get("primary"):
|
||||
sub_signals = {
|
||||
"embodied", "robotics", # → EmbodiedAIAgents
|
||||
"procode", "developer", "code", "vscode", "ide", # → GenAI_ProCode
|
||||
"studio", # → Joule_Studio variants
|
||||
"ecosystem", # → JouleAgentsToolsEcosystem
|
||||
}
|
||||
# If query has no specific sub-scenario signal, give the primary template a meaningful boost
|
||||
if not (q_tokens & sub_signals):
|
||||
value += 14
|
||||
reasons.append("metadata primary-template boost (+14)")
|
||||
if q_tokens & {"xsuaa", "oauth", "oidc", "saml"} and (
|
||||
{"authentication", "authn"} & filename_tokens or "cloud" in filename_lower and "identity" in filename_lower
|
||||
):
|
||||
value += 8
|
||||
reasons.append("exact authentication filename match (+8)")
|
||||
if q_tokens & {"bdc", "businessdatacloud", "aicore"} and filename_tokens & {"bdc", "businessdatacloud", "aicore"}:
|
||||
value += 12
|
||||
reasons.append("exact BDC / AI Core filename match (+12)")
|
||||
if "aicore" in q_tokens and "aicore" in filename_tokens:
|
||||
value += 10
|
||||
reasons.append("exact AI Core filename match (+10)")
|
||||
if {"agentic", "ai"} <= q_tokens:
|
||||
root_agentic = path.name == "ac_RA0029_AgenticAI_root.drawio"
|
||||
embodied_terms = {"embodied", "robotic", "robotics", "physical"}
|
||||
root_signals = {
|
||||
"gateway",
|
||||
"orchestrator",
|
||||
"capabilities",
|
||||
"subaccount",
|
||||
"businessdatacloud",
|
||||
"successfactors",
|
||||
"concur",
|
||||
"s4hana",
|
||||
"mcp",
|
||||
} & q_tokens
|
||||
if root_agentic and not (q_tokens & embodied_terms):
|
||||
value += 24
|
||||
reasons.append("generic Agentic AI root boost (+24)")
|
||||
if len(root_signals) >= 2:
|
||||
value += 12
|
||||
reasons.append("Agentic AI root component match (+12)")
|
||||
if ("embodied" in filename_tokens or "embodied" in m_tokens) and not (q_tokens & embodied_terms):
|
||||
value -= 18
|
||||
reasons.append("embodied-specific template penalty (-18)")
|
||||
if "joule" in filename_tokens and "joule" not in q_tokens:
|
||||
value -= 12
|
||||
reasons.append("Joule-specific template penalty (-12)")
|
||||
if metadata.get("generic") and strong_query_tags:
|
||||
value -= 12
|
||||
reasons.append("generic template penalty for specific scenario (-12)")
|
||||
|
||||
# Prefer canonical btp_ examples when equally relevant; otherwise prefer
|
||||
# Architecture Center diagrams with richer scenario labels.
|
||||
if path.name.startswith("btp_"):
|
||||
value += 1.0
|
||||
if not reasons:
|
||||
reasons.append("weak lexical match; review manually")
|
||||
|
||||
return Candidate(
|
||||
str(path),
|
||||
round(value, 1),
|
||||
reasons,
|
||||
token_hits[:12],
|
||||
metadata_title=str(title) if title else None,
|
||||
metadata_tags=list(metadata.get("tags", []))[:12] if isinstance(metadata.get("tags"), list) else [],
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("description", nargs="*", help="diagram request; stdin is used if omitted")
|
||||
ap.add_argument("--reference-dir", type=Path, default=default_reference_dir())
|
||||
ap.add_argument("--top", type=int, default=5)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
query = " ".join(args.description).strip() or sys.stdin.read().strip()
|
||||
if not query:
|
||||
print("description required", file=sys.stderr)
|
||||
return 2
|
||||
if not args.reference_dir.exists():
|
||||
print(f"{args.reference_dir}: reference directory not found", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
refs = sorted(args.reference_dir.rglob("*.drawio"))
|
||||
ranked = sorted((score(p, query) for p in refs), key=lambda c: (-c.score, c.path))[: args.top]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps([asdict(c) for c in ranked], indent=2))
|
||||
return 0
|
||||
|
||||
print(f"query: {query}")
|
||||
for i, cand in enumerate(ranked, 1):
|
||||
print(f"{i}. {cand.score:5.1f} {cand.path}")
|
||||
for reason in cand.reasons[:3]:
|
||||
print(f" - {reason}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user