issuer 0.1.0 → 0.2.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +150 -53
  3. data/examples/basic-example.yml +3 -0
  4. data/examples/new-project-issues.yml +3 -0
  5. data/examples/tag-removal-example.yml +41 -0
  6. data/examples/validation-test.yml +2 -0
  7. data/issuer.gemspec +6 -9
  8. data/lib/issuer/apis/github/client.rb +183 -3
  9. data/lib/issuer/cache.rb +13 -13
  10. data/lib/issuer/cli.rb +8 -10
  11. data/lib/issuer/issue.rb +119 -34
  12. data/lib/issuer/sites/base.rb +2 -1
  13. data/lib/issuer/sites/github.rb +56 -15
  14. data/lib/issuer.rb +6 -6
  15. metadata +4 -36
  16. data/.rspec +0 -3
  17. data/.vale/config/vocabularies/issuer/accept.txt +0 -63
  18. data/.vale/config/vocabularies/issuer/reject.txt +0 -21
  19. data/.vale.ini +0 -42
  20. data/Dockerfile +0 -43
  21. data/Rakefile +0 -70
  22. data/bin/console +0 -0
  23. data/bin/issuer +0 -13
  24. data/bin/setup +0 -0
  25. data/examples/README.adoc +0 -56
  26. data/scripts/build.sh +0 -40
  27. data/scripts/lint-docs.sh +0 -64
  28. data/scripts/manage-runs.rb +0 -175
  29. data/scripts/pre-commit-template.sh +0 -54
  30. data/scripts/publish.sh +0 -92
  31. data/scripts/setup-vale.sh +0 -59
  32. data/specs/tests/README.adoc +0 -451
  33. data/specs/tests/check-github-connectivity.sh +0 -130
  34. data/specs/tests/cleanup-github-tests.sh +0 -374
  35. data/specs/tests/github-api/01-auth-connection.yml +0 -21
  36. data/specs/tests/github-api/02-basic-issues.yml +0 -90
  37. data/specs/tests/github-api/03-milestone-tests.yml +0 -58
  38. data/specs/tests/github-api/04-label-tests.yml +0 -98
  39. data/specs/tests/github-api/05-assignment-tests.yml +0 -55
  40. data/specs/tests/github-api/06-automation-tests.yml +0 -102
  41. data/specs/tests/github-api/07-error-tests.yml +0 -29
  42. data/specs/tests/github-api/08-complex-tests.yml +0 -197
  43. data/specs/tests/github-api/config.yml.example +0 -17
  44. data/specs/tests/rspec/cli_spec.rb +0 -127
  45. data/specs/tests/rspec/issue_spec.rb +0 -184
  46. data/specs/tests/rspec/issuer_spec.rb +0 -5
  47. data/specs/tests/rspec/ops_spec.rb +0 -124
  48. data/specs/tests/rspec/spec_helper.rb +0 -54
  49. data/specs/tests/run-github-api-tests.sh +0 -424
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'octokit'
4
+ require 'ostruct'
5
+ require 'json'
6
+ require 'net/http'
7
+ require 'uri'
4
8
 
5
9
  module Issuer
6
10
  module APIs
@@ -24,6 +28,18 @@ module Issuer
24
28
  raise Issuer::Error, "Issue title is required"
25
29
  end
26
30
 
31
+ # If type is specified, use GraphQL API
32
+ if issue_params[:type]
33
+ return create_issue_with_type(repo, issue_params)
34
+ end
35
+
36
+ # Otherwise use REST API
37
+ create_issue_rest(repo, issue_params)
38
+ rescue Octokit::Error => e
39
+ raise Issuer::Error, "GitHub API error: #{e.message}"
40
+ end
41
+
42
+ def create_issue_rest repo, issue_params
27
43
  # Prepare issue creation parameters
