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.
- checksums.yaml +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- 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,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
|