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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module PredictabilityEngine
6
+ # Configuration management for the predictability engine.
7
+ class Config
8
+ CONFIG_FILE = '.predictability_engine.yml'
9
+
10
+ # Single source of truth: config key → JIRA_ env var suffix.
11
+ JIRA_FIELDS = {
12
+ site: 'SITE', email: 'EMAIL', token: 'API_TOKEN', project: 'PROJECT',
13
+ context_path: 'CONTEXT_PATH', auth_mode: 'AUTH_MODE',
14
+ bearer_token: 'BEARER_TOKEN', auth_cookie: 'AUTH_COOKIE',
15
+ password: 'PASSWORD', totp_secret: 'TOTP_SECRET',
16
+ mfa_login_url: 'MFA_LOGIN_URL', mfa_token_field: 'MFA_TOKEN_FIELD',
17
+ idp_login_url: 'IDP_LOGIN_URL', idp_callback_port: 'IDP_CALLBACK_PORT'
18
+ }.freeze
19
+ private_constant :JIRA_FIELDS
20
+
21
+ def self.jira(profile_name = nil)
22
+ load_jira_config(profile_name)
23
+ end
24
+
25
+ def self.jira_client(profile = nil)
26
+ require 'jira-ruby'
27
+ instrument_jira_http! unless ::JIRA::HttpClient.ancestors.include?(JiraHttpLogger)
28
+ config = jira(profile)
29
+ validate_jira!(config)
30
+ origin, derived_path = split_jira_site(config[:site])
31
+ base = { site: origin, context_path: config[:context_path] || derived_path, default_headers: {} }
32
+ auth = JiraAuth.build(config)
33
+ options = auth.jira_options(base)
34
+ options[:http_debug] = true if jira_http_debug?
35
+ client = ::JIRA::Client.new(options)
36
+ auth.post_init(client)
37
+ client
38
+ end
39
+
40
+ # Splits a full Jira site URL (e.g. "https://host/jira") into the origin
41
+ # ("https://host") and context path ("/jira") that jira-ruby expects.
42
+ # This avoids 301/302 redirect errors when the server requires the path.
43
+ def self.split_jira_site(site_url)
44
+ uri = URI.parse(site_url.to_s.chomp('/'))
45
+ port_suffix = [80, 443].include?(uri.port) ? '' : ":#{uri.port}"
46
+ origin = "#{uri.scheme}://#{uri.host}#{port_suffix}"
47
+ [origin, uri.path]
48
+ end
49
+ private_class_method :split_jira_site
50
+
51
+ # Prepended to JIRA::HttpClient to trace every HTTP call at DEBUG level.
52
+ # SemanticLogger evaluates blocks only when debug is active → zero overhead at
53
+ # INFO or higher.
54
+ module JiraHttpLogger
55
+ def make_request(http_method, url, body = '', headers = {})
56
+ log = SemanticLogger['JIRA::HTTP']
57
+ log.debug { "→ #{http_method.upcase} #{url}" }
58
+ result = super
59
+ log.debug { "← #{result.code} #{result.message}" }
60
+ result
61
+ rescue StandardError => e
62
+ SemanticLogger['JIRA::HTTP'].debug { "✗ #{http_method.upcase} #{url} (#{e.class}: #{e.message})" }
63
+ raise
64
+ end
65
+ end
66
+ private_constant :JiraHttpLogger
67
+
68
+ def self.instrument_jira_http!
69
+ ::JIRA::HttpClient.prepend(JiraHttpLogger)
70
+ end
71
+ private_class_method :instrument_jira_http!
72
+
73
+ def self.jira_http_debug?
74
+ ENV.fetch('JIRA_HTTP_DEBUG', nil).to_s.match?(/\A(true|1|yes)\z/i)
75
+ end
76
+ private_class_method :jira_http_debug?
77
+
78
+ def self.validate_jira!(config)
79
+ raise_missing(config, :site)
80
+ case config[:auth_mode].to_s
81
+ when 'bearer'
82
+ raise_missing(config, :bearer_token)
83
+ when 'cookie'
84
+ raise_missing(config, :auth_cookie)
85
+ when 'mfa_api'
86
+ %i[email password totp_secret mfa_login_url].each { |k| raise_missing(config, k) }
87
+ when 'mfa_browser'
88
+ raise_missing(config, :idp_login_url)
89
+ else
90
+ raise_missing(config, :email)
91
+ raise_missing(config, :token)
92
+ end
93
+ end
94
+
95
+ def self.raise_missing(config, key)
96
+ return if config[key]
97
+
98
+ env_suffix = JIRA_FIELDS[key]
99
+ env_var = env_suffix ? "JIRA_#{env_suffix}" : "JIRA_#{key.to_s.upcase}"
100
+ raise Error, "Jira #{key} not configured (use #{env_var} env var or credentials file)"
101
+ end
102
+ private_class_method :raise_missing
103
+
104
+ def self.load_jira_config(profile_name = nil)
105
+ profile_name ||= default_profile_name
106
+ global = load_global_jira_config
107
+ local = load_local_jira_config
108
+
109
+ config = load_profile(profile_name, global, local) if profile_name
110
+ config || fallback_config(global, local)
111
+ end
112
+
113
+ def self.default_profile_name
114
+ ENV.fetch('JIRA_PROFILE', nil) || ENV.fetch('JIRA_PROJECT', nil)
115
+ end
116
+
117
+ def self.fallback_config(global, local)
118
+ JIRA_FIELDS.transform_values { |env_key| jira_val(env_key, global, local) }
119
+ end
120
+
121
+ def self.jira_val(name, global, local)
122
+ key = name.downcase.to_sym
123
+ ENV.fetch("JIRA_#{name}", nil) || local[key] || global[key]
124
+ end
125
+
126
+ def self.jira_credentials_file
127
+ File.expand_path('~/.config/jira/jira_credentials.yml')
128
+ end
129
+
130
+ def self.load_global_jira_config
131
+ load_jira_file(jira_credentials_file, global: true)
132
+ end
133
+
134
+ def self.load_local_jira_config
135
+ load_jira_file(CONFIG_FILE, global: false)
136
+ end
137
+
138
+ def self.load_yaml_file(path)
139
+ YAML.load_file(path)
140
+ rescue Psych::SyntaxError => e
141
+ raise Error, "Invalid YAML in #{path}: #{e.message}"
142
+ end
143
+
144
+ def self.load_jira_file(path, global: true)
145
+ raw = File.exist?(path) ? load_yaml_file(path) : {}
146
+ raw ||= {}
147
+ config = global ? (raw['jira'] || raw) : raw.fetch('jira', {})
148
+ extract_fields(config).merge(profiles: config.fetch('profiles', {}))
149
+ end
150
+
151
+ def self.load_profile(name, global, local)
152
+ profile = global[:profiles][name.to_s] || local[:profiles][name.to_s] || {}
153
+ return if profile.empty?
154
+
155
+ extract_fields(profile)
156
+ end
157
+
158
+ def self.extract_fields(hash)
159
+ JIRA_FIELDS.keys.to_h { |key| [key, hash[key.to_s]] }
160
+ end
161
+ private_class_method :extract_fields
162
+
163
+ private_class_method :load_jira_config, :load_global_jira_config,
164
+ :load_local_jira_config, :load_profile, :default_profile_name,
165
+ :fallback_config, :load_jira_file, :jira_val
166
+ end
167
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module PredictabilityEngine
6
+ module DataGenerator
7
+ PRESETS = {
8
+ small: { completed: 10, wip: 4 },
9
+ medium: { completed: 40, wip: 10 },
10
+ large: { completed: 150, wip: 50 },
11
+ xl: { completed: 4000, wip: 400 }
12
+ }.freeze
13
+
14
+ def self.generate(output:, size: :medium, completed: nil, wip: nil)
15
+ File.binwrite(output, content(size: size, completed: completed, wip: wip))
16
+ output
17
+ end
18
+
19
+ def self.content(size: :medium, completed: nil, wip: nil)
20
+ preset = PRESETS.fetch(size.to_sym) do
21
+ raise ArgumentError, "Unknown size: #{size} (available: #{PRESETS.keys.join(', ')})"
22
+ end
23
+ build_csv(completed || preset[:completed], wip || preset[:wip])
24
+ end
25
+
26
+ def self.build_csv(completed_count, wip_count)
27
+ today = PredictabilityEngine.today
28
+ CSV.generate do |csv|
29
+ csv << %w[id title start_date end_date]
30
+ write_completed(csv, completed_count, today)
31
+ write_wip(csv, completed_count, wip_count, today)
32
+ end
33
+ end
34
+
35
+ def self.write_completed(csv, count, today)
36
+ (1..count).each do |i|
37
+ start_date = today - rand(200..400)
38
+ end_date = start_date + rand(5..30)
39
+ csv << ["PROJ-#{i}", "Task #{i}",
40
+ PredictabilityEngine.format_date(start_date),
41
+ PredictabilityEngine.format_date(end_date)]
42
+ end
43
+ end
44
+
45
+ def self.write_wip(csv, offset, count, today)
46
+ ((offset + 1)..(offset + count)).each do |i|
47
+ start_date = today - rand(1..100)
48
+ csv << ["PROJ-#{i}", "In Progress Task #{i}",
49
+ PredictabilityEngine.format_date(start_date), nil]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ class DataManager
5
+ attr_reader :work_items, :source
6
+
7
+ def initialize
8
+ @work_items = []
9
+ @source = nil
10
+ end
11
+
12
+ def load(spec, **)
13
+ @source = spec
14
+ @work_items = DataSources::Factory.for(spec, **).load(spec)
15
+ end
16
+
17
+ # Backward compatibility
18
+ alias load_csv load
19
+
20
+ def completed_items
21
+ @work_items.select(&:completed?)
22
+ end
23
+
24
+ def active_items
25
+ @work_items.reject(&:completed?)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module PredictabilityEngine
6
+ module DataSources
7
+ class Base
8
+ def configure(opts)
9
+ @url_prefix = opts[:url_prefix]
10
+ self
11
+ end
12
+
13
+ def load(source_spec)
14
+ apply_project_config_defaults
15
+ perform_load(source_spec)
16
+ rescue StandardError => e
17
+ raise Error, "Failed to load from #{source_name}: #{e.message}"
18
+ end
19
+
20
+ protected
21
+
22
+ def source_name
23
+ self.class.name.split('::').last
24
+ end
25
+
26
+ def perform_load(_source_spec)
27
+ raise NotImplementedError, "#{self.class} must implement perform_load"
28
+ end
29
+
30
+ def build_work_items(data)
31
+ data.map do |row|
32
+ item_data = map_row(row)
33
+ item_id = item_data.delete(:id)
34
+ Models::WorkItem.new(item_id: item_id, **item_data)
35
+ end
36
+ end
37
+
38
+ def parse_date(val)
39
+ return val if val.is_a?(Date) || val.is_a?(Time)
40
+ return nil if val.nil? || val.to_s.strip.empty?
41
+
42
+ Date.parse(val.to_s)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def load_data(iterator)
48
+ data = iterator.map do |row|
49
+ map_row(row)
50
+ end
51
+ build_work_items(data)
52
+ end
53
+
54
+ def map_row(row)
55
+ raw_id = row[:id] || row[:key] || row[:item_id]
56
+ {
57
+ id: raw_id,
58
+ title: row[:title] || row[:summary],
59
+ type: row[:type] || row[:issuetype],
60
+ priority: normalize_priority(row[:priority]),
61
+ start_date: parse_date(row[:start_date] || row[:created]),
62
+ end_date: parse_date(row[:end_date] || row[:resolutiondate] || row[:resolved]),
63
+ url: item_url(row[:url], raw_id)
64
+ }
65
+ end
66
+
67
+ def item_url(url, item_id)
68
+ url.presence || (@url_prefix && "#{@url_prefix}#{item_id}")
69
+ end
70
+
71
+ def normalize_priority(name)
72
+ return nil if name.nil?
73
+
74
+ (@priority_aliases || {})[name.to_s] || name
75
+ end
76
+
77
+ def mock_data(env_key)
78
+ json = ENV.fetch(env_key, '[]')
79
+ JSON.parse(json, symbolize_names: true)
80
+ end
81
+
82
+ private
83
+
84
+ def apply_project_config_defaults
85
+ return if @url_prefix
86
+ return unless File.exist?(Config::CONFIG_FILE)
87
+
88
+ config = YAML.load_file(Config::CONFIG_FILE) || {}
89
+ @url_prefix = config['url_prefix']
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'yaml'
5
+
6
+ module PredictabilityEngine
7
+ module DataSources
8
+ class Csv < Base
9
+ JIRA_HEADER_MAP = {
10
+ issue_key: :id,
11
+ issue_type: :type
12
+ }.freeze
13
+
14
+ def perform_load(path)
15
+ config = load_csv_config(path)
16
+ @url_prefix ||= config['url_prefix']
17
+ @done_statuses = load_done_statuses(config)
18
+ @source_url = "file://#{File.expand_path(path)}"
19
+ CSV.open(path, headers: true, header_converters: :symbol, encoding: 'bom|UTF-8', row_sep: :auto)
20
+ .then { |csv| load_data(csv.map { |row| apply_jira_header_map(row.to_h) }) }
21
+ end
22
+
23
+ private
24
+
25
+ def apply_jira_header_map(row_hash)
26
+ row_hash.transform_keys { |k| JIRA_HEADER_MAP.fetch(k, k) }
27
+ end
28
+
29
+ def map_row(row)
30
+ super.tap do |mapped|
31
+ mapped[:url] ||= @source_url unless @url_prefix
32
+ next if mapped[:end_date]
33
+ next unless @done_statuses.include?(row[:status].to_s.downcase)
34
+
35
+ mapped[:end_date] = parse_date(row[:updated])
36
+ end
37
+ end
38
+
39
+ def load_done_statuses(config)
40
+ if (wf_path = config['workflow_config_path'])
41
+ JiraWorkflow.load(File.expand_path(wf_path))&.departure_names.to_a.map(&:downcase)
42
+ elsif config.key?('statuses')
43
+ JiraWorkflow.new(statuses: Array(config['statuses'])).departure_names.map(&:downcase)
44
+ else
45
+ Array(config['done_statuses']).map(&:downcase)
46
+ end
47
+ end
48
+
49
+ def load_csv_config(csv_path)
50
+ sidecar = csv_path.sub(/\.csv$/i, '.yml')
51
+ return YAML.load_file(sidecar) || {} if File.exist?(sidecar)
52
+ return project_jira_csv_config if File.exist?(Config::CONFIG_FILE)
53
+
54
+ {}
55
+ end
56
+
57
+ def project_jira_csv_config
58
+ (YAML.load_file(Config::CONFIG_FILE) || {}).fetch('jira_csv', {})
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roo'
4
+
5
+ module PredictabilityEngine
6
+ module DataSources
7
+ class Excel < Base
8
+ def perform_load(path)
9
+ return build_work_items(mock_data('MOCK_EXCEL_DATA')) if ENV['MOCK_EXCEL_DATA']
10
+
11
+ xlsx = Roo::Spreadsheet.open(path)
12
+ iterator = xlsx.sheet(0).each(id: 'id', start_date: 'start_date', end_date: 'end_date')
13
+ .reject { |row| row[:id] == 'id' }
14
+ load_data(iterator)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module DataSources
5
+ class Factory
6
+ def self.for(spec, **opts)
7
+ source = case spec
8
+ when /^jira:/, /^jql:/, /\.yml$/, /\.yaml$/, 'jira', /^[A-Z][A-Z0-9]+$/
9
+ Jira.new
10
+ when /\.xlsx$/
11
+ Excel.new
12
+ else
13
+ Csv.new
14
+ end
15
+ source.configure(opts)
16
+ source
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jira-ruby'
4
+ require_relative 'jira_yaml'
5
+
6
+ module PredictabilityEngine
7
+ module DataSources
8
+ class Jira < Base
9
+ IN_PROGRESS_KEYWORDS = ['In Progress', 'Doing', 'Active', 'Development', 'Progress'].freeze
10
+ FIELDS = %w[summary issuetype created priority resolutiondate status].freeze
11
+
12
+ def perform_load(spec)
13
+ @priority_aliases = yaml_priority_aliases(spec)
14
+
15
+ if ENV['MOCK_JIRA'] == 'true'
16
+ data = mock_data('JIRA_MOCK_DATA')
17
+ # Even when mocked, we can validate the contract if requested
18
+ data.each { |row| validate_issue_contract!(row) } if ENV['JIRA_CONTRACT_CHECK'] == 'true'
19
+ return build_work_items(data)
20
+ end
21
+
22
+ profile, query, @workflow = resolve_source(spec)
23
+ @jira_site = Config.jira(profile)[:site]
24
+ client = build_client(profile)
25
+ issues = fetch_issues(client, query)
26
+
27
+ # Contract Validation
28
+ issues.each { |issue| validate_issue_contract!(issue) } if ENV['JIRA_CONTRACT_CHECK'] == 'true'
29
+
30
+ data = issues.map { |issue| map_issue(issue) }
31
+ build_work_items(data)
32
+ end
33
+
34
+ private
35
+
36
+ def validate_issue_contract!(issue)
37
+ # Handle both JIRA::Resource::Issue and Hash (for mocks)
38
+ is_hash = issue.is_a?(Hash)
39
+ key = get_field(issue, :key, is_hash)
40
+ validate_basic_fields(issue, key, is_hash)
41
+ validate_issuetype(issue, key, is_hash)
42
+ validate_created(issue, key, is_hash)
43
+ validate_changelog(issue, key, is_hash)
44
+ end
45
+
46
+ def validate_basic_fields(issue, key, is_hash)
47
+ summary = get_field(issue, :summary, is_hash)
48
+ raise Error, "Issue #{key} is missing 'key'" unless key
49
+ raise Error, "Issue #{key} is missing 'summary'" unless summary
50
+ end
51
+
52
+ def validate_issuetype(issue, key, is_hash)
53
+ issuetype = get_field(issue, :issuetype, is_hash)
54
+ raise Error, "Issue #{key} is missing 'issuetype'" unless issuetype
55
+
56
+ # Handle issuetype object or string
57
+ type_name = if issuetype.respond_to?(:name)
58
+ issuetype.name
59
+ elsif issuetype.is_a?(Hash)
60
+ issuetype[:name] || issuetype['name']
61
+ else
62
+ issuetype
63
+ end
64
+ raise Error, "Issue #{key} is missing 'issuetype.name'" unless type_name
65
+ end
66
+
67
+ def validate_created(issue, key, is_hash)
68
+ created = get_field(issue, :created, is_hash)
69
+ raise Error, "Issue #{key} is missing 'created'" unless created
70
+ end
71
+
72
+ def validate_changelog(issue, key, is_hash)
73
+ # Required for cycle time and aging
74
+ has_changelog = if is_hash
75
+ issue[:changelog] || issue['changelog']
76
+ else
77
+ issue.respond_to?(:changelog) && issue.changelog
78
+ end
79
+ return if has_changelog
80
+
81
+ PredictabilityEngine.logger.warn { "Issue #{key} is missing 'changelog' (expand=changelog failed?)" }
82
+ end
83
+
84
+ def get_field(issue, name, is_hash)
85
+ is_hash ? issue[name] || issue[name.to_s] : issue.send(name)
86
+ end
87
+
88
+ def yaml_priority_aliases(spec)
89
+ return {} unless spec.is_a?(String) && spec.match?(/\.ya?ml$/)
90
+
91
+ JiraYaml.new(spec).priority_aliases
92
+ end
93
+
94
+ def resolve_source(spec)
95
+ profile_name = ENV.fetch('JIRA_PROFILE', nil)
96
+ case spec
97
+ when /\.ya?ml$/
98
+ yaml = JiraYaml.new(spec)
99
+ [yaml.profile, yaml.query, load_workflow(yaml.workflow_config_path, yaml.profile)]
100
+ when 'jira'
101
+ [profile_name, query_from_config(profile_name), load_workflow(nil, profile_name)]
102
+ when /^[A-Z][A-Z0-9]+$/
103
+ [profile_name, "project = \"#{spec}\"", load_workflow(nil, profile_name)]
104
+ else
105
+ [nil, spec, nil]
106
+ end
107
+ end
108
+
109
+ def load_workflow(explicit_path, profile_name)
110
+ return JiraWorkflow.load(explicit_path) if explicit_path
111
+
112
+ profile_name && JiraWorkflow.load(JiraWorkflow.default_path(profile_name))
113
+ end
114
+
115
+ def query_from_config(profile_name)
116
+ config = Config.jira(profile_name)
117
+ project = config[:project]
118
+ query = ENV.fetch('JIRA_PROJECT_QUERY', nil) || (project ? "project = \"#{project}\"" : nil)
119
+ raise Error, 'No JIRA project specified (use JIRA_PROJECT env var or provide a query)' unless query
120
+
121
+ query
122
+ end
123
+
124
+ def build_client(profile = nil)
125
+ Config.jira_client(profile)
126
+ end
127
+
128
+ def fetch_issues(client, query)
129
+ jql = query.start_with?('jira:') ? "filter = #{query.sub('jira:', '')}" : query.sub('jql:', '')
130
+ logger.debug { "JIRA JQL: #{jql}" }
131
+ client.Issue.jql(jql, expand: 'changelog', fields: FIELDS)
132
+ rescue StandardError => e
133
+ logger.debug { "JIRA fetch failed: #{e.class} — #{e.message}" }
134
+ raise Error, "Failed to fetch issues from Jira: #{e.message}"
135
+ end
136
+
137
+ def map_issue(issue)
138
+ map_row({
139
+ id: issue.key,
140
+ url: "#{@jira_site.to_s.chomp('/')}/browse/#{issue.key}",
141
+ summary: issue.summary,
142
+ issuetype: issue.issuetype.name,
143
+ priority: jira_priority_name(issue),
144
+ created: issue.created,
145
+ start_date: first_in_progress_date(issue),
146
+ resolutiondate: last_departure_date(issue)
147
+ })
148
+ end
149
+
150
+ def jira_priority_name(issue)
151
+ return nil unless issue.respond_to?(:priority) && issue.priority
152
+
153
+ issue.priority.respond_to?(:name) ? issue.priority.name : issue.priority.to_s
154
+ end
155
+
156
+ def first_in_progress_date(issue)
157
+ names = @workflow&.arrival_names
158
+ return first_transition_matching(issue) { |t| names.any? { |n| t[:to].casecmp?(n) } } if names&.any?
159
+
160
+ warn_missing_workflow_once
161
+ first_transition_matching(issue) do |t|
162
+ IN_PROGRESS_KEYWORDS.any? { |kw| t[:to].downcase.include?(kw.downcase) }
163
+ end
164
+ end
165
+
166
+ def last_departure_date(issue)
167
+ names = @workflow&.departure_names
168
+ return issue.resolutiondate unless names&.any?
169
+
170
+ first_transition_matching(issue) { |t| names.any? { |n| t[:to].casecmp?(n) } } || issue.resolutiondate
171
+ end
172
+
173
+ def first_transition_matching(issue, &)
174
+ return nil unless issue.respond_to?(:changelog) && issue.changelog
175
+
176
+ transitions = status_transitions(issue.changelog.fetch('histories', [])).sort_by { |t| t[:date] }
177
+ match = transitions.find(&)
178
+ match ? match[:date] : nil
179
+ end
180
+
181
+ def warn_missing_workflow_once
182
+ return if @workflow_warning_emitted
183
+
184
+ @workflow_warning_emitted = true
185
+ PredictabilityEngine.logger.warn do
186
+ 'No Jira workflow mapping found; falling back to in-progress keyword heuristic. ' \
187
+ 'Run `./bin/predictability-engine jira_workflow <profile>` to generate an editable mapping.'
188
+ end
189
+ end
190
+
191
+ def status_transitions(histories)
192
+ histories.flat_map do |history|
193
+ created_at = history['created']
194
+ history['items'].select { |item| item['field'] == 'status' }.map do |item|
195
+ { date: created_at, to: item['toString'] }
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end