28
44
  params = {
29
45
  title: issue_params[:title],
@@ -47,8 +63,87 @@ module Issuer
47
63
  end
48
64
 
49
65
  @client.create_issue(repo, params[:title], params[:body], params)
50
- rescue Octokit::Error => e
51
- raise Issuer::Error, "GitHub API error: #{e.message}"
66
+ end
67
+
68
+ def create_issue_with_type repo, issue_params
69
+ # Get repository owner and name
70
+ owner, name = repo.split('/')
71
+
72
+ # Get issue type ID
73
+ issue_type_id = resolve_issue_type(repo, issue_params[:type])
74
+ unless issue_type_id
75
+ puts "⚠️ Warning: Issue type '#{issue_params[:type]}' not found. Falling back to REST API."
76
+ # Add type as a label when issue type is not found
77
+ fallback_params = issue_params.dup
78
+ type_label = "type:#{fallback_params[:type]}"
79
+ fallback_params[:labels] = (fallback_params[:labels] || []) + [type_label]
80
+ fallback_params.delete(:type) # Remove type since REST API doesn't support it
81
+ puts "⚠️ Adding label '#{type_label}' to preserve type information."
82
+ return create_issue_rest(repo, fallback_params)
83
+ end
84
+
85
+ # Prepare GraphQL input
86
+ input = {
87
+ repositoryId: get_repository_id(owner, name),
88
+ title: issue_params[:title],
89
+ body: issue_params[:body] || '',
90
+ issueTypeId: issue_type_id
91
+ }
92
+
93
+ # Handle labels
94
+ if issue_params[:labels] && !issue_params[:labels].empty?
95
+ input[:labelIds] = resolve_label_ids(repo, issue_params[:labels])
96
+ end
97
+
98
+ # Handle assignee
99
+ if issue_params[:assignee] && !issue_params[:assignee].strip.empty?
100
+ input[:assigneeIds] = [get_user_id(issue_params[:assignee])]
101
+ end
102
+
103
+ # Handle milestone
104
+ if issue_params[:milestone]
105
+ milestone = find_milestone(repo, issue_params[:milestone])
106
+ input[:milestoneId] = milestone.node_id if milestone
107
+ end
108
+
109
+ # Execute GraphQL mutation
110
+ mutation = <<~GRAPHQL
111
+ mutation CreateIssue($input: CreateIssueInput!) {
112
+ createIssue(input: $input) {
113
+ issue {
114
+ id
115
+ number
116
+ title
117
+ body
118
+ url
119
+ createdAt
120
+ }
121
+ }
122
+ }
123
+ GRAPHQL
124
+
125
+ result = execute_graphql_query(mutation, { "input" => input })
126
+
127
+ # Convert GraphQL response to REST-like format for compatibility
128
+ issue_data = result["data"]["createIssue"]["issue"]
129
+ OpenStruct.new(
130
+ number: issue_data["number"],
131
+ title: issue_data["title"],
132
+ body: issue_data["body"],
133
+ html_url: issue_data["url"],
134
+ created_at: issue_data["createdAt"]
135
+ )
136
+ rescue => e
137
+ puts "⚠️ Warning: GraphQL issue creation failed: #{e.message}. Falling back to REST API."
138
+ # Add type as a label when GraphQL fails
139
+ fallback_params = issue_params.dup
140
+ if fallback_params[:type]
141
+ type_label = "type:#{fallback_params[:type]}"
142
+ fallback_params[:labels] = (fallback_params[:labels] || []) + [type_label]
143
+ fallback_params.delete(:type) # Remove type since REST API doesn't support it
144
+ puts "⚠️ Adding label '#{type_label}' to preserve type information."
145
+ end
146
+ create_issue_rest(repo, fallback_params)
52
147
  end
53
148
 
54
149
  def find_milestone repo, milestone_title
@@ -100,13 +195,35 @@ module Issuer
100
195
  @client.rate_limit
101
196
  end
102
197
 
198
+ def get_issue_types repo
199
+ owner, name = repo.split('/')
200
+ query = <<~GRAPHQL
201
+ query GetIssueTypes($owner: String!, $name: String!) {
202
+ repository(owner: $owner, name: $name) {
203
+ issueTypes(first: 20) {
204
+ nodes {
205
+ id
206
+ name
207
+ description
208
+ }
209
+ }
210
+ }
211
+ }
212
+ GRAPHQL
213
+
214
+ result = execute_graphql_query(query, { owner: owner, name: name })
215
+ result['data']['repository']['issueTypes']['nodes']
216
+ rescue => e
217
+ raise Issuer::Error, "Error fetching issue types: #{e.message}"
218
+ end
219
+
103
220
  private
104
221
 
105
222
  def default_token_env_vars
106
223
  %w[ISSUER_API_TOKEN ISSUER_GITHUB_TOKEN GITHUB_ACCESS_TOKEN GITHUB_TOKEN]
107
224
  end
108
225
 
109
- def detect_github_token(custom_env_var)
226
+ def detect_github_token custom_env_var
110
227
  # Check custom env var first if provided
111
228
  return ENV[custom_env_var] if custom_env_var && ENV[custom_env_var]
112
229
 
@@ -118,6 +235,69 @@ module Issuer
118
235
 
119
236
  nil
120
237
  end
238
+
239
+ # GraphQL helper methods
240
+ def resolve_issue_type repo, type_name
241
+ issue_types = get_issue_types(repo)
242
+ issue_type = issue_types.find { |type| type['name'].downcase == type_name.downcase }
243
+ issue_type&.[]('id')
244
+ end
245
+
246
+ def get_repository_id owner, name
247
+ query = <<~GRAPHQL
248
+ query GetRepository($owner: String!, $name: String!) {
249
+ repository(owner: $owner, name: $name) {
250
+ id
251
+ }
252
+ }
253
+ GRAPHQL
254
+
255
+ result = execute_graphql_query(query, { owner: owner, name: name })
256
+ result['data']['repository']['id']
257
+ end
258
+
259
+ def resolve_label_ids repo, label_names
260
+ # For now, we'll skip complex label ID resolution
261
+ # GitHub GraphQL API requires label IDs, but REST API uses names
262
+ # This is a simplification - in practice, you'd need to fetch and match labels
263
+ []
264
+ end
265
+
266
+ def get_user_id username
267
+ query = <<~GRAPHQL
268
+ query GetUser($login: String!) {
269
+ user(login: $login) {
270
+ id
271
+ }
272
+ }
273
+ GRAPHQL
274
+
275
+ result = execute_graphql_query(query, { login: username })
276
+ result['data']['user']['id']
277
+ end
278
+
279
+ def execute_graphql_query query, variables = {}
280
+ uri = URI.parse("https://api.github.com/graphql")
281
+ request = Net::HTTP::Post.new(uri)
282
+ request.content_type = "application/json"
283
+ request["Authorization"] = "Bearer #{@token}"
284
+ request.body = JSON.dump({
285
+ "query" => query,
286
+ "variables" => variables
287
+ })
288
+
289
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
290
+ http.request(request)
291
+ end
292
+
293
+ result = JSON.parse(response.body)
294
+
295
+ if result["errors"] && !result["errors"].empty?
296
+ raise Issuer::Error, "GraphQL error: #{result["errors"].first['message']}"
297
+ end
298
+
299
+ result
300
+ end
121
301
  end
122
302
  end
123
303
  end
data/lib/issuer/cache.rb CHANGED
@@ -32,7 +32,7 @@ module Issuer
32
32
  FileUtils.mkdir_p(logs_dir) unless Dir.exist?(logs_dir)
33
33
  end
34
34
 
35
- def run_log_file(run_id)
35
+ def run_log_file run_id
36
36
  File.join(logs_dir, "#{run_id}.json")
37
37
  end
38
38
 
@@ -43,7 +43,7 @@ module Issuer
43
43
  end
44
44
 
45
45
  # Run tracking
46
- def start_run(metadata = {})
46
+ def start_run metadata = {}
47
47
  ensure_cache_directories
48
48
 
49
49
  run_id = generate_run_id
@@ -68,7 +68,7 @@ module Issuer
68
68
  run_id
69
69
  end
70
70
 
71
- def complete_run(run_id, issues_processed = nil)
71
+ def complete_run run_id, issues_processed = nil
72
72
  run_data = load_run_log(run_id)
73
73
  return unless run_data
74
74
 
@@ -89,7 +89,7 @@ module Issuer
89
89
  save_run_log(run_id, run_data)
90
90
  end
91
91
 
92
- def fail_run(run_id, error_message)
92
+ def fail_run run_id, error_message
93
93
  run_data = load_run_log(run_id)
94
94
  return unless run_data
95
95
 
@@ -105,19 +105,19 @@ module Issuer
105
105
  end
106
106
 
107
107
  # Artifact tracking
108
- def log_issue_created(run_id, issue_data)
108
+ def log_issue_created run_id, issue_data
109
109
  log_artifact(run_id, :issues, issue_data)
110
110
  end
111
111
 
112
- def log_milestone_created(run_id, milestone_data)
112
+ def log_milestone_created run_id, milestone_data
113
113
  log_artifact(run_id, :milestones, milestone_data)
114
114
  end
115
115
 
116
- def log_label_created(run_id, label_data)
116
+ def log_label_created run_id, label_data
117
117
  log_artifact(run_id, :labels, label_data)
118
118
  end
119
119
 
120
- def log_artifact(run_id, type, artifact_data)
120
+ def log_artifact run_id, type, artifact_data
121
121
  run_data = load_run_log(run_id)
122
122
  return unless run_data
123
123
 
@@ -150,11 +150,11 @@ module Issuer
150
150
  end
151
151
 
152
152
  # Data persistence
153
- def save_run_log(run_id, data)
153
+ def save_run_log run_id, data
154
154
  File.write(run_log_file(run_id), JSON.pretty_generate(data))
155
155
  end
156
156
 
157
- def load_run_log(run_id)
157
+ def load_run_log run_id
158
158
  log_file = run_log_file(run_id)
159
159
  return nil unless File.exist?(log_file)
160
160
 
@@ -165,7 +165,7 @@ module Issuer
165
165
  end
166
166
 
167
167
  # Query and listing
168
- def list_runs(status: nil, limit: nil)
168
+ def list_runs status: nil, limit: nil
169
169
  ensure_cache_directories
170
170
 
171
171
  log_files = Dir.glob(File.join(logs_dir, '*.json'))
@@ -185,11 +185,11 @@ module Issuer
185
185
  limit ? runs.take(limit) : runs
186
186
  end
187
187
 
188
- def get_run(run_id)
188
+ def get_run run_id
189
189
  load_run_log(run_id)
190
190
  end
191
191
 
192
- def delete_run_log(run_id)
192
+ def delete_run_log run_id
193
193
  log_file = run_log_file(run_id)
194
194
  File.delete(log_file) if File.exist?(log_file)
195
195
  end
data/lib/issuer/cli.rb CHANGED
@@ -15,9 +15,9 @@ module Issuer
15
15
  class_option :proj, type: :string, desc: 'Override $meta.proj (org/repo)'
16
16
  class_option :vrsn, type: :string, desc: 'Default version for all issues'
17
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'
18
+ class_option :tags, type: :string, desc: 'Comma-separated default or appended (+) labels for all issues'
19
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'
20
+ class_option :dry, type: :boolean, default: false, aliases: ['--dry-run'], desc: 'Print issues, don\'t post'
21
21
  class_option :tokenv, type: :string, desc: 'Name of environment variable containing GitHub token'
22
22
 
23
23
  # Resource automation options
@@ -127,8 +127,8 @@ module Issuer
127
127
  site_options[:token_env_var] = options[:tokenv] if options[:tokenv]
128
128
  site = Issuer::Sites::Factory.create('github', **site_options)
129
129
  automation_options = {
130
- auto_versions: options[:auto_versions] || options[:auto_metadata],
131
- auto_tags: options[:auto_tags] || options[:auto_metadata]
130
+ auto_versions: !!options[:auto_versions] || !!options[:auto_metadata],
131
+ auto_tags: !!options[:auto_tags] || !!options[:auto_metadata]
132
132
  }
133
133
 
134
134
  # Start run tracking for live operations
@@ -167,15 +167,14 @@ module Issuer
167
167
  Issue Default Options:
168
168
  --vrsn VERSION #{self.class_options[:vrsn].description}
169
169
  --user USERNAME #{self.class_options[:user].description}
170
- --tags tag1,tag2 #{self.class_options[:tags].description}
170
+ --tags tag1,+tag2 #{self.class_options[:tags].description}
171
171
  --stub #{self.class_options[:stub].description}
172
172
 
173
173
  Site Options:
174
174
  --proj org/repo #{self.class_options[:proj].description}
175
- --tokenv VAR_NAME #{self.class_options[:tokenv].description}
176
175
 
177
176
  Mode Options:
178
- --dry #{self.class_options[:dry].description}
177
+ --dry, --dry-run #{self.class_options[:dry].description}
179
178
  --auto-versions #{self.class_options[:auto_versions].description}
180
179
  --auto-milestones (alias for --auto-versions)
181
180
  --auto-tags #{self.class_options[:auto_tags].description}
@@ -184,7 +183,7 @@ module Issuer
184
183
 
185
184
  Info:
186
185
  -h, --help #{self.class_options[:help].description}
187
- -v, --version #{self.class_options[:version].description}
186
+ --version Show version
188
187
 
189
188
  Examples:
190
189
  issuer issues.yml --dry
@@ -196,7 +195,6 @@ module Issuer
196
195
 
197
196
  Authentication:
198
197
  Set GITHUB_TOKEN environment variable with your GitHub personal access token
199
- Or use --tokenv to specify a custom environment variable name
200
198
 
201
199
  HELP
202
200
  end
@@ -228,7 +226,7 @@ module Issuer
228
226
  puts "Would process #{valid_count} issues, skip #{invalid_count}"
229
227
  else
230
228
  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
229
+ # Note: Run ID is already displayed in the main flow, no need to repeat it here
232
230
  end
233
231
 
234
232
  end
data/lib/issuer/issue.rb CHANGED
@@ -15,6 +15,7 @@ module Issuer
15
15
  # +tags+:: Array of labels to apply
16
16
  # +user+:: Assignee username
17
17
  # +vrsn+:: Milestone/version
18
+ # +type+:: Issue type (e.g., Bug, Feature, Task)
18
19
  # +stub+:: Whether to apply stub text composition
19
20
  #
20
21
  # == Tag Logic
@@ -22,6 +23,7 @@ module Issuer
22
23
  # Tags support special prefix notation:
23
24
  # * Regular tags (e.g., +"bug"+) are only applied if the issue has no existing tags
24
25
  # * Append tags (e.g., +"+urgent"+) are always applied to all issues
26
+ # * Removal tags (e.g., +"-needs:docs"+) are removed from the default/appended tags list
25
27
  #
26
28
  # == Stub Composition
27
29
  #
@@ -50,7 +52,7 @@ module Issuer
50
52
  # valid_issues = Issuer::Issue.valid_issues_from_array(array_of_data, defaults)
51
53
  #
52
54
  class Issue
53
- attr_reader :summ, :tags, :user, :vrsn, :raw_data
55
+ attr_reader :summ, :tags, :user, :vrsn, :type, :raw_data
54
56
  attr_accessor :body, :stub
55
57
 
56
58
  ##
@@ -60,15 +62,26 @@ module Issuer
60
62
  # @param defaults [Hash] Default values to apply when issue data is missing properties
61
63
  #
62
64
  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']
65
+ # Handle string issues (simple format where string is the summary)
66
+ if issue_data.is_a?(String)
67
+ @raw_data = { 'summ' => issue_data }
68
+ else
69
+ @raw_data = issue_data || {}
70
+ end
71
+ @defaults = defaults
72
+
73
+ # For most fields, issue data overrides defaults
74
+ @summ = @raw_data['summ'] || defaults['summ']
75
+ @body = @raw_data['body'] || @raw_data['desc'] || defaults['body'] || defaults['desc'] || '' # Support both body and desc (legacy)
76
+ @user = @raw_data['user'] || defaults['user']
77
+ @vrsn = @raw_data['vrsn'] || defaults['vrsn']
78
+ @type = @raw_data['type'] || defaults['type']
79
+ @stub = @raw_data.key?('stub') ? @raw_data['stub'] : defaults['stub']
80
+
81
+ # For tags, we need special handling - combine defaults and issue tags for later processing
82
+ defaults_tags = Array(defaults['tags'])
83
+ issue_tags = Array(@raw_data['tags'])
84
+ @tags = defaults_tags + issue_tags
72
85
  end
73
86
 
74
87
  ##
@@ -157,43 +170,61 @@ module Issuer
157
170
 
158
171
  # Apply tag logic for this issue
159
172
  #
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.
173
+ # Processes existing tags with + prefix as append tags, - prefix as removal tags,
174
+ # combines them with CLI-provided tags, and determines final tag set based on precedence rules.
162
175
  #
163
176
  # @param cli_append_tags [Array<String>] Tags to always append from CLI
164
177
  # @param cli_default_tags [Array<String>] Default tags from CLI (used when no regular tags exist)
165
178
  # @return [void] Sets @tags instance variable
166
179
  #
167
180
  # @example
168
- # # Issue has tags: ['+urgent', 'bug']
169
- # issue.apply_tag_logic(['cli-tag'], ['default-tag'])
170
- # # Result: ['urgent', 'cli-tag', 'bug']
181
+ # # Issue has tags: ['+urgent', 'bug', '-needs:docs']
182
+ # issue.apply_tag_logic(['cli-tag'], ['default-tag', 'needs:docs'])
183
+ # # Result: ['urgent', 'cli-tag', 'bug', 'default-tag'] (needs:docs removed)
171
184
  def apply_tag_logic cli_append_tags, cli_default_tags
172
- # Parse existing tags for + prefix
185
+ # Parse existing tags for + and - prefixes
173
186
  existing_tags = tags || []
174
187
  append_tags = []
175
188
  regular_tags = []
189
+ remove_tags = []
176
190
 
177
191
  existing_tags.each do |tag|
178
- if tag.to_s.start_with?('+')
179
- append_tags << tag.to_s[1..] # Remove + prefix
192
+ tag_str = tag.to_s
193
+ if tag_str.start_with?('+')
194
+ append_tags << tag_str[1..] # Remove + prefix
195
+ elsif tag_str.start_with?('-')
196
+ remove_tags << tag_str[1..] # Remove - prefix
180
197
  else
181
- regular_tags << tag.to_s
198
+ regular_tags << tag_str
182
199
  end
183
200
  end
184
201
 
185
- # Start with append tags (always applied)
186
- final_tags = append_tags + cli_append_tags
202
+ # Start with append tags from both defaults and CLI (always applied)
203
+ defaults_append_tags = Array(@defaults['tags']).select { |tag| tag.to_s.start_with?('+') }.map { |tag| tag[1..] }
204
+ final_tags = append_tags + defaults_append_tags + cli_append_tags
205
+
206
+ # For regular tags, add issue's own tags, otherwise use default tags
207
+ issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') || tag.to_s.start_with?('-') }
187
208
 
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)
209
+ if !issue_regular_tags.empty?
210
+ # Issue has its own regular tags, use them
211
+ final_tags.concat(issue_regular_tags)
191
212
  else
