featureparity 0.0.20 → 0.0.22

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.
@@ -10,11 +10,36 @@ module Fp
10
10
  # asked for, and what prompted the ask. The requirement drops back to draft until a
11
11
  # human reviews the diff — agents never approve their own changes.
12
12
  class ProposeChange < Base
13
+ help_summary 'Propose a change to an existing requirement (or --removal to retire it)'
14
+ help_usage 'fp propose-change <slug> --project <slug> [--name/--why/--acceptance ...] --reason "..."',
15
+ 'fp propose-change <slug> --removal --reason "..."'
16
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
17
+ help_flag :name, 'Proposed new title'
18
+ help_flag :why, 'Proposed new Why'
19
+ help_flag :acceptance, 'Proposed new acceptance criteria'
20
+ help_flag :required, 'Proposed new required surfaces (comma-separated; cannot remove a surface)'
21
+ help_flag :required_types, 'Proposed new required test types (comma-separated)'
22
+ help_flag :category, 'Proposed new category: functional, non-functional, ux, performance, security, compliance'
23
+ help_flag :parent, "Set the parent requirement's slug"
24
+ help_flag :no_parent, 'Propose un-nesting the requirement back to the top level (clears --parent)'
25
+ help_flag :slug, 'Proposed new slug (evidence follows; markers still need updating)'
26
+ help_flag :reason, 'What prompted this change (use this, not --source-*, to record the prompt)'
27
+ help_flag :removal, 'Propose deleting the requirement instead of changing it'
28
+ help_flag :withdraw, 'Take back the open proposal you authored, restoring the requirement as it was'
29
+ help_flag :source_type, "Correct the requirement's recorded origin type (not the change's prompt)"
30
+ help_flag :source_url, "Correct the requirement's recorded origin URL"
31
+ help_flag :source_context, "Correct the requirement's recorded origin context"
32
+ help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
33
+ help_example 'fp propose-change print_qr --removal --reason "Superseded by print_container_label"'
34
+ help_note "Applies the change and drops the requirement to draft for a human to review. Use --reason\n" \
35
+ "to say what prompted it; --source-* overwrite the requirement's own origin fields."
36
+
13
37
  KNOWN_FLAGS = {
14
38
  project: :string, name: :string, why: :string, acceptance: :string,
15
39
  required: :string, required_types: :string, category: :string, parent: :string, slug: :string,
40
+ no_parent: :boolean,
16
41
  reason: :string, source_type: :string, source_url: :string, source_context: :string,
17
- removal: :boolean
42
+ removal: :boolean, withdraw: :boolean
18
43
  }.freeze
19
44
 
20
45
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
@@ -39,17 +64,21 @@ module Fp
39
64
  exit 1
40
65
  end
41
66
 
67
+ validate_flag_combinations!(opts)
42
68
  validate_enums!(opts)
43
69
 
44
70
  project_id = resolve_project_id(opts[:project])
45
71
  requirement = find_requirement!(project_id, slug)
46
72
 
73
+ return withdraw!(requirement, slug) if opts[:withdraw]
74
+
47
75
  params = build_params(opts, project_id)
48
76
  removal = opts[:removal] ? true : false
49
77
 
50
78
  if !removal && params.keys.none? { |k| proposed_field?(k) }
51
79
  output.error('Nothing to propose. Pass at least one of --name/--slug/--why/--acceptance/' \
52
- '--required/--required-types/--category/--parent, or --removal to propose deletion.')
80
+ '--required/--required-types/--category/--parent/--no-parent, or --removal to ' \
81
+ 'propose deletion.')
53
82
  exit 1
54
83
  end
55
84
 
@@ -68,6 +97,31 @@ module Fp
68
97
 
69
98
  private
70
99
 
100
+ # Withdraw the requirement's own open proposal (Fizzy #1492). The single-open-
101
+ # proposal rule means an agent that wants to *amend* what it proposed can't just
102
+ # open a second one — it withdraws the first (restoring the requirement to what it
103
+ # was) and proposes again. The server only lets the proposal's author withdraw it.
104
+ def withdraw!(requirement, slug)
105
+ change_id = requirement['pending_change_id']
106
+ if change_id.nil? || change_id.to_s.empty?
107
+ output.error("Requirement '#{slug}' has no open proposal to withdraw.")
108
+ exit 1
109
+ end
110
+
111
+ result = client.withdraw_requirement_change(requirement['id'], change_id)
112
+ unless result[:ok]
113
+ output.error(result[:error], status: result[:status])
114
+ exit 1
115
+ end
116
+
117
+ output.success(result[:data]) do
118
+ puts "Withdrew the open proposal for '#{slug}'."
119
+ puts
120
+ puts 'The requirement is restored to what it was before the proposal.'
121
+ puts 'To amend it, propose the change again with the corrected values.'
122
+ end
123
+ end
124
+
71
125
  # A 409 means the requirement already has an open proposal — the one-at-a-time rule
