issuer 0.1.0

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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.vale/config/vocabularies/issuer/accept.txt +63 -0
  4. data/.vale/config/vocabularies/issuer/reject.txt +21 -0
  5. data/.vale.ini +42 -0
  6. data/Dockerfile +43 -0
  7. data/LICENSE +21 -0
  8. data/README.adoc +539 -0
  9. data/Rakefile +70 -0
  10. data/bin/console +0 -0
  11. data/bin/issuer +13 -0
  12. data/bin/setup +0 -0
  13. data/examples/README.adoc +56 -0
  14. data/examples/advanced-stub-example.yml +50 -0
  15. data/examples/basic-example.yml +33 -0
  16. data/examples/minimal-example.yml +9 -0
  17. data/examples/new-project-issues.yml +162 -0
  18. data/examples/validation-test.yml +8 -0
  19. data/exe/issuer +5 -0
  20. data/issuer.gemspec +43 -0
  21. data/lib/issuer/apis/github/client.rb +124 -0
  22. data/lib/issuer/cache.rb +197 -0
  23. data/lib/issuer/cli.rb +241 -0
  24. data/lib/issuer/issue.rb +393 -0
  25. data/lib/issuer/ops.rb +281 -0
  26. data/lib/issuer/sites/base.rb +109 -0
  27. data/lib/issuer/sites/factory.rb +31 -0
  28. data/lib/issuer/sites/github.rb +248 -0
  29. data/lib/issuer/version.rb +21 -0
  30. data/lib/issuer.rb +238 -0
  31. data/scripts/build.sh +40 -0
  32. data/scripts/lint-docs.sh +64 -0
  33. data/scripts/manage-runs.rb +175 -0
  34. data/scripts/pre-commit-template.sh +54 -0
  35. data/scripts/publish.sh +92 -0
  36. data/scripts/setup-vale.sh +59 -0
  37. data/specs/tests/README.adoc +451 -0
  38. data/specs/tests/check-github-connectivity.sh +130 -0
  39. data/specs/tests/cleanup-github-tests.sh +374 -0
  40. data/specs/tests/github-api/01-auth-connection.yml +21 -0
  41. data/specs/tests/github-api/02-basic-issues.yml +90 -0
  42. data/specs/tests/github-api/03-milestone-tests.yml +58 -0
  43. data/specs/tests/github-api/04-label-tests.yml +98 -0
  44. data/specs/tests/github-api/05-assignment-tests.yml +55 -0
  45. data/specs/tests/github-api/06-automation-tests.yml +102 -0
  46. data/specs/tests/github-api/07-error-tests.yml +29 -0
  47. data/specs/tests/github-api/08-complex-tests.yml +197 -0
  48. data/specs/tests/github-api/config.yml.example +17 -0
  49. data/specs/tests/rspec/cli_spec.rb +127 -0
  50. data/specs/tests/rspec/issue_spec.rb +184 -0
  51. data/specs/tests/rspec/issuer_spec.rb +5 -0
  52. data/specs/tests/rspec/ops_spec.rb +124 -0
  53. data/specs/tests/rspec/spec_helper.rb +54 -0
  54. data/specs/tests/run-github-api-tests.sh +424 -0
  55. metadata +200 -0
