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 +4 -4
- data/lib/fp/cli.rb +40 -0
- data/lib/fp/client.rb +70 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -46
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +144 -0
- data/lib/fp/commands/changed_tests.rb +12 -0
- data/lib/fp/commands/changes.rb +9 -0
- data/lib/fp/commands/check.rb +41 -3
- data/lib/fp/commands/ci_report.rb +17 -0
- data/lib/fp/commands/comment.rb +10 -0
- data/lib/fp/commands/config_cmd.rb +9 -0
- data/lib/fp/commands/conversation.rb +5 -0
- data/lib/fp/commands/export.rb +10 -0
- data/lib/fp/commands/help.rb +81 -6
- data/lib/fp/commands/history.rb +8 -0
- data/lib/fp/commands/init.rb +50 -1
- data/lib/fp/commands/list.rb +12 -0
- data/lib/fp/commands/matrix.rb +9 -0
- data/lib/fp/commands/profile.rb +9 -0
- data/lib/fp/commands/projects.rb +63 -3
- data/lib/fp/commands/propose.rb +18 -0
- data/lib/fp/commands/propose_change.rb +73 -2
- data/lib/fp/commands/report.rb +157 -7
- data/lib/fp/commands/repos.rb +11 -0
- data/lib/fp/commands/setup.rb +11 -0
- data/lib/fp/commands/show.rb +42 -3
- data/lib/fp/commands/showroom.rb +194 -0
- data/lib/fp/commands/showroom_media.rb +334 -0
- data/lib/fp/commands/surfaces.rb +142 -3
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +45 -0
- data/skills/fp/references/cli.md +64 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fd282368d3fb2417adaf990518352726bc46fe816d70e73ec04f57e2a5ea067
|
|
4
|
+
data.tar.gz: 7edf6bda3e44e4b8cce99efbe59d1adff0cbfe9ab2b8c24613a4b0eaf456cb7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/fp/commands/analyze.rb
CHANGED
|
@@ -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,
|
data/lib/fp/commands/attach.rb
CHANGED
|
@@ -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
|
-
|
|
50
|
+
upload_attachments(opts, positional)
|
|
35
51
|
end
|
|
36
52
|
|
|
37
53
|
private
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
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
|
|
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
|
|
95
|
+
return { error: "File is empty: #{file_path}" } if size.zero?
|
|
54
96
|
if size > MAX_FILE_SIZE
|
|
55
|
-
return
|
|
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
|
|
100
|
+
return { error: '--surface is required for screenshot and video evidence' }
|
|
59
101
|
end
|
|
60
102
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'],
|
|
77
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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)
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -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,
|
data/lib/fp/commands/changes.rb
CHANGED
|
@@ -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
|
|