featureparity 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3dba0cc6ba18239f6952822d96bd4526d26d207e8b7e5448554de745e6517a8
4
- data.tar.gz: 1521147f4b1574062b3e04f22cef0ce1cb7ef6b822f767c3f75ed66a200b6a5c
3
+ metadata.gz: efbe901394a8f97be6d1340a877ecafa6a8151453ed5a78708e91b6e40af1fe1
4
+ data.tar.gz: 51358f9c7907107f3744775e414f175a1206ace2a104b44dcfcee4723a13e8e4
5
5
  SHA512:
6
- metadata.gz: a65ac140de45599a9a5a27f4705e179fd816bad77fe563477fd6b92efbc0d592cea80f8c90c2e519d559fc604927d6b321c3a3af99bb10c2b99b9e06b39467f5
7
- data.tar.gz: c9ba4966e9a2d036f266b3646eb0c7569399d606d196175df189047de7693c55badce253c94205045449ca7b20117a6f496eeee486067c03ca2291f2caed026f
6
+ metadata.gz: 16067e82a07cac5e0bf5df3548950ff5d966e8f822ba6315740f474a9d50e17fa25f99adfc9fc4a58a2af66bcd03f3e0edce7e9ab143e05fb6f4b9c2fe2e013e
7
+ data.tar.gz: 8f58e25f9465851931d0d763f1655b56112cd7899e919af8ee5bf0975fc6ec090c631fc42d993f76dce210468ce4d766cea2c1575b62282f35a969f2124e7530
data/lib/fp/cli.rb CHANGED
@@ -23,7 +23,10 @@ module Fp
23
23
  'setup' => Commands::Setup,
24
24
  'whoami' => Commands::Whoami,
25
25
  'version' => Commands::Version,
26
- 'help' => Commands::Help
26
+ 'help' => Commands::Help,
27
+ 'attach' => Commands::Attach,
28
+ 'attachments' => Commands::Attachments,
29
+ 'conversation' => Commands::Conversation
27
30
  }.freeze
28
31
 
29
32
  class << self
data/lib/fp/client.rb CHANGED
@@ -203,6 +203,84 @@ module Fp
203
203
  post("/api/projects/#{project_id}/parity/preview", body)
204
204
  end
205
205
 
