featureparity 0.0.21 → 0.0.23

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ae3f4be081207da9feb300fbdfaad6d2afddf2ffb021ffc8136508da964f0c2
4
- data.tar.gz: 74d2cf3feb18ea77cac72f38be15c5eb6870d237d76f2a21a3b859853649b02d
3
+ metadata.gz: 3fd282368d3fb2417adaf990518352726bc46fe816d70e73ec04f57e2a5ea067
4
+ data.tar.gz: 7edf6bda3e44e4b8cce99efbe59d1adff0cbfe9ab2b8c24613a4b0eaf456cb7f
5
5
  SHA512:
6
- metadata.gz: f14f50d9f5e6d620341081a55ee98734bafd6893ade078cb424246c6ddb16a2f85a8aff6b5d28dc1d969505708b0e59806628699c47146736b024f90c0e0c6e6
7
- data.tar.gz: 156d5c4e679017b8d5ec7825eb51d7eb64acec51daf02700a263f58e20251c2136efadab399244856f4ce943fe832825e89e45a75f57a814368e8dbc54a5189e
6
+ metadata.gz: 83b7560fa29083b77581005c176b3c01f9a2d60201616cbb97dc8b60ede719f8029b4a02b7d5012310a3a57ef6cd642bd21af32344c5e42c8c300ecb76617356
7
+ data.tar.gz: 1d2b3bf3e6500b1bed84696588225ad718f3aaa9016ffbeffc410226c631c8e55fb48cdafa30446f41814ac2869eb166b98e10fbb21d00b7fb8c2a4d73731802
data/lib/fp/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Fp
4
6
  # Main CLI entry point - parses global flags and dispatches to commands
5
7
  class CLI
@@ -7,6 +9,7 @@ module Fp
7
9
  'init' => Commands::Init,
8
10
  'projects' => Commands::Projects,
9
11
  'surfaces' => Commands::Surfaces,
12
+ 'showroom' => Commands::Showroom,
10
13
  'list' => Commands::List,
11
14
  'show' => Commands::Show,
12
15
  'propose' => Commands::Propose,
@@ -39,6 +42,12 @@ module Fp
39
42
  global_opts = extract_global_options(args)
40
43
 
41
44
  command_name = args.shift || 'help'
45
+
46
+ # `fp --version` / `fp -v` are aliases for `fp version` (Fizzy #1489 ask 3).
47
+ # A bare version flag is the universal expectation for a CLI; without this it
48
+ # reported "Unknown command: --version".
49
+ command_name = 'version' if %w[--version -v].include?(command_name)
50
+
42
51
  command_class = COMMANDS[command_name]
43
52
 
44
53
  unless command_class
@@ -48,6 +57,15 @@ module Fp
48
57
  exit 1
49
58
  end
50
59
 
60
+ # Per-command help (Fizzy #1489): `fp <cmd> --help` / `fp <cmd> -h` prints the
61
+ # command's own usage and flags instead of running it. Detected here, before
62
+ # auth, so `fp report --help` works with no API key — the whole point is to let
63
+ # someone discover a command's flags before they can run it.
64
+ if wants_help?(args)
65
+ print_command_help(command_name, command_class, json: global_opts[:json])
66
+ return
67
+ end
68
+
51
69
  # Profile/config commands don't need auth. `init` is here too: it only writes
52
70
  # files into the repo, and onboarding a repo must work before anyone has a key.
53
71
  needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
@@ -78,6 +96,28 @@ module Fp
78
96
 
79
97
  private
80
98
 
99
+ # True when the remaining args ask for help on the command: `--help` or `-h`
100
+ # anywhere in the args. We don't treat a lone `help` positional specially here —
101
+ # that's a real subcommand for some commands.
102
+ def wants_help?(args)
103
+ args.include?('--help') || args.include?('-h')
104
+ end
105
+
106
+ # Print a command's own help page. `fp help <cmd>` routes here too, via the Help
107
+ # command. Commands that haven't adopted the help DSL still get a usable page
108
+ # (summary falls back to a generic line, flags to the global set).
109
+ #
110
+ # With --json, emit the command's flag schema instead of the human page (Fizzy
111
+ # #1489 ask 4) so an agent or the skill can read a command's flags, required-ness,
112
+ # and usage straight from the gem rather than scraping KNOWN_FLAGS.
113
+ def print_command_help(command_name, command_class, json: false)
114
+ if json
115
+ puts JSON.pretty_generate(command_class.help_schema(command_name))
116
+ else
117
+ puts command_class.help_text(command_name)
118
+ end
119
+ end
120
+
81
121
  def extract_global_options(args)
