carson 3.22.1 → 3.23.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/API.md +15 -11
- data/MANUAL.md +32 -38
- data/README.md +6 -9
- data/RELEASE.md +23 -0
- data/VERSION +1 -1
- data/carson.gemspec +1 -0
- data/lib/carson/adapters/agent.rb +2 -2
- data/lib/carson/branch.rb +38 -0
- data/lib/carson/cli.rb +13 -14
- data/lib/carson/config.rb +34 -18
- data/lib/carson/delivery.rb +64 -0
- data/lib/carson/ledger.rb +305 -0
- data/lib/carson/repository.rb +47 -0
- data/lib/carson/revision.rb +30 -0
- data/lib/carson/runtime/audit.rb +6 -6
- data/lib/carson/runtime/deliver.rb +112 -169
- data/lib/carson/runtime/govern.rb +213 -399
- data/lib/carson/runtime/housekeep.rb +4 -4
- data/lib/carson/runtime/local/onboard.rb +5 -5
- data/lib/carson/runtime/local/prune.rb +4 -4
- data/lib/carson/runtime/local/template.rb +4 -4
- data/lib/carson/runtime/review/gate_support.rb +14 -12
- data/lib/carson/runtime/review/sweep_support.rb +2 -2
- data/lib/carson/runtime/review/utility.rb +1 -1
- data/lib/carson/runtime/setup.rb +10 -27
- data/lib/carson/runtime/status.rb +87 -226
- data/lib/carson/runtime.rb +25 -2
- data/lib/carson/worktree.rb +5 -5
- data/lib/carson.rb +5 -0
- metadata +27 -2
|
@@ -67,8 +67,8 @@ module Carson
|
|
|
67
67
|
results << entry
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
succeeded = results.count {
|
|
71
|
-
failed = results.count {
|
|
70
|
+
succeeded = results.count { |entry| entry[ :status ] == "ok" }
|
|
71
|
+
failed = results.count { |entry| entry[ :status ] != "ok" }
|
|
72
72
|
result = { command: "housekeep", status: failed.zero? ? "ok" : "partial", repos: results, succeeded: succeeded, failed: failed }
|
|
73
73
|
housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
|
|
74
74
|
end
|
|
@@ -90,10 +90,10 @@ module Carson
|
|
|
90
90
|
main_root = main_worktree_root
|
|
91
91
|
items = []
|
|
92
92
|
|
|
93
|
-
agent_prefixes = Worktree::AGENT_DIRS.
|
|
93
|
+
agent_prefixes = Worktree::AGENT_DIRS.map do |dir|
|
|
94
94
|
full = File.join( main_root, dir, "worktrees" )
|
|
95
95
|
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
96
|
-
end
|
|
96
|
+
end.compact
|
|
97
97
|
|
|
98
98
|
worktree_list.each do |worktree|
|
|
99
99
|
next if worktree.path == main_root
|
|
@@ -21,7 +21,7 @@ module Carson
|
|
|
21
21
|
puts_line "Onboarding #{repo_name}..."
|
|
22
22
|
|
|
23
23
|
if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
|
|
24
|
-
if
|
|
24
|
+
if input_stream.respond_to?( :tty? ) && input_stream.tty?
|
|
25
25
|
setup_status = setup!
|
|
26
26
|
return setup_status unless setup_status == EXIT_OK
|
|
27
27
|
else
|
|
@@ -48,7 +48,7 @@ module Carson
|
|
|
48
48
|
hook_status = prepare!
|
|
49
49
|
return hook_status unless hook_status == EXIT_OK
|
|
50
50
|
|
|
51
|
-
drift_count = template_results.count {
|
|
51
|
+
drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
52
52
|
stale_count = template_superseded_present.count
|
|
53
53
|
template_status = template_apply!
|
|
54
54
|
return template_status unless template_status == EXIT_OK
|
|
@@ -69,7 +69,7 @@ module Carson
|
|
|
69
69
|
return hook_status unless hook_status == EXIT_OK
|
|
70
70
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
71
71
|
|
|
72
|
-
template_drift_count = template_results.count {
|
|
72
|
+
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
73
73
|
stale_count = template_superseded_present.count
|
|
74
74
|
template_status = with_captured_output { template_apply! }
|
|
75
75
|
return template_status unless template_status == EXIT_OK
|
|
@@ -202,7 +202,7 @@ module Carson
|
|
|
202
202
|
puts_line "#{repo_root} is not a git repository."
|
|
203
203
|
return EXIT_ERROR
|
|
204
204
|
end
|
|
205
|
-
if
|
|
205
|
+
if input_stream.respond_to?( :tty? ) && input_stream.tty?
|
|
206
206
|
puts_line ""
|
|
207
207
|
puts_line "This will remove Carson hooks, managed .github/ files,"
|
|
208
208
|
puts_line "and deregister this repository from portfolio governance."
|
|
@@ -251,7 +251,7 @@ module Carson
|
|
|
251
251
|
return hook_status unless hook_status == EXIT_OK
|
|
252
252
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
253
253
|
|
|
254
|
-
template_drift_count = template_results.count {
|
|
254
|
+
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
255
255
|
template_status = with_captured_output { template_apply! }
|
|
256
256
|
return template_status unless template_status == EXIT_OK
|
|
257
257
|
if template_drift_count.positive?
|
|
@@ -278,7 +278,7 @@ module Carson
|
|
|
278
278
|
|
|
279
279
|
# Detects local branches with no upstream tracking ref — candidates for orphan pruning.
|
|
280
280
|
def orphan_local_branches( active_branch:, cwd_branch: nil )
|
|
281
|
-
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.
|
|
281
|
+
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.map do |line|
|
|
282
282
|
branch, upstream = line.strip.split( "\t", 2 )
|
|
283
283
|
branch = branch.to_s.strip
|
|
284
284
|
upstream = upstream.to_s.strip
|
|
@@ -290,14 +290,14 @@ module Carson
|
|
|
290
290
|
next if branch == TEMPLATE_SYNC_BRANCH
|
|
291
291
|
|
|
292
292
|
branch
|
|
293
|
-
end
|
|
293
|
+
end.compact
|
|
294
294
|
end
|
|
295
295
|
|
|
296
296
|
# Detects local branches whose upstream still exists but whose content is already on main.
|
|
297
297
|
# Two-step evidence: (1) find the merge-base, (2) verify every file the branch changed
|
|
298
298
|
# relative to the merge-base has identical content on main.
|
|
299
299
|
def absorbed_local_branches( active_branch:, cwd_branch: nil )
|
|
300
|
-
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.
|
|
300
|
+
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
|
|
301
301
|
branch, upstream, track = line.strip.split( "\t", 3 )
|
|
302
302
|
branch = branch.to_s.strip
|
|
303
303
|
upstream = upstream.to_s.strip
|
|
@@ -313,7 +313,7 @@ module Carson
|
|
|
313
313
|
next unless branch_absorbed_into_main?( branch: branch )
|
|
314
314
|
|
|
315
315
|
{ branch: branch, upstream: upstream }
|
|
316
|
-
end
|
|
316
|
+
end.compact
|
|
317
317
|
end
|
|
318
318
|
|
|
319
319
|
# Returns true when the branch has no unique content relative to main.
|
|
@@ -28,8 +28,8 @@ module Carson
|
|
|
28
28
|
puts_verbose "[Template Sync Check]"
|
|
29
29
|
results = template_results
|
|
30
30
|
stale = template_superseded_present
|
|
31
|
-
drift_count = results.count {
|
|
32
|
-
error_count = results.count {
|
|
31
|
+
drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
|
|
32
|
+
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
33
33
|
stale_count = stale.count
|
|
34
34
|
results.each do |entry|
|
|
35
35
|
puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
|
|
@@ -42,7 +42,7 @@ module Carson
|
|
|
42
42
|
summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
|
|
43
43
|
summary_parts << "#{stale_count} stale" if stale_count.positive?
|
|
44
44
|
puts_line "Templates: #{summary_parts.join( ", " )}"
|
|
45
|
-
results.select {
|
|
45
|
+
results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
|
|
46
46
|
stale.each { |file| puts_line " #{file} — superseded" }
|
|
47
47
|
else
|
|
48
48
|
puts_line "Templates: #{results.count} files in sync"
|
|
@@ -137,7 +137,7 @@ module Carson
|
|
|
137
137
|
removed += 1
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
error_count = results.count {
|
|
140
|
+
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
141
141
|
puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
|
|
142
142
|
unless verbose?
|
|
143
143
|
if applied.positive? || removed.positive?
|
|
@@ -98,7 +98,9 @@ module Carson
|
|
|
98
98
|
unresolved_threads = unresolved_thread_entries( details: details )
|
|
99
99
|
actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
|
|
100
100
|
acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
|
|
101
|
-
unacknowledged_actionable = actionable_top_level.reject
|
|
101
|
+
unacknowledged_actionable = actionable_top_level.reject do |item|
|
|
102
|
+
acknowledged_by_disposition?( item: item, acknowledgements: acknowledgements )
|
|
103
|
+
end
|
|
102
104
|
{
|
|
103
105
|
latest_activity: latest_review_activity( details: details ),
|
|
104
106
|
unresolved_threads: unresolved_threads,
|
|
@@ -112,8 +114,8 @@ module Carson
|
|
|
112
114
|
def review_gate_signature( snapshot: )
|
|
113
115
|
{
|
|
114
116
|
latest_activity: snapshot.fetch( :latest_activity ).to_s,
|
|
115
|
-
unresolved_urls: snapshot.fetch( :unresolved_threads ).map {
|
|
116
|
-
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map {
|
|
117
|
+
unresolved_urls: snapshot.fetch( :unresolved_threads ).map { |entry| entry.fetch( :url ) }.sort,
|
|
118
|
+
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { |entry| entry.fetch( :url ) }.sort
|
|
117
119
|
}
|
|
118
120
|
end
|
|
119
121
|
|
|
@@ -138,7 +140,7 @@ module Carson
|
|
|
138
140
|
# Normalise both sides by stripping the [bot] suffix for a consistent match.
|
|
139
141
|
def bot_username?( author: )
|
|
140
142
|
normalised = author.to_s.downcase.delete_suffix( "[bot]" )
|
|
141
|
-
config.review_bot_usernames.any? {
|
|
143
|
+
config.review_bot_usernames.any? { |username| username.downcase.delete_suffix( "[bot]" ) == normalised }
|
|
142
144
|
end
|
|
143
145
|
|
|
144
146
|
def unresolved_thread_entries( details: )
|
|
@@ -149,7 +151,7 @@ module Carson
|
|
|
149
151
|
comments = thread.fetch( :comments )
|
|
150
152
|
first_comment = comments.first || {}
|
|
151
153
|
next if bot_username?( author: first_comment.fetch( :author, "" ) )
|
|
152
|
-
latest_time = comments.map {
|
|
154
|
+
latest_time = comments.map { |comment| comment.fetch( :created_at ) }.max.to_s
|
|
153
155
|
{
|
|
154
156
|
url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
|
|
155
157
|
author: first_comment.fetch( :author, "" ),
|
|
@@ -201,7 +203,7 @@ module Carson
|
|
|
201
203
|
sources = []
|
|
202
204
|
sources.concat( Array( details.fetch( :comments ) ) )
|
|
203
205
|
sources.concat( Array( details.fetch( :reviews ) ) )
|
|
204
|
-
sources.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
206
|
+
sources.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) } )
|
|
205
207
|
sources.map do |entry|
|
|
206
208
|
next unless entry.fetch( :author, "" ) == pr_author
|
|
207
209
|
body = entry.fetch( :body, "" ).to_s
|
|
@@ -222,7 +224,7 @@ module Carson
|
|
|
222
224
|
# True when any disposition acknowledgement references the specific finding URL.
|
|
223
225
|
def acknowledged_by_disposition?( item:, acknowledgements: )
|
|
224
226
|
acknowledgements.any? do |ack|
|
|
225
|
-
Array( ack.fetch( :target_urls ) ).any? {
|
|
227
|
+
Array( ack.fetch( :target_urls ) ).any? { |target_url| target_url == item.fetch( :url ) }
|
|
226
228
|
end
|
|
227
229
|
end
|
|
228
230
|
|
|
@@ -230,10 +232,10 @@ module Carson
|
|
|
230
232
|
def latest_review_activity( details: )
|
|
231
233
|
timestamps = []
|
|
232
234
|
timestamps << details.fetch( :updated_at )
|
|
233
|
-
timestamps.concat( Array( details.fetch( :comments ) ).map {
|
|
234
|
-
timestamps.concat( Array( details.fetch( :reviews ) ).map {
|
|
235
|
-
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
236
|
-
timestamps.map { parse_time_or_nil( text:
|
|
235
|
+
timestamps.concat( Array( details.fetch( :comments ) ).map { |comment| comment.fetch( :created_at ) } )
|
|
236
|
+
timestamps.concat( Array( details.fetch( :reviews ) ).map { |review| review.fetch( :created_at ) } )
|
|
237
|
+
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.map { |comment| comment.fetch( :created_at ) } )
|
|
238
|
+
timestamps.map { |timestamp| parse_time_or_nil( text: timestamp ) }.compact.max&.utc&.iso8601
|
|
237
239
|
end
|
|
238
240
|
|
|
239
241
|
def resolved_review_gate_pr_summary( owner:, repo:, pr_number:, pr_summary: )
|
|
@@ -339,7 +341,7 @@ module Carson
|
|
|
339
341
|
if report.fetch( :block_reasons ).empty?
|
|
340
342
|
lines << "- none"
|
|
341
343
|
else
|
|
342
|
-
report.fetch( :block_reasons ).each { lines << "- #{
|
|
344
|
+
report.fetch( :block_reasons ).each { |reason| lines << "- #{reason}" }
|
|
343
345
|
end
|
|
344
346
|
lines << ""
|
|
345
347
|
lines << "## Unresolved Threads"
|
|
@@ -60,7 +60,7 @@ module Carson
|
|
|
60
60
|
)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
Array( details.fetch( :review_threads ) ).flat_map {
|
|
63
|
+
Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.each do |comment|
|
|
64
64
|
next if comment.fetch( :author ) == pr_author
|
|
65
65
|
next if bot_username?( author: comment.fetch( :author ) )
|
|
66
66
|
hits = matched_risk_keywords( text: comment.fetch( :body ) )
|
|
@@ -153,7 +153,7 @@ module Carson
|
|
|
153
153
|
stdout_text, stderr_text, success, = gh_run( "issue", "list", "--repo", repo_slug, "--state", "all", "--limit", "100", "--json", "number,title,state,url,labels" )
|
|
154
154
|
raise gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to list issues for review sweep" ) unless success
|
|
155
155
|
issues = Array( JSON.parse( stdout_text ) )
|
|
156
|
-
node = issues.find {
|
|
156
|
+
node = issues.find { |issue| issue[ "title" ].to_s == config.review_tracking_issue_title }
|
|
157
157
|
return nil if node.nil?
|
|
158
158
|
{
|
|
159
159
|
number: node[ "number" ],
|
|
@@ -25,7 +25,7 @@ module Carson
|
|
|
25
25
|
|
|
26
26
|
# GitHub URL extraction for mapping disposition acknowledgements to finding URLs.
|
|
27
27
|
def extract_github_urls( text: )
|
|
28
|
-
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map {
|
|
28
|
+
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map { |url| url.sub( /[.,;:]+$/, "" ) }.uniq
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Parse RFC3339 timestamps and return nil on blank/invalid values.
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Carson
|
|
|
21
21
|
return write_setup_config( choices: cli_choices )
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
if
|
|
24
|
+
if input_stream.respond_to?( :tty? ) && input_stream.tty?
|
|
25
25
|
interactive_setup!
|
|
26
26
|
else
|
|
27
27
|
silent_setup!
|
|
@@ -42,9 +42,6 @@ module Carson
|
|
|
42
42
|
workflow_choice = prompt_workflow_style
|
|
43
43
|
choices[ "workflow.style" ] = workflow_choice unless workflow_choice.nil?
|
|
44
44
|
|
|
45
|
-
merge_choice = prompt_merge_method
|
|
46
|
-
choices[ "govern.merge.method" ] = merge_choice unless merge_choice.nil?
|
|
47
|
-
|
|
48
45
|
canonical_choice = prompt_canonical_lint_policy
|
|
49
46
|
choices[ "lint.canonical" ] = canonical_choice unless canonical_choice.nil?
|
|
50
47
|
|
|
@@ -67,7 +64,7 @@ module Carson
|
|
|
67
64
|
duplicates = duplicate_remote_groups( remotes: remotes )
|
|
68
65
|
unless duplicates.empty?
|
|
69
66
|
duplicates.each_value do |group|
|
|
70
|
-
names = group.map {
|
|
67
|
+
names = group.map { |entry| entry.fetch( :name ) }.join( " and " )
|
|
71
68
|
puts_verbose "duplicate_remotes: #{names} share the same URL"
|
|
72
69
|
end
|
|
73
70
|
end
|
|
@@ -91,10 +88,10 @@ module Carson
|
|
|
91
88
|
end
|
|
92
89
|
|
|
93
90
|
duplicates = duplicate_remote_groups( remotes: remotes )
|
|
94
|
-
duplicate_names = duplicates.values.flatten.map {
|
|
91
|
+
duplicate_names = duplicates.values.flatten.map { |entry| entry.fetch( :name ) }.to_set
|
|
95
92
|
unless duplicates.empty?
|
|
96
93
|
duplicates.each_value do |group|
|
|
97
|
-
names = group.map {
|
|
94
|
+
names = group.map { |entry| entry.fetch( :name ) }.join( " and " )
|
|
98
95
|
puts_line "Remotes #{names} share the same URL. Consider removing the duplicate."
|
|
99
96
|
end
|
|
100
97
|
end
|
|
@@ -139,21 +136,7 @@ module Carson
|
|
|
139
136
|
{ label: "branch — enforce PR-only merges (default)", value: "branch" },
|
|
140
137
|
{ label: "trunk — commit directly to main", value: "trunk" }
|
|
141
138
|
]
|
|
142
|
-
default_index = options.index {
|
|
143
|
-
prompt_choice( options: options, default: default_index )
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def prompt_merge_method
|
|
147
|
-
puts_line ""
|
|
148
|
-
puts_line "Merge method"
|
|
149
|
-
current = config.govern_merge_method
|
|
150
|
-
puts_line " Currently: #{current}" unless current.nil? || current.empty?
|
|
151
|
-
options = [
|
|
152
|
-
{ label: "squash — one commit per PR (recommended)", value: "squash" },
|
|
153
|
-
{ label: "rebase — linear history, individual commits", value: "rebase" },
|
|
154
|
-
{ label: "merge — merge commits", value: "merge" }
|
|
155
|
-
]
|
|
156
|
-
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
139
|
+
default_index = options.index { |option| option.fetch( :value ) == current } || 0
|
|
157
140
|
prompt_choice( options: options, default: default_index )
|
|
158
141
|
end
|
|
159
142
|
|
|
@@ -177,7 +160,7 @@ module Carson
|
|
|
177
160
|
end
|
|
178
161
|
output.print "#{BADGE} Choice [#{default + 1}]: "
|
|
179
162
|
output.flush
|
|
180
|
-
raw =
|
|
163
|
+
raw = input_stream.gets
|
|
181
164
|
return options[ default ].fetch( :value ) if raw.nil?
|
|
182
165
|
|
|
183
166
|
input = raw.to_s.strip
|
|
@@ -194,7 +177,7 @@ module Carson
|
|
|
194
177
|
def prompt_custom_value( label: )
|
|
195
178
|
output.print "#{BADGE} #{label}: "
|
|
196
179
|
output.flush
|
|
197
|
-
raw =
|
|
180
|
+
raw = input_stream.gets
|
|
198
181
|
return nil if raw.nil?
|
|
199
182
|
|
|
200
183
|
value = raw.to_s.strip
|
|
@@ -221,7 +204,7 @@ module Carson
|
|
|
221
204
|
others << entry
|
|
222
205
|
end
|
|
223
206
|
end
|
|
224
|
-
well_known.sort_by { WELL_KNOWN_REMOTES.index(
|
|
207
|
+
well_known.sort_by { |entry| WELL_KNOWN_REMOTES.index( entry.fetch( :name ) ) || 999 } + others.sort_by { |entry| entry.fetch( :name ) }
|
|
225
208
|
end
|
|
226
209
|
|
|
227
210
|
# Normalises a remote URL so SSH and HTTPS variants of the same host/path compare equal.
|
|
@@ -297,7 +280,7 @@ module Carson
|
|
|
297
280
|
|
|
298
281
|
def detect_git_remote
|
|
299
282
|
remotes = list_git_remotes
|
|
300
|
-
remote_names = remotes.map {
|
|
283
|
+
remote_names = remotes.map { |entry| entry.fetch( :name ) }
|
|
301
284
|
return nil if remote_names.empty?
|
|
302
285
|
|
|
303
286
|
return config.git_remote if remote_names.include?( config.git_remote )
|
|
@@ -396,7 +379,7 @@ module Carson
|
|
|
396
379
|
hint = default ? "Y/n" : "y/N"
|
|
397
380
|
output.print "#{BADGE} [#{hint}]: "
|
|
398
381
|
output.flush
|
|
399
|
-
raw =
|
|
382
|
+
raw = input_stream.gets
|
|
400
383
|
return default if raw.nil?
|
|
401
384
|
|
|
402
385
|
input = raw.to_s.strip.downcase
|