206
+ # GET /conversations/:id — read a conversation by id alone, regardless of whether
207
+ # it is project- or account-scoped. The server resolves the owner from the record
208
+ # and authorizes the caller; the CLI (and the agent using it) never needs to know
209
+ # or pass the scope. A conversation is just a record.
210
+ def get_conversation(conversation_id)
211
+ get("/api/conversations/#{conversation_id}")
212
+ end
213
+
214
+ # --- Attachments ---
215
+
216
+ # GET /requirements/:requirement_id/attachments
217
+ # List all attachments for a requirement.
218
+ def list_attachments(requirement_id)
219
+ get("/api/requirements/#{requirement_id}/attachments")
220
+ end
221
+
222
+ # POST /requirements/:requirement_id/attachments/presign
223
+ # Get a presigned URL for uploading a file.
224
+ def presign_attachment(requirement_id, filename:, content_type:, size_bytes:)
225
+ post("/api/requirements/#{requirement_id}/attachments/presign", {
226
+ filename: filename,
227
+ content_type: content_type,
228
+ size_bytes: size_bytes
229
+ })
230
+ end
231
+
232
+ # POST /requirements/:requirement_id/attachments
233
+ # Confirm an upload and create the attachment record.
234
+ def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:)
235
+ post("/api/requirements/#{requirement_id}/attachments", {
236
+ attachment_id: attachment_id,
237
+ s3_key: s3_key,
238
+ filename: filename,
239
+ content_type: content_type,
240
+ size_bytes: size_bytes
241
+ })
242
+ end
243
+
244
+ # GET /requirements/:requirement_id/attachments/:id
245
+ # Get a presigned download URL for an attachment.
246
+ def get_attachment(requirement_id, attachment_id)
247
+ get("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
248
+ end
249
+
250
+ # DELETE /requirements/:requirement_id/attachments/:id
251
+ # Delete an attachment.
252
+ def delete_attachment(requirement_id, attachment_id)
253
+ delete("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
254
+ end
255
+
256
+ # Upload a file to S3 using a presigned URL.
257
+ # This is called after presign_attachment returns the upload_url.
258
+ def upload_to_presigned_url(url, file_path, content_type)
259
+ uri = URI.parse(url)
260
+ file_content = File.binread(file_path)
261
+
262
+ http = Net::HTTP.new(uri.host, uri.port)
263
+ http.use_ssl = uri.scheme == 'https'
264
+ http.open_timeout = 30
265
+ http.read_timeout = 120 # Allow longer for file upload
266
+
267
+ request = Net::HTTP::Put.new(uri)
268
+ request.body = file_content
269
+ request['Content-Type'] = content_type
270
+ request['Content-Length'] = file_content.bytesize.to_s
271
+
272
+ response = http.request(request)
273
+ status = response.code.to_i
274
+
275
+ if status >= 200 && status < 300
276
+ { ok: true, status: status }
277
+ else
278
+ { ok: false, error: "S3 upload failed: HTTP #{status}", status: status }
279
+ end
280
+ rescue StandardError => e
281
+ { ok: false, error: "Upload failed: #{e.message}", status: 0 }
282
+ end
283
+
206
284
  private
207
285
 
208
286
  def get(path, params = {})
@@ -227,6 +305,12 @@ module Fp
227
305
  execute(uri, request)
228
306
  end
229
307
 
308
+ def delete(path)
309
+ uri = build_uri(path)
310
+ request = Net::HTTP::Delete.new(uri)
311
+ execute(uri, request)
312
+ end
313
+
230
314
  def build_uri(path, params = {})
231
315
  uri = URI.parse("#{@base_url}#{path}")
232
316
  uri.query = URI.encode_www_form(params) unless params.empty?
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Fp
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
14
+ class Attach < Base
15
+ # Allowed content types and their file extensions
16
+ 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'
26
+ }.freeze
27
+
28
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
29
+
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
37
+ end
38
+
39
+ private
40
+
41
+ def upload_attachment(args)
42
+ slug = args[0]
43
+ file_path = args[1]
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
+ filename = File.basename(file_path)
65
+ ext = File.extname(filename).delete('.').downcase
66
+ content_type = CONTENT_TYPES[ext]
67
+
68
+ unless content_type
69
+ Output.error("Unsupported file type: .#{ext}")
70
+ Output.info("Allowed: #{CONTENT_TYPES.keys.join(', ')}")
71
+ return 1
72
+ end
73
+
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
78
+ 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
85
+ end
86
+
87
+ requirement = result[:data]['requirement']
88
+ requirement_id = requirement['id']
89
+
90
+ # Step 1: Get presigned URL
91
+ Output.info("Requesting upload URL for #{filename}...")
92
+ presign_result = client.presign_attachment(
93
+ requirement_id,
94
+ filename: filename,
95
+ content_type: content_type,
96
+ size_bytes: file_size
97
+ )
98
+
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
117
+
118
+ # Step 3: Confirm upload
119
+ Output.info('Confirming upload...')
120
+ create_result = client.create_attachment(
121
+ requirement_id,
122
+ attachment_id: attachment_id,
123
+ s3_key: s3_key,
124
+ filename: filename,
125
+ content_type: content_type,
126
+ size_bytes: file_size
127
+ )
128
+
129
+ unless create_result[:ok]
130
+ Output.error("Failed to confirm upload: #{create_result[:error]}")
131
+ return 1
132
+ end
133
+
134
+ attachment = create_result[:data]['attachment']
135
+ Output.success("Uploaded #{filename} to requirement '#{slug}'")
136
+ Output.info(" ID: #{attachment['id']}")
137
+ 0
138
+ end
139
+
140
+ def delete_attachment(args)
141
+ slug = args[0]
142
+ attachment_id = args[1]
143
+
144
+ if slug.nil? || slug.empty?
145
+ Output.error('Missing requirement slug. Usage: fp attachment rm <slug> <id>')
146
+ return 1
147
+ end
148
+
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
163
+
164
+ requirement = result[:data]['requirement']
165
+ requirement_id = requirement['id']
166
+
167
+ # Delete attachment
168
+ delete_result = client.delete_attachment(requirement_id, attachment_id)
169
+
170
+ unless delete_result[:ok]
171
+ Output.error("Failed to delete attachment: #{delete_result[:error]}")
172
+ return 1
173
+ end
174
+
175
+ Output.success("Deleted attachment #{attachment_id}")
176
+ 0
177
+ end
178
+
179
+ 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
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Fp
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
12
+ 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
21
+
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])
29
+ return 1
30
+ end
31
+
32
+ requirement = result[:data]['requirement']
33
+ requirement_id = requirement['id']
34
+
35
+ if attachment_id
36
+ show_attachment(requirement_id, attachment_id, slug)
37
+ else
38
+ list_attachments(requirement_id, slug)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def list_attachments(requirement_id, slug)
45
+ result = client.list_attachments(requirement_id)
46
+
47
+ unless result[:ok]
48
+ Output.error(result[:error])
49
+ return 1
50
+ end
51
+
52
+ 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
73
+ end
74
+
75
+ 0
76
+ end
77
+
78
+ def show_attachment(requirement_id, attachment_id, slug)
79
+ result = client.get_attachment(requirement_id, attachment_id)
80
+
81
+ unless result[:ok]
82
+ Output.error(result[:error])
83
+ return 1
84
+ end
85
+
86
+ data = result[:data]['attachment_response'] || result[:data]
87
+ 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
94
+ end
95
+
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
105
+
106
+ 0
107
+ end
108
+
109
+ def format_size(bytes)
110
+ return '0 bytes' if bytes.nil? || bytes.zero?
111
+
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
119
+ end
120
+ end
121
+ end
122
+ end
@@ -39,6 +39,7 @@ module Fp
39
39
  KNOWN_FLAGS = {
40
40
  project: :string,
41
41
  surface: :string,
42
+ test_type: :string,
42
43
  level: :string,
43
44
  junit: :string,
44
45
  base_dir: :string,
@@ -126,7 +127,8 @@ module Fp
126
127
 
127
128
  results = junit_paths.flat_map do |path|
128
129
  testcases = Fp::JUnit.parse_file(path)
129
- Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface)[:results]
130
+ Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface,
131
+ default_test_type: opts[:test_type])[:results]
130
132
  end
