featureparity 0.0.16 → 0.0.18
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 +3 -0
- data/lib/fp/client.rb +87 -18
- data/lib/fp/commands/analyze.rb +213 -0
- data/lib/fp/commands/attach.rb +100 -136
- data/lib/fp/commands/attachments.rb +56 -83
- data/lib/fp/commands/changed_tests.rb +126 -0
- data/lib/fp/commands/ci_report.rb +133 -28
- data/lib/fp/commands/comment.rb +99 -0
- data/lib/fp/commands/help.rb +65 -4
- data/lib/fp/commands/init.rb +52 -12
- data/lib/fp/commands/propose_change.rb +46 -1
- data/lib/fp/commands/report.rb +64 -7
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/junit.rb +50 -5
- data/lib/fp/source_fingerprint.rb +193 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +63 -0
- data/skills/fp/references/cli.md +92 -1
- metadata +6 -2
data/lib/fp/commands/attach.rb
CHANGED
|
@@ -4,186 +4,150 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
module Fp
|
|
6
6
|
module Commands
|
|
7
|
-
# Upload
|
|
8
|
-
#
|
|
9
|
-
# Usage:
|
|
10
|
-
# fp attach <slug> <file> Upload a file to a requirement
|
|
11
|
-
# fp attachments <slug> List attachments for a requirement
|
|
12
|
-
# fp attachment <slug> <id> Get download URL for an attachment
|
|
13
|
-
# fp attachment rm <slug> <id> Delete an attachment
|
|
7
|
+
# Upload visual test evidence or another supporting file to a requirement.
|
|
14
8
|
class Attach < Base
|
|
15
|
-
# Allowed content types and their file extensions
|
|
16
9
|
CONTENT_TYPES = {
|
|
17
|
-
'png' => 'image/png',
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
|
11
|
+
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
|
|
12
|
+
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
|
|
13
|
+
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
|
|
14
|
+
'json' => 'application/json'
|
|
15
|
+
}.freeze
|
|
16
|
+
VISUAL_EXTENSIONS = %w[png jpg jpeg gif webp mp4 webm mov].freeze
|
|
17
|
+
MAX_FILE_SIZE = 100 * 1024 * 1024
|
|
18
|
+
KNOWN_FLAGS = {
|
|
19
|
+
project: :string,
|
|
20
|
+
surface: :string,
|
|
21
|
+
captured_at: :string,
|
|
22
|
+
deployed_at: :string,
|
|
23
|
+
deployment_url: :string,
|
|
24
|
+
test_run_url: :string,
|
|
25
|
+
sha: :string,
|
|
26
|
+
example_title: :string,
|
|
27
|
+
evidence_key: :string
|
|
26
28
|
}.freeze
|
|
27
|
-
|
|
28
|
-
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
29
29
|
|
|
30
30
|
def run(args)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
upload_attachment(args)
|
|
36
|
-
end
|
|
31
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
32
|
+
return delete_attachment(opts, positional.drop(1)) if %w[rm delete].include?(positional.first)
|
|
33
|
+
|
|
34
|
+
upload_attachment(opts, positional)
|
|
37
35
|
end
|
|
38
36
|
|
|
39
37
|
private
|
|
40
38
|
|
|
41
|
-
def upload_attachment(
|
|
42
|
-
slug =
|
|
43
|
-
|
|
39
|
+
def upload_attachment(opts, positional)
|
|
40
|
+
slug, file_path = positional
|
|
41
|
+
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)
|
|
44
44
|
|
|
45
|
-
if slug.nil? || slug.empty?
|
|
46
|
-
Output.error('Missing requirement slug. Usage: fp attach <slug> <file>')
|
|
47
|
-
return 1
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
if file_path.nil? || file_path.empty?
|
|
51
|
-
Output.error('Missing file path. Usage: fp attach <slug> <file>')
|
|
52
|
-
return 1
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
unless File.exist?(file_path)
|
|
56
|
-
Output.error("File not found: #{file_path}")
|
|
57
|
-
return 1
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
project_id = require_project_id!
|
|
61
|
-
return 1 unless project_id
|
|
62
|
-
|
|
63
|
-
# Validate file
|
|
64
45
|
filename = File.basename(file_path)
|
|
65
|
-
|
|
66
|
-
content_type = CONTENT_TYPES[
|
|
67
|
-
|
|
46
|
+
extension = File.extname(filename).delete('.').downcase
|
|
47
|
+
content_type = CONTENT_TYPES[extension]
|
|
68
48
|
unless content_type
|
|
69
|
-
|
|
70
|
-
Output.info("Allowed: #{CONTENT_TYPES.keys.join(', ')}")
|
|
71
|
-
return 1
|
|
49
|
+
return command_error("Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}")
|
|
72
50
|
end
|
|
73
51
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return
|
|
52
|
+
size = File.size(file_path)
|
|
53
|
+
return command_error("File is empty: #{file_path}") if size.zero?
|
|
54
|
+
if size > MAX_FILE_SIZE
|
|
55
|
+
return command_error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
|
|
78
56
|
end
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
result = client.find_requirement_by_slug(project_id, slug)
|
|
82
|
-
unless result[:ok]
|
|
83
|
-
Output.error(result[:error])
|
|
84
|
-
return 1
|
|
57
|
+
if VISUAL_EXTENSIONS.include?(extension) && opts[:surface].to_s.empty?
|
|
58
|
+
return command_error('--surface is required for screenshot and video evidence')
|
|
85
59
|
end
|
|
86
60
|
|
|
87
|
-
|
|
88
|
-
|
|
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]
|
|
89
65
|
|
|
90
|
-
|
|
91
|
-
Output.info("Requesting upload URL for #{filename}...")
|
|
66
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
92
67
|
presign_result = client.presign_attachment(
|
|
93
68
|
requirement_id,
|
|
94
69
|
filename: filename,
|
|
95
70
|
content_type: content_type,
|
|
96
|
-
size_bytes:
|
|
71
|
+
size_bytes: size
|
|
97
72
|
)
|
|
73
|
+
return api_error(presign_result, prefix: 'Failed to get upload URL') unless presign_result[:ok]
|
|
98
74
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
presign_data = presign_result[:data]['presign']
|
|
105
|
-
upload_url = presign_data['upload_url']
|
|
106
|
-
attachment_id = presign_data['attachment_id']
|
|
107
|
-
s3_key = presign_data['s3_key']
|
|
108
|
-
|
|
109
|
-
# Step 2: Upload to S3
|
|
110
|
-
Output.info("Uploading #{filename} (#{format_size(file_size)})...")
|
|
111
|
-
upload_result = client.upload_to_presigned_url(upload_url, file_path, content_type)
|
|
112
|
-
|
|
113
|
-
unless upload_result[:ok]
|
|
114
|
-
Output.error("Upload failed: #{upload_result[:error]}")
|
|
115
|
-
return 1
|
|
116
|
-
end
|
|
75
|
+
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]
|
|
117
78
|
|
|
118
|
-
# Step 3: Confirm upload
|
|
119
|
-
Output.info('Confirming upload...')
|
|
120
79
|
create_result = client.create_attachment(
|
|
121
80
|
requirement_id,
|
|
122
|
-
attachment_id: attachment_id,
|
|
123
|
-
s3_key: s3_key,
|
|
81
|
+
attachment_id: presign['attachment_id'],
|
|
82
|
+
s3_key: presign['s3_key'],
|
|
124
83
|
filename: filename,
|
|
125
84
|
content_type: content_type,
|
|
126
|
-
size_bytes:
|
|
85
|
+
size_bytes: size,
|
|
86
|
+
surface: opts[:surface],
|
|
87
|
+
captured_at: opts[:captured_at],
|
|
88
|
+
deployed_at: opts[:deployed_at],
|
|
89
|
+
deployment_url: opts[:deployment_url],
|
|
90
|
+
test_run_url: opts[:test_run_url],
|
|
91
|
+
sha: opts[:sha],
|
|
92
|
+
example_title: opts[:example_title],
|
|
93
|
+
evidence_key: opts[:evidence_key]
|
|
127
94
|
)
|
|
128
|
-
|
|
129
|
-
unless create_result[:ok]
|
|
130
|
-
Output.error("Failed to confirm upload: #{create_result[:error]}")
|
|
131
|
-
return 1
|
|
132
|
-
end
|
|
95
|
+
return api_error(create_result, prefix: 'Failed to confirm upload') unless create_result[:ok]
|
|
133
96
|
|
|
134
97
|
attachment = create_result[:data]['attachment']
|
|
135
|
-
|
|
136
|
-
|
|
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?
|
|
107
|
+
end
|
|
137
108
|
0
|
|
138
109
|
end
|
|
139
110
|
|
|
140
|
-
def delete_attachment(
|
|
141
|
-
slug =
|
|
142
|
-
|
|
111
|
+
def delete_attachment(opts, positional)
|
|
112
|
+
slug, attachment_id = positional
|
|
113
|
+
return usage_error('Missing requirement slug', delete: true) if slug.to_s.empty?
|
|
114
|
+
return usage_error('Missing attachment ID', delete: true) if attachment_id.to_s.empty?
|
|
143
115
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
116
|
+
require_project!(opts)
|
|
117
|
+
project_id = resolve_project_id(opts[:project])
|
|
118
|
+
requirement_result = client.find_requirement_by_slug(project_id, slug)
|
|
119
|
+
return api_error(requirement_result) unless requirement_result[:ok]
|
|
148
120
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
project_id = require_project_id!
|
|
155
|
-
return 1 unless project_id
|
|
156
|
-
|
|
157
|
-
# Resolve requirement
|
|
158
|
-
result = client.find_requirement_by_slug(project_id, slug)
|
|
159
|
-
unless result[:ok]
|
|
160
|
-
Output.error(result[:error])
|
|
161
|
-
return 1
|
|
162
|
-
end
|
|
121
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
122
|
+
result = client.delete_attachment(requirement_id, attachment_id)
|
|
123
|
+
return api_error(result, prefix: 'Failed to delete attachment') unless result[:ok]
|
|
163
124
|
|
|
164
|
-
|
|
165
|
-
|
|
125
|
+
output.success({ id: attachment_id, deleted: true }) { puts "Deleted attachment #{attachment_id}." }
|
|
126
|
+
0
|
|
127
|
+
end
|
|
166
128
|
|
|
167
|
-
|
|
168
|
-
|
|
129
|
+
def usage_error(message, delete: false)
|
|
130
|
+
usage = delete ? 'fp attach rm <slug> <id> [--project <project>]' :
|
|
131
|
+
'fp attach <slug> <file> [--surface <surface>] [--project <project>]'
|
|
132
|
+
command_error("#{message}. Usage: #{usage}")
|
|
133
|
+
end
|
|
169
134
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
135
|
+
def api_error(result, prefix: nil)
|
|
136
|
+
message = [prefix, result[:error]].compact.join(': ')
|
|
137
|
+
output.error(message, status: result[:status])
|
|
138
|
+
1
|
|
139
|
+
end
|
|
174
140
|
|
|
175
|
-
|
|
176
|
-
|
|
141
|
+
def command_error(message)
|
|
142
|
+
output.error(message)
|
|
143
|
+
1
|
|
177
144
|
end
|
|
178
145
|
|
|
179
146
|
def format_size(bytes)
|
|
180
|
-
if bytes >= 1024 * 1024
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
else
|
|
185
|
-
"#{bytes} bytes"
|
|
186
|
-
end
|
|
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"
|
|
187
151
|
end
|
|
188
152
|
end
|
|
189
153
|
end
|
|
@@ -4,118 +4,91 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
module Fp
|
|
6
6
|
module Commands
|
|
7
|
-
# List
|
|
8
|
-
#
|
|
9
|
-
# Usage:
|
|
10
|
-
# fp attachments <slug> List attachments for a requirement
|
|
11
|
-
# fp attachments <slug> <id> Get download URL for an attachment
|
|
7
|
+
# List a requirement's attachments, or get a short-lived URL for one.
|
|
12
8
|
class Attachments < Base
|
|
13
|
-
|
|
14
|
-
slug = args[0]
|
|
15
|
-
attachment_id = args[1]
|
|
16
|
-
|
|
17
|
-
if slug.nil? || slug.empty?
|
|
18
|
-
Output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>]')
|
|
19
|
-
return 1
|
|
20
|
-
end
|
|
9
|
+
KNOWN_FLAGS = { project: :string }.freeze
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
unless result[:ok]
|
|
28
|
-
Output.error(result[:error])
|
|
11
|
+
def run(args)
|
|
12
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
13
|
+
slug, attachment_id = positional
|
|
14
|
+
if slug.to_s.empty?
|
|
15
|
+
output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>] [--project <project>]')
|
|
29
16
|
return 1
|
|
30
17
|
end
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
require_project!(opts)
|
|
20
|
+
project_id = resolve_project_id(opts[:project])
|
|
21
|
+
requirement_result = client.find_requirement_by_slug(project_id, slug)
|
|
22
|
+
return api_error(requirement_result) unless requirement_result[:ok]
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
else
|
|
38
|
-
list_attachments(requirement_id, slug)
|
|
39
|
-
end
|
|
24
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
25
|
+
attachment_id ? show_attachment(requirement_id, attachment_id) : list_attachments(requirement_id, slug)
|
|
40
26
|
end
|
|
41
27
|
|
|
42
28
|
private
|
|
43
29
|
|
|
44
30
|
def list_attachments(requirement_id, slug)
|
|
45
31
|
result = client.list_attachments(requirement_id)
|
|
46
|
-
|
|
47
|
-
unless result[:ok]
|
|
48
|
-
Output.error(result[:error])
|
|
49
|
-
return 1
|
|
50
|
-
end
|
|
32
|
+
return api_error(result) unless result[:ok]
|
|
51
33
|
|
|
52
34
|
attachments = result[:data]['attachments'] || []
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
puts " Filename: #{att['filename']}"
|
|
68
|
-
puts " Type: #{att['content_type']}"
|
|
69
|
-
puts " Size: #{format_size(att['size_bytes'])}"
|
|
70
|
-
puts " Uploaded by: #{att['uploaded_by']}"
|
|
71
|
-
puts " Uploaded at: #{att['created_at']}"
|
|
72
|
-
puts
|
|
35
|
+
output.success({ attachments: attachments }) do
|
|
36
|
+
if attachments.empty?
|
|
37
|
+
puts "No attachments for requirement '#{slug}'."
|
|
38
|
+
else
|
|
39
|
+
puts "Attachments for '#{slug}':"
|
|
40
|
+
attachments.each do |attachment|
|
|
41
|
+
puts
|
|
42
|
+
puts " #{attachment['id']} #{attachment['filename']} (#{format_size(attachment['size_bytes'])})"
|
|
43
|
+
puts " Surface: #{attachment['surface']}" if attachment['surface']
|
|
44
|
+
puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
|
|
45
|
+
puts " Deployment: #{deployment_label(attachment)}"
|
|
46
|
+
puts " Test run: #{attachment['test_run_url']}" if attachment['test_run_url']
|
|
47
|
+
end
|
|
48
|
+
end
|
|
73
49
|
end
|
|
74
|
-
|
|
75
50
|
0
|
|
76
51
|
end
|
|
77
52
|
|
|
78
|
-
def show_attachment(requirement_id, attachment_id
|
|
53
|
+
def show_attachment(requirement_id, attachment_id)
|
|
79
54
|
result = client.get_attachment(requirement_id, attachment_id)
|
|
80
|
-
|
|
81
|
-
unless result[:ok]
|
|
82
|
-
Output.error(result[:error])
|
|
83
|
-
return 1
|
|
84
|
-
end
|
|
55
|
+
return api_error(result) unless result[:ok]
|
|
85
56
|
|
|
86
57
|
data = result[:data]['attachment_response'] || result[:data]
|
|
87
58
|
attachment = data['attachment']
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
59
|
+
output.success(data) do
|
|
60
|
+
puts "Attachment: #{attachment['filename']}"
|
|
61
|
+
puts " Type: #{attachment['content_type']}"
|
|
62
|
+
puts " Size: #{format_size(attachment['size_bytes'])}"
|
|
63
|
+
puts " Surface: #{attachment['surface']}" if attachment['surface']
|
|
64
|
+
puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
|
|
65
|
+
puts " Deployment: #{deployment_label(attachment)}"
|
|
66
|
+
puts
|
|
67
|
+
puts "View URL (expires in #{data['expires_in'].to_i / 60} minutes):"
|
|
68
|
+
puts data['download_url']
|
|
94
69
|
end
|
|
70
|
+
0
|
|
71
|
+
end
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
puts "Download URL (expires in #{expires_in / 60} minutes):"
|
|
104
|
-
puts download_url
|
|
73
|
+
def deployment_label(attachment)
|
|
74
|
+
case attachment['deployment_status']
|
|
75
|
+
when 'after_deployment' then "captured after deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
|
|
76
|
+
when 'predates_deployment' then "capture predates deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
|
|
77
|
+
else 'unknown'
|
|
78
|
+
end
|
|
79
|
+
end
|
|
105
80
|
|
|
106
|
-
|
|
81
|
+
def api_error(result)
|
|
82
|
+
output.error(result[:error], status: result[:status])
|
|
83
|
+
1
|
|
107
84
|
end
|
|
108
85
|
|
|
109
86
|
def format_size(bytes)
|
|
110
|
-
|
|
87
|
+
bytes = bytes.to_i
|
|
88
|
+
return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
|
|
89
|
+
return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
|
|
111
90
|
|
|
112
|
-
|
|
113
|
-
format('%.1f MB', bytes.to_f / (1024 * 1024))
|
|
114
|
-
elsif bytes >= 1024
|
|
115
|
-
format('%.1f KB', bytes.to_f / 1024)
|
|
116
|
-
else
|
|
117
|
-
"#{bytes} bytes"
|
|
118
|
-
end
|
|
91
|
+
"#{bytes} bytes"
|
|
119
92
|
end
|
|
120
93
|
end
|
|
121
94
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# Lists CI-detected revisions to bound tests and records an agent's assessment of an
|
|
6
|
+
# exact candidate fingerprint.
|
|
7
|
+
class ChangedTests < Base
|
|
8
|
+
CLASSIFICATIONS = %w[still_proves no_longer_proves uncertain].freeze
|
|
9
|
+
KNOWN_FLAGS = {
|
|
10
|
+
project: :string,
|
|
11
|
+
surface: :string,
|
|
12
|
+
test_type: :string,
|
|
13
|
+
classification: :string,
|
|
14
|
+
explanation: :string,
|
|
15
|
+
candidate_fingerprint: :string
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def run(args)
|
|
19
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
20
|
+
require_project!(opts)
|
|
21
|
+
project_id = resolve_project_id(opts[:project])
|
|
22
|
+
|
|
23
|
+
if positional.first == 'assess'
|
|
24
|
+
assess(project_id, positional[1], opts)
|
|
25
|
+
else
|
|
26
|
+
list(project_id, opts)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def list(project_id, opts)
|
|
33
|
+
result = client.list_changed_test_findings(
|
|
34
|
+
project_id,
|
|
35
|
+
surface: opts[:surface],
|
|
36
|
+
test_type: opts[:test_type]
|
|
37
|
+
)
|
|
38
|
+
fail_result(result) unless result[:ok]
|
|
39
|
+
|
|
40
|
+
data = result[:data] || {}
|
|
41
|
+
findings = data['changed_test_findings'] || []
|
|
42
|
+
output.success(data) do
|
|
43
|
+
if findings.empty?
|
|
44
|
+
puts 'No unresolved changed-test findings.'
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
puts "#{findings.length} unresolved changed-test audit finding(s):"
|
|
49
|
+
findings.each { |finding| print_finding(finding) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assess(project_id, finding_id, opts)
|
|
54
|
+
if finding_id.nil? || finding_id.empty?
|
|
55
|
+
output.error('Finding id is required. Usage: fp changed-tests assess <id> ...')
|
|
56
|
+
exit 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
classification = opts[:classification].to_s.tr('-', '_')
|
|
60
|
+
unless CLASSIFICATIONS.include?(classification)
|
|
61
|
+
output.error("--classification must be one of: #{CLASSIFICATIONS.join(', ')}")
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
require_flag(opts, :candidate_fingerprint)
|
|
65
|
+
require_flag(opts, :explanation)
|
|
66
|
+
if opts[:explanation].to_s.strip.empty?
|
|
67
|
+
output.error('--explanation must not be blank')
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
result = client.assess_changed_test_finding(
|
|
72
|
+
project_id,
|
|
73
|
+
finding_id,
|
|
74
|
+
classification: classification,
|
|
75
|
+
explanation: opts[:explanation],
|
|
76
|
+
candidate_fingerprint: opts[:candidate_fingerprint]
|
|
77
|
+
)
|
|
78
|
+
fail_result(result) unless result[:ok]
|
|
79
|
+
|
|
80
|
+
data = result[:data] || {}
|
|
81
|
+
output.success(data) do
|
|
82
|
+
finding = data['changed_test_finding'] || {}
|
|
83
|
+
puts "Recorded #{classification.tr('_', ' ')} for changed-test finding #{finding_id}."
|
|
84
|
+
puts "Status: #{finding['status']}"
|
|
85
|
+
puts 'The CI outcome and requirement wording were not changed.'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_finding(finding)
|
|
90
|
+
requirement = finding['requirement'] || {}
|
|
91
|
+
testcase = finding['testcase'] || {}
|
|
92
|
+
accepted = finding['accepted'] || {}
|
|
93
|
+
candidate = finding['candidate'] || {}
|
|
94
|
+
assessment = finding['assessment'] || {}
|
|
95
|
+
|
|
96
|
+
puts
|
|
97
|
+
puts "#{finding['id']} #{requirement['slug']} [#{finding['surface']}#{type_suffix(finding)}]"
|
|
98
|
+
puts " Requirement: #{requirement['title']}"
|
|
99
|
+
puts " Acceptance: #{requirement['acceptance']}"
|
|
100
|
+
puts " Testcase: #{testcase['name']} (#{testcase['outcome']})"
|
|
101
|
+
puts " Scope: #{finding['fingerprint_scope']}"
|
|
102
|
+
puts " Accepted: #{revision_text(accepted)}"
|
|
103
|
+
puts " Candidate: #{revision_text(candidate)}"
|
|
104
|
+
puts " Assessment: #{assessment['classification']}: #{assessment['explanation']}" unless assessment.empty?
|
|
105
|
+
puts ' Assess: fp changed-tests assess ' \
|
|
106
|
+
"#{finding['id']} --candidate-fingerprint #{candidate['fingerprint']} " \
|
|
107
|
+
'--classification still_proves|no_longer_proves|uncertain --explanation "..."'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def type_suffix(finding)
|
|
111
|
+
type = finding['test_type'].to_s
|
|
112
|
+
type.empty? || type == 'unspecified' ? '' : ":#{type}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def revision_text(revision)
|
|
116
|
+
location = revision['source_url'] || [revision['source_file'], revision['start_line']].compact.join(':')
|
|
117
|
+
"#{revision['fingerprint']} @ #{revision['commit_sha']} #{location}".strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fail_result(result)
|
|
121
|
+
output.error(result[:error], status: result[:status])
|
|
122
|
+
exit 1
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|