72
126
  # (two "what it was" snapshots would make a rollback ambiguous). Rather than a dead
73
127
  # end, show what the open proposal is and point at `fp comment` so the agent can
@@ -109,6 +163,20 @@ module Fp
109
163
  warn ' Fields: ' + changes.keys.join(', ')
110
164
  end
111
165
 
166
+ # --parent and --no-parent contradict each other, and --withdraw is a standalone
167
+ # verb that ignores proposed values (Fizzy #1492).
168
+ def validate_flag_combinations!(opts)
169
+ if opts[:parent] && opts[:no_parent]
170
+ output.error('Pass either --parent <slug> or --no-parent, not both.')
171
+ exit 1
172
+ end
173
+
174
+ return unless opts[:withdraw] && opts[:removal]
175
+
176
+ output.error('--withdraw cancels an open proposal; it cannot be combined with --removal.')
177
+ exit 1
178
+ end
179
+
112
180
  def validate_enums!(opts)
113
181
  if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
114
182
  output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
@@ -143,6 +211,9 @@ module Fp
143
211
  params[:required_test_types] = opts[:required_types].split(',').map { |t| t.strip.downcase }.reject(&:empty?)
144
212
  end
145
213
  params[:parent_id] = resolve_parent_id(project_id, opts[:parent]) if opts[:parent]
214
+ # --no-parent un-nests a child back to top level: an empty parent_id. The server
215
+ # treats "" as "no parent" and diffs it against the current parent (Fizzy #1492).
216
+ params[:parent_id] = '' if opts[:no_parent]
146
217
 
147
218
  params[:removal] = true if opts[:removal]
148
219
  params[:reason] = opts[:reason] if opts[:reason]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'attach'
4
+
3
5
  module Fp
4
6
  module Commands
5
7
  # Single requirement:
@@ -8,13 +10,39 @@ module Fp
8
10
  #
9
11
  # Batch from a JUnit XML report (upload evidence without CI):
10
12
  # fp report --junit <path> --project <slug> --surface X --repo org/repo
11
- # [--base-dir DIR] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
13
+ # [--base-dir DIR] [--attach-media] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
12
14
  #
13
15
  # In --junit mode the CLI parses the report, discovers the fp:<slug> markers
14
16
  # in the referenced test files, and uploads evidence for every requirement
15
17
  # the suite covered. This lets an agent run the suite locally and report
16
- # evidence without waiting on CI.
18
+ # evidence without waiting on CI. Playwright's reporter is handled natively:
19
+ # the › / :: title separators, a classname relative to the runner's testDir,
20
+ # and (with --attach-media) the [[ATTACHMENT|…]] videos it records.
17
21
  class Report < Base
