Coverage for scripts / check_sla_breach.py: 12%
139 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-25 18:08 -0500
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-25 18:08 -0500
1#!/usr/bin/env python3
2"""
3Check JSM service request SLA breach status.
5Usage:
6 python check_sla_breach.py SD-123
7 python check_sla_breach.py SD-123 --threshold 30
8 python check_sla_breach.py SD-123 --sla-name "Time to First Response"
9 python check_sla_breach.py SD-123 --output json
10"""
12import sys
13import os
14import argparse
15import json
16from pathlib import Path
17from typing import Optional, Dict, Any, List
19sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'shared' / 'scripts' / 'lib'))
21from config_manager import get_jira_client
22from error_handler import print_error, JiraError, NotFoundError
23from formatters import print_success
24from jsm_utils import (
25 format_sla_time, format_duration, get_sla_status_emoji,
26 get_sla_status_text, is_sla_at_risk
27)
30def check_sla_breach(issue_key: str, threshold: float = 20.0,
31 sla_name: Optional[str] = None,
32 profile: Optional[str] = None) -> Dict[str, Any]:
33 """
34 Check SLA breach status for a service request.
36 Args:
37 issue_key: Request key (e.g., 'SD-123')
38 threshold: At-risk threshold percentage (default 20%)
39 sla_name: Filter to specific SLA name (optional)
40 profile: JIRA profile to use
42 Returns:
43 Dict with breach status, SLAs, and statistics
45 Raises:
46 NotFoundError: If request doesn't exist
47 """
48 with get_jira_client(profile) as client:
49 sla_data = client.get_request_slas(issue_key)
51 slas = sla_data.get('values', [])
53 # Filter by SLA name if specified
54 if sla_name:
55 slas = [sla for sla in slas if sla.get('name') == sla_name]
57 # Analyze each SLA
58 breached = []
59 at_risk = []
60 paused = []
61 met = []
63 for sla in slas:
64 ongoing = sla.get('ongoingCycle')
66 if ongoing:
67 if ongoing.get('breached'):
68 breached.append(sla)
69 elif ongoing.get('paused'):
70 paused.append(sla)
71 else:
72 remaining = ongoing.get('remainingTime', {}).get('millis', 0)
73 goal = ongoing.get('goalDuration', {}).get('millis', 0)
74 if is_sla_at_risk(remaining, goal, threshold):
75 at_risk.append(sla)
76 else:
77 # Check completed cycles
78 completed = sla.get('completedCycles', [])
79 if completed:
80 last_cycle = completed[-1]
81 if last_cycle.get('breached'):
82 breached.append(sla)
83 else:
84 met.append(sla)
86 return {
87 'issue_key': issue_key,
88 'total_slas': len(slas),
89 'breached': breached,
90 'at_risk': at_risk,
91 'paused': paused,
92 'met': met,
93 'threshold': threshold,
94 'has_breach': len(breached) > 0,
95 'has_risk': len(at_risk) > 0
96 }
99def format_breach_text(result: Dict[str, Any]) -> str:
100 """Format breach check result as text output."""
101 lines = []
103 lines.append(f"\nSLA Status for {result['issue_key']}:")
104 lines.append("=" * 60)
105 lines.append("")
107 # Breached SLAs
108 if result['breached']:
109 lines.append(f"✗ BREACHED ({len(result['breached'])} SLA{'s' if len(result['breached']) > 1 else ''}):")
110 for sla in result['breached']:
111 name = sla.get('name')
112 ongoing = sla.get('ongoingCycle')
113 if ongoing:
114 elapsed = format_duration(ongoing.get('elapsedTime'))
115 goal = format_duration(ongoing.get('goalDuration'))
116 lines.append(f" • {name}")
117 lines.append(f" Goal: {goal}")
118 lines.append(f" Elapsed: {elapsed}")
119 else:
120 completed = sla.get('completedCycles', [])
121 if completed:
122 last = completed[-1]
123 elapsed = format_duration(last.get('elapsedTime'))
124 goal = format_duration(last.get('goalDuration'))
125 lines.append(f" • {name}")
126 lines.append(f" Goal: {goal}")
127 lines.append(f" Actual: {elapsed}")
128 lines.append("")
130 # At-risk SLAs
131 if result['at_risk']:
132 lines.append(f"⚠ AT RISK ({len(result['at_risk'])} SLA{'s' if len(result['at_risk']) > 1 else ''}):")
133 for sla in result['at_risk']:
134 name = sla.get('name')
135 ongoing = sla.get('ongoingCycle')
136 elapsed = format_duration(ongoing.get('elapsedTime'))
137 remaining = format_duration(ongoing.get('remainingTime'))
138 breach_time = format_sla_time(ongoing.get('breachTime'))
139 remaining_millis = ongoing.get('remainingTime', {}).get('millis', 0)
140 goal_millis = ongoing.get('goalDuration', {}).get('millis', 0)
141 percentage = (remaining_millis / goal_millis * 100) if goal_millis > 0 else 0
143 lines.append(f" • {name}")
144 lines.append(f" Elapsed: {elapsed}")
145 lines.append(f" Remaining: {remaining} ({percentage:.1f}% of goal)")
146 lines.append(f" Breach at: {breach_time}")
147 lines.append(f" Warning: Less than {result['threshold']}% time remaining")
148 lines.append("")
150 # Paused SLAs
151 if result['paused']:
152 lines.append(f"⏸ PAUSED ({len(result['paused'])} SLA{'s' if len(result['paused']) > 1 else ''}):")
153 for sla in result['paused']:
154 name = sla.get('name')
155 lines.append(f" • {name}")
156 lines.append("")
158 # Met SLAs
159 if result['met']:
160 lines.append(f"✓ MET ({len(result['met'])} SLA{'s' if len(result['met']) > 1 else ''}):")
161 for sla in result['met']:
162 name = sla.get('name')
163 completed = sla.get('completedCycles', [])
164 if completed:
165 last = completed[-1]
166 elapsed = format_duration(last.get('elapsedTime'))
167 lines.append(f" • {name} (completed in {elapsed})")
168 lines.append("")
170 # Overall status
171 if result['has_breach']:
172 lines.append("Overall Status: BREACHED")
173 lines.append(f"Exit code: 1")
174 elif result['has_risk']:
175 lines.append("Overall Status: AT RISK")
176 lines.append(f"Exit code: 0 (use --fail-on-risk for non-zero exit)")
177 else:
178 lines.append("Overall Status: ALL CLEAR")
179 lines.append(f"Exit code: 0")
181 return '\n'.join(lines)
184def format_breach_json(result: Dict[str, Any]) -> str:
185 """Format breach check result as JSON output."""
186 # Convert to JSON-serializable format
187 output = {
188 'issue_key': result['issue_key'],
189 'total_slas': result['total_slas'],
190 'threshold': result['threshold'],
191 'has_breach': result['has_breach'],
192 'has_risk': result['has_risk'],
193 'breached_count': len(result['breached']),
194 'at_risk_count': len(result['at_risk']),
195 'paused_count': len(result['paused']),
196 'met_count': len(result['met']),
197 'breached': [{'name': s.get('name'), 'id': s.get('id')} for s in result['breached']],
198 'at_risk': [{'name': s.get('name'), 'id': s.get('id')} for s in result['at_risk']],
199 'paused': [{'name': s.get('name'), 'id': s.get('id')} for s in result['paused']],
200 'met': [{'name': s.get('name'), 'id': s.get('id')} for s in result['met']]
201 }
202 return json.dumps(output, indent=2)
205def main():
206 """Main entry point."""
207 parser = argparse.ArgumentParser(
208 description='Check JSM service request SLA breach status',
209 formatter_class=argparse.RawDescriptionHelpFormatter,
210 epilog="""
211Examples:
212 Check SLA status:
213 %(prog)s SD-123
215 Custom at-risk threshold:
216 %(prog)s SD-123 --threshold 30
218 Check specific SLA:
219 %(prog)s SD-123 --sla-name "Time to First Response"
221 JSON output:
222 %(prog)s SD-123 --output json
223 """
224 )
226 parser.add_argument('request_key',
227 help='Request key (e.g., SD-123)')
228 parser.add_argument('--threshold', type=float, default=20.0,
229 help='At-risk threshold percentage (default: 20%%)')
230 parser.add_argument('--sla-name',
231 help='Check specific SLA only')
232 parser.add_argument('--fail-on-risk', action='store_true',
233 help='Exit with code 1 if any SLA is at risk')
234 parser.add_argument('--output', choices=['text', 'json'], default='text',
235 help='Output format (default: text)')
236 parser.add_argument('--profile',
237 help='JIRA profile to use from config')
239 args = parser.parse_args()
241 try:
242 result = check_sla_breach(
243 issue_key=args.request_key,
244 threshold=args.threshold,
245 sla_name=args.sla_name,
246 profile=args.profile
247 )
249 if args.output == 'json':
250 print(format_breach_json(result))
251 else:
252 print(format_breach_text(result))
254 # Determine exit code
255 if result['has_breach']:
256 return 1
257 if args.fail_on_risk and result['has_risk']:
258 return 1
260 return 0
262 except NotFoundError as e:
263 print_error(f"Request not found: {e}")
264 return 1
265 except JiraError as e:
266 print_error(f"Failed to check SLA: {e}")
267 return 1
268 except Exception as e:
269 print_error(f"Unexpected error: {e}")
270 return 1
273if __name__ == '__main__':
274 sys.exit(main())