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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +40 -0
- data/lib/fp/client.rb +34 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -39
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +136 -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 -5
- data/lib/fp/commands/history.rb +20 -1
- 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 +141 -0
- data/lib/fp/commands/surfaces.rb +258 -0
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +27 -0
- data/skills/fp/references/cli.md +52 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b06053deae8d893e793e13d2dd56773db5ff1ce04d2cc5f2be40b65da3520d11
|
|
4
|
+
data.tar.gz: 0c7d0b2ae20e481fedab84bf188d7a3b8c3ae47d91c0b16c816d49e697d2f16a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8f6ae3cedd0abb1c6a1cc248092d83f1643858d08aee76bd4d6707f1a5072bbc1f06d0ba83ed35cc79cd36afd24b543e625d58c6f9044c102f47b77a4cc2d79
|
|
7
|
+
data.tar.gz: c938e91cd9bbf92197bb0a85dcb6488d0c0621ed6760f12bdac0e4c23002bac09ca7232d3a12d93618011261d90150f98a02dc861e0f2b893bc8ac9f003d0b10
|
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
|
@@ -72,6 +72,30 @@ module Fp
|
|
|
72
72
|
post("/api/projects/#{project_id}/surfaces", params)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# PUT /projects/:id/surfaces/:surface_id
|
|
76
|
+
def update_surface(project_id, surface_id, params)
|
|
77
|
+
put("/api/projects/#{project_id}/surfaces/#{surface_id}", params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# DELETE /projects/:id/surfaces/:surface_id
|
|
81
|
+
def delete_surface(project_id, surface_id)
|
|
82
|
+
delete("/api/projects/#{project_id}/surfaces/#{surface_id}")
|
|
83
|
+
end
|
|
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
|
+
|
|
75
99
|
# GET /requirements
|
|
76
100
|
def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
|
|
77
101
|
params = {}
|
|
@@ -169,6 +193,16 @@ module Fp
|
|
|
169
193
|
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
170
194
|
end
|
|
171
195
|
|
|
196
|
+
# DELETE /requirements/:id/changes/:change_id
|
|
197
|
+
#
|
|
198
|
+
# Withdraw an open proposal. Only the proposal's author can withdraw it; the
|
|
199
|
+
# requirement is restored to what it was before the proposal (the same rollback a
|
|
200
|
+
# reject performs). This is how an agent *amends* a proposal under the one-open-
|
|
201
|
+
# proposal rule: withdraw, then propose again.
|
|
202
|
+
def withdraw_requirement_change(requirement_id, change_id)
|
|
203
|
+
delete("/api/requirements/#{requirement_id}/changes/#{change_id}")
|
|
204
|
+
end
|
|
205
|
+
|
|
172
206
|
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
173
207
|
# optionally narrowed to one requirement slug and/or surface.
|
|
174
208
|
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,16 +195,19 @@ 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
|
|
@@ -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
|
|
@@ -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
|
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -36,6 +36,21 @@ module Fp
|
|
|
36
36
|
# one. Prefer setting the project's level to `regression` over living on --warn-only
|
|
37
37
|
# forever, since a warn-only gate is one nobody reads.
|
|
38
38
|
class Check < Base
|
|
39
|
+
help_summary 'Enforce parity; exits non-zero when the project violates its level'
|
|
40
|
+
help_usage 'fp check [--project <slug>] [--surface X] [--level L] [--junit FILE]... [--warn-only]'
|
|
41
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
42
|
+
help_flag :surface, 'Gate a single surface'
|
|
43
|
+
help_flag :test_type, 'Gate a single test type'
|
|
44
|
+
help_flag :level, 'Override the enforcement level for this run (off/regression/covered/strict)'
|
|
45
|
+
help_flag :junit, "Gate the branch on this run's results, overlaid on the grid (repeatable, one per surface)"
|
|
46
|
+
help_flag :base_dir, "Where the report's relative test-file paths resolve from"
|
|
47
|
+
help_flag :warn_only, 'Report violations but still exit 0'
|
|
48
|
+
help_example 'fp check', 'Gate on recorded state (project from .featureparity.yml)'
|
|
49
|
+
help_example 'fp check --surface api --junit junit.xml', "Gate a PR branch on its own results"
|
|
50
|
+
help_example 'fp check --warn-only --json'
|
|
51
|
+
help_note "Exit 0 = passed, 1 = violations (or request failed). The level lives on the project\n" \
|
|
52
|
+
'server-side; --level only overrides this one run.'
|
|
53
|
+
|
|
39
54
|
KNOWN_FLAGS = {
|
|
40
55
|
project: :string,
|
|
41
56
|
surface: :string,
|
|
@@ -182,11 +197,12 @@ module Fp
|
|
|
182
197
|
end
|
|
183
198
|
|
|
184
199
|
print_state_counts(summary)
|
|
200
|
+
print_scope_notice(report)
|
|
185
201
|
print_violations(violations)
|
|
186
202
|
print_orphans(report)
|
|
187
203
|
print_pending_reviews(report, project_slug)
|
|
188
204
|
print_unscoped_warning(summary, project_slug)
|
|
189
|
-
print_verdict(passed, violations, opts)
|
|
205
|
+
print_verdict(passed, violations, opts, report)
|
|
190
206
|
end
|
|
191
207
|
end
|
|
192
208
|
|
|
@@ -198,6 +214,24 @@ module Fp
|
|
|
198
214
|
puts
|
|
199
215
|
end
|
|
200
216
|
|
|
217
|
+
# A surface-scoped run (--surface, or a single `surface:` in .featureparity.yml)
|
|
218
|
+
# only measures the one surface it was given. That's correct for a per-surface CI
|
|
219
|
+
# job, but it means the verdict covers a slice of the project — so a bare "Gate
|
|
220
|
+
# passed" would read as "the whole project is in parity" when other required
|
|
221
|
+
# surfaces were never looked at (Fizzy #1490). Name them up front, and the verdict
|
|
222
|
+
# line qualifies itself to match.
|
|
223
|
+
def print_scope_notice(report)
|
|
224
|
+
return unless report['scoped']
|
|
225
|
+
|
|
226
|
+
excluded = report['scoped_out_surfaces'] || []
|
|
227
|
+
return if excluded.empty?
|
|
228
|
+
|
|
229
|
+
puts "Scope: this check only evaluated the '#{report['surface']}' surface."
|
|
230
|
+
puts " #{excluded.size} other required surface(s) were NOT checked: #{excluded.join(', ')}."
|
|
231
|
+
puts ' Run fp check per surface (or without --surface) to cover the whole project.'
|
|
232
|
+
puts
|
|
233
|
+
end
|
|
234
|
+
|
|
201
235
|
# Requirements a proposed change has knocked back to draft. Informational, never a
|
|
202
236
|
# violation — a human's review latency is not something to fail an agent's build
|
|
203
237
|
# over. But they stay in the grid (proposing a change must not be a way out of the
|
|
@@ -302,9 +336,13 @@ module Fp
|
|
|
302
336
|
puts
|
|
303
337
|
end
|
|
304
338
|
|
|
305
|
-
def print_verdict(passed, violations, opts)
|
|
339
|
+
def print_verdict(passed, violations, opts, report = {})
|
|
340
|
+
# A scoped pass is not a project pass: say which surface it passed for, so nobody
|
|
341
|
+
# reads "Gate passed" as "the whole project is covered" (Fizzy #1490).
|
|
342
|
+
scope = report['scoped'] ? " for surface '#{report['surface']}' (other required surfaces not checked)" : ''
|
|
343
|
+
|
|
306
344
|
if passed
|
|
307
|
-
puts
|
|
345
|
+
puts "Gate passed#{scope}."
|
|
308
346
|
elsif opts[:warn_only]
|
|
309
347
|
puts "Gate FAILED with #{violations.size} violation(s) — exiting 0 because --warn-only was given."
|
|
310
348
|
else
|