featureparity 0.0.28 → 0.0.30
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/lib/fp/cli.rb +11 -2
- data/lib/fp/client.rb +31 -1
- data/lib/fp/commands/base.rb +14 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +35 -5
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +13 -1
- data/lib/fp/commands/history.rb +4 -0
- data/lib/fp/commands/init.rb +172 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +1 -0
- data/lib/fp/commands/propose.rb +401 -52
- data/lib/fp/commands/propose_change.rb +7 -1
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +23 -3
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +27 -0
- data/lib/fp/commands.rb +5 -0
- data/lib/fp/junit.rb +60 -7
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +231 -3
- metadata +10 -2
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative '../requirement_lint'
|
|
5
|
+
|
|
3
6
|
module Fp
|
|
4
7
|
module Commands
|
|
5
8
|
# fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --required-types unit,e2e --acceptance "..." --parent <slug> --category <cat>
|
|
6
|
-
#
|
|
9
|
+
# fp propose --file requirements.yml [--dry-run]
|
|
10
|
+
# Create a new requirement as draft — one from flags, or a batch from a file.
|
|
11
|
+
#
|
|
12
|
+
# --dry-run validates and lints without writing (Fizzy #1786): an agent can check its
|
|
13
|
+
# wording, and a human can review a whole batch, before anything lands in the catalog.
|
|
14
|
+
# --file validates every entry first — slugs, parents, categories, source types,
|
|
15
|
+
# collisions with the catalog — and writes nothing if any of them is wrong.
|
|
7
16
|
class Propose < Base
|
|
8
17
|
help_summary 'Propose a new requirement (created as a draft; a human activates it)'
|
|
9
|
-
help_usage 'fp propose --project <slug> --slug <key> --name "..." [flags]'
|
|
18
|
+
help_usage 'fp propose --project <slug> --slug <key> --name "..." [flags]',
|
|
19
|
+
'fp propose --file requirements.yml [--dry-run]'
|
|
10
20
|
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
11
21
|
help_flag :slug, 'Initial key for the requirement', required: true
|
|
12
22
|
help_flag :name, 'Human-readable title (one product promise, present tense)', required: true
|
|
@@ -17,11 +27,20 @@ module Fp
|
|
|
17
27
|
help_flag :parent, "Parent requirement's slug (nests one level; subset of parent's surfaces)"
|
|
18
28
|
help_flag :category, 'One of: functional, non-functional, ux, performance, security, compliance'
|
|
19
29
|
help_flag :tags, 'Comma-separated free-form labels'
|
|
20
|
-
help_flag :source_type, 'Where it came from: fizzy, discord, github_issue, jira, notion, manual, ai_chat'
|
|
30
|
+
help_flag :source_type, 'Where it came from: fizzy, discord, github_issue, jira, notion, manual, ai_chat, repo_scan (an agent read the code)'
|
|
21
31
|
help_flag :source_url, 'URL of the originating card/issue/thread'
|
|
22
32
|
help_flag :source_context, 'Free-form note about the origin'
|
|
33
|
+
help_flag :source_ref, 'A place in the repo it was read from, e.g. app/models/order.rb:42 (repeatable)'
|
|
34
|
+
help_flag :file, 'YAML or JSON file of requirements to propose in one batch (validated all-or-nothing)'
|
|
35
|
+
help_flag :dry_run, 'Validate and lint the wording; write nothing'
|
|
23
36
|
help_example 'fp propose --project stowzilla --slug print_qr --name "Print QR on container label" --why "Staff mis-type the ID without a scannable code" --required api,customer_ios --acceptance "QR scans to the container detail page"'
|
|
24
|
-
|
|
37
|
+
help_example 'fp propose --file requirements.yml --dry-run', 'Lint a batch and show what would be created'
|
|
38
|
+
help_example 'fp propose --file requirements.yml --source-type repo_scan', 'Create the batch; flags are defaults for every entry'
|
|
39
|
+
help_note "Agents can only create drafts. A human activates it in the web app.\n" \
|
|
40
|
+
"A --file holds `requirements:` (a list of entries with slug, name, why, acceptance,\n" \
|
|
41
|
+
"required, required_types, parent, category, tags, source_type, source_url,\n" \
|
|
42
|
+
"source_context, source_refs) and optional `defaults:` merged into every entry.\n" \
|
|
43
|
+
'A parent may be another entry in the same file; it is created first.'
|
|
25
44
|
|
|
26
45
|
KNOWN_FLAGS = {
|
|
27
46
|
project: :string,
|
|
@@ -36,70 +55,46 @@ module Fp
|
|
|
36
55
|
source_type: :string,
|
|
37
56
|
source_url: :string,
|
|
38
57
|
source_context: :string,
|
|
39
|
-
tags: :string
|
|
58
|
+
tags: :string,
|
|
59
|
+
file: :string,
|
|
60
|
+
dry_run: :boolean
|
|
40
61
|
}.freeze
|
|
41
62
|
|
|
42
63
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
43
|
-
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
64
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
|
|
65
|
+
|
|
66
|
+
# Keys an entry in a --file may carry. Anything else is refused, so a typo like
|
|
67
|
+
# `acceptence` fails the batch instead of silently dropping the criteria.
|
|
68
|
+
FILE_KEYS = %w[slug name title why acceptance required required_surfaces required_types
|
|
69
|
+
required_test_types parent category tags source_type source_url
|
|
70
|
+
source_context source_refs].freeze
|
|
44
71
|
|
|
45
72
|
def run(args)
|
|
73
|
+
source_refs, args = extract_repeated(args, '--source-ref')
|
|
46
74
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
47
|
-
|
|
48
75
|
require_project!(opts)
|
|
76
|
+
|
|
77
|
+
return run_batch(opts, source_refs) if opts[:file]
|
|
78
|
+
|
|
49
79
|
require_flag(opts, :slug, '--slug is required (initial key for the requirement)')
|
|
50
80
|
require_flag(opts, :name, '--name is required (human-readable title)')
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
spec = spec_from_flags(opts, source_refs)
|
|
83
|
+
errors = validate_spec(spec)
|
|
84
|
+
unless errors.empty?
|
|
85
|
+
errors.each { |e| output.error(e) }
|
|
55
86
|
exit 1
|
|
56
87
|
end
|
|
57
88
|
|
|
58
|
-
if opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
|
|
59
|
-
output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
|
|
60
|
-
exit 1
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Parse and validate required test types (comma-separated).
|
|
64
|
-
required_test_types = parse_test_types(opts[:required_types])
|
|
65
|
-
|
|
66
89
|
project_id = resolve_project_id(opts[:project])
|
|
90
|
+
return dry_run_single(spec, project_id) if opts[:dry_run]
|
|
67
91
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
opts[:required].split(',').map(&:strip)
|
|
71
|
-
else
|
|
72
|
-
[]
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
params = {
|
|
76
|
-
slug: opts[:slug],
|
|
77
|
-
title: opts[:name],
|
|
78
|
-
project_id: project_id,
|
|
79
|
-
required_surfaces: required_surfaces
|
|
80
|
-
}
|
|
81
|
-
params[:required_test_types] = required_test_types unless required_test_types.empty?
|
|
82
|
-
params[:why] = opts[:why] if opts[:why]
|
|
83
|
-
params[:acceptance] = opts[:acceptance] if opts[:acceptance]
|
|
84
|
-
params[:category] = opts[:category] if opts[:category]
|
|
85
|
-
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
86
|
-
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
87
|
-
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
88
|
-
|
|
89
|
-
# Tags are comma-separated. Send the raw list — the API normalizes
|
|
90
|
-
# (downcase/trim/dedupe) and validates.
|
|
91
|
-
if opts[:tags]
|
|
92
|
-
params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Resolve parent slug to parent_id if provided
|
|
96
|
-
if opts[:parent]
|
|
97
|
-
parent_id = resolve_parent_id(project_id, opts[:parent])
|
|
98
|
-
params[:parent_id] = parent_id
|
|
99
|
-
end
|
|
92
|
+
params = params_for(spec, project_id)
|
|
93
|
+
params[:parent_id] = resolve_parent_id(project_id, spec[:parent]) if spec[:parent]
|
|
100
94
|
|
|
95
|
+
lint_warnings(spec)
|
|
101
96
|
# Note: status is NOT sent - the API enforces draft for agents
|
|
102
|
-
|
|
97
|
+
warn_if_not_production!
|
|
103
98
|
result = client.create_requirement(params)
|
|
104
99
|
|
|
105
100
|
unless result[:ok]
|
|
@@ -122,7 +117,7 @@ module Fp
|
|
|
122
117
|
unless (requirement['required_test_types'] || []).empty?
|
|
123
118
|
puts " Test types: #{(requirement['required_test_types'] || []).join(', ')} (each must have evidence on every surface)"
|
|
124
119
|
end
|
|
125
|
-
puts " Parent: #{
|
|
120
|
+
puts " Parent: #{spec[:parent] || '(none)'}" if spec[:parent]
|
|
126
121
|
puts
|
|
127
122
|
puts '⚠️ This requirement is a DRAFT.'
|
|
128
123
|
puts ' A human must activate it in the web app before it appears in the parity matrix.'
|
|
@@ -133,6 +128,360 @@ module Fp
|
|
|
133
128
|
|
|
134
129
|
private
|
|
135
130
|
|
|
131
|
+
# --- Building and checking a spec ---------------------------------------------
|
|
132
|
+
|
|
133
|
+
# A requirement to propose, normalized from flags or a file entry.
|
|
134
|
+
def spec_from_flags(opts, source_refs)
|
|
135
|
+
{
|
|
136
|
+
slug: opts[:slug],
|
|
137
|
+
title: opts[:name],
|
|
138
|
+
why: opts[:why],
|
|
139
|
+
acceptance: opts[:acceptance],
|
|
140
|
+
required_surfaces: split_list(opts[:required]),
|
|
141
|
+
required_test_types: parse_test_types(opts[:required_types]),
|
|
142
|
+
parent: opts[:parent],
|
|
143
|
+
category: opts[:category],
|
|
144
|
+
tags: split_list(opts[:tags]),
|
|
145
|
+
source_type: opts[:source_type],
|
|
146
|
+
source_url: opts[:source_url],
|
|
147
|
+
source_context: opts[:source_context],
|
|
148
|
+
source_refs: source_refs.map(&:strip).reject(&:empty?)
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_spec(spec)
|
|
153
|
+
errors = []
|
|
154
|
+
errors << '--slug is required (initial key for the requirement)' if blank?(spec[:slug])
|
|
155
|
+
errors << '--name is required (human-readable title)' if blank?(spec[:title])
|
|
156
|
+
# Validated client-side for better UX (fail fast with the valid list).
|
|
157
|
+
if spec[:category] && !VALID_CATEGORIES.include?(spec[:category])
|
|
158
|
+
errors << "Invalid category '#{spec[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}"
|
|
159
|
+
end
|
|
160
|
+
if spec[:source_type] && !VALID_SOURCE_TYPES.include?(spec[:source_type])
|
|
161
|
+
errors << "Invalid source type '#{spec[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
if spec[:source_refs].any? { |r| r.include?("\n") }
|
|
164
|
+
errors << 'Each --source-ref must be a single line, e.g. app/models/order.rb:42'
|
|
165
|
+
end
|
|
166
|
+
errors
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def params_for(spec, project_id)
|
|
170
|
+
params = {
|
|
171
|
+
slug: spec[:slug],
|
|
172
|
+
title: spec[:title],
|
|
173
|
+
project_id: project_id,
|
|
174
|
+
required_surfaces: spec[:required_surfaces]
|
|
175
|
+
}
|
|
176
|
+
params[:required_test_types] = spec[:required_test_types] unless spec[:required_test_types].empty?
|
|
177
|
+
%i[why acceptance category source_type source_url source_context].each do |key|
|
|
178
|
+
params[key] = spec[key] if spec[key]
|
|
179
|
+
end
|
|
180
|
+
params[:source_refs] = spec[:source_refs] unless spec[:source_refs].empty?
|
|
181
|
+
# Send the raw list — the API normalizes (downcase/trim/dedupe) and validates.
|
|
182
|
+
params[:tags] = spec[:tags] unless spec[:tags].empty?
|
|
183
|
+
params
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def lint_for(spec)
|
|
187
|
+
RequirementLint.check(spec).map { |f| { 'field' => f.field, 'message' => f.message } }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Wording notes on stderr, so the human output reads as before and --json stays clean.
|
|
191
|
+
def lint_warnings(spec)
|
|
192
|
+
notes = lint_for(spec)
|
|
193
|
+
return if notes.empty?
|
|
194
|
+
|
|
195
|
+
$stderr.puts "Wording notes for '#{spec[:slug]}' (run with --dry-run to check before writing):"
|
|
196
|
+
notes.each { |n| $stderr.puts " - #{n['field']} #{n['message']}" }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- Dry run -------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def dry_run_single(spec, project_id)
|
|
202
|
+
existing = catalog(project_id)
|
|
203
|
+
problems = catalog_problems([spec], existing)
|
|
204
|
+
lint = lint_for(spec)
|
|
205
|
+
data = { dry_run: true, valid: problems.empty?, requirement: preview_params(spec), problems: problems, lint: lint }
|
|
206
|
+
|
|
207
|
+
output.success(data) do
|
|
208
|
+
puts "Dry run — nothing was written. fp propose would create '#{spec[:slug]}' as a DRAFT:"
|
|
209
|
+
print_preview(spec)
|
|
210
|
+
print_problems(problems)
|
|
211
|
+
print_lint(spec[:slug], lint)
|
|
212
|
+
end
|
|
213
|
+
exit 1 unless problems.empty?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def preview_params(spec)
|
|
217
|
+
params_for(spec, nil).reject { |k, _| k == :project_id }.merge(parent: spec[:parent]).compact
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def print_preview(spec)
|
|
221
|
+
puts " Title: #{spec[:title]}"
|
|
222
|
+
puts " Surfaces: #{spec[:required_surfaces].join(', ')}" unless spec[:required_surfaces].empty?
|
|
223
|
+
puts " Test types: #{spec[:required_test_types].join(', ')}" unless spec[:required_test_types].empty?
|
|
224
|
+
puts " Parent: #{spec[:parent]}" if spec[:parent]
|
|
225
|
+
puts " Category: #{spec[:category]}" if spec[:category]
|
|
226
|
+
puts " Why: #{spec[:why]}" if spec[:why]
|
|
227
|
+
puts " Acceptance: #{spec[:acceptance]}" if spec[:acceptance]
|
|
228
|
+
source = [spec[:source_type], spec[:source_url]].compact.join(' ')
|
|
229
|
+
puts " Source: #{source}" unless source.empty?
|
|
230
|
+
spec[:source_refs].each { |r| puts " #{r}" }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def print_problems(problems)
|
|
234
|
+
return if problems.empty?
|
|
235
|
+
|
|
236
|
+
puts
|
|
237
|
+
puts "✗ #{problems.length} problem(s) would stop this from being created:"
|
|
238
|
+
problems.each { |p| puts " - #{p}" }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def print_lint(slug, lint)
|
|
242
|
+
puts
|
|
243
|
+
if lint.empty?
|
|
244
|
+
puts "✓ No wording notes for '#{slug}'."
|
|
245
|
+
else
|
|
246
|
+
puts "Wording notes for '#{slug}' (advice, not errors — a human decides):"
|
|
247
|
+
lint.each { |n| puts " - #{n['field']} #{n['message']}" }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# --- Batch from a file ----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def run_batch(opts, flag_refs)
|
|
254
|
+
entries = load_entries(opts[:file])
|
|
255
|
+
defaults = spec_from_flags(opts.merge(slug: nil, name: nil), flag_refs)
|
|
256
|
+
|
|
257
|
+
specs = []
|
|
258
|
+
errors = []
|
|
259
|
+
entries[:list].each_with_index do |entry, i|
|
|
260
|
+
label = "entry #{i + 1}#{entry.is_a?(Hash) && entry['slug'] ? " (#{entry['slug']})" : ''}"
|
|
261
|
+
unless entry.is_a?(Hash)
|
|
262
|
+
errors << "#{label}: must be a mapping of fields, got #{entry.class}"
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
merged = entries[:defaults].merge(entry)
|
|
267
|
+
unknown = merged.keys - FILE_KEYS
|
|
268
|
+
errors << "#{label}: unknown field(s) #{unknown.join(', ')} (allowed: #{FILE_KEYS.join(', ')})" unless unknown.empty?
|
|
269
|
+
|
|
270
|
+
spec = spec_from_entry(merged, defaults)
|
|
271
|
+
validate_spec(spec).each { |e| errors << "#{label}: #{e.sub(/\A--(slug|name) is required.*/) { "#{Regexp.last_match(1)} is required" }}" }
|
|
272
|
+
specs << spec
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if specs.empty? && errors.empty?
|
|
276
|
+
output.error("#{opts[:file]} has no requirements to propose")
|
|
277
|
+
exit 1
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
unless errors.empty?
|
|
281
|
+
summary = "#{opts[:file]}: #{errors.length} problem(s); nothing was created."
|
|
282
|
+
if output.json?
|
|
283
|
+
output.error("#{summary} #{errors.join('; ')}")
|
|
284
|
+
else
|
|
285
|
+
output.error(summary)
|
|
286
|
+
errors.each { |e| warn " #{e}" }
|
|
287
|
+
end
|
|
288
|
+
exit 1
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
project_id = resolve_project_id(opts[:project])
|
|
292
|
+
existing = catalog(project_id)
|
|
293
|
+
problems = catalog_problems(specs, existing)
|
|
294
|
+
ordered = parents_first(specs)
|
|
295
|
+
lint = ordered.to_h { |s| [s[:slug], lint_for(s)] }
|
|
296
|
+
|
|
297
|
+
if opts[:dry_run] || !problems.empty?
|
|
298
|
+
data = { dry_run: !!opts[:dry_run], valid: problems.empty?, problems: problems,
|
|
299
|
+
requirements: ordered.map { |s| preview_params(s).merge(lint: lint[s[:slug]]) } }
|
|
300
|
+
output.success(data) do
|
|
301
|
+
heading = opts[:dry_run] ? 'Dry run — nothing was written.' : 'Nothing was created.'
|
|
302
|
+
puts "#{heading} #{ordered.length} requirement(s) in #{opts[:file]}:"
|
|
303
|
+
print_batch_table(ordered, lint)
|
|
304
|
+
print_problems(problems)
|
|
305
|
+
print_batch_lint(ordered, lint)
|
|
306
|
+
end
|
|
307
|
+
exit 1 unless problems.empty?
|
|
308
|
+
return
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
warn_if_not_production!
|
|
312
|
+
create_batch(ordered, project_id, existing, lint, opts)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def load_entries(path)
|
|
316
|
+
unless File.file?(path)
|
|
317
|
+
output.error("No such file: #{path}")
|
|
318
|
+
exit 1
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
doc = begin
|
|
322
|
+
YAML.safe_load(File.read(path), aliases: false)
|
|
323
|
+
rescue Psych::Exception => e
|
|
324
|
+
output.error("Could not parse #{path}: #{e.message}")
|
|
325
|
+
exit 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
case doc
|
|
329
|
+
when Array
|
|
330
|
+
{ list: doc, defaults: {} }
|
|
331
|
+
when Hash
|
|
332
|
+
defaults = doc['defaults'] || {}
|
|
333
|
+
unless doc['requirements'].is_a?(Array) && defaults.is_a?(Hash)
|
|
334
|
+
output.error("#{path} must hold a `requirements:` list (and optionally a `defaults:` mapping), or be a list")
|
|
335
|
+
exit 1
|
|
336
|
+
end
|
|
337
|
+
{ list: doc['requirements'], defaults: defaults.transform_keys(&:to_s) }
|
|
338
|
+
else
|
|
339
|
+
output.error("#{path} must hold a `requirements:` list, or be a list")
|
|
340
|
+
exit 1
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# A file entry wins over its `defaults:`, which win over the command's flags.
|
|
345
|
+
def spec_from_entry(entry, flag_defaults)
|
|
346
|
+
list = ->(v) { v.is_a?(Array) ? v.map { |x| x.to_s.strip }.reject(&:empty?) : split_list(v&.to_s) }
|
|
347
|
+
fetch = ->(*keys) { keys.map { |k| entry[k] }.find { |v| !v.nil? } }
|
|
348
|
+
str = ->(*keys) { v = fetch.call(*keys); v.nil? ? nil : v.to_s }
|
|
349
|
+
|
|
350
|
+
refs = fetch.call('source_refs')
|
|
351
|
+
types = fetch.call('required_types', 'required_test_types')
|
|
352
|
+
surfaces = fetch.call('required', 'required_surfaces')
|
|
353
|
+
tags = fetch.call('tags')
|
|
354
|
+
{
|
|
355
|
+
slug: str.call('slug'),
|
|
356
|
+
title: str.call('name', 'title'),
|
|
357
|
+
why: str.call('why'),
|
|
358
|
+
acceptance: str.call('acceptance'),
|
|
359
|
+
required_surfaces: surfaces.nil? ? flag_defaults[:required_surfaces] : list.call(surfaces),
|
|
360
|
+
required_test_types: types.nil? ? flag_defaults[:required_test_types] : list.call(types).map(&:downcase).uniq,
|
|
361
|
+
parent: str.call('parent') || flag_defaults[:parent],
|
|
362
|
+
category: str.call('category') || flag_defaults[:category],
|
|
363
|
+
tags: tags.nil? ? flag_defaults[:tags] : list.call(tags),
|
|
364
|
+
source_type: str.call('source_type') || flag_defaults[:source_type],
|
|
365
|
+
source_url: str.call('source_url') || flag_defaults[:source_url],
|
|
366
|
+
source_context: str.call('source_context') || flag_defaults[:source_context],
|
|
367
|
+
source_refs: refs.nil? ? flag_defaults[:source_refs] : Array(refs).map { |r| r.to_s.strip }.reject(&:empty?)
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Problems only the catalog can reveal: a slug already taken, a parent that
|
|
372
|
+
# doesn't exist or is itself a child (nesting is one level), and duplicates
|
|
373
|
+
# within the batch.
|
|
374
|
+
def catalog_problems(specs, existing)
|
|
375
|
+
by_slug = existing.to_h { |r| [r['slug'], r] }
|
|
376
|
+
in_batch = specs.to_h { |s| [s[:slug], s] }
|
|
377
|
+
problems = []
|
|
378
|
+
|
|
379
|
+
specs.group_by { |s| s[:slug] }.each do |slug, group|
|
|
380
|
+
problems << "'#{slug}' appears #{group.length} times in the batch" if group.length > 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
specs.each do |spec|
|
|
384
|
+
problems << "'#{spec[:slug]}' already exists in this project (#{by_slug[spec[:slug]]['status']})" if by_slug.key?(spec[:slug])
|
|
385
|
+
next unless spec[:parent]
|
|
386
|
+
|
|
387
|
+
if (parent = in_batch[spec[:parent]])
|
|
388
|
+
if parent[:parent]
|
|
389
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is itself a child — requirements nest one level"
|
|
390
|
+
end
|
|
391
|
+
elsif (parent = by_slug[spec[:parent]])
|
|
392
|
+
unless blank?(parent['parent_id'])
|
|
393
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is itself a child — requirements nest one level"
|
|
394
|
+
end
|
|
395
|
+
else
|
|
396
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is neither in this project nor in the batch"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
problems
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Entries whose parent is another entry go after it.
|
|
403
|
+
def parents_first(specs)
|
|
404
|
+
batch_slugs = specs.map { |s| s[:slug] }
|
|
405
|
+
top, children = specs.partition { |s| s[:parent].nil? || !batch_slugs.include?(s[:parent]) }
|
|
406
|
+
top + children
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def create_batch(ordered, project_id, existing, lint, opts)
|
|
410
|
+
ids = existing.to_h { |r| [r['slug'], r['id']] }
|
|
411
|
+
created = []
|
|
412
|
+
|
|
413
|
+
ordered.each do |spec|
|
|
414
|
+
params = params_for(spec, project_id)
|
|
415
|
+
params[:parent_id] = ids.fetch(spec[:parent]) if spec[:parent]
|
|
416
|
+
result = client.create_requirement(params)
|
|
417
|
+
|
|
418
|
+
unless result[:ok]
|
|
419
|
+
remaining = ordered.drop(created.length).map { |s| s[:slug] }
|
|
420
|
+
output.error("Creating '#{spec[:slug]}' failed: #{result[:error]}", status: result[:status])
|
|
421
|
+
unless output.json?
|
|
422
|
+
warn "Created #{created.length} of #{ordered.length} before that: #{created.map { |c| c['slug'] }.join(', ')}" unless created.empty?
|
|
423
|
+
warn "Not created: #{remaining.join(', ')}. Fix the entry and run again with only those."
|
|
424
|
+
end
|
|
425
|
+
exit 1
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
requirement = result[:data]['requirement'] || {}
|
|
429
|
+
ids[spec[:slug]] = requirement['id']
|
|
430
|
+
created << requirement.merge('parent' => spec[:parent])
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
output.success({ created: created, lint: lint }) do
|
|
434
|
+
puts "Created #{created.length} requirement(s) from #{opts[:file]} as DRAFTS:"
|
|
435
|
+
print_batch_table(ordered, lint, created)
|
|
436
|
+
print_batch_lint(ordered, lint)
|
|
437
|
+
puts
|
|
438
|
+
puts '⚠️ These are DRAFTS. A human must activate them in the web app; agents cannot.'
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def print_batch_table(ordered, lint, created = nil)
|
|
443
|
+
status = created&.to_h { |c| [c['slug'], c['status']] }
|
|
444
|
+
rows = ordered.map do |s|
|
|
445
|
+
[s[:slug], truncate(s[:title].to_s, 50), s[:parent] || '', s[:required_surfaces].join(','),
|
|
446
|
+
status ? status[s[:slug]].to_s : 'would create', lint[s[:slug]].length.to_s]
|
|
447
|
+
end
|
|
448
|
+
puts
|
|
449
|
+
output.table(%w[SLUG TITLE PARENT SURFACES STATUS NOTES], rows)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def print_batch_lint(ordered, lint)
|
|
453
|
+
noted = ordered.reject { |s| lint[s[:slug]].empty? }
|
|
454
|
+
return if noted.empty?
|
|
455
|
+
|
|
456
|
+
puts
|
|
457
|
+
puts 'Wording notes (advice, not errors — a human decides):'
|
|
458
|
+
noted.each do |s|
|
|
459
|
+
puts " #{s[:slug]}:"
|
|
460
|
+
lint[s[:slug]].each { |n| puts " - #{n['field']} #{n['message']}" }
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# --- Helpers -------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def catalog(project_id)
|
|
467
|
+
result = client.list_requirements(project_id: project_id)
|
|
468
|
+
unless result[:ok]
|
|
469
|
+
output.error("Failed to read the project's requirements: #{result[:error]}", status: result[:status])
|
|
470
|
+
exit 1
|
|
471
|
+
end
|
|
472
|
+
result[:data]['requirements'] || []
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def split_list(raw)
|
|
476
|
+
return [] if raw.nil?
|
|
477
|
+
|
|
478
|
+
raw.split(',').map(&:strip).reject(&:empty?)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def blank?(value)
|
|
482
|
+
value.nil? || value.to_s.strip.empty?
|
|
483
|
+
end
|
|
484
|
+
|
|
136
485
|
# Quality feedback on the draft just created, as part of the same command, so an
|
|
137
486
|
# agent learns at once that its draft is weak or shouldn't exist and can fix or
|
|
138
487
|
# withdraw it before a human looks (requirement_analysis_runs_on_draft). It never
|
|
@@ -29,6 +29,7 @@ module Fp
|
|
|
29
29
|
help_flag :source_type, "Where this proposal came from (recorded on the change; the requirement's origin is kept)"
|
|
30
30
|
help_flag :source_url, 'URL of the card/issue/thread that prompted this proposal'
|
|
31
31
|
help_flag :source_context, 'Free-form note about what prompted this proposal'
|
|
32
|
+
help_flag :source_ref, 'A place in the repo that prompted it, e.g. app/models/order.rb:42 (repeatable)'
|
|
32
33
|
help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
|
|
33
34
|
help_example 'fp propose-change print_qr --removal --reason "Superseded by print_container_label"'
|
|
34
35
|
help_note "Applies the change and drops the requirement to draft for a human to review. --reason and\n" \
|
|
@@ -43,7 +44,7 @@ module Fp
|
|
|
43
44
|
}.freeze
|
|
44
45
|
|
|
45
46
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
46
|
-
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
47
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
|
|
47
48
|
|
|
48
49
|
# Flags that describe a proposed new value, mapped to their API field.
|
|
49
50
|
FIELD_FLAGS = {
|
|
@@ -55,7 +56,9 @@ module Fp
|
|
|
55
56
|
}.freeze
|
|
56
57
|
|
|
57
58
|
def run(args)
|
|
59
|
+
source_refs, args = extract_repeated(args, '--source-ref')
|
|
58
60
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
61
|
+
opts[:source_refs] = source_refs.map(&:strip).reject(&:empty?)
|
|
59
62
|
require_project!(opts)
|
|
60
63
|
|
|
61
64
|
slug = positional.first
|
|
@@ -82,6 +85,7 @@ module Fp
|
|
|
82
85
|
exit 1
|
|
83
86
|
end
|
|
84
87
|
|
|
88
|
+
warn_if_not_production!
|
|
85
89
|
result = client.propose_requirement_change(requirement['id'], params)
|
|
86
90
|
unless result[:ok]
|
|
87
91
|
if result[:status] == 409 && result[:details].is_a?(Hash)
|
|
@@ -220,6 +224,7 @@ module Fp
|
|
|
220
224
|
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
221
225
|
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
222
226
|
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
227
|
+
params[:source_refs] = opts[:source_refs] unless opts[:source_refs].nil? || opts[:source_refs].empty?
|
|
223
228
|
|
|
224
229
|
params
|
|
225
230
|
end
|
|
@@ -290,6 +295,7 @@ module Fp
|
|
|
290
295
|
puts " Source: #{source['type']}" if source['type']
|
|
291
296
|
puts " URL: #{source['url']}" if source['url']
|
|
292
297
|
puts " Context: #{source['context']}" if source['context']
|
|
298
|
+
Array(source['refs']).each { |ref| puts " Ref: #{ref}" }
|
|
293
299
|
puts
|
|
294
300
|
end
|
|
295
301
|
|