predictability-engine 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/bin/predictability-engine +19 -0
  3. data/bin/predictability-engine.bat +2 -0
  4. data/bin/setup +47 -0
  5. data/data/samples/sample_data.csv +19 -0
  6. data/data/samples/sample_data_large.csv +201 -0
  7. data/data/samples/wip_data.csv +5 -0
  8. data/lib/predictability_engine/agents/assistant.rb +29 -0
  9. data/lib/predictability_engine/agents/tools.rb +98 -0
  10. data/lib/predictability_engine/calculators/aging.rb +36 -0
  11. data/lib/predictability_engine/calculators/cfd.rb +80 -0
  12. data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
  13. data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
  14. data/lib/predictability_engine/calculators/throughput.rb +42 -0
  15. data/lib/predictability_engine/cli.rb +414 -0
  16. data/lib/predictability_engine/config.rb +167 -0
  17. data/lib/predictability_engine/data_generator.rb +53 -0
  18. data/lib/predictability_engine/data_manager.rb +28 -0
  19. data/lib/predictability_engine/data_sources/base.rb +93 -0
  20. data/lib/predictability_engine/data_sources/csv.rb +62 -0
  21. data/lib/predictability_engine/data_sources/excel.rb +18 -0
  22. data/lib/predictability_engine/data_sources/factory.rb +20 -0
  23. data/lib/predictability_engine/data_sources/jira.rb +201 -0
  24. data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
  25. data/lib/predictability_engine/duration.rb +16 -0
  26. data/lib/predictability_engine/excel_exporter.rb +48 -0
  27. data/lib/predictability_engine/html_style.rb +63 -0
  28. data/lib/predictability_engine/html_templates.rb +70 -0
  29. data/lib/predictability_engine/jira_auth/base.rb +26 -0
  30. data/lib/predictability_engine/jira_auth/basic.rb +15 -0
  31. data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
  32. data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
  33. data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
  34. data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
  35. data/lib/predictability_engine/jira_auth.rb +22 -0
  36. data/lib/predictability_engine/jira_config_prompter.rb +48 -0
  37. data/lib/predictability_engine/jira_workflow.rb +137 -0
  38. data/lib/predictability_engine/logger.rb +45 -0
  39. data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
  40. data/lib/predictability_engine/models/work_item.rb +43 -0
  41. data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
  42. data/lib/predictability_engine/pdf_visualizer.rb +64 -0
  43. data/lib/predictability_engine/raw_data_exporter.rb +46 -0
  44. data/lib/predictability_engine/report/constants.rb +70 -0
  45. data/lib/predictability_engine/report/image_generator.rb +36 -0
  46. data/lib/predictability_engine/report/text_renderer.rb +84 -0
  47. data/lib/predictability_engine/report.rb +328 -0
  48. data/lib/predictability_engine/report_generator.rb +170 -0
  49. data/lib/predictability_engine/setup_manager.rb +127 -0
  50. data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
  51. data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
  52. data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
  53. data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
  54. data/lib/predictability_engine/summary_visualizer.rb +36 -0
  55. data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
  56. data/lib/predictability_engine/terminal_visualizer.rb +97 -0
  57. data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
  58. data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
  59. data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
  60. data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
  61. data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
  62. data/lib/predictability_engine/vega_visualizer.rb +106 -0
  63. data/lib/predictability_engine/version.rb +5 -0
  64. data/lib/predictability_engine/visualizer.rb +114 -0
  65. data/lib/predictability_engine.rb +117 -0
  66. metadata +566 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module PredictabilityEngine