213
+ # Issue has no regular tags, use defaults from CLI
192
214
  final_tags.concat(cli_default_tags)
215
+ # Also add non-append defaults tags (- prefix ignored in defaults)
216
+ defaults_regular_tags = Array(@defaults['tags']).reject { |tag| tag.to_s.start_with?('+') }
217
+ final_tags.concat(defaults_regular_tags)
193
218
  end
194
219
 
195
- # Set the final tags (removing duplicates)
196
- @tags = final_tags.uniq
220
+ # Collect removal tags from issue only (not defaults)
221
+ all_remove_tags = remove_tags
222
+
223
+ # Remove duplicates first, then remove tags specified for removal
224
+ final_tags = final_tags.uniq - all_remove_tags
225
+
226
+ # Set the final tags
227
+ @tags = final_tags
197
228
  end
198
229
 
199
230
  # Apply stub logic for this issue
@@ -239,7 +270,8 @@ module Issuer
239
270
  #
240
271
  # Separates tags with + prefix (append tags) from regular tags (default tags).
241
272
  # Tags with + prefix are always applied, while regular tags are only used
242
- # when the issue has no existing regular tags.
273
+ # when the issue has no existing regular tags. Tags with - prefix are handled
274
+ # in the apply_tag_logic method for removal.
243
275
  #