131
133
 
132
134
  if results.empty?
@@ -141,6 +143,7 @@ module Fp
141
143
  {
142
144
  slug: r.slug,
143
145
  surface: r.surface,
146
+ test_type: r.test_type,
144
147
  status: r.status,
145
148
  source_file: r.file,
146
149
  source_line: r.line,
@@ -219,15 +222,35 @@ module Fp
219
222
  return if violations.empty?
220
223
 
221
224
  puts "#{violations.size} violation(s):"
222
- output.table(
223
- %w[SLUG SURFACE STATE WHY],
224
- violations.map do |v|
225
- [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
226
- end
227
- )
225
+ # Show the TYPE column only when some requirement demands specific test types —
226
+ # otherwise it's an empty column of dashes on every project that doesn't use the
227
+ # feature.
228
+ if violations.any? { |v| presence(v['test_type']) }
229
+ output.table(
230
+ %w[SLUG SURFACE TYPE STATE WHY],
231
+ violations.map do |v|
232
+ [v['slug'], v['surface'], v['test_type'] || '—',
233
+ "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
234
+ end
235
+ )
236
+ else
237
+ output.table(
238
+ %w[SLUG SURFACE STATE WHY],
239
+ violations.map do |v|
240
+ [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
241
+ end
242
+ )
243
+ end
228
244
  puts
229
245
  end
230
246
 
247
+ # nil/blank → nil, so `presence(x)` reads as falsey in a guard.
248
+ def presence(value)
249
+ return nil if value.nil? || value.to_s.strip.empty?
250
+
251
+ value
252
+ end
253
+
231
254
  # Markers the run carried that bind to no requirement in this project — the test
232
255
  # claims to cover something that doesn't exist. FP identifies them; it does not
233
256
  # propose the requirement, because a slug and a test name aren't enough to write a
@@ -50,6 +50,7 @@ module Fp
50
50
  KNOWN_FLAGS = {
51
51
  project: :string,
52
52
  surface: :string,
53
+ test_type: :string,
53
54
  ci_url: :string,
54
55
  pr: :string,
55
56
  junit: :string,
@@ -214,7 +215,8 @@ module Fp
214
215
  resolved = Fp::JUnit.ci_results(
215
216
  testcases,
216
217
  base_dir: opts[:base_dir] || Dir.pwd,
217
- default_surface: opts[:surface]
218
+ default_surface: opts[:surface],
219
+ default_test_type: opts[:test_type]
218
220
  )
219
221
 
220
222
  {
@@ -222,9 +224,11 @@ module Fp
222
224
  {
223
225
  slug: r.slug,
224
226
  surface: r.surface,
227
+ test_type: r.test_type,
225
228
  status: r.status,
226
229
  source_file: r.file,
227
230
  source_line: r.line,
231
+ title: r.title,
228
232
  test_count: r.test_count,
229
233
  failure_count: r.failure_count
230
234
  }
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp conversation <conversation_id>
6
+ # Read a Pari conversation so agents can review AI suggestions and implement them.
7
+ #
8
+ # A conversation is just a record identified by its id — the command doesn't care,
9
+ # and doesn't ask, whether it came from a project panel or the account-level one.
10
+ # The server resolves the owner and authorizes the caller. That's the whole point:
11
+ # a user copies a reference, hands it to an agent, and the agent reads it.
12
+ #
13
+ # Typical workflow:
14
+ # 1. User opens Pari, has a conversation that suggests requirements or code
15
+ # 2. User clicks "Copy Reference" (or "Copy Prompt") to get the lookup command
16
+ # 3. User gives it to an agent: "implement what Pari suggested in <id>"
17
+ # 4. Agent runs `fp conversation <id>` to read the full context
18
+ # 5. Agent implements based on Pari's suggestions
19
+ class Conversation < Base
20
+ def run(args)
21
+ _opts, positional = parse_flags(args)
22
+ conversation_id = positional.first
23
+
24
+ unless conversation_id
25
+ output.error(<<~MSG.strip)
26
+ Conversation ID is required.
27
+
28
+ Usage:
29
+ fp conversation <id> Read a Pari conversation
30
+
31
+ The conversation ID is a KSUID like "03h0abc123xyz...". Copy it from Pari's
32
+ header (the copy buttons next to the history icon), or use "Copy Reference"
33
+ to get this whole command ready to paste.
34
+ MSG
35
+ exit 1
36
+ end
37
+
38
+ result = client.get_conversation(conversation_id)
39
+
40
+ unless result[:ok]
41
+ output.error(result[:error], status: result[:status])
42
+ exit 1
43
+ end
44
+
45
+ conversation = result[:data]['conversation']
46
+ messages = result[:data]['messages'] || []
47
+
48
+ output.success(result[:data]) do
49
+ print_conversation(conversation, messages)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def print_conversation(conversation, messages)
56
+ title = conversation['title'] || 'Untitled conversation'
57
+ puts title
58
+ puts '=' * title.length
59
+ puts
60
+ puts "ID: #{conversation['id']}"
61
+ puts "Project: #{conversation['project_id']}" if conversation['project_id'].to_s != ''
62
+ puts "Status: #{conversation['status']}"
63
+ puts "Created: #{conversation['created_at']}"
64
+ puts "Updated: #{conversation['updated_at']}"
65
+ puts
66
+
67
+ if messages.empty?
68
+ puts '(no messages)'
69
+ else
70
+ puts 'Messages:'
71
+ puts '-' * 40
72
+ messages.each_with_index do |msg, idx|
73
+ role = msg['role'] == 'user' ? 'USER' : 'PARI'
74
+ puts
75
+ puts "[#{idx + 1}] #{role}:"
76
+ puts msg['content']
77
+
78
+ # Show any requirements Pari created during this turn
79
+ created = msg['created_requirement_ids'] || []
80
+ unless created.empty?
81
+ puts
82
+ puts " → Created draft requirement(s): #{created.join(', ')}"
83
+ end
84
+
85
+ puts
86
+ puts '-' * 40
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end