82
122
  opts = { json: false, profile: nil, api_url: nil }
83
123
 
data/lib/fp/client.rb CHANGED
@@ -82,6 +82,66 @@ module Fp
82
82
  delete("/api/projects/#{project_id}/surfaces/#{surface_id}")
83
83
  end
84
84
 
85
+ # GET /projects/:id/showroom
86
+ # The client showroom as a client would see it (Fizzy #1479). Open to the team and
87
+ # their agents; the response carries only what a client is shown.
88
+ def get_showroom(project_id)
89
+ get("/api/projects/#{project_id}/showroom")
90
+ end
91
+
92
+ # GET /projects/:id/showroom/share_link
93
+ # The share link, or null when sharing is off. Humans only: an agent's key gets 403,
94
+ # which `fp showroom` reports as "unknown" rather than treating as an error.
95
+ def get_showroom_share_link(project_id)
96
+ get("/api/projects/#{project_id}/showroom/share_link")
97
+ end
98
+
99
+ # --- Showroom media ---
100
+ #
101
+ # Screenshots and videos on the client showroom: on a surface's card, or in the
102
+ # project gallery under the welcome note (surface_id nil). Uploaded the same way as
103
+ # attachments — presign, PUT the bytes, then confirm.
104
+
105
+ # GET /projects/:id/showroom/media
106
+ def list_showroom_media(project_id)
107
+ get("/api/projects/#{project_id}/showroom/media")
108
+ end
109
+
110
+ # POST /projects/:id/showroom/media/presign
111
+ def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil)
112
+ body = { filename: filename, content_type: content_type, size_bytes: size_bytes }
113
+ body[:surface_id] = surface_id if surface_id
114
+ post("/api/projects/#{project_id}/showroom/media/presign", body)
115
+ end
116
+
117
+ # POST /projects/:id/showroom/media
118
+ # Confirm an upload and create the media record.
119
+ def create_showroom_media(project_id, media_id:, s3_key:, filename:, content_type:, size_bytes:,
120
+ surface_id: nil, caption: nil, position: nil)
121
+ body = {
122
+ media_id: media_id,
123
+ s3_key: s3_key,
124
+ filename: filename,
125
+ content_type: content_type,
126
+ size_bytes: size_bytes
127
+ }
128
+ body[:surface_id] = surface_id if surface_id
129
+ body[:caption] = caption if caption
130
+ body[:position] = position if position
131
+ post("/api/projects/#{project_id}/showroom/media", body)
132
+ end
133
+
134
+ # PUT /projects/:id/showroom/media/:media_id
135
+ # { caption?, position? } — an empty caption clears it, position 0 clears it.
136
+ def update_showroom_media(project_id, media_id, params)
137
+ put("/api/projects/#{project_id}/showroom/media/#{media_id}", params)
138
+ end
139
+
140
+ # DELETE /projects/:id/showroom/media/:media_id
141
+ def delete_showroom_media(project_id, media_id)
142
+ delete("/api/projects/#{project_id}/showroom/media/#{media_id}")
143
+ end
144
+
85
145
  # GET /requirements
86
146
  def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
87
147
  params = {}
@@ -179,6 +239,16 @@ module Fp
179
239
  post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
180
240
  end
181
241
 
242
+ # DELETE /requirements/:id/changes/:change_id
243
+ #
244
+ # Withdraw an open proposal. Only the proposal's author can withdraw it; the
245
+ # requirement is restored to what it was before the proposal (the same rollback a
246
+ # reject performs). This is how an agent *amends* a proposal under the one-open-
247
+ # proposal rule: withdraw, then propose again.
248
+ def withdraw_requirement_change(requirement_id, change_id)
249
+ delete("/api/requirements/#{requirement_id}/changes/#{change_id}")
250
+ end
251
+
182
252
  # GET /projects/:project_id/evidence — the receipts already filed for a project,
183
253
  # optionally narrowed to one requirement slug and/or surface.
184
254
  def list_evidence(project_id, slug: nil, surface: nil)
@@ -15,6 +15,16 @@ module Fp
15
15
  # put dozens of requirements up for review at once. The report says what *kind* of