244
276
  # @param tags_string [String] Comma-separated tag string
245
277
  # @return [Array<Array<String>>] Two-element array: [append_tags, default_tags]
@@ -286,14 +318,14 @@ module Issuer
286
318
  # Users cannot log in properly after the recent update.
287
319
  # This affects all user accounts.
288
320
  #
289
- # repo: myorg/myproject
321
+ # type: Bug
290
322
  # milestone: 1.0.0
291
323
  # labels:
292
324
  # - bug
293
325
  # - urgent
294
326
  # assignee: developer1
295
327
  # ------
296
- def formatted_output(site, repo)
328
+ def formatted_output site, repo
297
329
  # Get site-specific field mappings
298
330
  field_map = site.field_mappings
299
331
 
@@ -310,17 +342,22 @@ module Issuer
310
342
  if site_params[:body] && !site_params[:body].strip.empty?
311
343
  body_field = field_map[:body] || 'body'
312
344
  output << "#{body_field}:"
313
- # Indent body content
345
+ # Indent body content with proper line wrapping
314
346
  body_lines = site_params[:body].strip.split("\n")
315
347
  body_lines.each do |line|
316
- output << " #{line}"
348
+ wrapped_lines = wrap_line_with_indentation(line, 12)
349
+ wrapped_lines.each do |wrapped_line|
350
+ output << wrapped_line
351
+ end
317
352
  end