22
+ help_summary 'Report test evidence for a requirement (agents report present/stub only)'
23
+ help_usage 'fp report <slug> --project <slug> --surface <surface> --file <path> --repo <org/repo>',
24
+ 'fp report --junit <file> --project <slug> --surface <surface> --repo <org/repo>'
25
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
26
+ help_flag :surface, 'Surface this evidence is for', required: true
27
+ help_flag :test_type, "Kind of test (project's vocabulary, e.g. unit, integration, e2e)"
28
+ help_flag :file, 'Path to the test file (single-requirement mode)', required: true
29
+ help_flag :line, 'Line number of the test within the file'
30
+ help_flag :repo, 'Repository the test lives in (org/repo)', required: true
31
+ help_flag :pr, 'Pull request URL'
32
+ help_flag :sha, 'Commit SHA the evidence was observed at'
33
+ help_flag :work_item, 'Work-item URL (card/issue) that prompted the work'
34
+ help_flag :ci_url, 'CI run URL'
35
+ help_flag :title, 'Human-readable example/test name to display'
36
+ help_flag :state, 'Evidence state: present or stub (default present)'
37
+ help_flag :junit, 'JUnit XML report to batch-upload evidence from'
38
+ help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
39
+ help_flag :attach_media, 'With --junit: upload the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual evidence'
40
+ help_example 'fp report print_container_qr --project stowzilla --surface api --file spec/qr_spec.rb --repo stowzilla/app'
41
+ help_example 'fp report --junit junit.xml --project stowzilla --surface api --repo stowzilla/app',
42
+ 'Batch-upload from a JUnit report (slugs come from fp:<slug> markers)'
43
+ help_note "Agents report 'present' or 'stub' only. CI writes 'passing'/'failing' via fp ci-report.\n" \
44
+ 'Always pass --test-type when a surface has more than one suite, or they overwrite each other.'
45
+
18
46
  KNOWN_FLAGS = {
19
47
  project: :string,
20
48
  surface: :string,
@@ -29,7 +57,8 @@ module Fp
29
57
  title: :string,
30
58
  state: :string,
31
59
  junit: :string,
32
- base_dir: :string
60
+ base_dir: :string,
61
+ attach_media: :boolean
33
62
  }.freeze
34
63
 
35
64
  VALID_STATES = %w[present stub].freeze
@@ -212,6 +241,9 @@ module Fp
212
241
  bindings = result[:bindings]
213
242
  unmatched = result[:unmatched]
214
243
  missing_surface = result[:missing_surface] || []
244
+ media = result[:media] || []
245
+ empty_name = result[:empty_name] || []
246
+ collisions = result[:collisions] || []
215
247
 
216
248
  # Markers that pinned no surface AND no --surface default was given.
217
249
  unless missing_surface.empty?
@@ -232,13 +264,16 @@ module Fp
232
264
  reported = []
233
265
  skipped_unknown = []
234
266
  api_unavailable = false
267
+ # slug -> requirement record (or nil when the slug names no requirement), so a
268
+ # slug looked up for evidence isn't fetched again to upload its media.
269
+ requirement_cache = {}
235
270
 
236
271
  bindings.each do |binding|
237
272
  state = state_override || binding.state
238
273
 
239
274
  # Verify the requirement exists before reporting (skip unknown slugs
240
275
  # with a warning rather than aborting the whole batch).
241
- req_result = client.find_requirement_by_slug(project_id, binding.slug)
276
+ req_result = requirement_cache[binding.slug] ||= client.find_requirement_by_slug(project_id, binding.slug)
242
277
  unless req_result[:ok]
243
278
  skipped_unknown << binding
244
279
  next
@@ -280,7 +315,69 @@ module Fp
280
315
  stored_source: stored_source_from(report_result) }
281
316
  end
282
317
 
283
- emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
318
+ uploaded_media = []
319
+ if opts[:attach_media]
320
+ uploaded_media = upload_media(opts, media, base_dir, project_id, requirement_cache)
321
+ end
322
+
323
+ emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions,
324
+ empty_name: empty_name, media: media, uploaded_media: uploaded_media)
325
+ end
326
+
327
+ # Upload the media (videos/screenshots) a run recorded for its bound testcases as
328
+ # visual evidence, one per MediaBinding, nesting each under the test it proves via
329
+ # example_title. Unknown slugs and unsupported/oversized files are skipped with a
330
+ # note rather than aborting the batch. Reuses the slug->requirement cache the
331
+ # evidence loop already filled.
332
+ def upload_media(opts, media, base_dir, project_id, requirement_cache)
333
+ media.map do |m|
334
+ path = File.expand_path(m.path, base_dir)
335
+ req_result = requirement_cache[m.slug] ||= client.find_requirement_by_slug(project_id, m.slug)
336
+
337
+ outcome = { slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title }
338
+ if !req_result[:ok]
339
+ outcome.merge(uploaded: false, reason: 'unknown requirement')
340
+ else
341
+ requirement_id = req_result[:data].dig('requirement', 'id')
342
+ upload_one_media(requirement_id, path, m, opts).merge(
343
+ slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title
344
+ )
345
+ end
346
+ end
347
+ end
348
+
349
+ # Presign → PUT → create, mirroring `fp attach`, for a single media file.
350
+ def upload_one_media(requirement_id, path, media_binding, opts)
351
+ return { uploaded: false, reason: 'file not found' } unless File.file?(path)
352
+
353
+ filename = File.basename(path)
354
+ ext = File.extname(filename).delete('.').downcase
355
+ content_type = Fp::Commands::Attach::CONTENT_TYPES[ext]
356
+ return { uploaded: false, reason: "unsupported type .#{ext}" } unless content_type
357
+
358
+ size = File.size(path)
359
+ return { uploaded: false, reason: 'empty file' } if size.zero?
360
+ if size > Fp::Commands::Attach::MAX_FILE_SIZE
361
+ return { uploaded: false, reason: 'file too large' }
362
+ end
363
+
364
+ presign = client.presign_attachment(requirement_id, filename: filename,
365
+ content_type: content_type, size_bytes: size)
366
+ return { uploaded: false, reason: presign[:error] || 'presign failed' } unless presign[:ok]
367
+
368
+ p = presign[:data]['presign']
369
+ up = client.upload_to_presigned_url(p['upload_url'], path, content_type)
370
+ return { uploaded: false, reason: up[:error] || 'upload failed' } unless up[:ok]
371
+
372
+ created = client.create_attachment(requirement_id, attachment_id: p['attachment_id'],
373
+ s3_key: p['s3_key'], filename: filename,
374
+ content_type: content_type, size_bytes: size,
375
+ surface: media_binding.surface,
376
+ example_title: media_binding.example_title,
377
+ test_run_url: opts[:ci_url], sha: opts[:sha])
378
+ return { uploaded: false, reason: created[:error] || 'create failed' } unless created[:ok]
379
+
380
+ { uploaded: true }
284
381
  end
