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