16
16
  # follow-up each finding suggests; a human picks which proposals get filed.
17
17
  class Analyze < Base
18
+ help_summary 'Triage requirement quality (read-only; never proposes or files anything)'
19
+ help_usage 'fp analyze [<slug>] --project <slug> [--status S] [--conflicts] [--cached]'
20
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
21
+ help_flag :status, 'Limit to requirements with this status'
22
+ help_flag :conflicts, 'Include cross-requirement conflict findings'
23
+ help_flag :force, 'Re-analyze even cached (unchanged) requirements'
24
+ help_flag :cached, 'Read cached analyses only; do not spend model calls'
25
+ help_example 'fp analyze --project stowzilla'
26
+ help_example 'fp analyze print_container_qr --project stowzilla --conflicts'
27
+
18
28
  KNOWN_FLAGS = {
19
29
  project: :string,
20
30
  status: :string,
@@ -6,6 +6,22 @@ module Fp
6
6
  module Commands
7
7
  # Upload visual test evidence or another supporting file to a requirement.
8
8
  class Attach < Base
9
+ help_summary 'Upload screenshot/video/file evidence to a requirement (rm to delete)'
10
+ help_usage 'fp attach <slug> <file> --project <slug> --surface <surface> [flags]',
11
+ 'fp attach rm <slug> <attachment-id> --project <slug>'
12
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
13
+ help_flag :surface, 'Surface the capture is for (required for images/videos)'
14
+ help_flag :captured_at, 'ISO8601 timestamp the capture was taken'
15
+ help_flag :deployed_at, 'ISO8601 timestamp of the deployment the capture observed'
16
+ help_flag :deployment_url, 'URL of the deployment under test'
17
+ help_flag :test_run_url, 'CI run / test-run URL'
18
+ help_flag :sha, 'Commit SHA the capture observed'
19
+ help_flag :example_title, 'Name of the individual test this media proves'
20
+ help_flag :evidence_key, 'Composite receipt key (surface:repo:test_type) to bind to'
21
+ help_example 'fp attach print_qr e2e-run.webm --project stowzilla --surface customer_web --example-title "prints a QR code onto the container label"'
22
+ help_example 'fp attach rm print_qr <attachment-id> --project stowzilla'
23
+ help_note 'Visual: png, jpg, gif, webp, mp4, webm, mov. Text/docs: txt, md, json, svg, pdf. Max 100 MB.'
24
+
9
25
  CONTENT_TYPES = {
10
26
  'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
11
27
  'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
@@ -31,58 +47,88 @@ module Fp
31
47
  opts, positional = parse_flags(args, KNOWN_FLAGS)
32
48
  return delete_attachment(opts, positional.drop(1)) if %w[rm delete].include?(positional.first)
33
49
 
34
- upload_attachment(opts, positional)
50
+ upload_attachments(opts, positional)
35
51
  end
36
52
 
37
53
  private
38
54
 
39
- def upload_attachment(opts, positional)
40
- slug, file_path = positional
55
+ # fp attach <slug> <file> [<file> ...]
56
+ # Several files attach in one command: each is uploaded to the same requirement with
57
+ # the shared flags (surface, bound test, deployment context). A failure on one file
58
+ # doesn't abort the rest, and the exit status reflects whether every upload succeeded.
59
+ def upload_attachments(opts, positional)
60
+ slug = positional.first
61
+ file_paths = positional.drop(1)
41
62
  return usage_error('Missing requirement slug') if slug.to_s.empty?
42
- return usage_error('Missing file path') if file_path.to_s.empty?
43
- return command_error("File not found: #{file_path}") unless File.file?(file_path)
63
+ return usage_error('Missing file path') if file_paths.empty?
64
+
65
+ # Validate every file up front and resolve the requirement once, so a bad path in a
66
+ # batch is reported before any upload begins and all files share one lookup.
67
+ prepared = file_paths.map { |path| prepare_file(path, opts) }
68
+ invalid = prepared.find { |p| p[:error] }
69
+ return command_error(invalid[:error]) if invalid
70
+
71
+ require_project!(opts)
72
+ project_id = resolve_project_id(opts[:project])
73
+ requirement_result = client.find_requirement_by_slug(project_id, slug)
74
+ return api_error(requirement_result) unless requirement_result[:ok]
75
+
76
+ requirement_id = requirement_result[:data].dig('requirement', 'id')
77
+
78
+ results = prepared.map { |file| upload_one(requirement_id, slug, file, opts) }
79
+ report_batch(results, slug)
80
+ end
81
+
82
+ # Validate a single file and compute its upload metadata. Returns a hash carrying
83
+ # either an :error string or the resolved filename/extension/content_type/size.
84
+ def prepare_file(file_path, opts)
85
+ return { error: "File not found: #{file_path}" } unless File.file?(file_path)
44
86
 
45
87
  filename = File.basename(file_path)
46
88
  extension = File.extname(filename).delete('.').downcase
47
89
  content_type = CONTENT_TYPES[extension]
48
90
  unless content_type
49
- return command_error("Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}")
91
+ return { error: "Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}" }
50
92
  end
51
93
 
52
94
  size = File.size(file_path)
53
- return command_error("File is empty: #{file_path}") if size.zero?
95
+ return { error: "File is empty: #{file_path}" } if size.zero?
54
96
  if size > MAX_FILE_SIZE
55
- return command_error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
97
+ return { error: "File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})" }
56
98
  end
57
99
  if VISUAL_EXTENSIONS.include?(extension) && opts[:surface].to_s.empty?
58
- return command_error('--surface is required for screenshot and video evidence')
100
+ return { error: '--surface is required for screenshot and video evidence' }
59
101
  end
60
102
 
61
- require_project!(opts)
62
- project_id = resolve_project_id(opts[:project])
63
- requirement_result = client.find_requirement_by_slug(project_id, slug)
64
- return api_error(requirement_result) unless requirement_result[:ok]
103
+ { path: file_path, filename: filename, extension: extension, content_type: content_type, size: size }
104
+ end
65
105
 
66
- requirement_id = requirement_result[:data].dig('requirement', 'id')
106
+ # Upload one prepared file. Returns { ok:, filename:, attachment: } or
107
+ # { ok: false, filename:, error: } so the batch can report per-file outcomes.
108
+ def upload_one(requirement_id, slug, file, opts)
67
109
  presign_result = client.presign_attachment(
68
110
  requirement_id,
69
- filename: filename,
70
- content_type: content_type,
71
- size_bytes: size
111
+ filename: file[:filename],
112
+ content_type: file[:content_type],
113
+ size_bytes: file[:size]
72
114
  )
73
- return api_error(presign_result, prefix: 'Failed to get upload URL') unless presign_result[:ok]
115
+ unless presign_result[:ok]
116
+ return { ok: false, filename: file[:filename], error: error_text(presign_result, 'Failed to get upload URL') }
117
+ end
74
118
 
75
119
  presign = presign_result[:data]['presign']
76
- upload_result = client.upload_to_presigned_url(presign['upload_url'], file_path, content_type)
77
- return api_error(upload_result, prefix: 'Upload failed') unless upload_result[:ok]
120
+ upload_result = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
121
+ unless upload_result[:ok]
122
+ return { ok: false, filename: file[:filename], error: error_text(upload_result, 'Upload failed') }
123
+ end
78
124
 
79
125
  create_result = client.create_attachment(
80
126
  requirement_id,
81
127
  attachment_id: presign['attachment_id'],
82
128
  s3_key: presign['s3_key'],
83
- filename: filename,
84
- content_type: content_type,
85
- size_bytes: size,
129
+ filename: file[:filename],
130
+ content_type: file[:content_type],
131
+ size_bytes: file[:size],
86
132
  surface: opts[:surface],
87
133
  captured_at: opts[:captured_at],
88
134
  deployed_at: opts[:deployed_at],
@@ -92,20 +138,41 @@ module Fp
92
138
  example_title: opts[:example_title],
93
139
  evidence_key: opts[:evidence_key]
94
140
  )
95
- return api_error(create_result, prefix: 'Failed to confirm upload') unless create_result[:ok]
96
-
97
- attachment = create_result[:data]['attachment']
98
- output.success({ attachment: attachment }) do
99
- puts "Uploaded #{filename} to requirement '#{slug}'."
100
- puts " ID: #{attachment['id']}"
101
- puts " Surface: #{attachment['surface']}" if attachment['surface']
102
- puts " Test: #{attachment['example_title']}" if attachment['example_title']
103
- puts " Evidence key: #{attachment['evidence_key']}" if attachment['evidence_key']
104
- puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
105
- puts " Deploy: #{attachment['deployment_url']}" if attachment['deployment_url']
106
- puts ' Deployment context: unknown' if VISUAL_EXTENSIONS.include?(extension) && attachment['deployed_at'].to_s.empty?
141
+ unless create_result[:ok]
142
+ return { ok: false, filename: file[:filename], error: error_text(create_result, 'Failed to confirm upload') }
143
+ end
144
+
145
+ { ok: true, filename: file[:filename], extension: file[:extension], attachment: create_result[:data]['attachment'] }
146
+ end
147
+
148
+ def report_batch(results, slug)
149
+ uploaded = results.select { |r| r[:ok] }
150
+ failed = results.reject { |r| r[:ok] }
151
+
152
+ output.success({ slug: slug, uploaded: uploaded.map { |r| r[:attachment] }, failed: failed.map { |r| { filename: r[:filename], error: r[:error] } } }) do
153
+ uploaded.each { |r| print_uploaded(r, slug) }
154
+ failed.each { |r| $stderr.puts "Failed: #{r[:filename]}: #{r[:error]}" }
155
+ if results.length > 1
156
+ puts
157
+ puts "#{uploaded.length} of #{results.length} file(s) uploaded to requirement '#{slug}'."
158
+ end
159
+ end
160
+
161
+ failed.empty? ? 0 : 1
162
+ end
163
+
164
+ def print_uploaded(result, slug)
165
+ attachment = result[:attachment]
166
+ puts "Uploaded #{result[:filename]} to requirement '#{slug}'."
167
+ puts " ID: #{attachment['id']}"
168
+ puts " Surface: #{attachment['surface']}" if attachment['surface']
169
+ puts " Test: #{attachment['example_title']}" if attachment['example_title']
170
+ puts " Evidence key: #{attachment['evidence_key']}" if attachment['evidence_key']
171
+ puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
172
+ puts " Deploy: #{attachment['deployment_url']}" if attachment['deployment_url']
173
+ if VISUAL_EXTENSIONS.include?(result[:extension]) && attachment['deployed_at'].to_s.empty?
174
+ puts ' Deployment context: unknown'
107
175
  end
108
- 0
109
176
  end
110
177
 
111
178
  def delete_attachment(opts, positional)
@@ -128,27 +195,23 @@ module Fp
128
195
 
129
196
  def usage_error(message, delete: false)
130
197
  usage = delete ? 'fp attach rm <slug> <id> [--project <project>]' :
131
- 'fp attach <slug> <file> [--surface <surface>] [--project <project>]'
198
+ 'fp attach <slug> <file> [<file> ...] [--surface <surface>] [--project <project>]'
132
199
  command_error("#{message}. Usage: #{usage}")
133
200
  end
134
201
 
135
202
  def api_error(result, prefix: nil)
136
- message = [prefix, result[:error]].compact.join(': ')
137
- output.error(message, status: result[:status])
203
+ output.error(error_text(result, prefix), status: result[:status])
138
204
  1
139
205
  end
140
206
 
207
+ def error_text(result, prefix = nil)
208
+ [prefix, result[:error]].compact.join(': ')
209
+ end
210
+
141
211
  def command_error(message)
142
212
  output.error(message)
143
213
  1
144
214
  end
145
-
146
- def format_size(bytes)
147
- return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
148
- return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
149
-
150
- "#{bytes} bytes"
151
- end
152
215
  end
153
216
  end
154
217
  end
@@ -6,6 +6,12 @@ module Fp
6
6
  module Commands
7
7
  # List a requirement's attachments, or get a short-lived URL for one.
8
8
  class Attachments < Base
9
+ help_summary "List a requirement's attachments, or get a download URL for one"
10
+ help_usage 'fp attachments <slug> --project <slug>',
11
+ 'fp attachments <slug> <attachment-id> --project <slug>'
12
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
13
+ help_example 'fp attachments print_container_qr --project stowzilla'
14
+
9
15
  KNOWN_FLAGS = { project: :string }.freeze
10
16
 
11
17
  def run(args)
@@ -4,6 +4,142 @@ module Fp
4
4
  module Commands
5
5
  # Base class for all commands
6
6
  class Base
7
+ # --- Per-command help (Fizzy #1489) -------------------------------------
8
+ #
9
+ # Every command declares its own usage, a one-line summary, its flags, and a
10
+ # few examples here, so `fp <cmd> --help`, `fp <cmd> -h`, and `fp help <cmd>`
11
+ # can all print them. Before this, a command's flags were only discoverable by
12
+ # reading KNOWN_FLAGS in the gem — `fp report --help` just ran `fp report` and
13
+ # failed validation.
14
+ #
15
+ # This is a small declarative DSL rather than a scrape of KNOWN_FLAGS on
16
+ # purpose: the flag constants are named inconsistently across commands
17
+ # (KNOWN_FLAGS, ADD_FLAGS, CREATE_FLAGS, FIELD_FLAGS...), some commands take
18
+ # positional subcommands with no constant at all, and — most importantly — a
19
+ # bare list of flag *names* barely improves on "read the gem". The value an
20
+ # agent needs is what each flag means and which are required, which only a
21
+ # written description carries.
22
+ class << self
23
+ # A one-line description shown in `fp help` and at the top of per-command help.
24
+ def help_summary(text = nil)
25
+ @help_summary = text if text
26
+ @help_summary
27
+ end
28
+
29
+ # One or more usage lines, e.g. "fp report <slug> --project <slug> --surface X".
30
+ def help_usage(*lines)
31
+ @help_usage ||= []
32
+ @help_usage.concat(lines.flatten) unless lines.empty?
33
+ @help_usage
34
+ end
35
+
36
+ # Declare a flag: name (symbol or string), a description, and whether it's
37
+ # required. Order of declaration is the order shown.
38
+ def help_flag(name, description, required: false)
39
+ @help_flags ||= []
40
+ @help_flags << { name: name.to_s.tr('_', '-'), description: description, required: required }
41
+ end
42
+
43
+ def help_flags
44
+ @help_flags ||= []
45
+ end
46
+
47
+ # Example invocations, each an array [command_line, optional_comment].
48
+ def help_example(command_line, comment = nil)
49
+ @help_examples ||= []
50
+ @help_examples << { command: command_line, comment: comment }
51
+ end
52
+
53
+ def help_examples
54
+ @help_examples ||= []
55
+ end
56
+
57
+ # A short free-form note shown after the flags (caveats, related commands).
58
+ def help_note(text = nil)
59
+ @help_note = text if text
60
+ @help_note
61
+ end
62
+
63
+ # True when a command has opted into the help DSL. Commands that haven't
64
+ # still get a generic help page listing global flags.
65
+ def help_defined?
66
+ !help_summary.nil? || !help_usage.empty? || !help_flags.empty?
67
+ end
68
+
69
+ # Render the full help page for this command.
70
+ # command_name - the dispatch name (e.g. "ci-report"), so usage lines and
71
+ # the header match what the user typed.
72
+ def help_text(command_name)
73
+ lines = []
74
+ lines << "fp #{command_name} - #{help_summary}" if help_summary
75
+ lines << '' unless lines.empty?
76
+
77
+ usage = help_usage.empty? ? ["fp #{command_name} [flags]"] : help_usage
78
+ lines << 'USAGE'
79
+ usage.each { |u| lines << " #{u}" }
80
+
81
+ unless help_flags.empty?
82
+ lines << ''
83
+ lines << 'FLAGS'
84
+ width = help_flags.map { |f| f[:name].length }.max
85
+ help_flags.each do |f|
86
+ label = "--#{f[:name].ljust(width)}"
87
+ req = f[:required] ? ' (required)' : ''
88
+ lines << " #{label} #{f[:description]}#{req}"
89
+ end
90
+ end
91
+
92
+ lines << ''
93
+ lines << 'GLOBAL FLAGS'
94
+ lines << ' --json Output as a JSON envelope'
95
+ lines << ' --profile <name> Use a named profile (-p for short)'
96
+ lines << ' --api-url <url> Override the API URL for this command'
97
+
98
+ unless help_examples.empty?
99
+ lines << ''
100
+ lines << 'EXAMPLES'
101
+ help_examples.each do |ex|
102
+ lines << " # #{ex[:comment]}" if ex[:comment]
103
+ lines << " #{ex[:command]}"
104
+ end
105
+ end
106
+
107
+ if help_note
108
+ lines << ''
109
+ help_note.each_line { |l| lines << " #{l.chomp}" }
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ # The command's flag schema as a plain hash (Fizzy #1489 ask 4). `--json` on a
116
+ # help invocation emits this, so agents and the Agent Skills package can read a
117
+ # command's real flags — names, descriptions, required-ness, usage, examples —
118
+ # straight from the installed gem and stay in sync with it automatically.
119
+ #
120
+ # Global flags are reported separately from the command's own so a consumer can
121
+ # tell "every command accepts --json" from "this command accepts --surface".
122
+ def help_schema(command_name)
123
+ {
124
+ 'command' => command_name,
125
+ 'summary' => help_summary,
126
+ 'usage' => (help_usage.empty? ? ["fp #{command_name} [flags]"] : help_usage),
127
+ 'flags' => help_flags.map do |f|
128
+ { 'name' => f[:name], 'description' => f[:description], 'required' => f[:required] }
129
+ end,
130
+ 'global_flags' => [
131
+ { 'name' => 'json', 'description' => 'Output as a JSON envelope', 'required' => false },
132
+ { 'name' => 'profile', 'description' => 'Use a named profile (-p for short)', 'required' => false },
133
+ { 'name' => 'api-url', 'description' => 'Override the API URL for this command', 'required' => false }
134
+ ],
135
+ 'examples' => help_examples.map do |ex|
136
+ { 'command' => ex[:command], 'comment' => ex[:comment] }
137
+ end,
138
+ 'note' => help_note
139
+ }
140
+ end
141
+ end
142
+
7
143
  def initialize(client: nil, output: nil, config: nil)
8
144
  @client = client
9
145
  @output = output
@@ -103,6 +239,14 @@ module Fp
103
239
  opts[:project]
104
240
  end
105
241
 
242
+ # "12.3 MB" / "4.0 KB" / "512 bytes", for upload limits and file listings.
243
+ def format_size(bytes)
244
+ return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
245
+ return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
246
+
247
+ "#{bytes} bytes"
248
+ end
249
+
106
250
  def truncate(str, max)
107
251
  return str if str.nil? || str.length <= max
108
252
 
@@ -6,6 +6,18 @@ module Fp
6
6
  # exact candidate fingerprint.
7
7
  class ChangedTests < Base
8
8
  CLASSIFICATIONS = %w[still_proves no_longer_proves uncertain].freeze
9
+
10
+ help_summary 'List CI-detected revisions to bound tests, and assess a candidate'
11
+ help_usage 'fp changed-tests [--json]',
12
+ 'fp changed-tests assess <finding-id> --candidate-fingerprint <sha256> --classification C --explanation "..."'
13
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
14
+ help_flag :surface, 'Limit to one surface'
15
+ help_flag :test_type, 'Limit to one test type'
16
+ help_flag :classification, "Assessment: #{CLASSIFICATIONS.join(', ')}"
17
+ help_flag :explanation, 'Plain-language reason for the assessment'
18
+ help_flag :candidate_fingerprint, 'The exact SHA-256 being assessed'
19
+ help_example 'fp changed-tests'
20
+ help_example 'fp changed-tests assess abc123 --candidate-fingerprint <sha> --classification still_proves --explanation "Same result asserted"'
9
21
  KNOWN_FLAGS = {
10
22
  project: :string,
11
23
  surface: :string,
@@ -11,6 +11,15 @@ module Fp
11
11
  # Actions it can discover source URLs from the pull request body, append a job
12
12
  # summary, and keep a managed table in the pull request description itself.
13
13
  class Changes < Base
14
+ help_summary 'Show (or publish to a PR) requirement activity linked to a work item'
15
+ help_usage 'fp changes [--source-url URL]... [--github-pr] [--github-summary]'
16
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
17
+ help_flag :source_url, 'Work-item URL to match (repeatable; auto-discovered in a PR body otherwise)'
18
+ help_flag :github_pr, "Write a managed table into the pull request's description"
19
+ help_flag :github_summary, "Append the table to the GitHub Actions job summary"
20
+ help_example 'fp changes --source-url https://app.fizzy.do/123/cards/456'
21
+ help_example 'fp changes --github-pr --github-summary', 'In CI: keep a linked table in the PR'
22
+
14
23
  PR_SECTION_BEGIN = '<!-- feature-parity:requirement-changes:begin -->'
15
24
  PR_SECTION_END = '<!-- feature-parity:requirement-changes:end -->'
16
25