6
+ module DataSources
7
+ class JiraYaml
8
+ PROJECT_KEY_PATTERN = /\A[A-Z][A-Z0-9]+\z/
9
+
10
+ attr_reader :path, :config
11
+
12
+ def initialize(path)
13
+ @path = Pathname.new(path)
14
+ @config = load_config
15
+ end
16
+
17
+ def profile
18
+ return @config['jira_profile'] if @config['jira_profile']
19
+ return nil unless @path.basename.to_s.count('.') >= 2
20
+
21
+ @path.basename.to_s.split('.').first
22
+ end
23
+
24
+ def query
25
+ @config['query'] || project_query || filter_query || convention_query
26
+ end
27
+
28
+ def priority_aliases
29
+ profile_priority_aliases.merge(inline_priority_aliases)
30
+ end
31
+
32
+ def workflow_config_path
33
+ raw = @config['workflow_config']
34
+ if raw && !raw.to_s.empty?
35
+ return Pathname.new(raw).absolute? ? raw : File.expand_path(raw, @path.dirname.to_s)
36
+ end
37
+
38
+ name = middle_segment
39
+ return nil if name.nil? || name.empty?
40
+
41
+ candidate = File.expand_path("~/.config/jira/#{name}.workflow.yml")
42
+ candidate if File.exist?(candidate)
43
+ end
44
+
45
+ private
46
+
47
+ def inline_priority_aliases
48
+ raw = @config['priority_aliases']
49
+ stringify_hash(raw)
50
+ end
51
+
52
+ def profile_priority_aliases
53
+ return {} unless profile
54
+
55
+ path = File.expand_path("~/.config/jira/#{profile}.priorities.yml")
56
+ return {} unless File.exist?(path)
57
+
58
+ stringify_hash(YAML.load_file(path))
59
+ end
60
+
61
+ def stringify_hash(raw)
62
+ return {} unless raw.is_a?(Hash)
63
+
64
+ raw.transform_keys(&:to_s).transform_values(&:to_s)
65
+ end
66
+
67
+ def load_config
68
+ return {} unless @path.exist?
69
+
70
+ YAML.load_file(@path) || {}
71
+ end
72
+
73
+ def middle_segment
74
+ if @path.basename.to_s.count('.') >= 2
75
+ @path.basename.to_s.split('.')[1...-1].join('.')
76
+ else
77
+ @path.basename.to_s.sub(@path.extname, '')
78
+ end
79
+ end
80
+
81
+ def convention_query
82
+ name = middle_segment
83
+ return nil if name.nil? || name.empty?
84
+ return "(project = \"#{name}\" OR filter = \"#{name}\")" if name.match?(PROJECT_KEY_PATTERN)
85
+
86
+ "filter = \"#{name}\""
87
+ end
88
+
89
+ def project_query
90
+ return nil unless @config['project']
91
+
92
+ "project = \"#{@config['project']}\""
93
+ end
94
+
95
+ def filter_query
96
+ return "filter = \"#{@config['filter_id']}\"" if @config['filter_id']
97
+ return "filter = \"#{@config['filter_name']}\"" if @config['filter_name']
98
+
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module Duration
5
+ UNITS = { 'd' => 1, 'w' => 7, 'm' => 30 }.freeze
6
+
7
+ def self.parse(spec)
8
+ return nil if spec.nil?
9
+
10
+ match = spec.to_s.strip.downcase.match(/\A(\d+)([dwm])\z/)
11
+ raise ArgumentError, "Invalid duration #{spec.inspect} (expected e.g. 1w, 2m, 30d)" unless match
12
+
13
+ match[1].to_i * UNITS.fetch(match[2])
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'caxlsx'
4
+ require_relative 'raw_data_exporter'
5
+
6
+ module PredictabilityEngine
7
+ module ExcelExporter
8
+ CAPTURE_WIDTH = 1920
9
+ CAPTURE_HEIGHT = 1080
10
+ CHART_SCALE = 2 # deviceScaleFactor: PNG is 2× logical size → crisp on HiDPI/4K screens
11
+
12
+ def self.generate(items, images_path: nil)
13
+ Axlsx::Package.new do |p|
14
+ add_work_items_sheet(p.workbook, items)
15
+ add_chart_sheets(p.workbook, images_path) if images_path
16
+ return p.to_stream.read
17
+ end
18
+ end
19
+
20
+ def self.add_work_items_sheet(workbook, items)
21
+ workbook.add_worksheet(name: 'Work Items') do |sheet|
22
+ sheet.add_row(RawDataExporter::HEADERS)
23
+ items.each { |item| sheet.add_row(RawDataExporter.item_row(item)) }
24
+ end
25
+ end
26
+
27
+ def self.add_chart_sheets(workbook, images_path)
28
+ Dir.glob(File.join(images_path, '*.png')).each do |img_path|
29
+ sheet_name = File.basename(img_path, '.png').tr('_', ' ').split.map(&:capitalize).join(' ')
30
+ workbook.add_worksheet(name: sheet_name[0, 31]) do |sheet|
31
+ sheet.page_setup.set(orientation: :landscape)
32
+ png_w, png_h = png_dimensions(img_path)
33
+ sheet.add_image(image_src: img_path, noSelect: true, noMove: true) do |image|
34
+ image.width = png_w / CHART_SCALE
35
+ image.height = png_h / CHART_SCALE
36
+ image.start_at(0, 0)
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def self.png_dimensions(path)
43
+ File.binread(path, 24).unpack('x16NN')
44
+ end
45
+
46
+ private_class_method :add_work_items_sheet, :add_chart_sheets, :png_dimensions
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ HTML_BASE_STYLE = 'font-family: sans-serif; background: #f8f9fa;'
5
+
6
+ HTML_STYLE_LANDSCAPE = <<~CSS.freeze
7
+ <style>
8
+ body { #{HTML_BASE_STYLE} margin: 0; padding: 15px; box-sizing: border-box; display: flex; flex-direction: column; background: #f4f7f6; }
9
+ header { display: flex; justify-content: space-between; align-items: baseline; padding: 0 10px 10px 10px; border-bottom: 2px solid #e9ecef; margin-bottom: 15px; }
10
+ h1 { margin: 0; font-size: 1.5rem; color: #2c3e50; font-weight: 700; }
11
+ .nav-links { display: flex; gap: 10px; list-style: none; margin: 0; padding: 0; align-items: center; }
12
+ .nav-links li { margin: 0; display: block; }
13
+ .nav-links a { text-decoration: none; color: #3498db; font-size: 0.9rem; padding: 5px 12px; border-radius: 20px; border: 1.5px solid #3498db; font-weight: 600; transition: all 0.2s; }
14
+ .nav-links a:hover { background: #3498db; color: white; }
15
+ .nav-links a.active { background: #2c3e50; color: white; border-color: #2c3e50; cursor: default; }
16
+ .nav-links li.nav-sep { color: #bbb; padding: 0 4px; user-select: none; }
17
+ .dashboard-container { display: grid; grid-template-columns: 260px 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 15px; flex-grow: 1; min-height: 0; min-width: 1300px; }
18
+ .summary-panel { grid-row: span 2; background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); overflow-y: auto; border: 1px solid #e9ecef; }
19
+ .summary-panel h2 { font-size: 1.25rem; margin-top: 0; color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 8px; margin-bottom: 15px; }
20
+ .summary-panel h3 { font-size: 1.1rem; color: #34495e; margin-top: 25px; border-bottom: 1px solid #eee; padding-bottom: 5px; }
21
+ .chart-panel { background: white; padding: 15px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); display: flex; flex-direction: column; border: 1px solid #e9ecef; min-height: 280px; min-width: 0; }
22
+ .panel-header { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
23
+ .panel-header h2 { margin: 0; flex-grow: 1; font-size: 1rem; color: #34495e; font-weight: 600; }
24
+ .chart-container { flex-grow: 1; min-height: 0; width: 100%; overflow: hidden; display: flex; justify-content: center; align-items: center; }
25
+ .chart-container > div { width: 100% !important; height: 100% !important; }
26
+ ul { list-style: none; padding: 0; margin: 10px 0; }
27
+ li { margin-bottom: 8px; font-size: 0.95rem; color: #505d6b; display: flex; flex-wrap: wrap; gap: 0 8px; }
28
+ li strong { color: #2c3e50; white-space: nowrap; }
29
+ .metric-value { margin-left: auto; text-align: right; }
30
+
31
+ @media screen {
32
+ body { height: 100vh; overflow: auto; }
33
+ }
34
+
35
+ @media print {
36
+ body { height: 100vh; overflow: hidden; padding: 5px; background: white; }
37
+ .dashboard-container { min-width: 0; gap: 8px; }
38
+ .chart-panel, .summary-panel { box-shadow: none; border: 1px solid #eee; padding: 8px; }
39
+ header { margin-bottom: 8px; padding-bottom: 5px; }
40
+ h1 { font-size: 1.2rem; }
41
+ .vega-bindings { display: none; }
42
+ .chart-expand { display: none; }
43
+ .nav-links { display: none; }
44
+ }
45
+
46
+ .vega-bindings { font-size: 0.85rem; }
47
+ .chart-expand { flex-shrink: 0; background: none; border: none; cursor: pointer; font-size: 1rem; color: #adb5bd; padding: 2px 4px; border-radius: 4px; line-height: 1; transition: color 0.2s, background 0.2s; }
48
+ .chart-expand:hover { color: #2c3e50; background: rgba(0,0,0,0.06); }
49
+ .chart-expand::before { content: '⛶'; }
50
+ .chart-panel.fullscreen .chart-expand::before { content: '✕'; }
51
+ .chart-panel.fullscreen { position: fixed; inset: 0; z-index: 1000; border-radius: 0; }
52
+ .fullscreen-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 999; }
53
+ .vg-tooltip td { vertical-align: top !important; }
54
+ .vg-tooltip td.value { white-space: pre-wrap !important; max-width: 250px !important; }
55
+
56
+ li.breakdown { flex-direction: column; align-items: flex-start; }
57
+ li.breakdown ul { list-style: none; padding: 0 0 0 10px; margin: 4px 0 0 0; }
58
+ li.breakdown ul li { display: block; margin-bottom: 2px; justify-content: flex-start; }
59
+ </style>
60
+ CSS
61
+
62
+ private_constant :HTML_BASE_STYLE, :HTML_STYLE_LANDSCAPE
63
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ HTML_HEADER = <<~HTML
5
+ <head>
6
+ <title>{{TITLE}}</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
10
+ HTML
11
+
12
+ HTML_BASE = <<~HTML.freeze
13
+ <!DOCTYPE html>
14
+ <html>
15
+ #{HTML_HEADER}
16
+ {{STYLE}}
17
+ </head>
18
+ <body>
19
+ {{BODY}}
20
+ </body>
21
+ </html>
22
+ HTML
23
+
24
+ HTML_LANDSCAPE_BODY = <<~HTML
25
+ <header>
26
+ <h1>{{TITLE}}</h1>
27
+ <nav>{{NAV_BAR}}</nav>
28
+ <div style="font-size: 0.8rem; color: #6c757d;">Generated: {{DATE}}</div>
29
+ </header>
30
+ <div class="dashboard-container">
31
+ <div class="summary-panel">{{SUMMARY_CONTENT}}</div>
32
+ {{CHART_PANELS}}
33
+ </div>
34
+ <script>
35
+ function toggleFullscreen(btn) {
36
+ var panel = btn.closest('.chart-panel');
37
+ if (panel.classList.contains('fullscreen')) {
38
+ panel.classList.remove('fullscreen');
39
+ var bd = document.querySelector('.fullscreen-backdrop');
40
+ if (bd) bd.remove();
41
+ } else {
42
+ var bd = document.createElement('div');
43
+ bd.className = 'fullscreen-backdrop';
44
+ bd.onclick = function() { toggleFullscreen(btn); };
45
+ document.body.appendChild(bd);
46
+ panel.classList.add('fullscreen');
47
+ }
48
+ setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 50);
49
+ }
50
+ document.addEventListener('keydown', function(e) {
51
+ if (e.key !== 'Escape') return;
52
+ var fp = document.querySelector('.chart-panel.fullscreen');
53
+ if (fp) toggleFullscreen(fp.querySelector('.chart-expand'));
54
+ });
55
+ var obs = new MutationObserver(function() {
56
+ document.querySelectorAll('.chart-panel').forEach(function(panel) {
57
+ var bindings = panel.querySelector('.vega-bindings');
58
+ var header = panel.querySelector('.panel-header');
59
+ var btn = header && header.querySelector('.chart-expand');
60
+ if (bindings && btn && bindings.parentElement !== header) {
61
+ header.insertBefore(bindings, btn);
62
+ }
63
+ });
64
+ });
65
+ obs.observe(document.body, { childList: true, subtree: true });
66
+ </script>
67
+ HTML
68
+
69
+ private_constant :HTML_HEADER, :HTML_BASE, :HTML_LANDSCAPE_BODY
70
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module JiraAuth
5
+ class Base
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def jira_options(_base_options)
11
+ raise NotImplementedError, "#{self.class} must implement jira_options"
12
+ end
13
+
14
+ def post_init(_client); end
15
+
16
+ protected
17
+
18
+ def bearer_merge(base_options, token)
19
+ base_options.merge(
20
+ auth_type: :basic,
21
+ default_headers: base_options[:default_headers].merge('Authorization' => "Bearer #{token}")
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module JiraAuth
5
+ class Basic < Base
6
+ def jira_options(base_options)
7
+ base_options.merge(
8
+ username: @config[:email],
9
+ password: @config[:token],
10
+ auth_type: :basic
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module JiraAuth
5
+ class Bearer < Base
6
+ def jira_options(base_options)
7
+ bearer_merge(base_options, @config[:bearer_token])
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module JiraAuth
5
+ class Cookie < Base
6
+ def jira_options(base_options)
7
+ base_options.merge(
8
+ auth_type: :basic,
9
+ use_cookies: true,
10
+ additional_cookies: [@config[:auth_cookie]]
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/https'
4
+ require 'json'
5
+ require 'rotp'
6
+
7
+ module PredictabilityEngine
8
+ module JiraAuth
9
+ class MfaApi < Base
10
+ def jira_options(base_options)
11
+ bearer_merge(base_options, fetch_token)
12
+ end
13
+
14
+ private
15
+
16
+ def fetch_token
17
+ otp = ROTP::TOTP.new(@config[:totp_secret]).now
18
+ uri = URI(@config[:mfa_login_url])
19
+ response = Net::HTTP.post(uri, build_payload(otp).to_json, 'Content-Type' => 'application/json')
20
+ raise Error, "MFA login failed (HTTP #{response.code}): #{response.body}" unless response.is_a?(Net::HTTPOK)
21
+
22
+ JSON.parse(response.body).fetch(token_field) do
23
+ raise Error, "MFA login response missing field '#{token_field}'"
24
+ end
25
+ end
26
+
27
+ def build_payload(otp)
28
+ { username: @config[:email], password: @config[:password], otp: otp }
29
+ end
30
+
31
+ def token_field
32
+ @config[:mfa_token_field] || 'access_token'
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/https'
4
+ require 'uri'
5
+
6
+ module PredictabilityEngine
7
+ module JiraAuth
8
+ # Browser-assisted IdP authentication for MFA gateways.
9
+ #
10
+ # Two sub-modes selected by `idp_callback_port`:
11
+ #
12
+ # A. Manual-paste (default, no extra deps):
13
+ # Prints the idp_login_url and asks the user to paste back
14
+ # the resulting bearer token or cookie string.
15
+ #
16
+ # B. Local callback server (experimental, set idp_callback_port: PORT):
17
+ # Starts a WEBrick listener on localhost:PORT, opens the browser,
18
+ # captures the token/code from the redirect query params, then
19
+ # exchanges a code for a token if mfa_token_exchange_url is set.
20
+ class MfaBrowser < Base
21
+ CALLBACK_TIMEOUT = 120
22
+
23
+ def jira_options(base_options)
24
+ bearer_merge(base_options, @config[:idp_callback_port] ? callback_server_flow : manual_paste_flow)
25
+ end
26
+
27
+ private
28
+
29
+ def manual_paste_flow
30
+ $stdout.puts "\nOpen this URL in your browser to authenticate:"
31
+ $stdout.puts " #{@config[:idp_login_url]}"
32
+ $stdout.puts "\nAfter logging in, paste the bearer token below and press Enter:"
33
+ $stdout.print '> '
34
+ token = $stdin.gets.to_s.strip
35
+ raise Error, 'No token provided' if token.empty?
36
+
37
+ token
38
+ end
39
+
40
+ # @experimental
41
+ def callback_server_flow
42
+ require 'webrick'
43
+ port = Integer(@config[:idp_callback_port])
44
+ token_queue = Queue.new
45
+
46
+ server = build_callback_server(port, token_queue)
47
+ open_browser(callback_url(port))
48
+
49
+ PredictabilityEngine.logger.info { "Waiting for IdP callback on port #{port}..." }
50
+ token = Timeout.timeout(CALLBACK_TIMEOUT) { token_queue.pop }
51
+ raise Error, 'No token received from IdP callback' if token.nil? || token.empty?
52
+
53
+ token
54
+ ensure
55
+ server&.shutdown
56
+ end
57
+
58
+ def build_callback_server(port, token_queue)
59
+ server = WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new(nil, 0),
60
+ AccessLog: [])
61
+ server.mount_proc('/callback') do |req, res|
62
+ token_queue.push(req.query['token'] || req.query['access_token'] || req.query['code'])
63
+ res.body = 'Authentication received. You may close this tab.'
64
+ server.shutdown
65
+ end
66
+ Thread.new { server.start }
67
+ server
68
+ end
69
+
70
+ def callback_url(port)
71
+ "#{@config[:idp_login_url]}&redirect_uri=#{URI.encode_www_form_component("http://localhost:#{port}/callback")}"
72
+ end
73
+
74
+ def open_browser(url)
75
+ commands = %w[xdg-open open start]
76
+ cmd = commands.find { |c| system("which #{c} > /dev/null 2>&1") }
77
+ if cmd
78
+ system("#{cmd} '#{url}'")
79
+ else
80
+ $stdout.puts "Could not open browser automatically. Please visit:\n #{url}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jira_auth/base'
4
+ require_relative 'jira_auth/basic'
5
+ require_relative 'jira_auth/bearer'
6
+ require_relative 'jira_auth/cookie'
7
+ require_relative 'jira_auth/mfa_api'
8
+ require_relative 'jira_auth/mfa_browser'
9
+
10
+ module PredictabilityEngine
11
+ module JiraAuth
12
+ MODES = %w[basic bearer cookie mfa_api mfa_browser].freeze
13
+
14
+ def self.build(config)
15
+ mode = config[:auth_mode].to_s
16
+ mode = 'basic' if mode.empty?
17
+ raise Error, "Unknown Jira auth_mode '#{mode}'" unless MODES.include?(mode)
18
+
19
+ const_get(mode.split('_').map(&:capitalize).join).new(config)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ # Mixin providing interactive credential prompts for `jira_config --auth-mode`.
5
+ # Relies on `ask` / `ask_secret` being defined by the including class (Thor).
6
+ module JiraConfigPrompter
7
+ def build_profile_data(site, context_path, mode)
8
+ data = { 'site' => site }
9
+ data['context_path'] = context_path unless context_path.strip.empty?
10
+ data['auth_mode'] = mode unless mode == 'basic'
11
+ data.merge!(prompt_auth_fields(mode))
12
+ data
13
+ end
14
+
15
+ def prompt_auth_fields(mode)
16
+ case mode
17
+ when 'bearer'
18
+ { 'bearer_token' => ask_secret('Bearer token:') }
19
+ when 'cookie'
20
+ { 'auth_cookie' => ask_secret('Session cookie (e.g., JSESSIONID=abc; crowd.token_key=xyz):') }
21
+ when 'mfa_api'
22
+ prompt_mfa_api_fields
23
+ when 'mfa_browser'
24
+ prompt_mfa_browser_fields
25
+ else
26
+ { 'email' => ask('Jira email:'), 'token' => ask_secret('Jira API token:') }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def prompt_mfa_api_fields
33
+ field = ask('Token field in login response (default: access_token):').strip
34
+ { 'email' => ask('Jira email:'),
35
+ 'password' => ask_secret('Password:'),
36
+ 'totp_secret' => ask_secret('TOTP secret (base32):'),
37
+ 'mfa_login_url' => ask('MFA API login URL:'),
38
+ 'mfa_token_field' => field.empty? ? 'access_token' : field }
39
+ end
40
+
41
+ def prompt_mfa_browser_fields
42
+ port = ask('Callback port for local server (leave blank for manual-paste mode):').strip
43
+ data = { 'idp_login_url' => ask('IdP login URL:') }
44
+ data['idp_callback_port'] = Integer(port) unless port.empty?
45
+ data
46
+ end
47
+ end
48
+ end