issuer 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -0
- data/lib/issuer/apis/github/client.rb +183 -3
- data/lib/issuer/cache.rb +13 -13
- data/lib/issuer/cli.rb +3 -5
- data/lib/issuer/issue.rb +95 -23
- data/lib/issuer/sites/github.rb +27 -11
- data/lib/issuer.rb +6 -6
- metadata +4 -2
@@ -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,7 +15,7 @@ module Issuer
|
|
15
15
|
class_option :proj, type: :string, desc: 'Override $meta.proj (org/repo)'
|
16
16
|
class_option :vrsn, type: :string, desc: 'Default version for all issues'
|
17
17
|
class_option :user, type: :string, desc: 'Default assignee (GitHub username)'
|
18
|
-
class_option :tags, type: :string, desc: 'Comma-separated
|
18
|
+
class_option :tags, type: :string, desc: 'Comma-separated default or appended (+) labels for all issues'
|
19
19
|
class_option :stub, type: :boolean, desc: 'Enable stub mode for all issues'
|
20
20
|
class_option :dry, type: :boolean, default: false, aliases: ['--dry-run'], desc: 'Print issues, don\'t post'
|
21
21
|
class_option :tokenv, type: :string, desc: 'Name of environment variable containing GitHub token'
|
@@ -167,12 +167,11 @@ module Issuer
|
|
167
167
|
Issue Default Options:
|
168
168
|
--vrsn VERSION #{self.class_options[:vrsn].description}
|
169
169
|
--user USERNAME #{self.class_options[:user].description}
|
170
|
-
--tags tag1
|
170
|
+
--tags tag1,+tag2 #{self.class_options[:tags].description}
|
171
171
|
--stub #{self.class_options[:stub].description}
|
172
172
|
|
173
173
|
Site Options:
|
174
174
|
--proj org/repo #{self.class_options[:proj].description}
|
175
|
-
--tokenv VAR_NAME #{self.class_options[:tokenv].description}
|
176
175
|
|
177
176
|
Mode Options:
|
178
177
|
--dry, --dry-run #{self.class_options[:dry].description}
|
@@ -184,7 +183,7 @@ module Issuer
|
|
184
183
|
|
185
184
|
Info:
|
186
185
|
-h, --help #{self.class_options[:help].description}
|
187
|
-
|
186
|
+
--version Show version
|
188
187
|
|
189
188
|
Examples:
|
190
189
|
issuer issues.yml --dry
|
@@ -196,7 +195,6 @@ module Issuer
|
|
196
195
|
|
197
196
|
Authentication:
|
198
197
|
Set GITHUB_TOKEN environment variable with your GitHub personal access token
|
199
|
-
Or use --tokenv to specify a custom environment variable name
|
200
198
|
|
201
199
|
HELP
|
202
200
|
end
|
data/lib/issuer/issue.rb
CHANGED
@@ -15,6 +15,7 @@ module Issuer
|
|
15
15
|
# +tags+:: Array of labels to apply
|
16
16
|
# +user+:: Assignee username
|
17
17
|
# +vrsn+:: Milestone/version
|
18
|
+
# +type+:: Issue type (e.g., Bug, Feature, Task)
|
18
19
|
# +stub+:: Whether to apply stub text composition
|
19
20
|
#
|
20
21
|
# == Tag Logic
|
@@ -22,6 +23,7 @@ module Issuer
|
|
22
23
|
# Tags support special prefix notation:
|
23
24
|
# * Regular tags (e.g., +"bug"+) are only applied if the issue has no existing tags
|
24
25
|
# * Append tags (e.g., +"+urgent"+) are always applied to all issues
|
26
|
+
# * Removal tags (e.g., +"-needs:docs"+) are removed from the default/appended tags list
|
25
27
|
#
|
26
28
|
# == Stub Composition
|
27
29
|
#
|
@@ -50,7 +52,7 @@ module Issuer
|
|
50
52
|
# valid_issues = Issuer::Issue.valid_issues_from_array(array_of_data, defaults)
|
51
53
|
#
|
52
54
|
class Issue
|
53
|
-
attr_reader :summ, :tags, :user, :vrsn, :raw_data
|
55
|
+
attr_reader :summ, :tags, :user, :vrsn, :type, :raw_data
|
54
56
|
attr_accessor :body, :stub
|
55
57
|
|
56
58
|
##
|
@@ -60,7 +62,12 @@ module Issuer
|
|
60
62
|
# @param defaults [Hash] Default values to apply when issue data is missing properties
|
61
63
|
#
|
62
64
|
def initialize issue_data, defaults={}
|
63
|
-
|
65
|
+
# Handle string issues (simple format where string is the summary)
|
66
|
+
if issue_data.is_a?(String)
|
67
|
+
@raw_data = { 'summ' => issue_data }
|
68
|
+
else
|
69
|
+
@raw_data = issue_data || {}
|
70
|
+
end
|
64
71
|
@defaults = defaults
|
65
72
|
|
66
73
|
# For most fields, issue data overrides defaults
|
@@ -68,6 +75,7 @@ module Issuer
|
|
68
75
|
@body = @raw_data['body'] || @raw_data['desc'] || defaults['body'] || defaults['desc'] || '' # Support both body and desc (legacy)
|
69
76
|
@user = @raw_data['user'] || defaults['user']
|
70
77
|
@vrsn = @raw_data['vrsn'] || defaults['vrsn']
|
78
|
+
@type = @raw_data['type'] || defaults['type']
|
71
79
|
@stub = @raw_data.key?('stub') ? @raw_data['stub'] : defaults['stub']
|
72
80
|
|
73
81
|
# For tags, we need special handling - combine defaults and issue tags for later processing
|
@@ -162,28 +170,32 @@ module Issuer
|
|
162
170
|
|
163
171
|
# Apply tag logic for this issue
|
164
172
|
#
|
165
|
-
# Processes existing tags with + prefix as append tags,
|
166
|
-
# CLI-provided tags, and determines final tag set based on precedence rules.
|
173
|
+
# Processes existing tags with + prefix as append tags, - prefix as removal tags,
|
174
|
+
# combines them with CLI-provided tags, and determines final tag set based on precedence rules.
|
167
175
|
#
|
168
176
|
# @param cli_append_tags [Array<String>] Tags to always append from CLI
|
169
177
|
# @param cli_default_tags [Array<String>] Default tags from CLI (used when no regular tags exist)
|
170
178
|
# @return [void] Sets @tags instance variable
|
171
179
|
#
|
172
180
|
# @example
|
173
|
-
# # Issue has tags: ['+urgent', 'bug']
|
174
|
-
# issue.apply_tag_logic(['cli-tag'], ['default-tag'])
|
175
|
-
# # Result: ['urgent', 'cli-tag', 'bug']
|
181
|
+
# # Issue has tags: ['+urgent', 'bug', '-needs:docs']
|
182
|
+
# issue.apply_tag_logic(['cli-tag'], ['default-tag', 'needs:docs'])
|
183
|
+
# # Result: ['urgent', 'cli-tag', 'bug', 'default-tag'] (needs:docs removed)
|
176
184
|
def apply_tag_logic cli_append_tags, cli_default_tags
|
177
|
-
# Parse existing tags for +
|
185
|
+
# Parse existing tags for + and - prefixes
|
178
186
|
existing_tags = tags || []
|
179
187
|
append_tags = []
|
180
188
|
regular_tags = []
|
189
|
+
remove_tags = []
|
181
190
|
|
182
191
|
existing_tags.each do |tag|
|
183
|
-
|
184
|
-
|
192
|
+
tag_str = tag.to_s
|
193
|
+
if tag_str.start_with?('+')
|
194
|
+
append_tags << tag_str[1..] # Remove + prefix
|
195
|
+
elsif tag_str.start_with?('-')
|
196
|
+
remove_tags << tag_str[1..] # Remove - prefix
|
185
197
|
else
|
186
|
-
regular_tags <<
|
198
|
+
regular_tags << tag_str
|
187
199
|
end
|
188
200
|
end
|
189
201
|
|
@@ -192,7 +204,7 @@ module Issuer
|
|
192
204
|
final_tags = append_tags + defaults_append_tags + cli_append_tags
|
193
205
|
|
194
206
|
# For regular tags, add issue's own tags, otherwise use default tags
|
195
|
-
issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') }
|
207
|
+
issue_regular_tags = Array(@raw_data['tags']).reject { |tag| tag.to_s.start_with?('+') || tag.to_s.start_with?('-') }
|
196
208
|
|
197
209
|
if !issue_regular_tags.empty?
|
198
210
|
# Issue has its own regular tags, use them
|
@@ -200,13 +212,19 @@ module Issuer
|
|
200
212
|
else
|
201
213
|
# Issue has no regular tags, use defaults from CLI
|
202
214
|
final_tags.concat(cli_default_tags)
|
203
|
-
# Also add non-append defaults tags
|
215
|
+
# Also add non-append defaults tags (- prefix ignored in defaults)
|
204
216
|
defaults_regular_tags = Array(@defaults['tags']).reject { |tag| tag.to_s.start_with?('+') }
|
205
217
|
final_tags.concat(defaults_regular_tags)
|
206
218
|
end
|
207
219
|
|
208
|
-
#
|
209
|
-
|
220
|
+
# Collect removal tags from issue only (not defaults)
|
221
|
+
all_remove_tags = remove_tags
|
222
|
+
|
223
|
+
# Remove duplicates first, then remove tags specified for removal
|
224
|
+
final_tags = final_tags.uniq - all_remove_tags
|
225
|
+
|
226
|
+
# Set the final tags
|
227
|
+
@tags = final_tags
|
210
228
|
end
|
211
229
|
|
212
230
|
# Apply stub logic for this issue
|
@@ -252,7 +270,8 @@ module Issuer
|
|
252
270
|
#
|
253
271
|
# Separates tags with + prefix (append tags) from regular tags (default tags).
|
254
272
|
# Tags with + prefix are always applied, while regular tags are only used
|
255
|
-
# when the issue has no existing regular tags.
|
273
|
+
# when the issue has no existing regular tags. Tags with - prefix are handled
|
274
|
+
# in the apply_tag_logic method for removal.
|
256
275
|
#
|
257
276
|
# @param tags_string [String] Comma-separated tag string
|
258
277
|
# @return [Array<Array<String>>] Two-element array: [append_tags, default_tags]
|
@@ -299,14 +318,14 @@ module Issuer
|
|
299
318
|
# Users cannot log in properly after the recent update.
|
300
319
|
# This affects all user accounts.
|
301
320
|
#
|
302
|
-
#
|
321
|
+
# type: Bug
|
303
322
|
# milestone: 1.0.0
|
304
323
|
# labels:
|
305
324
|
# - bug
|
306
325
|
# - urgent
|
307
326
|
# assignee: developer1
|
308
327
|
# ------
|
309
|
-
def formatted_output
|
328
|
+
def formatted_output site, repo
|
310
329
|
# Get site-specific field mappings
|
311
330
|
field_map = site.field_mappings
|
312
331
|
|
@@ -323,17 +342,22 @@ module Issuer
|
|
323
342
|
if site_params[:body] && !site_params[:body].strip.empty?
|
324
343
|
body_field = field_map[:body] || 'body'
|
325
344
|
output << "#{body_field}:"
|
326
|
-
# Indent body content
|
345
|
+
# Indent body content with proper line wrapping
|
327
346
|
body_lines = site_params[:body].strip.split("\n")
|
328
347
|
body_lines.each do |line|
|
329
|
-
|
348
|
+
wrapped_lines = wrap_line_with_indentation(line, 12)
|
349
|
+
wrapped_lines.each do |wrapped_line|
|
350
|
+
output << wrapped_line
|
351
|
+
end
|
330
352
|
end
|
331
353
|
output << "" # Empty line after body
|
332
354
|
end
|
333
355
|
|
334
|
-
#
|
335
|
-
|
336
|
-
|
356
|
+
# Type
|
357
|
+
if site_params[:type]
|
358
|
+
type_field = field_map[:type] || 'type'
|
359
|
+
output << sprintf("%-12s%s", "#{type_field}:", site_params[:type])
|
360
|
+
end
|
337
361
|
|
338
362
|
# Milestone/Version
|
339
363
|
if site_params[:milestone]
|
@@ -364,6 +388,54 @@ module Issuer
|
|
364
388
|
|
365
389
|
private
|
366
390
|
|
391
|
+
# Wrap a line with proper indentation, handling long lines that exceed terminal width
|
392
|
+
#
|
393
|
+
# @param line [String] The line to wrap
|
394
|
+
# @param indent_size [Integer] Number of spaces for indentation
|
395
|
+
# @return [Array<String>] Array of wrapped lines with proper indentation
|
396
|
+
#
|
397
|
+
# @example
|
398
|
+
# wrap_line_with_indentation("This is a very long line that needs wrapping", 4)
|
399
|
+
# # => [" This is a very long line that needs", " wrapping"]
|
400
|
+
def wrap_line_with_indentation line, indent_size
|
401
|
+
# Get terminal width, default to 80 if not available
|
402
|
+
terminal_width = ENV['COLUMNS']&.to_i || 80
|
403
|
+
|
404
|
+
# Calculate available width for content (terminal width - indentation)
|
405
|
+
available_width = terminal_width - indent_size
|
406
|
+
|
407
|
+
# If line fits within available width, just return it with indentation
|
408
|
+
if line.length <= available_width
|
409
|
+
return [' ' * indent_size + line]
|
410
|
+
end
|
411
|
+
|
412
|
+
# Split long line into chunks that fit
|
413
|
+
wrapped_lines = []
|
414
|
+
remaining_text = line
|
415
|
+
|
416
|
+
while remaining_text.length > available_width
|
417
|
+
# Find the last space before the available width limit
|
418
|
+
break_point = remaining_text.rindex(' ', available_width)
|
419
|
+
|
420
|
+
# If no space found, break at the available width (hard wrap)
|
421
|
+
break_point = available_width if break_point.nil?
|
422
|
+
|
423
|
+
# Extract the chunk and add it with proper indentation
|
424
|
+
chunk = remaining_text[0...break_point]
|
425
|
+
wrapped_lines << (' ' * indent_size + chunk)
|
426
|
+
|
427
|
+
# Remove the processed chunk from remaining text
|
428
|
+
remaining_text = remaining_text[break_point..].lstrip
|
429
|
+
end
|
430
|
+
|
431
|
+
# Add the final chunk if any text remains
|
432
|
+
if !remaining_text.empty?
|
433
|
+
wrapped_lines << (' ' * indent_size + remaining_text)
|
434
|
+
end
|
435
|
+
|
436
|
+
wrapped_lines
|
437
|
+
end
|
438
|
+
|
367
439
|
# Determine if stub logic should be applied to this issue
|
368
440
|
#
|
369
441
|
# Checks issue-level stub property first, then falls back to defaults.
|