featureparity 0.0.14 → 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 +4 -4
- data/lib/fp/cli.rb +4 -1
- data/lib/fp/client.rb +84 -0
- data/lib/fp/commands/attach.rb +190 -0
- data/lib/fp/commands/attachments.rb +122 -0
- data/lib/fp/commands/check.rb +30 -7
- data/lib/fp/commands/ci_report.rb +5 -1
- data/lib/fp/commands/conversation.rb +92 -0
- data/lib/fp/commands/help.rb +64 -9
- data/lib/fp/commands/init.rb +87 -21
- data/lib/fp/commands/propose.rb +23 -2
- data/lib/fp/commands/propose_change.rb +13 -5
- data/lib/fp/commands/report.rb +19 -3
- data/lib/fp/commands/show.rb +14 -0
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/junit.rb +114 -97
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +110 -25
- data/skills/fp/references/cli.md +62 -23
- data/skills/fp/references/markers.md +25 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: efbe901394a8f97be6d1340a877ecafa6a8151453ed5a78708e91b6e40af1fe1
|
|
4
|
+
data.tar.gz: 51358f9c7907107f3744775e414f175a1206ace2a104b44dcfcee4723a13e8e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|