285
382
 
286
383
  # Pull the stored status/source out of the server's echoed evidence record so the
@@ -299,12 +396,17 @@ module Fp
299
396
  (data['evidence'] || {})['source']
300
397
  end
301
398
 
302
- def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
399
+ def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions = [],
400
+ empty_name: [], media: [], uploaded_media: [])
303
401
  data = {
304
402
  repo: opts[:repo],
305
403
  reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) } },
306
404
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
307
- unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
405
+ unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
406
+ empty_name: empty_name,
407
+ media: media.map { |m| { slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title } },
408
+ uploaded_media: uploaded_media,
409
+ collisions: collisions.map { |c| { slug: c[:slug], surface: c[:surface], test_type: c[:test_type], files: c[:files] } }
308
410
  }
309
411
 
310
412
  output.success(data) do
@@ -324,6 +426,8 @@ module Fp
324
426
  puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}"
325
427
  end
326
428
 
429
+ emit_collision_warning(collisions)
430
+
327
431
  unless skipped_unknown.empty?
328
432
  puts
329
433
  puts "⚠️ Skipped #{skipped_unknown.length} marker(s) with no matching requirement in this project:"
@@ -335,12 +439,58 @@ module Fp
335
439
  puts "ℹ️ #{unmatched.length} testcase(s) had no fp:<slug> marker and were skipped."
336
440
  end
337
441
 
442
+ unless empty_name.empty?
443
+ puts
444
+ puts "⚠️ #{empty_name.length} bound testcase(s) have an empty name — the receipt will have no title."
445
+ puts ' This is almost always an upstream reporter problem; check the JUnit XML.'
446
+ empty_name.each { |e| puts " - #{e[:slug]} → #{e[:surface]} (#{e[:file]})" }
447
+ end
448
+
449
+ unless media.empty?
450
+ puts
451
+ if opts[:attach_media]
452
+ ok = uploaded_media.count { |m| m[:uploaded] }
453
+ puts "📎 Uploaded #{ok}/#{media.length} media attachment(s):"
454
+ uploaded_media.each do |m|
455
+ mark = m[:uploaded] ? '✓' : '✗'
456
+ title = m[:example_title] ? " [#{m[:example_title]}]" : ''
457
+ suffix = m[:uploaded] ? '' : " (skipped: #{m[:reason]})"
458
+ puts " #{mark} #{m[:slug]} → #{m[:surface]}#{title} #{m[:path]}#{suffix}"
459
+ end
460
+ else
461
+ puts "📎 #{media.length} media attachment(s) found in the report (e.g. Playwright videos)."
462
+ puts ' Pass --attach-media to upload them as visual evidence.'
463
+ end
464
+ end
465
+
338
466
  if api_unavailable
339
467
  puts
340
468
  puts '⚠️ Evidence API not available yet. These will be recorded when #1214 is complete.'