318
353
  output << "" # Empty line after body
319
354
  end
320
355
 
321
- # Repository
322
- repo_field = field_map[:repo] || 'repo'
323
- output << sprintf("%-12s%s", "#{repo_field}:", repo) if repo
356
+ # Type
357
+ if site_params[:type]
358
+ type_field = field_map[:type] || 'type'
359
+ output << sprintf("%-12s%s", "#{type_field}:", site_params[:type])
360
+ end
324
361
 
325
362
  # Milestone/Version
326
363
  if site_params[:milestone]
@@ -351,6 +388,54 @@ module Issuer
351
388
 
352
389
  private
353
390
 
391
+ # Wrap a line with proper indentation, handling long lines that exceed terminal width
392
+ #
393
+ # @param line [String] The line to wrap
394
+ # @param indent_size [Integer] Number of spaces for indentation
395
+ # @return [Array<String>] Array of wrapped lines with proper indentation
396
+ #
397
+ # @example
398
+ # wrap_line_with_indentation("This is a very long line that needs wrapping", 4)
399
+ # # => [" This is a very long line that needs", " wrapping"]
400
+ def wrap_line_with_indentation line, indent_size
401
+ # Get terminal width, default to 80 if not available
402
+ terminal_width = ENV['COLUMNS']&.to_i || 80
403
+
404
+ # Calculate available width for content (terminal width - indentation)
405
+ available_width = terminal_width - indent_size
406
+
407
+ # If line fits within available width, just return it with indentation
408
+ if line.length <= available_width
409
+ return [' ' * indent_size + line]
410
+ end
411
+
412
+ # Split long line into chunks that fit
413
+ wrapped_lines = []
414
+ remaining_text = line
415
+
416
+ while remaining_text.length > available_width
417
+ # Find the last space before the available width limit
418
+ break_point = remaining_text.rindex(' ', available_width)
419
+
420
+ # If no space found, break at the available width (hard wrap)
421
+ break_point = available_width if break_point.nil?
422
+
423
+ # Extract the chunk and add it with proper indentation
424
+ chunk = remaining_text[0...break_point]
425
+ wrapped_lines << (' ' * indent_size + chunk)
426
+
427
+ # Remove the processed chunk from remaining text
428
+ remaining_text = remaining_text[break_point..].lstrip
429
+ end
430
+
431
+ # Add the final chunk if any text remains
432
+ if !remaining_text.empty?
433
+ wrapped_lines << (' ' * indent_size + remaining_text)
434
+ end
435
+
436
+ wrapped_lines
437
+ end
438
+
354
439
  # Determine if stub logic should be applied to this issue
355
440
  #
356
441
  # Checks issue-level stub property first, then falls back to defaults.
@@ -67,7 +67,8 @@ module Issuer
67
67
  issues.each do |issue|
68
68
  begin
69
69
  # Convert IMYML issue to site-specific parameters
70
- site_params = convert_issue_to_site_params(issue, proj)
70
+ # Pass run_id to enable post-validation milestone lookup
71
+ site_params = convert_issue_to_site_params(issue, proj, dry_run: false, post_validation: true)
71
72
  result = create_issue(proj, site_params)
72
73
 
73
74
  # Extract the created issue object (for backwards compatibility)