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.
@@ -4,186 +4,150 @@ require_relative 'base'
4
4
 
5
5
  module Fp
6
6
  module Commands
7
- # Upload, list, and manage attachments on requirements.
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
- 'jpg' => 'image/jpeg',
19
- 'jpeg' => 'image/jpeg',
20
- 'gif' => 'image/gif',
21
- 'webp' => 'image/webp',
22
- 'svg' => 'image/svg+xml',
23
- 'pdf' => 'application/pdf',
24
- 'txt' => 'text/plain',
25
- 'md' => 'text/markdown'
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
- case args[0]
32
- when 'rm', 'delete'
33
- delete_attachment(args[1..])
34
- else
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(args)
42
- slug = args[0]
43
- file_path = args[1]
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
- ext = File.extname(filename).delete('.').downcase
66
- content_type = CONTENT_TYPES[ext]
67
-
46
+ extension = File.extname(filename).delete('.').downcase
47
+ content_type = CONTENT_TYPES[extension]
68
48
  unless content_type
69
- Output.error("Unsupported file type: .#{ext}")
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
- file_size = File.size(file_path)
75
- if file_size > MAX_FILE_SIZE
76
- Output.error("File too large: #{format_size(file_size)} (max #{format_size(MAX_FILE_SIZE)})")
77
- return 1
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
- # Resolve requirement
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
- requirement = result[:data]['requirement']
88
- requirement_id = requirement['id']
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
- # Step 1: Get presigned URL
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: file_size
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
- unless presign_result[:ok]
100
- Output.error("Failed to get upload URL: #{presign_result[:error]}")
101
- return 1
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: file_size
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
- Output.success("Uploaded #{filename} to requirement '#{slug}'")
136
- Output.info(" ID: #{attachment['id']}")
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(args)
141
- slug = args[0]
142
- attachment_id = args[1]
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
- if slug.nil? || slug.empty?
145
- Output.error('Missing requirement slug. Usage: fp attachment rm <slug> <id>')
146
- return 1
147
- end
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
- if attachment_id.nil? || attachment_id.empty?
150
- Output.error('Missing attachment ID. Usage: fp attachment rm <slug> <id>')
151
- return 1
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
- requirement = result[:data]['requirement']
165
- requirement_id = requirement['id']
125
+ output.success({ id: attachment_id, deleted: true }) { puts "Deleted attachment #{attachment_id}." }
126
+ 0
127
+ end
166
128
 
167
- # Delete attachment
168
- delete_result = client.delete_attachment(requirement_id, attachment_id)
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
- unless delete_result[:ok]
171
- Output.error("Failed to delete attachment: #{delete_result[:error]}")
172
- return 1
173
- end
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
- Output.success("Deleted attachment #{attachment_id}")
176
- 0
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
- format('%.1f MB', bytes.to_f / (1024 * 1024))
182
- elsif bytes >= 1024
183
- format('%.1f KB', bytes.to_f / 1024)
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 attachments for a requirement, or get a download URL for one.
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
- def run(args)
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
- project_id = require_project_id!
23
- return 1 unless project_id
24
-
25
- # Resolve requirement
26
- result = client.find_requirement_by_slug(project_id, slug)
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
- requirement = result[:data]['requirement']
33
- requirement_id = requirement['id']
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
- if attachment_id
36
- show_attachment(requirement_id, attachment_id, slug)
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
- if json_output?
55
- Output.json({ attachments: attachments })
56
- return 0
57
- end
58
-
59
- if attachments.empty?
60
- Output.info("No attachments for requirement '#{slug}'")
61
- return 0
62
- end
63
-
64
- Output.info("Attachments for '#{slug}':\n")
65
- attachments.each do |att|
66
- puts " #{att['id']}"
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, slug)
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
- download_url = data['download_url']
89
- expires_in = data['expires_in']
90
-
91
- if json_output?
92
- Output.json(data)
93
- return 0
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
- Output.info("Attachment: #{attachment['filename']}")
97
- puts " ID: #{attachment['id']}"
98
- puts " Type: #{attachment['content_type']}"
99
- puts " Size: #{format_size(attachment['size_bytes'])}"
100
- puts " Uploaded by: #{attachment['uploaded_by']}"
101
- puts " Uploaded at: #{attachment['created_at']}"
102
- puts
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
- 0
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
- return '0 bytes' if bytes.nil? || bytes.zero?
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
- if bytes >= 1024 * 1024
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