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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.vale/config/vocabularies/issuer/accept.txt +63 -0
- data/.vale/config/vocabularies/issuer/reject.txt +21 -0
- data/.vale.ini +42 -0
- data/Dockerfile +43 -0
- data/LICENSE +21 -0
- data/README.adoc +539 -0
- data/Rakefile +70 -0
- data/bin/console +0 -0
- data/bin/issuer +13 -0
- data/bin/setup +0 -0
- data/examples/README.adoc +56 -0
- data/examples/advanced-stub-example.yml +50 -0
- data/examples/basic-example.yml +33 -0
- data/examples/minimal-example.yml +9 -0
- data/examples/new-project-issues.yml +162 -0
- data/examples/validation-test.yml +8 -0
- data/exe/issuer +5 -0
- data/issuer.gemspec +43 -0
- data/lib/issuer/apis/github/client.rb +124 -0
- data/lib/issuer/cache.rb +197 -0
- data/lib/issuer/cli.rb +241 -0
- data/lib/issuer/issue.rb +393 -0
- data/lib/issuer/ops.rb +281 -0
- data/lib/issuer/sites/base.rb +109 -0
- data/lib/issuer/sites/factory.rb +31 -0
- data/lib/issuer/sites/github.rb +248 -0
- data/lib/issuer/version.rb +21 -0
- data/lib/issuer.rb +238 -0
- data/scripts/build.sh +40 -0
- data/scripts/lint-docs.sh +64 -0
- data/scripts/manage-runs.rb +175 -0
- data/scripts/pre-commit-template.sh +54 -0
- data/scripts/publish.sh +92 -0
- data/scripts/setup-vale.sh +59 -0
- data/specs/tests/README.adoc +451 -0
- data/specs/tests/check-github-connectivity.sh +130 -0
- data/specs/tests/cleanup-github-tests.sh +374 -0
- data/specs/tests/github-api/01-auth-connection.yml +21 -0
- data/specs/tests/github-api/02-basic-issues.yml +90 -0
- data/specs/tests/github-api/03-milestone-tests.yml +58 -0
- data/specs/tests/github-api/04-label-tests.yml +98 -0
- data/specs/tests/github-api/05-assignment-tests.yml +55 -0
- data/specs/tests/github-api/06-automation-tests.yml +102 -0
- data/specs/tests/github-api/07-error-tests.yml +29 -0
- data/specs/tests/github-api/08-complex-tests.yml +197 -0
- data/specs/tests/github-api/config.yml.example +17 -0
- data/specs/tests/rspec/cli_spec.rb +127 -0
- data/specs/tests/rspec/issue_spec.rb +184 -0
- data/specs/tests/rspec/issuer_spec.rb +5 -0
- data/specs/tests/rspec/ops_spec.rb +124 -0
- data/specs/tests/rspec/spec_helper.rb +54 -0
- data/specs/tests/run-github-api-tests.sh +424 -0
- 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
|