data/lib/issuer/cli.rb ADDED
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'thor'
5
+
6
+ module Issuer
7
+ class CLI < Thor
8
+ # Silence Thor deprecation warning
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ # Define class-level options for access in help
14
+ class_option :file, type: :string, desc: 'IMYML file path (alternative to positional argument)'
15
+ class_option :proj, type: :string, desc: 'Override $meta.proj (org/repo)'
16
+ class_option :vrsn, type: :string, desc: 'Default version for all issues'
17
+ class_option :user, type: :string, desc: 'Default assignee (GitHub username)'
18
+ class_option :tags, type: :string, desc: 'Comma-separated extra labels for all issues'
19
+ class_option :stub, type: :boolean, desc: 'Enable stub mode for all issues'
20
+ class_option :dry, type: :boolean, default: false, desc: 'Print issues, don\'t post'
21
+ class_option :tokenv, type: :string, desc: 'Name of environment variable containing GitHub token'
22
+
23
+ # Resource automation options
24
+ class_option :auto_versions, type: :boolean, aliases: ['--auto-milestones'], desc: 'Automatically create missing versions/milestones without prompting'
25
+ class_option :auto_tags, type: :boolean, aliases: ['--auto-labels'], desc: 'Automatically create missing tags/labels without prompting'
26
+ class_option :auto_metadata, type: :boolean, desc: 'Automatically create all missing metadata (versions and tags) without prompting'
27
+
28
+ class_option :help, type: :boolean, aliases: ['-h'], desc: 'Show help message'
29
+ class_option :version, type: :boolean, aliases: ['-v'], desc: 'Show version'
30
+
31
+ # Handle special options first
32
+ def self.start given_args=ARGV, config={}
33
+ # Handle --version option
34
+ if given_args.include?('--version') || given_args.include?('-v')
35
+ puts "Issuer version #{Issuer::VERSION}"
36
+ exit 0
37
+ end
38
+
39
+ # Handle --help option
40
+ if given_args.include?('--help') || given_args.include?('-h') || given_args.empty?
41
+ show_help
42
+ exit 0
43
+ end
44
+
45
+ # For all other cases, treat first argument as file and process as main command
46
+ given_args.unshift('main') unless given_args[0] == 'main'
47
+ super(given_args, config)
48
+ end
49
+
50
+ desc 'main [IMYML_FILE]', 'Create GitHub issues from an IMYML YAML file', hide: true
51
+
52
+ def main file=nil
53
+ # Handle options that should exit early
54
+ if options[:help]
55
+ self.class.show_help
56
+ return
57
+ end
58
+
59
+ if options[:version]
60
+ puts "Issuer version #{Issuer::VERSION}"
61
+ return
62
+ end
63
+
64
+ # Determine file path: --file option takes precedence over positional argument
65
+ file_path = options[:file] || file
66
+ if file_path.nil?
67
+ abort "Error: No IMYML file specified. Use 'issuer FILE' or 'issuer --file FILE'"
68
+ end
69
+
70
+ unless File.exist?(file_path)
71
+ abort "Error: File not found: #{file_path}"
72
+ end
73
+
74
+ begin
75
+ raw = File.open(file_path) { |f| YAML.load(f) }
76
+ rescue => e
77
+ abort "Error: Could not parse YAML file: #{file_path}\n#{e.message}"
78
+ end
79
+
80
+ if raw.nil?
81
+ abort "Error: YAML file appears to be empty: #{file_path}"
82
+ end
83
+
84
+ meta = raw['$meta'] || {}
85
+ issues_data = raw['issues'] || raw # fallback if no $meta
86
+
87
+ unless issues_data.is_a?(Array)
88
+ abort 'Error: No issues array found (root or under "issues")'
89
+ end
90
+
91
+ # Build defaults merging: CLI > $meta.defaults > issue
92
+ defaults = (meta['defaults'] || {}).dup
93
+ defaults['proj'] = meta['proj'] if meta['proj']
94
+ defaults['vrsn'] = options[:vrsn] if options[:vrsn]
95
+ defaults['user'] = options[:user] if options[:user]
96
+ defaults['stub'] = options[:stub] if !options[:stub].nil?
97
+
98
+ # Determine target repository
99
+ repo = options[:proj] || meta['proj'] || ENV['ISSUER_REPO'] || ENV['ISSUER_PROJ']
100
+ if repo.nil? && !options[:dry]
101
+ abort 'No target repo set. Use --proj, $meta.proj, or ENV[ISSUER_REPO].'
102
+ end
103
+
104
+ # Process issues with new Ops module
105
+ issues = Issuer::Ops.process_issues_data(issues_data, defaults)
106
+
107
+ # Apply tag logic (append vs default behavior)
108
+ issues = Issuer::Ops.apply_tag_logic(issues, options[:tags])
109
+
110
+ # Apply stub logic with head/tail/body composition
111
+ issues = Issuer::Ops.apply_stub_logic(issues, defaults)
112
+
113
+ # Separate valid and invalid issues
114
+ valid_issues = issues.select(&:valid?)
115
+ invalid_issues = issues.reject(&:valid?)
116
+
117
+ # Report invalid issues
118
+ invalid_issues.each_with_index do |issue, idx|
119
+ puts "Skipping issue ##{find_original_index(issues, issue) + 1}: #{issue.validation_errors.join(', ')}"
120
+ end
121
+
122
+ if options[:dry]
123
+ perform_dry_run(valid_issues, repo)
124
+ else
125
+ # Use Sites architecture for validation and posting
126
+ site_options = {}
127
+ site_options[:token_env_var] = options[:tokenv] if options[:tokenv]
128
+ site = Issuer::Sites::Factory.create('github', **site_options)
129
+ automation_options = {
130
+ auto_versions: options[:auto_versions] || options[:auto_metadata],
131
+ auto_tags: options[:auto_tags] || options[:auto_metadata]
132
+ }
133
+
134
+ # Start run tracking for live operations
135
+ require_relative 'cache'
136
+ run_id = Issuer::Cache.start_run(issues_planned: valid_issues.length)
137
+ puts "🏃 Started run #{run_id} - tracking #{valid_issues.length} issues"
138
+
139
+ begin
140
+ Issuer::Ops.validate_and_prepare_resources(site, repo, valid_issues, automation_options, run_id) unless valid_issues.empty?
141
+ processed_count = site.post_issues(repo, valid_issues, run_id)
142
+
143
+ # Complete the run successfully
144
+ Issuer::Cache.complete_run(run_id, processed_count)
145
+ puts "✅ Run #{run_id} completed successfully - #{processed_count} issues created"
146
+ rescue => e
147
+ # Mark run as failed
148
+ Issuer::Cache.fail_run(run_id, e.message)
149
+ puts "❌ Run #{run_id} failed: #{e.message}"
150
+ raise
151
+ end
152
+ end
153
+
154
+ print_summary(valid_issues.length, invalid_issues.length, options[:dry])
155
+ end
156
+
157
+ private
158
+
159
+ def self.show_help
160
+ puts <<~HELP
161
+ Issuer: Bulk GitHub issue creator from YAML definitions
162
+
163
+ Usage:
164
+ issuer IMYML_FILE [options]
165
+ issuer --file IMYML_FILE [options]
166
+
167
+ Issue Default Options:
168
+ --vrsn VERSION #{self.class_options[:vrsn].description}
169
+ --user USERNAME #{self.class_options[:user].description}
170
+ --tags tag1,tag2 #{self.class_options[:tags].description}
171
+ --stub #{self.class_options[:stub].description}
172
+
173
+ Site Options:
174
+ --proj org/repo #{self.class_options[:proj].description}
175
+ --tokenv VAR_NAME #{self.class_options[:tokenv].description}
176
+
177
+ Mode Options:
178
+ --dry #{self.class_options[:dry].description}
179
+ --auto-versions #{self.class_options[:auto_versions].description}
180
+ --auto-milestones (alias for --auto-versions)
181
+ --auto-tags #{self.class_options[:auto_tags].description}
182
+ --auto-labels (alias for --auto-tags)
183
+ --auto-metadata #{self.class_options[:auto_metadata].description}
184
+
185
+ Info:
186
+ -h, --help #{self.class_options[:help].description}
187
+ -v, --version #{self.class_options[:version].description}
188
+
189
+ Examples:
190
+ issuer issues.yml --dry
191
+ issuer issues.yml --proj myorg/myrepo
192
+ issuer --file issues.yml --proj myorg/myrepo --dry
193
+ issuer issues.yml --vrsn 1.1.2
194
+ issuer --version
195
+ issuer --help
196
+
197
+ Authentication:
198
+ Set GITHUB_TOKEN environment variable with your GitHub personal access token
199
+ Or use --tokenv to specify a custom environment variable name
200
+
201
+ HELP
202
+ end
203
+
204
+ def find_original_index issues_array, target_issue
205
+ issues_array.find_index(target_issue) || 0
206
+ end
207
+
208
+ def perform_dry_run issues, repo
209
+ # Create site instance for parameter conversion in dry-run mode
210
+ site_options = { token: 'dry-run-token' }
211
+ site_options[:token_env_var] = options[:tokenv] if options[:tokenv]
212
+ site = Issuer::Sites::Factory.create('github', **site_options)
213
+
214
+ issues.each do |issue|
215
+ print_issue_summary(issue, repo, site)
216
+ end
217
+
218
+ # Add project summary at the end
219
+ if repo
220
+ project_term = site.field_mappings[:project_name] || 'project'
221
+ puts "Would process #{issues.length} issues for #{project_term}: #{repo}"
222
+ end
223
+ end
224
+
225
+ def print_summary valid_count, invalid_count, dry_run
226
+ if dry_run
227
+ puts "\nDry run complete (use without --dry to actually post)"
228
+ puts "Would process #{valid_count} issues, skip #{invalid_count}"
229
+ else
230
+ puts "\n✅ Completed: #{valid_count} issues processed, #{invalid_count} skipped"
231
+ puts "Run ID: #{Issuer::Cache.current_run_id}" if Issuer::Cache.current_run_id
232
+ end
233
+
234
+ end
235
+
236
+ def print_issue_summary issue, repo, site
237
+ # Use the new formatted output method from the Issue class
238
+ puts issue.formatted_output(site, repo)
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Issuer
4
+ ##
5
+ # Represents a single issue in the IMYML format.
6
+ #
7
+ # The Issue class is the core data model that represents individual work items.
8
+ # It handles validation, default application, and processing of IMYML properties
9
+ # like stub composition and tag logic.
10
+ #
11
+ # == IMYML Properties
12
+ #
13
+ # +summ+:: (Required) Issue title/summary
14
+ # +body+:: Issue description/body text
15
+ # +tags+:: Array of labels to apply
16
+ # +user+:: Assignee username
17
+ # +vrsn+:: Milestone/version
18
+ # +stub+:: Whether to apply stub text composition
19
+ #
20
+ # == Tag Logic
21
+ #
22
+ # Tags support special prefix notation:
23
+ # * Regular tags (e.g., +"bug"+) are only applied if the issue has no existing tags
24
+ # * Append tags (e.g., +"+urgent"+) are always applied to all issues
25
+ #
26
+ # == Stub Composition
27
+ #
28
+ # When +stub+ is true, the final body is composed from:
29
+ # 1. +head+ text (if provided in defaults)
30
+ # 2. Issue +body+ or default +body+
31
+ # 3. +tail+ text (if provided in defaults)
32
+ #
33
+ # @example Creating an issue from IMYML data
34
+ # data = {
35
+ # 'summ' => 'Fix authentication bug',
36
+ # 'body' => 'Users cannot log in',
37
+ # 'tags' => ['bug', '+urgent'],
38
+ # 'user' => 'developer1',
39
+ # 'vrsn' => '1.0.0'
40
+ # }
41
+ # issue = Issuer::Issue.new(data)
42
+ # issue.valid? # => true
43
+ #
44
+ # @example Applying defaults
45
+ # defaults = { 'user' => 'default-user', 'tags' => ['needs-triage'] }
46
+ # issue = Issuer::Issue.new(minimal_data, defaults)
47
+ #
48
+ # @example Processing multiple issues
49
+ # issues = Issuer::Issue.from_array(array_of_data, defaults)
50
+ # valid_issues = Issuer::Issue.valid_issues_from_array(array_of_data, defaults)
51
+ #
52
+ class Issue
53
+ attr_reader :summ, :tags, :user, :vrsn, :raw_data
54
+ attr_accessor :body, :stub
55
+
56
+ ##
57
+ # Create a new Issue instance from IMYML data
58
+ #
59
+ # @param issue_data [Hash, String] The issue data from IMYML. If String, treated as +summ+.
60
+ # @param defaults [Hash] Default values to apply when issue data is missing properties
61
+ #
62
+ def initialize issue_data, defaults={}
63
+ @raw_data = issue_data || {}
64
+ @merged_data = defaults.merge(@raw_data)
65
+
66
+ @summ = @merged_data['summ']
67
+ @body = @merged_data['body'] || @merged_data['desc'] || '' # Support both body and desc (legacy)
68
+ @tags = Array(@merged_data['tags'])
69
+ @user = @merged_data['user']
70
+ @vrsn = @merged_data['vrsn']
71
+ @stub = @merged_data['stub']
72
+ end
73
+
74
+ ##
75
+ # Check if this issue has the minimum required data
76
+ #
77
+ # @return [Boolean] True if the issue has a non-empty +summ+ (title)
78
+ #
79
+ def valid?
80
+ !summ.nil? && !summ.strip.empty?
81
+ end
82
+
83
+ ##
84
+ # Get validation error messages for this issue
85
+ #
86
+ # @return [Array<String>] Array of error messages, empty if valid
87
+ #
88
+ def validation_errors
89
+ errors = []
90
+ errors << "missing required 'summ' field" if summ.nil? || summ.strip.empty?
91
+ errors
92
+ end
93
+
94
+ ##
95
+ # Add additional tags to this issue
96
+ #
97
+ # @param additional_tags [Array<String>, String] Tags to add, duplicates removed
98
+ # @return [Array<String>] The updated tags array
99
+ #
100
+ def add_tags additional_tags
101
+ @tags = (@tags + Array(additional_tags)).uniq
102
+ end
103
+
104
+ def summary_description
105
+ truncated_body = body.strip[0..50].gsub(/\n.*/m, '…')
106
+ {
107
+ summ: summ,
108
+ body_preview: truncated_body,
109
+ tags: tags,
110
+ user: user,
111
+ vrsn: vrsn
112
+ }
113
+ end
114
+
115
+ def ==(other)
116
+ return false unless other.is_a?(Issue)
117
+
118
+ summ == other.summ &&
119
+ body == other.body &&
120
+ tags.sort == other.tags.sort &&
121
+ user == other.user &&
122
+ vrsn == other.vrsn
123
+ end
124
+
125
+ def self.from_array issues_array, defaults={}
126
+ issues_array.map { |issue_data| new(issue_data, defaults) }
127
+ end
128
+
129
+ def self.valid_issues_from_array issues_array, defaults={}
130
+ from_array(issues_array, defaults).select(&:valid?)
131
+ end
132
+
133
+ def self.invalid_issues_from_array issues_array, defaults={}
134
+ from_array(issues_array, defaults).reject(&:valid?)
135
+ end
136
+
137
+ # Apply tag logic (append vs default behavior)
138
+ def self.apply_tag_logic issues, cli_tags
139
+ # Parse CLI tags for append (+) vs default-only behavior
140
+ cli_append_tags, cli_default_tags = parse_tag_logic(cli_tags)
141
+
142
+ issues.each do |issue|
143
+ issue.apply_tag_logic(cli_append_tags, cli_default_tags)
144
+ end
145
+
146
+ issues
147
+ end
148
+
149
+ # Apply stub logic with head/tail/body composition
150
+ def self.apply_stub_logic issues, defaults
151
+ issues.each do |issue|
152
+ issue.apply_stub_logic(defaults)
153
+ end
154
+
155
+ issues
156
+ end
157
+
158
+ # Apply tag logic for this issue
159
+ #
160
+ # Processes existing tags with + prefix as append tags, combines them with
161
+ # CLI-provided tags, and determines final tag set based on precedence rules.
162
+ #
163
+ # @param cli_append_tags [Array<String>] Tags to always append from CLI
164
+ # @param cli_default_tags [Array<String>] Default tags from CLI (used when no regular tags exist)
165
+ # @return [void] Sets @tags instance variable
166
+ #
167
+ # @example
168
+ # # Issue has tags: ['+urgent', 'bug']
169
+ # issue.apply_tag_logic(['cli-tag'], ['default-tag'])
170
+ # # Result: ['urgent', 'cli-tag', 'bug']
171
+ def apply_tag_logic cli_append_tags, cli_default_tags
172
+ # Parse existing tags for + prefix
173
+ existing_tags = tags || []
174
+ append_tags = []
175
+ regular_tags = []
176
+
177
+ existing_tags.each do |tag|
178
+ if tag.to_s.start_with?('+')
179
+ append_tags << tag.to_s[1..] # Remove + prefix
180
+ else
181
+ regular_tags << tag.to_s
182
+ end
183
+ end
184
+
185
+ # Start with append tags (always applied)
186
+ final_tags = append_tags + cli_append_tags
187
+
188
+ # Add regular tags if the issue has them, otherwise add CLI default tags
189
+ if !regular_tags.empty?
190
+ final_tags.concat(regular_tags)
191
+ else
192
+ final_tags.concat(cli_default_tags)
193
+ end
194
+
195
+ # Set the final tags (removing duplicates)
196
+ @tags = final_tags.uniq
197
+ end
198
+
199
+ # Apply stub logic for this issue
200
+ #
201
+ # Composes issue body by combining head, body, and tail components
202
+ # from defaults when stub application is enabled.
203
+ #
204
+ # @param defaults [Hash] Default values containing 'head', 'body', 'tail', and 'stub' keys
205
+ # @return [void] Sets @body instance variable with composed content
206
+ #
207
+ # @example
208
+ # defaults = {'head' => 'Header', 'body' => 'Default body', 'tail' => 'Footer'}
209
+ # issue.apply_stub_logic(defaults)
210
+ # # Composes body as "Header\nIssue body\nFooter"
211
+ def apply_stub_logic defaults
212
+ return unless should_apply_stub?(defaults)
213
+
214
+ # Build body with stub components
215
+ body_parts = []
216
+
217
+ # Add head if present
218
+ if defaults['head'] && !defaults['head'].to_s.strip.empty?
219
+ body_parts << defaults['head'].to_s.strip
220
+ end
221
+
222
+ # Add main body (issue body or default body)
223
+ main_body = body
224
+ if main_body.nil? || main_body.to_s.strip.empty?
225
+ main_body = defaults['body'] if defaults['body']
226
+ end
227
+ body_parts << main_body.to_s.strip if main_body
228
+
229
+ # Add tail if present
230
+ if defaults['tail'] && !defaults['tail'].to_s.strip.empty?
231
+ body_parts << defaults['tail'].to_s.strip
232
+ end
233
+
234
+ # Set the composed body
235
+ @body = body_parts.join("\n")
236
+ end
237
+
238
+ # Parse tag logic from a comma-separated string
239
+ #
240
+ # Separates tags with + prefix (append tags) from regular tags (default tags).
241
+ # Tags with + prefix are always applied, while regular tags are only used
242
+ # when the issue has no existing regular tags.
243
+ #
244
+ # @param tags_string [String] Comma-separated tag string
245
+ # @return [Array<Array<String>>] Two-element array: [append_tags, default_tags]
246
+ #
247
+ # @example
248
+ # Issue.parse_tag_logic("+urgent,bug,+critical")
249
+ # # => [['urgent', 'critical'], ['bug']]
250
+ def self.parse_tag_logic tags_string
251
+ return [[], []] if tags_string.nil? || tags_string.strip.empty?
252
+
253
+ tags = tags_string.split(',').map(&:strip).reject(&:empty?)
254
+ append_tags = []
255
+ default_tags = []
256
+
257
+ tags.each do |tag|
258
+ if tag.start_with?('+')
259
+ # Remove + prefix and add to append list
260
+ append_tags << tag[1..]
261
+ else
262
+ # Add to default-only list
263
+ default_tags << tag
264
+ end
265
+ end
266
+
267
+ [append_tags, default_tags]
268
+ end
269
+
270
+ # Generate formatted output for dry-run display
271
+ #
272
+ # This method produces a standardized format for displaying issues during dry runs.
273
+ # Process: IMYML properties → site-specific parameters → display labels
274
+ #
275
+ # 1. convert_issue_to_site_params() converts IMYML (summ→title, vrsn→milestone, etc.)
276
+ # 2. field_mappings() provides display labels for those converted parameters
277
+ #
278
+ # @param site [Issuer::Sites::Base] The target site/platform
279
+ # @param repo [String] The repository identifier
280
+ # @return [String] Formatted issue display
281
+ #
282
+ # @example Output format
283
+ # ------
284
+ # title: "Fix authentication bug"
285
+ # body:
286
+ # Users cannot log in properly after the recent update.
287
+ # This affects all user accounts.
288
+ #
289
+ # repo: myorg/myproject
290
+ # milestone: 1.0.0
291
+ # labels:
292
+ # - bug
293
+ # - urgent
294
+ # assignee: developer1
295
+ # ------
296
+ def formatted_output(site, repo)
297
+ # Get site-specific field mappings
298
+ field_map = site.field_mappings
299
+
300
+ # Convert to site-specific parameters
301
+ site_params = site.convert_issue_to_site_params(self, repo, dry_run: true)
302
+
303
+ output = [""]
304
+
305
+ # Title (always present)
306
+ title_field = field_map[:title] || 'title'
307
+ output << sprintf("%-12s%s", "#{title_field}:", site_params[:title].inspect)
308
+
309
+ # Body (with special formatting if present)
310
+ if site_params[:body] && !site_params[:body].strip.empty?
311
+ body_field = field_map[:body] || 'body'
312
+ output << "#{body_field}:"
313
+ # Indent body content
314
+ body_lines = site_params[:body].strip.split("\n")
315
+ body_lines.each do |line|
316
+ output << " #{line}"
317
+ end
318
+ output << "" # Empty line after body
319
+ end
320
+
321
+ # Repository
322
+ repo_field = field_map[:repo] || 'repo'
323
+ output << sprintf("%-12s%s", "#{repo_field}:", repo) if repo
324
+
325
+ # Milestone/Version
326
+ if site_params[:milestone]
327
+ milestone_field = field_map[:milestone] || 'milestone'
328
+ output << sprintf("%-12s%s", "#{milestone_field}:", site_params[:milestone])
329
+ end
330
+
331
+ # Labels/Tags
332
+ if site_params[:labels] && !site_params[:labels].empty?
333
+ labels_field = field_map[:labels] || 'labels'
334
+ output << "#{labels_field}:"
335
+ site_params[:labels].each do |label|
336
+ output << " - #{label}"
337
+ end
338
+ end
339
+
340
+ # Assignee/User
341
+ if site_params[:assignee]
342
+ assignee_field = field_map[:assignee] || 'assignee'
343
+ output << sprintf("%-12s%s", "#{assignee_field}:", site_params[:assignee])
344
+ end
345
+
346
+ output << "------"
347
+ output << "" # Empty line after each issue
348
+
349
+ output.join("\n")
350
+ end
351
+
352
+ private
353
+
354
+ # Determine if stub logic should be applied to this issue
355
+ #
356
+ # Checks issue-level stub property first, then falls back to defaults.
357
+ # Converts various truthy values to boolean.
358
+ #
359
+ # @param defaults [Hash] Default configuration containing 'stub' key
360
+ # @return [Boolean] True if stub should be applied, false otherwise
361
+ #
362
+ # @example
363
+ # issue.stub = 'yes'
364
+ # issue.should_apply_stub?({'stub' => false}) # => true (issue-level wins)
365
+ def should_apply_stub? defaults
366
+ # Check if stub should be applied:
367
+ # 1. Issue-level stub property takes precedence
368
+ # 2. Falls back to defaults stub setting
369
+ # 3. Defaults to false if not specified
370
+
371
+ issue_stub = stub
372
+ default_stub = defaults['stub']
373
+
374
+ if issue_stub.nil?
375
+ # Use default stub setting (convert to boolean)
376
+ case default_stub
377
+ when true, 'true', 'yes', '1'
378
+ true
379
+ else
380
+ false
381
+ end
382
+ else
383
+ # Use issue-level setting (convert to boolean)
384
+ case issue_stub
385
+ when true, 'true', 'yes', '1'
386
+ true
387
+ else
388
+ false
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end