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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fc65a33008ccbd43f9b8ec10f7e3c013ab3ed502980f97b64c55e14ff1d2eb5
4
- data.tar.gz: a841a639236c27ea6bf7c30ba1a7f4a5710176bc0fd4bb153daac009d89e90db
3
+ metadata.gz: dd15c5892d88c0202433fd6e11993440f45cd3cb8c754c0c7138309008190cbb
4
+ data.tar.gz: 356c8c54af360a47639e2dfc6313774247ff93534eb02b6666656f738d69ce70
5
5
  SHA512:
6
- metadata.gz: df0c381690d9fa55e189e3a057eddd35c979bb970fac56b9294ad426d758613374c483c5f4317829cedd5a85d3ff3443a543e8dbcfdff99a146a82c7de831c53
7
- data.tar.gz: f2d8dd3a182c74e6e7ef5e4395803eaf86a62b510532227c2b7ec4c3bf4a3fe06db62c8866280d016c7cb4d1cb6a786b2b5c2dcb731f5f0ada518de288eb3714
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