341
469
  end
342
470
  end
343
471
  end
472
+
473
+ # Warn when two or more distinct test files in this run resolved to the *same*
474
+ # evidence cell. Server-side identity is (project, slug, surface, repo, test_type) —
475
+ # the file isn't part of it — so those reports upsert the same row and the last one
476
+ # posted wins. The suites silently overwrite each other, and only one survives on the
477
+ # matrix. It's almost always the `unspecified` default: neither suite pinned a test
478
+ # type. Make it loud and point at the fix.
479
+ def emit_collision_warning(collisions)
480
+ return if collisions.nil? || collisions.empty?
481
+
482
+ puts
483
+ puts "⚠️ #{collisions.length} evidence cell(s) were fed by more than one test file in this run."
484
+ puts ' These share one evidence row server-side, so the last report wins and the'
485
+ puts ' others are silently overwritten. Give each kind of test its own test type'
486
+ puts ' (a `:type` marker suffix, e.g. fp:slug@surface:e2e, or --test-type) so they'
487
+ puts ' become distinct cells instead of colliding:'
488
+ collisions.each do |c|
489
+ type = c[:test_type] ? c[:test_type] : 'unspecified'
490
+ puts " - #{c[:slug]} → #{c[:surface]} [#{type}] collided across:"
491
+ c[:files].each { |f| puts " #{f}" }
492
+ end
493
+ end
344
494
  end
345
495
  end
346
496
  end
@@ -16,6 +16,17 @@ module Fp
16
16
  # fp repos list [--project <slug>]
17
17
  # fp repos unset <surface> --project <slug>
18
18
  class Repos < Base
