issuer 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +150 -53
- data/examples/basic-example.yml +3 -0
- data/examples/new-project-issues.yml +3 -0
- data/examples/tag-removal-example.yml +41 -0
- data/examples/validation-test.yml +2 -0
- data/issuer.gemspec +6 -9
- data/lib/issuer/apis/github/client.rb +183 -3
- data/lib/issuer/cache.rb +13 -13
- data/lib/issuer/cli.rb +8 -10
- data/lib/issuer/issue.rb +119 -34
- data/lib/issuer/sites/base.rb +2 -1
- data/lib/issuer/sites/github.rb +56 -15
- data/lib/issuer.rb +6 -6
- metadata +4 -36
- data/.rspec +0 -3
- data/.vale/config/vocabularies/issuer/accept.txt +0 -63
- data/.vale/config/vocabularies/issuer/reject.txt +0 -21
- data/.vale.ini +0 -42
- data/Dockerfile +0 -43
- data/Rakefile +0 -70
- data/bin/console +0 -0
- data/bin/issuer +0 -13
- data/bin/setup +0 -0
- data/examples/README.adoc +0 -56
- data/scripts/build.sh +0 -40
- data/scripts/lint-docs.sh +0 -64
- data/scripts/manage-runs.rb +0 -175
- data/scripts/pre-commit-template.sh +0 -54
- data/scripts/publish.sh +0 -92
- data/scripts/setup-vale.sh +0 -59
- data/specs/tests/README.adoc +0 -451
- data/specs/tests/check-github-connectivity.sh +0 -130
- data/specs/tests/cleanup-github-tests.sh +0 -374
- data/specs/tests/github-api/01-auth-connection.yml +0 -21
- data/specs/tests/github-api/02-basic-issues.yml +0 -90
- data/specs/tests/github-api/03-milestone-tests.yml +0 -58
- data/specs/tests/github-api/04-label-tests.yml +0 -98
- data/specs/tests/github-api/05-assignment-tests.yml +0 -55
- data/specs/tests/github-api/06-automation-tests.yml +0 -102
- data/specs/tests/github-api/07-error-tests.yml +0 -29
- data/specs/tests/github-api/08-complex-tests.yml +0 -197
- data/specs/tests/github-api/config.yml.example +0 -17
- data/specs/tests/rspec/cli_spec.rb +0 -127
- data/specs/tests/rspec/issue_spec.rb +0 -184
- data/specs/tests/rspec/issuer_spec.rb +0 -5
- data/specs/tests/rspec/ops_spec.rb +0 -124
- data/specs/tests/rspec/spec_helper.rb +0 -54
- data/specs/tests/run-github-api-tests.sh +0 -424
@@ -1,6 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'octokit'
|
4
|
+
require 'ostruct'
|
5
|
+
require 'json'
|
6
|
+
require 'net/http'
|
7
|
+
require 'uri'
|
4
8
|
|
5
9
|
module Issuer
|
6
10
|
module APIs
|
@@ -24,6 +28,18 @@ module Issuer
|
|
24
28
|
raise Issuer::Error, "Issue title is required"
|
25
29
|
end
|
26
30
|
|
31
|
+
# If type is specified, use GraphQL API
|
32
|
+
if issue_params[:type]
|
33
|
+
return create_issue_with_type(repo, issue_params)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Otherwise use REST API
|
37
|
+
create_issue_rest(repo, issue_params)
|
38
|
+
rescue Octokit::Error => e
|
39
|
+
raise Issuer::Error, "GitHub API error: #{e.message}"
|
40
|
+
end
|
41
|
+
|
42
|
+
def create_issue_rest repo, issue_params
|
27
43
|
# Prepare issue creation parameters
|
28
44
|
params = {
|
29
45
|
title: issue_params[:title],
|
@@ -47,8 +63,87 @@ module Issuer
|
|
47
63
|
end
|
48
64
|
|
49
65
|
@client.create_issue(repo, params[:title], params[:body], params)
|
50
|
-
|
51
|
-
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
188
|
+
def get_run run_id
|
189
189
|
load_run_log(run_id)
|
190
190
|
end
|
191
191
|
|
192
|
-
def delete_run_log
|
192
|
+
def delete_run_log run_id
|
193
193
|
log_file = run_log_file(run_id)
|
194
194
|
File.delete(log_file) if File.exist?(log_file)
|
195
195
|
end
|
data/lib/issuer/cli.rb
CHANGED
@@ -15,9 +15,9 @@ module Issuer
|
|
15
15
|
class_option :proj, type: :string, desc: 'Override $meta.proj (org/repo)'
|
16
16
|
class_option :vrsn, type: :string, desc: 'Default version for all issues'
|
17
17
|
class_option :user, type: :string, desc: 'Default assignee (GitHub username)'
|
18
|
-
class_option :tags, type: :string, desc: 'Comma-separated
|
18
|
+
class_option :tags, type: :string, desc: 'Comma-separated default or appended (+) labels for all issues'
|
19
19
|
class_option :stub, type: :boolean, desc: 'Enable stub mode for all issues'
|
20
|
-
class_option :dry, type: :boolean, default: false, desc: 'Print issues, don\'t post'
|
20
|
+
class_option :dry, type: :boolean, default: false, aliases: ['--dry-run'], desc: 'Print issues, don\'t post'
|
21
21
|
class_option :tokenv, type: :string, desc: 'Name of environment variable containing GitHub token'
|
22
22
|
|
23
23
|
# Resource automation options
|
@@ -127,8 +127,8 @@ module Issuer
|
|
127
127
|
site_options[:token_env_var] = options[:tokenv] if options[:tokenv]
|
128
128
|
site = Issuer::Sites::Factory.create('github', **site_options)
|
129
129
|
automation_options = {
|
130
|
-
auto_versions: options[:auto_versions] || options[:auto_metadata],
|
131
|
-
auto_tags: options[:auto_tags] || options[:auto_metadata]
|
130
|
+
auto_versions: !!options[:auto_versions] || !!options[:auto_metadata],
|
131
|
+
auto_tags: !!options[:auto_tags] || !!options[:auto_metadata]
|
132
132
|
}
|
133
133
|
|
134
134
|
# Start run tracking for live operations
|
@@ -167,15 +167,14 @@ module Issuer
|
|
167
167
|
Issue Default Options:
|
168
168
|
--vrsn VERSION #{self.class_options[:vrsn].description}
|
169
169
|
--user USERNAME #{self.class_options[:user].description}
|
170
|
-
--tags tag1
|
170
|
+
--tags tag1,+tag2 #{self.class_options[:tags].description}
|
171
171
|
--stub #{self.class_options[:stub].description}
|
172
172
|
|
173
173
|
Site Options:
|
174
174
|
--proj org/repo #{self.class_options[:proj].description}
|
175
|
-
--tokenv VAR_NAME #{self.class_options[:tokenv].description}
|
176
175
|
|
177
176
|
Mode Options:
|
178
|
-
--dry
|
177
|
+
--dry, --dry-run #{self.class_options[:dry].description}
|
179
178
|
--auto-versions #{self.class_options[:auto_versions].description}
|
180
179
|
--auto-milestones (alias for --auto-versions)
|
181
180
|
--auto-tags #{self.class_options[:auto_tags].description}
|
@@ -184,7 +183,7 @@ module Issuer
|
|
184
183
|
|
185
184
|
Info:
|
186
185
|
-h, --help #{self.class_options[:help].description}
|
187
|
-
|
186
|
+
--version Show version
|
188
187
|
|
189
188
|
Examples:
|
190
189
|
issuer issues.yml --dry
|
@@ -196,7 +195,6 @@ module Issuer
|
|
196
195
|
|
197
196
|
Authentication:
|
198
197
|
Set GITHUB_TOKEN environment variable with your GitHub personal access token
|
199
|
-
Or use --tokenv to specify a custom environment variable name
|
200
198
|
|
201
199
|
HELP
|
202
200
|
end
|
@@ -228,7 +226,7 @@ module Issuer
|
|
228
226
|
puts "Would process #{valid_count} issues, skip #{invalid_count}"
|
229
227
|
else
|
230
228
|
puts "\n✅ Completed: #{valid_count} issues processed, #{invalid_count} skipped"
|
231
|
-
|
229
|
+
# Note: Run ID is already displayed in the main flow, no need to repeat it here
|
232
230
|
end
|
233
231
|
|
234
232
|
end
|
data/lib/issuer/issue.rb
CHANGED
@@ -15,6 +15,7 @@ module Issuer
|
|
15
15
|
# +tags+:: Array of labels to apply
|
16
16
|
# +user+:: Assignee username
|
17
17
|
# +vrsn+:: Milestone/version
|
18
|
+
# +type+:: Issue type (e.g., Bug, Feature, Task)
|
18
19
|
# +stub+:: Whether to apply stub text composition
|
19
20
|
#
|
20
21
|
# == Tag Logic
|
@@ -22,6 +23,7 @@ module Issuer
|
|
22
23
|
# Tags support special prefix notation:
|
23
24
|
# * Regular tags (e.g., +"bug"+) are only applied if the issue has no existing tags
|
24
25
|
# * Append tags (e.g., +"+urgent"+) are always applied to all issues
|
26
|
+
# * Removal tags (e.g., +"-needs:docs"+) are removed from the default/appended tags list
|
25
27
|
#
|
26
28
|
# == Stub Composition
|
27
29
|
#
|
@@ -50,7 +52,7 @@ module Issuer
|
|
50
52
|
# valid_issues = Issuer::Issue.valid_issues_from_array(array_of_data, defaults)
|
51
53
|
#
|
52
54
|
class Issue
|
53
|
-
attr_reader :summ, :tags, :user, :vrsn, :raw_data
|
55
|
+
attr_reader :summ, :tags, :user, :vrsn, :type, :raw_data
|
54
56
|
attr_accessor :body, :stub
|
55
57
|
|
56
58
|
##
|
@@ -60,15 +62,26 @@ module Issuer
|
|
60
62
|
# @param defaults [Hash] Default values to apply when issue data is missing properties
|
61
63
|
#
|
62
64
|
def initialize issue_data, defaults={}
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
@
|
70
|
-
|
71
|
-
|
65
|
+
# Handle string issues (simple format where string is the summary)
|
66
|
+
if issue_data.is_a?(String)
|
67
|
+
@raw_data = { 'summ' => issue_data }
|
68
|
+
else
|
69
|
+
@raw_data = issue_data || {}
|
70
|
+
end
|
71
|
+
@defaults = defaults
|
72
|
+
|
73
|
+
# For most fields, issue data overrides defaults
|
74
|
+
@summ = @raw_data['summ'] || defaults['summ']
|
75
|
+
@body = @raw_data['body'] || @raw_data['desc'] || defaults['body'] || defaults['desc'] || '' # Support both body and desc (legacy)
|
76
|
+
@user = @raw_data['user'] || defaults['user']
|
77
|
+
@vrsn = @raw_data['vrsn'] || defaults['vrsn']
|
78
|
+
@type = @raw_data['type'] || defaults['type']
|
79
|
+
@stub = @raw_data.key?('stub') ? @raw_data['stub'] : defaults['stub']
|
80
|
+
|
81
|
+
# For tags, we need special handling - combine defaults and issue tags for later processing
|
82
|
+
defaults_tags = Array(defaults['tags'])
|
83
|
+
issue_tags = Array(@raw_data['tags'])
|
84
|
+
@tags = defaults_tags + issue_tags
|
72
85
|
end
|
73
86
|
|
74
87
|
##
|
@@ -157,43 +170,61 @@ module Issuer
|
|
157
170
|
|
158
171
|
# Apply tag logic for this issue
|
159
172
|
#
|
160
|
-
# Processes existing tags with + prefix as append tags,
|
161
|
-
# CLI-provided tags, and determines final tag set based on precedence rules.
|
173
|
+
# Processes existing tags with + prefix as append tags, - prefix as removal tags,
|
174
|
+
# combines them with CLI-provided tags, and determines final tag set based on precedence rules.
|
162
175
|
#
|
163
176
|
# @param cli_append_tags [Array<String>] Tags to always append from CLI
|
164
177
|
# @param cli_default_tags [Array<String>] Default tags from CLI (used when no regular tags exist)
|
165
178
|
# @return [void] Sets @tags instance variable
|
166
179
|
#
|
167
180
|
# @example
|
168
|
-
# # Issue has tags: ['+urgent', 'bug']
|
169
|
-
# issue.apply_tag_logic(['cli-tag'], ['default-tag'])
|
170
|
-
# # Result: ['urgent', 'cli-tag', 'bug']
|
181
|
+
# # Issue has tags: ['+urgent', 'bug', '-needs:docs']
|
182
|
+
# issue.apply_tag_logic(['cli-tag'], ['default-tag', 'needs:docs'])
|
183
|
+
# # Result: ['urgent', 'cli-tag', 'bug', 'default-tag'] (needs:docs removed)
|
171
184
|
def apply_tag_logic cli_append_tags, cli_default_tags
|
172
|
-
# Parse existing tags for +
|
185
|
+
# Parse existing tags for + and - prefixes
|
173
186
|
existing_tags = tags || []
|
174
187
|
append_tags = []
|
175
188
|
regular_tags = []
|
189
|
+
remove_tags = []
|
176
190
|
|
177
191
|
existing_tags.each do |tag|
|
178
|
-
|
179
|
-
|
192
|
+
tag_str = tag.to_s
|
193
|
+
if tag_str.start_with?('+')
|
194
|
+
append_tags << tag_str[1..] # Remove + prefix
|
195
|
+
elsif tag_str.start_with?('-')
|
196
|
+
remove_tags << tag_str[1..] # Remove - prefix
|
180
197
|
else
|
181
|
-
regular_tags <<
|
198
|
+
regular_tags << tag_str
|
182
199
|
end
|
183
200
|
end
|
184
201
|
|
185
|
-
# Start with append tags (always applied)
|
186
|
-
|
202
|
+
# Start with append tags from both defaults and CLI (always applied)
|
203
|
+
defaults_append_tags = Array(@defaults['tags']).select { |tag| tag.to_s.start_with?('+') }.map { |tag| tag[1..] }
|
204
|
+
final_tags = append_tags + defaults_append_tags + cli_append_tags
|
205
|
+
|
206
|
+
# For regular tags, add issue's own tags, otherwise use default tags
|
207
|
+
issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') || tag.to_s.start_with?('-') }
|
187
208
|
|
188
|
-
|
189
|
-
|
190
|
-
final_tags.concat(
|
209
|
+
if !issue_regular_tags.empty?
|
210
|
+
# Issue has its own regular tags, use them
|
211
|
+
final_tags.concat(issue_regular_tags)
|
191
212
|
else
|
213
|
+
# Issue has no regular tags, use defaults from CLI
|
192
214
|
final_tags.concat(cli_default_tags)
|
215
|
+
# Also add non-append defaults tags (- prefix ignored in defaults)
|
216
|
+
defaults_regular_tags = Array(@defaults['tags']).reject { |tag| tag.to_s.start_with?('+') }
|
217
|
+
final_tags.concat(defaults_regular_tags)
|
193
218
|
end
|
194
219
|
|
195
|
-
#
|
196
|
-
|
220
|
+
# Collect removal tags from issue only (not defaults)
|
221
|
+
all_remove_tags = remove_tags
|
222
|
+
|
223
|
+
# Remove duplicates first, then remove tags specified for removal
|
224
|
+
final_tags = final_tags.uniq - all_remove_tags
|
225
|
+
|
226
|
+
# Set the final tags
|
227
|
+
@tags = final_tags
|
197
228
|
end
|
198
229
|
|
199
230
|
# Apply stub logic for this issue
|
@@ -239,7 +270,8 @@ module Issuer
|
|
239
270
|
#
|
240
271
|
# Separates tags with + prefix (append tags) from regular tags (default tags).
|
241
272
|
# Tags with + prefix are always applied, while regular tags are only used
|
242
|
-
# when the issue has no existing regular tags.
|
273
|
+
# when the issue has no existing regular tags. Tags with - prefix are handled
|
274
|
+
# in the apply_tag_logic method for removal.
|
243
275
|
#
|
244
276
|
# @param tags_string [String] Comma-separated tag string
|
245
277
|
# @return [Array<Array<String>>] Two-element array: [append_tags, default_tags]
|
@@ -286,14 +318,14 @@ module Issuer
|
|
286
318
|
# Users cannot log in properly after the recent update.
|
287
319
|
# This affects all user accounts.
|
288
320
|
#
|
289
|
-
#
|
321
|
+
# type: Bug
|
290
322
|
# milestone: 1.0.0
|
291
323
|
# labels:
|
292
324
|
# - bug
|
293
325
|
# - urgent
|
294
326
|
# assignee: developer1
|
295
327
|
# ------
|
296
|
-
def formatted_output
|
328
|
+
def formatted_output site, repo
|
297
329
|
# Get site-specific field mappings
|
298
330
|
field_map = site.field_mappings
|
299
331
|
|
@@ -310,17 +342,22 @@ module Issuer
|
|
310
342
|
if site_params[:body] && !site_params[:body].strip.empty?
|
311
343
|
body_field = field_map[:body] || 'body'
|
312
344
|
output << "#{body_field}:"
|
313
|
-
# Indent body content
|
345
|
+
# Indent body content with proper line wrapping
|
314
346
|
body_lines = site_params[:body].strip.split("\n")
|
315
347
|
body_lines.each do |line|
|
316
|
-
|
348
|
+
wrapped_lines = wrap_line_with_indentation(line, 12)
|
349
|
+
wrapped_lines.each do |wrapped_line|
|
350
|
+
output << wrapped_line
|
351
|
+
end
|
317
352
|
end
|
318
353
|
output << "" # Empty line after body
|
319
354
|
end
|
320
355
|
|
321
|
-
#
|
322
|
-
|
323
|
-
|
356
|
+
# Type
|
357
|
+
if site_params[:type]
|
358
|
+
type_field = field_map[:type] || 'type'
|
359
|
+
output << sprintf("%-12s%s", "#{type_field}:", site_params[:type])
|
360
|
+
end
|
324
361
|
|
325
362
|
# Milestone/Version
|
326
363
|
if site_params[:milestone]
|
@@ -351,6 +388,54 @@ module Issuer
|
|
351
388
|
|
352
389
|
private
|
353
390
|
|
391
|
+
# Wrap a line with proper indentation, handling long lines that exceed terminal width
|
392
|
+
#
|
393
|
+
# @param line [String] The line to wrap
|
394
|
+
# @param indent_size [Integer] Number of spaces for indentation
|
395
|
+
# @return [Array<String>] Array of wrapped lines with proper indentation
|
396
|
+
#
|
397
|
+
# @example
|
398
|
+
# wrap_line_with_indentation("This is a very long line that needs wrapping", 4)
|
399
|
+
# # => [" This is a very long line that needs", " wrapping"]
|
400
|
+
def wrap_line_with_indentation line, indent_size
|
401
|
+
# Get terminal width, default to 80 if not available
|
402
|
+
terminal_width = ENV['COLUMNS']&.to_i || 80
|
403
|
+
|
404
|
+
# Calculate available width for content (terminal width - indentation)
|
405
|
+
available_width = terminal_width - indent_size
|
406
|
+
|
407
|
+
# If line fits within available width, just return it with indentation
|
408
|
+
if line.length <= available_width
|
409
|
+
return [' ' * indent_size + line]
|
410
|
+
end
|
411
|
+
|
412
|
+
# Split long line into chunks that fit
|
413
|
+
wrapped_lines = []
|
414
|
+
remaining_text = line
|
415
|
+
|
416
|
+
while remaining_text.length > available_width
|
417
|
+
# Find the last space before the available width limit
|
418
|
+
break_point = remaining_text.rindex(' ', available_width)
|
419
|
+
|
420
|
+
# If no space found, break at the available width (hard wrap)
|
421
|
+
break_point = available_width if break_point.nil?
|
422
|
+
|
423
|
+
# Extract the chunk and add it with proper indentation
|
424
|
+
chunk = remaining_text[0...break_point]
|
425
|
+
wrapped_lines << (' ' * indent_size + chunk)
|
426
|
+
|
427
|
+
# Remove the processed chunk from remaining text
|
428
|
+
remaining_text = remaining_text[break_point..].lstrip
|
429
|
+
end
|
430
|
+
|
431
|
+
# Add the final chunk if any text remains
|
432
|
+
if !remaining_text.empty?
|
433
|
+
wrapped_lines << (' ' * indent_size + remaining_text)
|
434
|
+
end
|
435
|
+
|
436
|
+
wrapped_lines
|
437
|
+
end
|
438
|
+
|
354
439
|
# Determine if stub logic should be applied to this issue
|
355
440
|
#
|
356
441
|
# Checks issue-level stub property first, then falls back to defaults.
|
data/lib/issuer/sites/base.rb
CHANGED
@@ -67,7 +67,8 @@ module Issuer
|
|
67
67
|
issues.each do |issue|
|
68
68
|
begin
|
69
69
|
# Convert IMYML issue to site-specific parameters
|
70
|
-
|
70
|
+
# Pass run_id to enable post-validation milestone lookup
|
71
|
+
site_params = convert_issue_to_site_params(issue, proj, dry_run: false, post_validation: true)
|
71
72
|
result = create_issue(proj, site_params)
|
72
73
|
|
73
74
|
# Extract the created issue object (for backwards compatibility)
|