Phase 1 — Agent & Command Foundation: - Rewrite cs-project-manager agent (55→515 lines, 4 workflows, 6 skill integrations) - Expand cs-product-manager agent (408→684 lines, orchestrates all 8 product skills) - Add 7 slash commands: /rice, /okr, /persona, /user-story, /sprint-health, /project-health, /retro Phase 2 — Script Gap Closure (2,779 lines): - jira-expert: jql_query_builder.py (22 patterns), workflow_validator.py - confluence-expert: space_structure_generator.py, content_audit_analyzer.py - atlassian-admin: permission_audit_tool.py - atlassian-templates: template_scaffolder.py (Confluence XHTML generation) Phase 3 — Reference & Asset Enrichment: - 9 product references (competitive-teardown, landing-page-generator, saas-scaffolder) - 6 PM references (confluence-expert, atlassian-admin, atlassian-templates) - 7 product assets (templates for PRD, RICE, sprint, stories, OKR, research, design system) - 1 PM asset (permission_scheme_template.json) Phase 4 — New Agents: - cs-agile-product-owner, cs-product-strategist, cs-ux-researcher Phase 5 — Integration & Polish: - Related Skills cross-references in 8 SKILL.md files - Updated product-team/CLAUDE.md (5→8 skills, 6→9 tools, 4 agents, 5 commands) - Updated project-management/CLAUDE.md (0→12 scripts, 3 commands) - Regenerated docs site (177 pages), updated homepage and getting-started Quality audit: 31 files reviewed, 29 PASS, 2 fixed (copy-frameworks.md, governance-framework.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
495 lines
18 KiB
Python
495 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
JQL Query Builder
|
|
|
|
Pattern-matching JQL builder from natural language descriptions. Maps common
|
|
phrases to JQL operators and constructs valid queries with syntax validation.
|
|
|
|
Usage:
|
|
python jql_query_builder.py "high priority bugs in PROJECT assigned to me"
|
|
python jql_query_builder.py "overdue tasks in PROJ" --format json
|
|
python jql_query_builder.py --patterns
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pattern Library
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PATTERN_LIBRARY = {
|
|
"my_open_bugs": {
|
|
"phrases": ["my open bugs", "my bugs", "bugs assigned to me"],
|
|
"jql": 'assignee = currentUser() AND type = Bug AND status != Done',
|
|
"description": "All open bugs assigned to current user",
|
|
},
|
|
"high_priority_bugs": {
|
|
"phrases": ["high priority bugs", "critical bugs", "urgent bugs", "p1 bugs"],
|
|
"jql": 'type = Bug AND priority in (Highest, High) AND status != Done',
|
|
"description": "High and highest priority open bugs",
|
|
},
|
|
"my_open_tasks": {
|
|
"phrases": ["my open tasks", "my tasks", "tasks assigned to me", "my work"],
|
|
"jql": 'assignee = currentUser() AND status != Done',
|
|
"description": "All open issues assigned to current user",
|
|
},
|
|
"unassigned_issues": {
|
|
"phrases": ["unassigned", "unassigned issues", "no assignee"],
|
|
"jql": 'assignee is EMPTY AND status != Done',
|
|
"description": "Issues with no assignee",
|
|
},
|
|
"recently_created": {
|
|
"phrases": ["recently created", "new issues", "created this week", "recent"],
|
|
"jql": 'created >= -7d ORDER BY created DESC',
|
|
"description": "Issues created in the last 7 days",
|
|
},
|
|
"recently_updated": {
|
|
"phrases": ["recently updated", "updated this week", "recent changes"],
|
|
"jql": 'updated >= -7d ORDER BY updated DESC',
|
|
"description": "Issues updated in the last 7 days",
|
|
},
|
|
"overdue": {
|
|
"phrases": ["overdue", "past due", "missed deadline", "overdue tasks"],
|
|
"jql": 'duedate < now() AND status != Done',
|
|
"description": "Issues past their due date",
|
|
},
|
|
"due_this_week": {
|
|
"phrases": ["due this week", "due soon", "upcoming deadlines"],
|
|
"jql": 'duedate >= startOfWeek() AND duedate <= endOfWeek() AND status != Done',
|
|
"description": "Issues due this week",
|
|
},
|
|
"blocked_issues": {
|
|
"phrases": ["blocked", "blocked issues", "impediments"],
|
|
"jql": 'status = Blocked OR status = Impediment',
|
|
"description": "Issues in blocked or impediment status",
|
|
},
|
|
"in_progress": {
|
|
"phrases": ["in progress", "being worked on", "active work"],
|
|
"jql": 'status = "In Progress"',
|
|
"description": "Issues currently in progress",
|
|
},
|
|
"sprint_issues": {
|
|
"phrases": ["current sprint", "this sprint", "active sprint"],
|
|
"jql": 'sprint in openSprints()',
|
|
"description": "Issues in the current active sprint",
|
|
},
|
|
"backlog": {
|
|
"phrases": ["backlog", "backlog items", "not started"],
|
|
"jql": 'sprint is EMPTY AND status = "To Do" ORDER BY priority DESC',
|
|
"description": "Issues in the backlog not assigned to a sprint",
|
|
},
|
|
"stories_without_estimates": {
|
|
"phrases": ["no estimates", "unestimated", "missing estimates", "no story points"],
|
|
"jql": 'type = Story AND (storyPoints is EMPTY OR storyPoints = 0) AND status != Done',
|
|
"description": "Stories missing story point estimates",
|
|
},
|
|
"epics_in_progress": {
|
|
"phrases": ["active epics", "epics in progress", "open epics"],
|
|
"jql": 'type = Epic AND status != Done ORDER BY priority DESC',
|
|
"description": "Epics that are not yet completed",
|
|
},
|
|
"done_this_week": {
|
|
"phrases": ["done this week", "completed this week", "resolved this week"],
|
|
"jql": 'status changed to Done DURING (startOfWeek(), now())',
|
|
"description": "Issues completed during the current week",
|
|
},
|
|
"created_vs_resolved": {
|
|
"phrases": ["created vs resolved", "issue flow", "throughput"],
|
|
"jql": 'created >= -30d ORDER BY created DESC',
|
|
"description": "Issues created in the last 30 days for flow analysis",
|
|
},
|
|
"my_reported_issues": {
|
|
"phrases": ["my reported", "reported by me", "i created", "i reported"],
|
|
"jql": 'reporter = currentUser() ORDER BY created DESC',
|
|
"description": "Issues reported by current user",
|
|
},
|
|
"stale_issues": {
|
|
"phrases": ["stale", "stale issues", "not updated", "abandoned"],
|
|
"jql": 'updated <= -30d AND status != Done ORDER BY updated ASC',
|
|
"description": "Issues not updated in 30+ days",
|
|
},
|
|
"subtasks_without_parent": {
|
|
"phrases": ["orphan subtasks", "subtasks no parent", "loose subtasks"],
|
|
"jql": 'type = Sub-task AND parent is EMPTY',
|
|
"description": "Subtasks missing parent issues",
|
|
},
|
|
"high_priority_unassigned": {
|
|
"phrases": ["high priority unassigned", "urgent unassigned", "critical no owner"],
|
|
"jql": 'priority in (Highest, High) AND assignee is EMPTY AND status != Done',
|
|
"description": "High priority issues with no assignee",
|
|
},
|
|
"bugs_by_component": {
|
|
"phrases": ["bugs by component", "component bugs"],
|
|
"jql": 'type = Bug AND status != Done ORDER BY component ASC',
|
|
"description": "Open bugs organized by component",
|
|
},
|
|
"resolved_recently": {
|
|
"phrases": ["resolved recently", "recently resolved", "fixed this month"],
|
|
"jql": 'resolved >= -30d ORDER BY resolved DESC',
|
|
"description": "Issues resolved in the last 30 days",
|
|
},
|
|
}
|
|
|
|
# Keyword-to-JQL fragment mapping for dynamic query building
|
|
KEYWORD_FRAGMENTS = {
|
|
# Issue types
|
|
"bug": ("type", "= Bug"),
|
|
"bugs": ("type", "= Bug"),
|
|
"story": ("type", "= Story"),
|
|
"stories": ("type", "= Story"),
|
|
"task": ("type", "= Task"),
|
|
"tasks": ("type", "= Task"),
|
|
"epic": ("type", "= Epic"),
|
|
"epics": ("type", "= Epic"),
|
|
"subtask": ("type", "= Sub-task"),
|
|
"sub-task": ("type", "= Sub-task"),
|
|
# Statuses
|
|
"open": ("status", "!= Done"),
|
|
"closed": ("status", "= Done"),
|
|
"done": ("status", "= Done"),
|
|
"resolved": ("status", "= Done"),
|
|
"todo": ("status", '= "To Do"'),
|
|
# Priorities
|
|
"critical": ("priority", "= Highest"),
|
|
"highest": ("priority", "= Highest"),
|
|
"high": ("priority", "in (Highest, High)"),
|
|
"medium": ("priority", "= Medium"),
|
|
"low": ("priority", "in (Low, Lowest)"),
|
|
"lowest": ("priority", "= Lowest"),
|
|
# Assignee
|
|
"me": ("assignee", "= currentUser()"),
|
|
"mine": ("assignee", "= currentUser()"),
|
|
"unassigned": ("assignee", "is EMPTY"),
|
|
# Time
|
|
"overdue": ("duedate", "< now()"),
|
|
"today": ("duedate", "= now()"),
|
|
}
|
|
|
|
PROJECT_PATTERN = re.compile(r'\b([A-Z]{2,10})\b')
|
|
ASSIGNEE_PATTERN = re.compile(r'assigned\s+to\s+(\w+)', re.IGNORECASE)
|
|
LABEL_PATTERN = re.compile(r'label[s]?\s*[=:]\s*["\']?(\w+)["\']?', re.IGNORECASE)
|
|
COMPONENT_PATTERN = re.compile(r'component[s]?\s*[=:]\s*["\']?(\w+)["\']?', re.IGNORECASE)
|
|
DATE_RANGE_PATTERN = re.compile(r'last\s+(\d+)\s+(day|week|month)s?', re.IGNORECASE)
|
|
SPRINT_NAME_PATTERN = re.compile(r'sprint\s+["\']?(\w[\w\s]*\w)["\']?', re.IGNORECASE)
|
|
|
|
# Words to exclude from project matching
|
|
EXCLUDED_WORDS = {
|
|
"AND", "OR", "NOT", "IN", "IS", "TO", "BY", "ON", "DO", "BE",
|
|
"THE", "ALL", "MY", "NO", "OF", "AT", "AS", "IF", "IT",
|
|
"BUG", "BUGS", "TASK", "TASKS", "STORY", "EPIC", "DONE",
|
|
"HIGH", "LOW", "MEDIUM", "JQL",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Query Builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def find_matching_pattern(description: str) -> Optional[Dict[str, Any]]:
|
|
"""Check if description matches a known pattern exactly."""
|
|
desc_lower = description.lower().strip()
|
|
for pattern_name, pattern_data in PATTERN_LIBRARY.items():
|
|
for phrase in pattern_data["phrases"]:
|
|
if phrase in desc_lower or desc_lower in phrase:
|
|
return {
|
|
"pattern_name": pattern_name,
|
|
"jql": pattern_data["jql"],
|
|
"description": pattern_data["description"],
|
|
"match_type": "exact_pattern",
|
|
}
|
|
return None
|
|
|
|
|
|
def build_jql_from_description(description: str) -> Dict[str, Any]:
|
|
"""Build JQL query from natural language description."""
|
|
# First try exact pattern match
|
|
pattern_match = find_matching_pattern(description)
|
|
if pattern_match:
|
|
# Augment with project if mentioned
|
|
project = _extract_project(description)
|
|
if project:
|
|
pattern_match["jql"] = f'project = {project} AND {pattern_match["jql"]}'
|
|
return pattern_match
|
|
|
|
# Dynamic query building
|
|
clauses = []
|
|
used_fields = set()
|
|
desc_lower = description.lower()
|
|
|
|
# Extract project
|
|
project = _extract_project(description)
|
|
if project:
|
|
clauses.append(f"project = {project}")
|
|
used_fields.add("project")
|
|
|
|
# Extract keyword-based fragments
|
|
for keyword, (field, fragment) in KEYWORD_FRAGMENTS.items():
|
|
if keyword in desc_lower.split() and field not in used_fields:
|
|
clauses.append(f"{field} {fragment}")
|
|
used_fields.add(field)
|
|
|
|
# Extract explicit assignee
|
|
assignee_match = ASSIGNEE_PATTERN.search(description)
|
|
if assignee_match and "assignee" not in used_fields:
|
|
assignee = assignee_match.group(1)
|
|
if assignee.lower() in ("me", "myself"):
|
|
clauses.append("assignee = currentUser()")
|
|
else:
|
|
clauses.append(f'assignee = "{assignee}"')
|
|
used_fields.add("assignee")
|
|
|
|
# Extract labels
|
|
label_match = LABEL_PATTERN.search(description)
|
|
if label_match:
|
|
clauses.append(f'labels = "{label_match.group(1)}"')
|
|
|
|
# Extract component
|
|
component_match = COMPONENT_PATTERN.search(description)
|
|
if component_match:
|
|
clauses.append(f'component = "{component_match.group(1)}"')
|
|
|
|
# Extract date ranges
|
|
date_match = DATE_RANGE_PATTERN.search(description)
|
|
if date_match:
|
|
amount = date_match.group(1)
|
|
unit = date_match.group(2).lower()
|
|
unit_char = {"day": "d", "week": "w", "month": "m"}.get(unit, "d")
|
|
clauses.append(f"created >= -{amount}{unit_char}")
|
|
|
|
# Extract sprint reference
|
|
sprint_match = SPRINT_NAME_PATTERN.search(description)
|
|
if sprint_match:
|
|
sprint_name = sprint_match.group(1).strip()
|
|
if sprint_name.lower() in ("current", "active", "open"):
|
|
clauses.append("sprint in openSprints()")
|
|
else:
|
|
clauses.append(f'sprint = "{sprint_name}"')
|
|
|
|
# Default: if no status clause and not looking for done items
|
|
if "status" not in used_fields and "done" not in desc_lower and "closed" not in desc_lower:
|
|
clauses.append("status != Done")
|
|
|
|
if not clauses:
|
|
return {
|
|
"jql": "",
|
|
"description": "Could not build query from description",
|
|
"match_type": "no_match",
|
|
"error": "No recognizable patterns found in description",
|
|
}
|
|
|
|
jql = " AND ".join(clauses)
|
|
|
|
# Add ORDER BY for common scenarios
|
|
if "recent" in desc_lower or "latest" in desc_lower:
|
|
jql += " ORDER BY created DESC"
|
|
elif "priority" in desc_lower or "urgent" in desc_lower:
|
|
jql += " ORDER BY priority DESC"
|
|
|
|
return {
|
|
"jql": jql,
|
|
"description": f"Dynamic query from: {description}",
|
|
"match_type": "dynamic",
|
|
"clauses_used": len(clauses),
|
|
}
|
|
|
|
|
|
def _extract_project(description: str) -> Optional[str]:
|
|
"""Extract project key from description."""
|
|
# Look for IN/in PROJECT pattern
|
|
in_project = re.search(r'\bin\s+([A-Z]{2,10})\b', description)
|
|
if in_project and in_project.group(1) not in EXCLUDED_WORDS:
|
|
return in_project.group(1)
|
|
|
|
# Look for standalone project keys
|
|
for match in PROJECT_PATTERN.finditer(description):
|
|
word = match.group(1)
|
|
if word not in EXCLUDED_WORDS:
|
|
return word
|
|
|
|
return None
|
|
|
|
|
|
def validate_jql_syntax(jql: str) -> Dict[str, Any]:
|
|
"""Basic JQL syntax validation."""
|
|
issues = []
|
|
|
|
if not jql.strip():
|
|
return {"valid": False, "issues": ["Empty query"]}
|
|
|
|
# Check balanced quotes
|
|
single_quotes = jql.count("'")
|
|
double_quotes = jql.count('"')
|
|
if single_quotes % 2 != 0:
|
|
issues.append("Unbalanced single quotes")
|
|
if double_quotes % 2 != 0:
|
|
issues.append("Unbalanced double quotes")
|
|
|
|
# Check balanced parentheses
|
|
open_parens = jql.count("(")
|
|
close_parens = jql.count(")")
|
|
if open_parens != close_parens:
|
|
issues.append(f"Unbalanced parentheses: {open_parens} open, {close_parens} close")
|
|
|
|
# Check for known JQL operators
|
|
valid_operators = {"=", "!=", ">", "<", ">=", "<=", "~", "!~", "in", "not in", "is", "is not", "was", "was not", "changed"}
|
|
jql_upper = jql.upper()
|
|
|
|
# Check AND/OR placement
|
|
if jql_upper.strip().startswith("AND") or jql_upper.strip().startswith("OR"):
|
|
issues.append("Query cannot start with AND/OR")
|
|
if jql_upper.strip().endswith("AND") or jql_upper.strip().endswith("OR"):
|
|
issues.append("Query cannot end with AND/OR")
|
|
|
|
# Check ORDER BY syntax
|
|
order_match = re.search(r'ORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?', jql, re.IGNORECASE)
|
|
if "ORDER" in jql_upper and not order_match:
|
|
issues.append("Invalid ORDER BY syntax")
|
|
|
|
return {
|
|
"valid": len(issues) == 0,
|
|
"issues": issues,
|
|
"query_length": len(jql),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output Formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def format_text_output(result: Dict[str, Any]) -> str:
|
|
"""Format results as readable text report."""
|
|
lines = []
|
|
lines.append("=" * 60)
|
|
lines.append("JQL QUERY BUILDER RESULTS")
|
|
lines.append("=" * 60)
|
|
lines.append("")
|
|
|
|
if "error" in result:
|
|
lines.append(f"ERROR: {result['error']}")
|
|
return "\n".join(lines)
|
|
|
|
lines.append(f"Match Type: {result.get('match_type', 'unknown')}")
|
|
lines.append(f"Description: {result.get('description', '')}")
|
|
lines.append("")
|
|
lines.append("GENERATED JQL")
|
|
lines.append("-" * 30)
|
|
lines.append(result.get("jql", ""))
|
|
lines.append("")
|
|
|
|
validation = result.get("validation", {})
|
|
if validation:
|
|
lines.append("VALIDATION")
|
|
lines.append("-" * 30)
|
|
lines.append(f"Valid: {'Yes' if validation.get('valid') else 'No'}")
|
|
if validation.get("issues"):
|
|
for issue in validation["issues"]:
|
|
lines.append(f" - {issue}")
|
|
|
|
if result.get("pattern_name"):
|
|
lines.append("")
|
|
lines.append(f"Matched Pattern: {result['pattern_name']}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_patterns_output(output_format: str) -> str:
|
|
"""Format available patterns list."""
|
|
if output_format == "json":
|
|
patterns = {}
|
|
for name, data in PATTERN_LIBRARY.items():
|
|
patterns[name] = {
|
|
"description": data["description"],
|
|
"phrases": data["phrases"],
|
|
"jql": data["jql"],
|
|
}
|
|
return json.dumps(patterns, indent=2)
|
|
|
|
lines = []
|
|
lines.append("=" * 60)
|
|
lines.append("AVAILABLE JQL PATTERNS")
|
|
lines.append("=" * 60)
|
|
lines.append("")
|
|
|
|
for name, data in PATTERN_LIBRARY.items():
|
|
lines.append(f" {name}")
|
|
lines.append(f" Description: {data['description']}")
|
|
lines.append(f" Phrases: {', '.join(data['phrases'])}")
|
|
lines.append(f" JQL: {data['jql']}")
|
|
lines.append("")
|
|
|
|
lines.append(f"Total patterns: {len(PATTERN_LIBRARY)}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_json_output(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Format results as JSON."""
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI Interface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
"""Main CLI entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Build JQL queries from natural language descriptions"
|
|
)
|
|
parser.add_argument(
|
|
"description",
|
|
nargs="?",
|
|
help="Natural language description of the query",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=["text", "json"],
|
|
default="text",
|
|
help="Output format (default: text)",
|
|
)
|
|
parser.add_argument(
|
|
"--patterns",
|
|
action="store_true",
|
|
help="List all available query patterns",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
if args.patterns:
|
|
print(format_patterns_output(args.format))
|
|
return 0
|
|
|
|
if not args.description:
|
|
parser.error("description is required unless --patterns is used")
|
|
|
|
# Build query
|
|
result = build_jql_from_description(args.description)
|
|
|
|
# Validate
|
|
if result.get("jql"):
|
|
result["validation"] = validate_jql_syntax(result["jql"])
|
|
|
|
# Output results
|
|
if args.format == "json":
|
|
output = format_json_output(result)
|
|
print(json.dumps(output, indent=2))
|
|
else:
|
|
output = format_text_output(result)
|
|
print(output)
|
|
|
|
return 0
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|