issuer 0.1.1 → 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.
@@ -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,7 +15,7 @@ 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
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'
@@ -167,12 +167,11 @@ 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
177
  --dry, --dry-run #{self.class_options[:dry].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
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,7 +62,12 @@ 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 || {}
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
64
71
  @defaults = defaults
65
72
 
66
73
  # For most fields, issue data overrides defaults
@@ -68,6 +75,7 @@ module Issuer
68
75
  @body = @raw_data['body'] || @raw_data['desc'] || defaults['body'] || defaults['desc'] || '' # Support both body and desc (legacy)
69
76
  @user = @raw_data['user'] || defaults['user']
70
77
  @vrsn = @raw_data['vrsn'] || defaults['vrsn']
78
+ @type = @raw_data['type'] || defaults['type']
71
79
  @stub = @raw_data.key?('stub') ? @raw_data['stub'] : defaults['stub']
72
80
 
73
81
  # For tags, we need special handling - combine defaults and issue tags for later processing
@@ -162,28 +170,32 @@ module Issuer
162
170
 
163
171
  # Apply tag logic for this issue
164
172
  #
165
- # Processes existing tags with + prefix as append tags, combines them with
166
- # 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.
167
175
  #
168
176
  # @param cli_append_tags [Array<String>] Tags to always append from CLI
169
177
  # @param cli_default_tags [Array<String>] Default tags from CLI (used when no regular tags exist)
170
178
  # @return [void] Sets @tags instance variable
171
179
  #
172
180
  # @example
173
- # # Issue has tags: ['+urgent', 'bug']
174
- # issue.apply_tag_logic(['cli-tag'], ['default-tag'])
175
- # # 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)
176
184
  def apply_tag_logic cli_append_tags, cli_default_tags
177
- # Parse existing tags for + prefix
185
+ # Parse existing tags for + and - prefixes
178
186
  existing_tags = tags || []
179
187
  append_tags = []
180
188
  regular_tags = []
189
+ remove_tags = []
181
190
 
182
191
  existing_tags.each do |tag|
183
- if tag.to_s.start_with?('+')
184
- 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
185
197
  else
186
- regular_tags << tag.to_s
198
+ regular_tags << tag_str
187
199
  end
188
200
  end
189
201
 
@@ -192,7 +204,7 @@ module Issuer
192
204
  final_tags = append_tags + defaults_append_tags + cli_append_tags
193
205
 
194
206
  # For regular tags, add issue's own tags, otherwise use default tags
195
- issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') }
207
+ issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') || tag.to_s.start_with?('-') }
196
208
 
197
209
  if !issue_regular_tags.empty?
198
210
  # Issue has its own regular tags, use them
@@ -200,13 +212,19 @@ module Issuer
200
212
  else
201
213
  # Issue has no regular tags, use defaults from CLI
202
214
  final_tags.concat(cli_default_tags)
203
- # Also add non-append defaults tags
215
+ # Also add non-append defaults tags (- prefix ignored in defaults)
204
216
  defaults_regular_tags = Array(@defaults['tags']).reject { |tag| tag.to_s.start_with?('+') }
205
217
  final_tags.concat(defaults_regular_tags)
206
218
  end
207
219
 
208
- # Set the final tags (removing duplicates)
209
- @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
210
228
  end
211
229
 
212
230
  # Apply stub logic for this issue
@@ -252,7 +270,8 @@ module Issuer
252
270
  #
253
271
  # Separates tags with + prefix (append tags) from regular tags (default tags).
254
272
  # Tags with + prefix are always applied, while regular tags are only used
255
- # 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.
256
275
  #
257
276
  # @param tags_string [String] Comma-separated tag string
258
277
  # @return [Array<Array<String>>] Two-element array: [append_tags, default_tags]
@@ -299,14 +318,14 @@ module Issuer
299
318
  # Users cannot log in properly after the recent update.
300
319
  # This affects all user accounts.
301
320
  #
302
- # repo: myorg/myproject
321
+ # type: Bug
303
322
  # milestone: 1.0.0
304
323
  # labels:
305
324
  # - bug
306
325
  # - urgent
307
326
  # assignee: developer1
308
327
  # ------
309
- def formatted_output(site, repo)
328
+ def formatted_output site, repo
310
329
  # Get site-specific field mappings
311
330
  field_map = site.field_mappings
312
331
 
@@ -323,17 +342,22 @@ module Issuer
323
342
  if site_params[:body] && !site_params[:body].strip.empty?
324
343
  body_field = field_map[:body] || 'body'
325
344
  output << "#{body_field}:"
326
- # Indent body content
345
+ # Indent body content with proper line wrapping
327
346
  body_lines = site_params[:body].strip.split("\n")
328
347
  body_lines.each do |line|
329
- output << " #{line}"
348
+ wrapped_lines = wrap_line_with_indentation(line, 12)
349
+ wrapped_lines.each do |wrapped_line|
350
+ output << wrapped_line
351
+ end
330
352
  end
331
353
  output << "" # Empty line after body
332
354
  end
333
355
 
334
- # Repository
335
- repo_field = field_map[:repo] || 'repo'
336
- 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
337
361
 
338
362
  # Milestone/Version
339
363
  if site_params[:milestone]
@@ -364,6 +388,54 @@ module Issuer
364
388
 
365
389
  private
366
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
+
367
439
  # Determine if stub logic should be applied to this issue
368
440
  #
369
441
  # Checks issue-level stub property first, then falls back to defaults.