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/ops.rb ADDED
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Issuer
6
+ # The Ops module provides high-level operations for processing collections of issues,
7
+ # including data normalization, tag and stub logic application, and resource validation.
8
+ # This module serves as an orchestrator that delegates specific behaviors to the Issue class.
9
+ module Ops
10
+ module_function
11
+
12
+ # Processes raw issue data into normalized Issue objects.
13
+ #
14
+ # This method converts scalar strings to proper issue hashes and creates Issue objects
15
+ # with enhanced defaults processing. It handles both string and hash inputs gracefully.
16
+ #
17
+ # @param issues_data [Array<String, Hash>] Raw issue data - can be strings or hashes
18
+ # @param defaults [Hash] Default values to apply to all issues
19
+ # @return [Array<Issuer::Issue>] Array of processed Issue objects
20
+ #
21
+ # @example
22
+ # issues_data = ["Fix login bug", {"summ" => "Add feature", "tags" => ["enhancement"]}]
23
+ # defaults = {"assignee" => "admin", "priority" => "normal"}
24
+ # issues = Ops.process_issues_data(issues_data, defaults)
25
+ def self.process_issues_data issues_data, defaults
26
+ # Convert scalar strings to issue objects
27
+ normalized_data = self.normalize_issue_records(issues_data)
28
+
29
+ # Create Issue objects with enhanced defaults processing
30
+ issues = Issuer::Issue.from_array(normalized_data, defaults)
31
+
32
+ issues
33
+ end
34
+
35
+ # Applies tag logic to a collection of issues, typically based on CLI-provided tags.
36
+ #
37
+ # This method delegates to the Issue class for consistency and to avoid code duplication.
38
+ # Tags can be applied based on various conditions or merged with existing issue tags.
39
+ #
40
+ # @param issues [Array<Issuer::Issue>] Collection of issues to process
41
+ # @param cli_tags [Array<String>] Tags provided via CLI arguments
42
+ # @return [Array<Issuer::Issue>] Issues with updated tag logic applied
43
+ #
44
+ # @example
45
+ # issues = [issue1, issue2]
46
+ # cli_tags = ["urgent", "frontend"]
47
+ # Ops.apply_tag_logic(issues, cli_tags)
48
+ def self.apply_tag_logic issues, cli_tags
49
+ # Delegate to Issue class method for consistency
50
+ Issuer::Issue.apply_tag_logic(issues, cli_tags)
51
+ end
52
+
53
+ # Applies stub logic to a collection of issues based on provided defaults.
54
+ #
55
+ # This method delegates to the Issue class for consistency and to avoid code duplication.
56
+ # Stub logic determines whether to create stub issues or apply certain transformations.
57
+ #
58
+ # @param issues [Array<Issuer::Issue>] Collection of issues to process
59
+ # @param defaults [Hash] Default values and configuration for stub logic
60
+ # @return [Array<Issuer::Issue>] Issues with updated stub logic applied
61
+ #
62
+ # @example
63
+ # issues = [issue1, issue2]
64
+ # defaults = {"stub_enabled" => true, "stub_prefix" => "[STUB]"}
65
+ # Ops.apply_stub_logic(issues, defaults)
66
+ def self.apply_stub_logic issues, defaults
67
+ # Delegate to Issue class method for consistency
68
+ Issuer::Issue.apply_stub_logic(issues, defaults)
69
+ end
70
+
71
+ # Validates and prepares resources (versions/milestones and tags/labels) needed for issues.
72
+ #
73
+ # This method ensures that all versions and tags referenced by issues exist in the target
74
+ # project. It can interactively prompt for creation of missing resources or auto-create
75
+ # them based on automation options.
76
+ #
77
+ # @param site [Object] Site connector object (e.g., GitHub, GitLab, Jira)
78
+ # @param proj [String] Project identifier (repository name, project key, etc.)
79
+ # @param issues [Array<Issuer::Issue>] Issues that will be created
80
+ # @param automation_options [Hash] Options for automatic resource creation
81
+ # @option automation_options [Boolean] :auto_versions Auto-create missing versions
82
+ # @option automation_options [Boolean] :auto_tags Auto-create missing tags
83
+ # @param run_id [String] Optional run identifier for tracking created resources
84
+ # @return [void]
85
+ #
86
+ # @example
87
+ # automation = {auto_versions: true, auto_tags: false}
88
+ # Ops.validate_and_prepare_resources(github_site, "my-repo", issues, automation, "run123")
89
+ def self.validate_and_prepare_resources site, proj, issues, automation_options = {}, run_id = nil
90
+ return if issues.empty?
91
+
92
+ # Step 1: Collect all unique versions and tags from issues
93
+ required_versions, required_tags = self.collect_required_resources(issues)
94
+
95
+ return if required_versions.empty? && required_tags.empty?
96
+
97
+ # Get site-specific terminology
98
+ version_term, tag_term = self.get_site_terminology(site)
99
+
100
+ puts "🔍 Checking #{site.site_name} project for existing #{version_term} and #{tag_term}..."
101
+
102
+ # Step 2: Check what exists vs what's missing
103
+ missing_versions, missing_tags = self.check_missing_resources(site, proj, required_versions, required_tags)
104
+
105
+ if missing_versions.empty? && missing_tags.empty?
106
+ puts "✅ All #{version_term} and #{tag_term} already exist in project"
107
+ return
108
+ end
109
+
110
+ puts "⚠️ Found missing #{version_term} (vrsn entries) and/or #{tag_term} (tags) that need to be created:"
111
+
112
+ # Step 3: Interactive or automatic creation of missing resources
113
+ auto_versions = automation_options[:auto_versions] || false
114
+ auto_tags = automation_options[:auto_tags] || false
115
+
116
+ self.create_missing_versions(site, proj, missing_versions, version_term, auto_versions, run_id) unless missing_versions.empty?
117
+ self.create_missing_tags(site, proj, missing_tags, tag_term, auto_tags, run_id) unless missing_tags.empty?
118
+
119
+ puts ""
120
+ puts "✅ Resource validation complete. Proceeding with issue creation..."
121
+ rescue => e
122
+ puts "❌ Error during validation: #{e.message}"
123
+ puts "Proceeding anyway, but some issues might fail to create..."
124
+ end
125
+
126
+ private
127
+
128
+ def self.get_site_terminology site
129
+ case site.site_name.downcase
130
+ when 'github'
131
+ ['milestones', 'labels']
132
+ when 'jira'
133
+ ['versions', 'labels']
134
+ when 'gitlab'
135
+ ['milestones', 'labels']
136
+ else
137
+ ['versions', 'tags']
138
+ end
139
+ end
140
+
141
+ def self.normalize_issue_records issues_data
142
+ issues_data.map do |item|
143
+ if item.is_a?(String)
144
+ # Convert scalar string to hash with summ property
145
+ { 'summ' => item }
146
+ else
147
+ # Already a hash, return as-is
148
+ item
149
+ end
150
+ end
151
+ end
152
+
153
+ def self.collect_required_resources issues
154
+ versions = Set.new
155
+ tags = Set.new
156
+
157
+ issues.each do |issue|
158
+ # Collect version (vrsn in IMYML)
159
+ if issue.vrsn && !issue.vrsn.to_s.strip.empty?
160
+ versions << issue.vrsn.to_s.strip
161
+ end
162
+
163
+ # Collect tags (tags in IMYML)
164
+ issue.tags.each do |tag|
165
+ tag_name = tag.to_s.strip
166
+ tags << tag_name unless tag_name.empty?
167
+ end
168
+ end
169
+
170
+ [versions.to_a, tags.to_a]
171
+ end
172
+
173
+ def self.check_missing_resources site, proj, required_versions, required_tags
174
+ existing_versions = site.get_versions(proj).map(&:title)
175
+ existing_tags = site.get_tags(proj).map(&:name)
176
+
177
+ missing_versions = required_versions - existing_versions
178
+ missing_tags = required_tags - existing_tags
179
+
180
+ [missing_versions, missing_tags]
181
+ end
182
+
183
+ def self.create_missing_versions site, proj, missing_versions, version_term = 'versions', auto_create = false, run_id = nil
184
+ missing_versions.each do |version_name|
185
+ puts ""
186
+ puts "📋 #{version_term.capitalize.chomp('s')} '#{version_name}' does not exist in project '#{proj}'"
187
+
188
+ if auto_create
189
+ puts "Auto-creating #{version_term.chomp('s').downcase}: #{version_name}"
190
+ result = site.create_version(proj, version_name)
191
+ puts "✅ #{version_term.capitalize.chomp('s')} '#{version_name}' created successfully"
192
+
193
+ # Log the created milestone if tracking is enabled
194
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
195
+ require_relative 'cache'
196
+ Issuer::Cache.log_milestone_created(run_id, result[:tracking_data])
197
+ end
198
+ else
199
+ print "Create #{version_term.chomp('s').downcase} '#{version_name}'? [Y/n/q]: "
200
+
201
+ response = STDIN.gets.chomp.downcase
202
+ case response
203
+ when '', 'y', 'yes'
204
+ puts "Creating #{version_term.chomp('s').downcase}: #{version_name}"
205
+ result = site.create_version(proj, version_name)
206
+ puts "✅ #{version_term.capitalize.chomp('s')} '#{version_name}' created successfully"
207
+
208
+ # Log the created milestone if tracking is enabled
209
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
210
+ require_relative 'cache'
211
+ Issuer::Cache.log_milestone_created(run_id, result[:tracking_data])
212
+ end
213
+ when 'q', 'quit'
214
+ puts "❌ Exiting - please resolve missing #{version_term} and try again"
215
+ exit 1
216
+ else
217
+ puts "⚠️ Skipping #{version_term.chomp('s').downcase} creation. Issues with this #{version_term.chomp('s').downcase} may fail to create."
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ def self.create_missing_tags site, proj, missing_tags, tag_term = 'tags', auto_create = false, run_id = nil
224
+ missing_tags.each do |tag_name|
225
+ puts ""
226
+ puts "🏷️ #{tag_term.capitalize.chomp('s')} '#{tag_name}' does not exist in project '#{proj}'"
227
+
228
+ if auto_create
229
+ puts "Auto-creating #{tag_term.chomp('s').downcase}: #{tag_name}"
230
+ result = site.create_tag(proj, tag_name)
231
+ puts "✅ #{tag_term.capitalize.chomp('s')} '#{tag_name}' created successfully"
232
+
233
+ # Log the created label if tracking is enabled
234
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
235
+ require_relative 'cache'
236
+ Issuer::Cache.log_label_created(run_id, result[:tracking_data])
237
+ end
238
+ else
239
+ print "Create #{tag_term.chomp('s').downcase} '#{tag_name}' with default color? [Y/n/c/q]: "
240
+
241
+ response = STDIN.gets.chomp.downcase
242
+ case response
243
+ when '', 'y', 'yes'
244
+ puts "Creating #{tag_term.chomp('s').downcase}: #{tag_name}"
245
+ result = site.create_tag(proj, tag_name)
246
+ puts "✅ #{tag_term.capitalize.chomp('s')} '#{tag_name}' created successfully"
247
+
248
+ # Log the created label if tracking is enabled
249
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
250
+ require_relative 'cache'
251
+ Issuer::Cache.log_label_created(run_id, result[:tracking_data])
252
+ end
253
+ when 'c', 'custom'
254
+ print "Enter hex color (without #, e.g. 'f29513'): "
255
+ color = STDIN.gets.chomp
256
+ color = 'f29513' if color.empty?
257
+
258
+ print "Enter description (optional): "
259
+ description = STDIN.gets.chomp
260
+ description = nil if description.empty?
261
+
262
+ puts "Creating #{tag_term.chomp('s').downcase}: #{tag_name} with color ##{color}"
263
+ result = site.create_tag(proj, tag_name, color: color, description: description)
264
+ puts "✅ #{tag_term.capitalize.chomp('s')} '#{tag_name}' created successfully"
265
+
266
+ # Log the created label if tracking is enabled
267
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
268
+ require_relative 'cache'
269
+ Issuer::Cache.log_label_created(run_id, result[:tracking_data])
270
+ end
271
+ when 'q', 'quit'
272
+ puts "❌ Exiting - please resolve missing #{tag_term} and try again"
273
+ exit 1
274
+ else
275
+ puts "⚠️ Skipping #{tag_term.chomp('s').downcase} creation. Issues with this #{tag_term.chomp('s').downcase} may not be tagged properly."
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Issuer
4
+ module Sites
5
+ class Base
6
+ # Site identification
7
+ def site_name
8
+ raise NotImplementedError, "Subclasses must implement site_name"
9
+ end
10
+
11
+ # Field display labels for dry-run output formatting
12
+ # Maps site-specific parameter names to user-friendly display labels
13
+ def field_mappings
14
+ raise NotImplementedError, "Subclasses must implement field_mappings"
15
+ end
16
+
17
+ # Resource validation and preparation
18
+ def validate_and_prepare_resources proj, issues
19
+ raise NotImplementedError, "Subclasses must implement validate_and_prepare_resources"
20
+ end
21
+
22
+ # Issue creation
23
+ def create_issue proj, issue_params
24
+ raise NotImplementedError, "Subclasses must implement create_issue"
25
+ end
26
+
27
+ # Resource queries for validation
28
+ def get_versions proj
29
+ raise NotImplementedError, "Subclasses must implement get_versions"
30
+ end
31
+
32
+ def get_tags proj
33
+ raise NotImplementedError, "Subclasses must implement get_tags"
34
+ end
35
+
36
+ # Resource creation for validation
37
+ def create_version proj, version_name, options={}
38
+ raise NotImplementedError, "Subclasses must implement create_version"
39
+ end
40
+
41
+ def create_tag proj, tag_name, options={}
42
+ raise NotImplementedError, "Subclasses must implement create_tag"
43
+ end
44
+
45
+ # Resource cleanup methods for caching/undo functionality
46
+ def close_issue proj, issue_number
47
+ raise NotImplementedError, "Subclasses must implement close_issue"
48
+ end
49
+
50
+ def delete_milestone proj, milestone_number
51
+ raise NotImplementedError, "Subclasses must implement delete_milestone"
52
+ end
53
+
54
+ def delete_label proj, label_name
55
+ raise NotImplementedError, "Subclasses must implement delete_label"
56
+ end
57
+
58
+ # Convert IMYML issue to site-specific parameters
59
+ def convert_issue_to_site_params issue, proj, dry_run: false
60
+ raise NotImplementedError, "Subclasses must implement convert_issue_to_site_params"
61
+ end
62
+
63
+ # Issue posting
64
+ def post_issues proj, issues, run_id = nil
65
+ processed_count = 0
66
+
67
+ issues.each do |issue|
68
+ begin
69
+ # Convert IMYML issue to site-specific parameters
70
+ site_params = convert_issue_to_site_params(issue, proj)
71
+ result = create_issue(proj, site_params)
72
+
73
+ # Extract the created issue object (for backwards compatibility)
74
+ created_issue = result.is_a?(Hash) ? result[:object] : result
75
+
76
+ puts "✅ Created issue ##{created_issue.number}: #{issue.summ}"
77
+ puts " URL: #{created_issue.html_url}" if created_issue.respond_to?(:html_url)
78
+ processed_count += 1
79
+
80
+ # Log the created issue if tracking is enabled
81
+ if run_id && result.is_a?(Hash) && result[:tracking_data]
82
+ require_relative '../cache'
83
+ Issuer::Cache.log_issue_created(run_id, result[:tracking_data])
84
+ end
85
+
86
+ # Rate limiting courtesy
87
+ sleep(1) if processed_count % 10 == 0
88
+ rescue => e
89
+ puts "❌ Failed to create issue '#{issue.summ}': #{e.message}"
90
+ end
91
+ end
92
+
93
+ processed_count
94
+ end
95
+
96
+ protected
97
+
98
+ # Site-specific configuration validation
99
+ def validate_configuration
100
+ raise NotImplementedError, "Subclasses must implement validate_configuration"
101
+ end
102
+
103
+ # Site-specific authentication
104
+ def authenticate
105
+ raise NotImplementedError, "Subclasses must implement authenticate"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Issuer
4
+ module Sites
5
+ class Factory
6
+ SUPPORTED_SITES = {
7
+ 'github' => 'Issuer::Sites::GitHub'
8
+ }.freeze
9
+
10
+ def self.create site_name, **options
11
+ site_name = site_name.to_s.downcase
12
+
13
+ unless SUPPORTED_SITES.key?(site_name)
14
+ available = SUPPORTED_SITES.keys.join(', ')
15
+ raise Issuer::Error, "Unsupported site '#{site_name}'. Available sites: #{available}"
16
+ end
17
+
18
+ site_class = Object.const_get(SUPPORTED_SITES[site_name])
19
+ site_class.new(**options)
20
+ end
21
+
22
+ def self.supported_sites
23
+ SUPPORTED_SITES.keys
24
+ end
25
+
26
+ def self.default_site
27
+ 'github'
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+ require_relative 'base'
5
+
6
+ module Issuer
7
+ module Sites
8
+ class GitHub < Base
9
+ def initialize token: nil, token_env_var: nil
10
+ @token = token || self.class.detect_github_token(custom_env_var: token_env_var)
11
+
12
+ # Skip authentication validation for dry-run mode
13
+ if @token == 'dry-run-token'
14
+ @client = nil
15
+ return
16
+ end
17
+
18
+ unless @token
19
+ env_vars = [token_env_var, *self.class.default_token_env_vars].compact.uniq
20
+ raise Issuer::Error, "GitHub token not found. Set #{env_vars.join(', ')} environment variable."
21
+ end
22
+
23
+ @client = Octokit::Client.new(access_token: @token)
24
+ @client.auto_paginate = true
25
+ end
26
+
27
+ def site_name
28
+ 'github'
29
+ end
30
+
31
+ # Return field display labels for dry-run output
32
+ # Maps site-specific parameter names to user-friendly display labels
33
+ # Note: Issue properties are already converted by convert_issue_to_site_params
34
+ def field_mappings
35
+ {
36
+ title: 'title', # site_params[:title] displays as "title:"
37
+ body: 'body', # site_params[:body] displays as "body:"
38
+ repo: 'repo', # repo displays as "repo:"
39
+ milestone: 'milestone', # site_params[:milestone] displays as "milestone:"
40
+ labels: 'labels', # site_params[:labels] displays as "labels:"
41
+ assignee: 'assignee', # site_params[:assignee] displays as "assignee:"
42
+ project_name: 'repo' # For summary messages: "repo: owner/name"
43
+ }
44
+ end
45
+
46
+ def validate_and_prepare_resources proj, issues
47
+ Issuer::Ops.validate_and_prepare_resources(self, proj, issues)
48
+ end
49
+
50
+ def create_issue proj, issue_params
51
+ # Validate required fields
52
+ unless issue_params[:title] && !issue_params[:title].strip.empty?
53
+ raise Issuer::Error, "Issue title is required"
54
+ end
55
+
56
+ # Prepare issue creation parameters
57
+ params = {
58
+ title: issue_params[:title],
59
+ body: issue_params[:body] || ''
60
+ }
61
+
62
+ # Handle labels
63
+ if issue_params[:labels] && !issue_params[:labels].empty?
64
+ params[:labels] = issue_params[:labels].map(&:strip).reject(&:empty?)
65
+ end
66
+
67
+ # Handle assignee
68
+ if issue_params[:assignee] && !issue_params[:assignee].strip.empty?
69
+ params[:assignee] = issue_params[:assignee].strip
70
+ end
71
+
72
+ # Handle milestone - only if milestone exists
73
+ if issue_params[:milestone]
74
+ milestone = find_milestone(proj, issue_params[:milestone])
75
+ params[:milestone] = milestone.number if milestone
76
+ end
77
+
78
+ created_issue = @client.create_issue(proj, params[:title], params[:body], params)
79
+
80
+ # Extract relevant data for potential cleanup tracking
81
+ issue_data = {
82
+ number: created_issue.number,
83
+ title: created_issue.title,
84
+ url: created_issue.html_url,
85
+ created_at: created_issue.created_at,
86
+ repository: proj
87
+ }
88
+
89
+ # Return both the created issue and tracking data
90
+ { object: created_issue, tracking_data: issue_data }
91
+ rescue Octokit::Error => e
92
+ raise Issuer::Error, "GitHub API error: #{e.message}"
93
+ end
94
+
95
+ def get_versions proj
96
+ @client.milestones(proj, state: 'all')
97
+ rescue Octokit::Error => e
98
+ raise Issuer::Error, "Failed to fetch milestones: #{e.message}"
99
+ end
100
+
101
+ def get_tags proj
102
+ @client.labels(proj)
103
+ rescue Octokit::Error => e
104
+ raise Issuer::Error, "Failed to fetch labels: #{e.message}"
105
+ end
106
+
107
+ def create_version proj, version_name, options={}
108
+ description = options[:description] || "Created by issuer CLI"
109
+
110
+ # Call create_milestone with proper parameters
111
+ created_milestone = @client.create_milestone(proj, version_name, description: description)
112
+
113
+ # Return tracking data
114
+ {
115
+ object: created_milestone,
116
+ tracking_data: {
117
+ number: created_milestone.number,
118
+ title: created_milestone.title,
119
+ url: created_milestone.html_url,
120
+ created_at: created_milestone.created_at,
121
+ repository: proj
122
+ }
123
+ }
124
+ rescue Octokit::Error => e
125
+ raise Issuer::Error, "Failed to create milestone '#{version_name}': #{e.message}"
126
+ end
127
+
128
+ def create_tag proj, tag_name, options={}
129
+ color = options[:color] || 'f29513'
130
+ description = options[:description]
131
+
132
+ # Call add_label with proper parameters
133
+ created_label = @client.add_label(proj, tag_name, color, description: description)
134
+
135
+ # Return tracking data
136
+ {
137
+ object: created_label,
138
+ tracking_data: {
139
+ name: created_label.name,
140
+ color: created_label.color,
141
+ description: created_label.description,
142
+ url: created_label.url,
143
+ repository: proj
144
+ }
145
+ }
146
+ rescue Octokit::Error => e
147
+ raise Issuer::Error, "Failed to create label '#{tag_name}': #{e.message}"
148
+ end
149
+
150
+ # Cleanup methods
151
+ def close_issue proj, issue_number
152
+ @client.close_issue(proj, issue_number)
153
+ rescue Octokit::Error => e
154
+ raise Issuer::Error, "Failed to close issue ##{issue_number}: #{e.message}"
155
+ end
156
+
157
+ def delete_milestone proj, milestone_number
158
+ @client.delete_milestone(proj, milestone_number)
159
+ rescue Octokit::Error => e
160
+ raise Issuer::Error, "Failed to delete milestone ##{milestone_number}: #{e.message}"
161
+ end
162
+
163
+ def delete_label proj, label_name
164
+ @client.delete_label!(proj, label_name)
165
+ rescue Octokit::Error => e
166
+ raise Issuer::Error, "Failed to delete label '#{label_name}': #{e.message}"
167
+ end
168
+
169
+ def validate_configuration
170
+ # Test API access
171
+ @client.user
172
+ true
173
+ rescue Octokit::Error => e
174
+ raise Issuer::Error, "GitHub authentication failed: #{e.message}"
175
+ end
176
+
177
+ def authenticate
178
+ validate_configuration
179
+ end
180
+
181
+ # Convert IMYML issue to GitHub-specific parameters
182
+ def convert_issue_to_site_params issue, proj, dry_run: false
183
+ params = {
184
+ title: issue.summ,
185
+ body: issue.body || ''
186
+ }
187
+
188
+ # Handle tags -> labels
189
+ if issue.tags && !issue.tags.empty?
190
+ params[:labels] = issue.tags.map(&:strip).reject(&:empty?)
191
+ end
192
+
193
+ # Handle user -> assignee
194
+ if issue.user && !issue.user.strip.empty?
195
+ params[:assignee] = issue.user.strip
196
+ end
197
+
198
+ # Handle vrsn -> milestone
199
+ if issue.vrsn
200
+ if dry_run
201
+ # In dry-run mode, just show the milestone name without API lookup
202
+ params[:milestone] = issue.vrsn
203
+ else
204
+ # In normal mode, resolve milestone name to number
205
+ milestone = find_milestone(proj, issue.vrsn)
206
+ params[:milestone] = milestone.number if milestone
207
+ end
208
+ end
209
+
210
+ params
211
+ end
212
+
213
+ protected
214
+
215
+ # Class method to get standard GitHub token environment variable names
216
+ def self.default_token_env_vars
217
+ %w[ISSUER_API_TOKEN ISSUER_GITHUB_TOKEN GITHUB_ACCESS_TOKEN GITHUB_TOKEN]
218
+ end
219
+
220
+ # Class method to detect GitHub token from environment
221
+ # @param custom_env_var [String, nil] Custom environment variable name to check first
222
+ # @return [String, nil] The token value or nil if not found
223
+ def self.detect_github_token(custom_env_var: nil)
224
+ # Check custom env var first if provided
225
+ return ENV[custom_env_var] if custom_env_var && ENV[custom_env_var]
226
+
227
+ # Fall back to standard env vars
228
+ default_token_env_vars.each do |env_var|
229
+ token = ENV[env_var]
230
+ return token if token
231
+ end
232
+
233
+ nil
234
+ end
235
+
236
+ private
237
+
238
+ def detect_github_token
239
+ self.class.detect_github_token
240
+ end
241
+
242
+ def find_milestone proj, milestone_name
243
+ milestones = get_versions(proj)
244
+ milestones.find { |m| m.title == milestone_name.to_s }
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Issuer
4
+ unless defined?(VERSION)
5
+ # Read version from README.adoc (source repo only)
6
+ readme_path = File.join(File.dirname(__FILE__), '..', '..', 'README.adoc')
7
+
8
+ if File.exist?(readme_path)
9
+ # Parse README.adoc to find :this_prod_vrsn: attribute
10
+ File.foreach(readme_path) do |line|
11
+ if line.match(/^:this_prod_vrsn:\s*(.+)$/)
12
+ VERSION = $1.strip
13
+ break
14
+ end
15
+ end
16
+ end
17
+
18
+ # Fallback if README.adoc not found or version not found
19
+ VERSION ||= '0.0.0'
20
+ end
21
+ end