19
+ help_summary 'Map surfaces to local repo paths on this machine'
20
+ help_usage 'fp repos set <surface> <path> --project <slug> [--repo org/repo]',
21
+ 'fp repos get <surface> --project <slug>',
22
+ 'fp repos list [--project <slug>]',
23
+ 'fp repos unset <surface> --project <slug>'
24
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
25
+ help_flag :repo, 'Optional org/repo to record alongside the local path (set only)'
26
+ help_example 'fp repos set customer_android ~/code/customer-android --project stowzilla --repo stowzilla/customer-android'
27
+ help_example 'cd "$(fp repos get customer_android --project stowzilla)"'
28
+ help_note 'Stored per-installation in ~/.config/fp/config.yml, not in the web app.'
29
+
19
30
  KNOWN_FLAGS = {
20
31
  project: :string,
21
32
  repo: :string
@@ -6,6 +6,17 @@ module Fp
6
6
  # Interactive guided setup for new users.
7
7
  # Validates the API key, checks connectivity, and saves a profile.
8
8
  class Setup < Base
9
+ help_summary 'Interactive first-time setup: validate a key and save a profile'
10
+ help_usage 'fp setup',
11
+ 'fp setup --api-key fp_... [--profile NAME] [--non-interactive]'
12
+ help_flag :api_key, 'API key to validate and save (starts with fp_)'
13
+ help_flag :profile, 'Profile name to write (defaults to default)'
14
+ help_flag :api_url, 'Override API URL'
15
+ help_flag :environment, 'Named environment for the profile'
16
+ help_flag :non_interactive, 'Do not prompt; require flags to be supplied'
17
+ help_example 'fp setup'
18
+ help_example 'fp setup --api-key fp_... --profile ci-agent --non-interactive'
19
+
9
20
  KNOWN_FLAGS = {
10
21
  api_key: :string,
11
22
  profile: :string,
@@ -5,6 +5,11 @@ module Fp
5
5
  # fp show <slug> --project <slug>
6
6
  # Show requirement details including evidence receipts
7
7
  class Show < Base
8
+ help_summary 'Show a requirement, its acceptance criteria, and evidence receipts'
9
+ help_usage 'fp show <slug> --project <slug>'
10
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
11
+ help_example 'fp show print_container_qr --project stowzilla'
12
+
8
13
  def run(args)
9
14
  opts, positional = parse_flags(args)
10
15
  require_project!(opts)
@@ -31,14 +36,20 @@ module Fp
31
36
  # every requirement no matter how many receipts have been filed.
32
37
  evidence = fetch_evidence(project_id, slug)
33
38
 
39
+ # Attachments (screenshots, recordings, downloadable files) live on their own
40
+ # collection keyed by the requirement's id, so fetch them here to show an agent
41
+ # what visual evidence is already on the requirement without a second command.
42
+ attachments = fetch_attachments(requirement['id'])
43
+
34
44
  # Agents only ever speak slugs, so translate the parent's ID back into one.
35
45
  parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
36
46
 
37
- payload = result[:data].merge('evidence' => evidence)
47
+ payload = result[:data].merge('evidence' => evidence, 'attachments' => attachments)
38
48
  payload['parent_slug'] = parent_slug if parent_slug
39
49
 
40
50
  output.success(payload) do
41
- print_requirement(requirement, evidence: evidence, parent_slug: parent_slug, source_history: source_history)
51
+ print_requirement(requirement, evidence: evidence, attachments: attachments,
52
+ parent_slug: parent_slug, source_history: source_history)
42
53
  end
43
54
  end
44
55
 
@@ -51,6 +62,15 @@ module Fp
51
62
  result[:data]['evidence'] || []
52
63
  end
53
64
 
65
+ def fetch_attachments(requirement_id)
66
+ return [] if requirement_id.to_s.empty?
67
+
68
+ result = client.list_attachments(requirement_id)
69
+ return [] unless result[:ok]
70
+
71
+ result[:data]['attachments'] || []
72
+ end
73
+
54
74
  def resolve_parent_slug(project_id, parent_id)
55
75
  return nil if parent_id.nil? || parent_id.to_s.empty?
56
76
 
@@ -61,7 +81,7 @@ module Fp
61
81
  parent && parent['slug']
62
82
  end
63
83
 
64
- def print_requirement(req, evidence: [], parent_slug: nil, source_history: [])
84
+ def print_requirement(req, evidence: [], attachments: [], parent_slug: nil, source_history: [])
65
85
  puts "#{req['title']}"
66
86
  puts "=" * req['title'].length
67
87
  puts
@@ -156,10 +176,29 @@ module Fp
156
176
  end
157
177
  end
158
178
 
179
+ print_attachments(attachments)
180
+
159
181
  puts "Created: #{req['created_at']}"
160
182
  puts "Updated: #{req['updated_at']}"
161
183
  puts "By: #{req['created_by']}" if req['created_by']
162
184
  end
185
+
186
+ # Attachments: visual evidence (screenshots, recordings) and downloadable files on
187
+ # the requirement, so an agent can see what's already captured without a separate
188
+ # command. Shown even when empty so the absence is explicit (Fizzy #1488).
189
+ def print_attachments(attachments)
190
+ puts 'Attachments:'
191
+ if attachments.empty?
192
+ puts ' (none)'
193
+ else
194
+ attachments.each do |att|
195
+ puts " #{att['id']} #{att['filename']}"
196
+ puts " Surface: #{att['surface']}" if att['surface'] && !att['surface'].to_s.empty?
197
+ puts " Test: #{att['example_title']}" if att['example_title'] && !att['example_title'].to_s.empty?
198
+ end
199
+ end
200
+ puts
201
+ end
163
202
  end
164
203
  end
165
204
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp showroom --project <slug> - the client showroom, as a client will see it
6
+ #
7
+ # The showroom (Fizzy #1479) is the one page a client gets: the welcome note, then
8
+ # a card per surface with a link, a button label, and instructions. Its content is
9
+ # written through `fp projects edit --showroom-welcome` and `fp surfaces edit
10
+ # --showroom-*`, and this command is how an agent checks the result without a
11
+ # browser: the same payload the client is served, plus the surfaces that are *not*
12
+ # on it and why, so a card that silently dropped out (both fields cleared) is
13
+ # noticed.
14
+ #
15
+ # Whether the share link is on is a human's business — the API refuses an agent's
16
+ # key — so it is reported as unknown rather than guessed at.
17
+ class Showroom < Base
18
+ help_summary 'Preview the client showroom exactly as a client will see it'
19
+ help_usage 'fp showroom --project <slug>'
20
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
21
+ help_example 'fp showroom --project stowzilla'
22
+ help_note "Prints the welcome note and each surface's card, then the surfaces that are NOT shown and why.\n" \
23
+ "Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
24
+ 'The share link is reported as unknown: only a person can see or change it.'
25
+ def run(args)
26
+ opts, = parse_flags(args)
27
+ require_project!(opts)
28
+
29
+ project_id = resolve_project_id(opts[:project])
30
+
31
+ result = client.get_showroom(project_id)
32
+ unless result[:ok]
33
+ output.error(result[:error], status: result[:status])
34
+ exit 1
35
+ end
36
+
37
+ showroom = result[:data]['showroom'] || {}
38
+ shown_ids = (showroom['surfaces'] || []).map { |surface| surface['id'] }
39
+ hidden = hidden_surfaces(project_id, shown_ids)
40
+ share = share_link_status(project_id)
41
+
42
+ output.success({ showroom: showroom, hidden: hidden, share_link: share, project: opts[:project] }) do
43
+ print_showroom(showroom, hidden, share, project: opts[:project])
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Surfaces with neither a link nor instructions. The showroom payload leaves them
50
+ # out, so they are read from the surface list instead.
51
+ def hidden_surfaces(project_id, shown_ids)
52
+ result = client.list_surfaces(project_id)
53
+ return [] unless result[:ok]
54
+
55
+ (result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
56
+ { 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
57
+ 'reason' => 'no showroom link or instructions' }
58
+ end
59
+ end
60
+
61
+ # { 'state' => 'on' | 'off' | 'unknown', 'token' => ..., 'reason' => ... }
62
+ def share_link_status(project_id)
63
+ result = client.get_showroom_share_link(project_id)
64
+ if result[:ok]
65
+ link = result[:data]['share_link']
66
+ link ? { 'state' => 'on', 'token' => link['token'], 'created_by' => link['created_by'] } : { 'state' => 'off' }
67
+ elsif result[:status] == 403
68
+ { 'state' => 'unknown', 'reason' => 'only a person can see or change the share link (Project Settings → Showroom)' }
69
+ else
70
+ { 'state' => 'unknown', 'reason' => result[:error] }
71
+ end
72
+ end
73
+
74
+ def print_showroom(showroom, hidden, share, project:)
75
+ info = showroom['project'] || {}
76
+ title = "Showroom: #{info['name'] || project}"
77
+ puts title
78
+ puts '=' * title.length
79
+ puts
80
+
81
+ welcome = text_or_nil(info['welcome'])
82
+ if welcome
83
+ puts 'Welcome:'
84
+ welcome.each_line { |line| puts " #{line.rstrip}" }
85
+ else
86
+ puts 'Welcome: (none) — set one with: fp projects edit ' \
87
+ "#{info['slug'] || project} --showroom-welcome \"...\""
88
+ end
89
+ puts
90
+
91
+ surfaces = showroom['surfaces'] || []
92
+ if surfaces.empty?
93
+ puts 'Surfaces shown: none. The client sees only the welcome note.'
94
+ else
95
+ puts "Surfaces shown (#{surfaces.size}):"
96
+ surfaces.each { |surface| print_card(surface) }
97
+ end
98
+
99
+ unless hidden.empty?
100
+ puts
101
+ puts "Not shown (#{hidden.size}):"
102
+ hidden.each do |surface|
103
+ puts " - #{surface['name'] || surface['key']} (#{surface['key']}): #{surface['reason']}"
104
+ end
105
+ puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
106
+ '--showroom-instructions-file ...'
107
+ end
108
+
109
+ puts
110
+ case share['state']
111
+ when 'on'
112
+ puts "Share link: on (created by #{share['created_by'] || 'unknown'}) — the content above is live."
113
+ when 'off'
114
+ puts 'Share link: off — only signed-in clients and the team can see this.'
115
+ else
116
+ puts "Share link: unknown — #{share['reason']}"
117
+ end
118
+ end
119
+
120
+ def print_card(surface)
121
+ puts
122
+ puts " #{surface['name']} (#{surface['kind']})"
123
+ url = text_or_nil(surface['url'])
124
+ label = text_or_nil(surface['label'])
125
+ puts " Link: #{url ? "#{url}#{label ? " [#{label}]" : ''}" : '(none)'}"
126
+ instructions = text_or_nil(surface['instructions'])
127
+ if instructions
128
+ puts ' Instructions:'
129
+ instructions.each_line { |line| puts " #{line.rstrip}" }
130
+ else
131
+ puts ' Instructions: (none)'
132
+ end
133
+ end
134
+
135
+ def text_or_nil(value)
136
+ text = value.to_s.strip
137
+ text.empty? ? nil : text
138
+ end
139
+ end
140
+ end
141
+ end