featureparity 0.0.14 → 0.0.16
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 +5 -1
- data/lib/fp/client.rb +92 -0
- data/lib/fp/commands/attach.rb +190 -0
- data/lib/fp/commands/attachments.rb +122 -0
- data/lib/fp/commands/changes.rb +359 -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 +65 -9
- data/lib/fp/commands/init.rb +106 -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 +34 -2
- data/lib/fp/commands.rb +4 -0
- data/lib/fp/junit.rb +114 -97
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +112 -25
- data/skills/fp/references/cli.md +88 -24
- data/skills/fp/references/markers.md +25 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd15c5892d88c0202433fd6e11993440f45cd3cb8c754c0c7138309008190cbb
|
|
4
|
+
data.tar.gz: 356c8c54af360a47639e2dfc6313774247ff93534eb02b6666656f738d69ce70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0d96204c4cf14ff9c7f5bbf1930f1ad6f170b11be67651df1cda342c2ad41e53e1a09ea93bed84db620d2c867f26ffbc313fa2d132f7b5b8b474026b4ea9ee8
|
|
7
|
+
data.tar.gz: c43e169274e9c6484b6e41c2bcb1c5748eaee9dabad8c39f523c7ed30a08f0edaeae3df7637edc799d2089165624108e72fdb517c95d580cc51c1b747064726b
|
data/lib/fp/cli.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Fp
|
|
|
12
12
|
'propose' => Commands::Propose,
|
|
13
13
|
'propose-change' => Commands::ProposeChange,
|
|
14
14
|
'history' => Commands::History,
|
|
15
|
+
'changes' => Commands::Changes,
|
|
15
16
|
'report' => Commands::Report,
|
|
16
17
|
'ci-report' => Commands::CiReport,
|
|
17
18
|
'check' => Commands::Check,
|
|
@@ -23,7 +24,10 @@ module Fp
|
|
|
23
24
|
'setup' => Commands::Setup,
|
|
24
25
|
'whoami' => Commands::Whoami,
|
|
25
26
|
'version' => Commands::Version,
|
|
26
|
-
'help' => Commands::Help
|
|
27
|
+
'help' => Commands::Help,
|
|
28
|
+
'attach' => Commands::Attach,
|
|
29
|
+
'attachments' => Commands::Attachments,
|
|
30
|
+
'conversation' => Commands::Conversation
|
|
27
31
|
}.freeze
|
|
28
32
|
|
|
29
33
|
class << self
|
data/lib/fp/client.rb
CHANGED
|
@@ -98,6 +98,14 @@ module Fp
|
|
|
98
98
|
put("/api/requirements/#{id}", params)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# GET /projects/:project_id/requirement_changes — project-wide audit activity,
|
|
102
|
+
# optionally narrowed to the exact work-item URL that prompted it.
|
|
103
|
+
def list_project_requirement_changes(project_id, source_url: nil)
|
|
104
|
+
params = {}
|
|
105
|
+
params[:source_url] = source_url if source_url
|
|
106
|
+
get("/api/projects/#{project_id}/requirement_changes", params)
|
|
107
|
+
end
|
|
108
|
+
|
|
101
109
|
# GET /requirements/:id/changes
|
|
102
110
|
#
|
|
103
111
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -203,6 +211,84 @@ module Fp
|
|
|
203
211
|
post("/api/projects/#{project_id}/parity/preview", body)
|
|
204
212
|
end
|
|
205
213
|
|
|
214
|
+
# GET /conversations/:id — read a conversation by id alone, regardless of whether
|
|
215
|
+
# it is project- or account-scoped. The server resolves the owner from the record
|
|
216
|
+
# and authorizes the caller; the CLI (and the agent using it) never needs to know
|
|
217
|
+
# or pass the scope. A conversation is just a record.
|
|
218
|
+
def get_conversation(conversation_id)
|
|
219
|
+
get("/api/conversations/#{conversation_id}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# --- Attachments ---
|
|
223
|
+
|
|
224
|
+
# GET /requirements/:requirement_id/attachments
|
|
225
|
+
# List all attachments for a requirement.
|
|
226
|
+
def list_attachments(requirement_id)
|
|
227
|
+
get("/api/requirements/#{requirement_id}/attachments")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# POST /requirements/:requirement_id/attachments/presign
|
|
231
|
+
# Get a presigned URL for uploading a file.
|
|
232
|
+
def presign_attachment(requirement_id, filename:, content_type:, size_bytes:)
|
|
233
|
+
post("/api/requirements/#{requirement_id}/attachments/presign", {
|
|
234
|
+
filename: filename,
|
|
235
|
+
content_type: content_type,
|
|
236
|
+
size_bytes: size_bytes
|
|
237
|
+
})
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# POST /requirements/:requirement_id/attachments
|
|
241
|
+
# Confirm an upload and create the attachment record.
|
|
242
|
+
def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:)
|
|
243
|
+
post("/api/requirements/#{requirement_id}/attachments", {
|
|
244
|
+
attachment_id: attachment_id,
|
|
245
|
+
s3_key: s3_key,
|
|
246
|
+
filename: filename,
|
|
247
|
+
content_type: content_type,
|
|
248
|
+
size_bytes: size_bytes
|
|
249
|
+
})
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# GET /requirements/:requirement_id/attachments/:id
|
|
253
|
+
# Get a presigned download URL for an attachment.
|
|
254
|
+
def get_attachment(requirement_id, attachment_id)
|
|
255
|
+
get("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# DELETE /requirements/:requirement_id/attachments/:id
|
|
259
|
+
# Delete an attachment.
|
|
260
|
+
def delete_attachment(requirement_id, attachment_id)
|
|
261
|
+
delete("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Upload a file to S3 using a presigned URL.
|
|
265
|
+
# This is called after presign_attachment returns the upload_url.
|
|
266
|
+
def upload_to_presigned_url(url, file_path, content_type)
|
|
267
|
+
uri = URI.parse(url)
|
|
268
|
+
file_content = File.binread(file_path)
|
|
269
|
+
|
|
270
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
271
|
+
http.use_ssl = uri.scheme == 'https'
|
|
272
|
+
http.open_timeout = 30
|
|
273
|
+
http.read_timeout = 120 # Allow longer for file upload
|
|
274
|
+
|
|
275
|
+
request = Net::HTTP::Put.new(uri)
|
|
276
|
+
request.body = file_content
|
|
277
|
+
request['Content-Type'] = content_type
|
|
278
|
+
request['Content-Length'] = file_content.bytesize.to_s
|
|
279
|
+
|
|
280
|
+
response = http.request(request)
|
|
281
|
+
status = response.code.to_i
|
|
282
|
+
|
|
283
|
+
if status >= 200 && status < 300
|
|
284
|
+
{ ok: true, status: status }
|
|
285
|
+
else
|
|
286
|
+
{ ok: false, error: "S3 upload failed: HTTP #{status}", status: status }
|
|
287
|
+
end
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
{ ok: false, error: "Upload failed: #{e.message}", status: 0 }
|
|
290
|
+
end
|
|
291
|
+
|
|
206
292
|
private
|
|
207
293
|
|
|
208
294
|
def get(path, params = {})
|
|
@@ -227,6 +313,12 @@ module Fp
|
|
|
227
313
|
execute(uri, request)
|
|
228
314
|
end
|
|
229
315
|
|
|
316
|
+
def delete(path)
|
|
317
|
+
uri = build_uri(path)
|
|
318
|
+
request = Net::HTTP::Delete.new(uri)
|
|
319
|
+
execute(uri, request)
|
|
320
|
+
end
|
|
321
|
+
|
|
230
322
|
def build_uri(path, params = {})
|
|
231
323
|
uri = URI.parse("#{@base_url}#{path}")
|
|
232
324
|
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
|