featureparity 0.0.20 → 0.0.21

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: 65fc8c40a37a8d05f6fd566fd6362a5b5f489c3ee516ea4643c37afedb0d4534
4
- data.tar.gz: 2a05968f741537fbc82fc0cc12cb67d7f4bf9fa2c5cebd11bc3514a9637e1745
3
+ metadata.gz: 6ae3f4be081207da9feb300fbdfaad6d2afddf2ffb021ffc8136508da964f0c2
4
+ data.tar.gz: 74d2cf3feb18ea77cac72f38be15c5eb6870d237d76f2a21a3b859853649b02d
5
5
  SHA512:
6
- metadata.gz: a9dc847a7281c8b018547b97fb3c06b9bbcf0bcaee596cf1afb098428f025a48af5b01acaad7b76583ed18f0385602dbc9ccae47f5a9605220bacfe31f543723
7
- data.tar.gz: 95909562928c8f9780eafed047018e8d9e752d6a3e10a5feeb09cdd716ac14945e39948d692aa04102d233f1403570f8e4e926225257093e56a7ed08c1af7b1e
6
+ metadata.gz: f14f50d9f5e6d620341081a55ee98734bafd6893ade078cb424246c6ddb16a2f85a8aff6b5d28dc1d969505708b0e59806628699c47146736b024f90c0e0c6e6
7
+ data.tar.gz: 156d5c4e679017b8d5ec7825eb51d7eb64acec51daf02700a263f58e20251c2136efadab399244856f4ce943fe832825e89e45a75f57a814368e8dbc54a5189e
data/lib/fp/client.rb CHANGED
@@ -72,6 +72,16 @@ module Fp
72
72
  post("/api/projects/#{project_id}/surfaces", params)
73
73
  end
74
74
 
75
+ # PUT /projects/:id/surfaces/:surface_id
76
+ def update_surface(project_id, surface_id, params)
77
+ put("/api/projects/#{project_id}/surfaces/#{surface_id}", params)
78
+ end
79
+
80
+ # DELETE /projects/:id/surfaces/:surface_id
81
+ def delete_surface(project_id, surface_id)
82
+ delete("/api/projects/#{project_id}/surfaces/#{surface_id}")
83
+ end
84
+
75
85
  # GET /requirements
76
86
  def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
77
87
  params = {}
@@ -21,6 +21,8 @@ module Fp
21
21
  surfaces --project <slug> List surfaces for a project
22
22
  surfaces show <key> --project ... View a single surface's details
23
23
  surfaces add <key> --project ... Create a surface
24
+ surfaces edit <key> --project ... Change a surface (name, kind, repo, key...)
25
+ surfaces delete <key> --project . Delete a surface (asks first; --yes to skip)
24
26
  list --project <slug> List active requirements
25
27
  show <slug> --project <slug> Show requirement details
26
28
  propose --project <slug> ... Propose a new requirement (as draft)
@@ -178,6 +180,12 @@ module Fp
178
180
  --kind android \\
179
181
  --repo stowzilla/customer-android
180
182
 
183
+ # Fix up an existing surface (add refuses a key that already exists)
184
+ fp surfaces edit web --project stowzilla --kind web --repo stowzilla/web
185
+
186
+ # Delete a surface (type the key back, or --yes non-interactively)
187
+ fp surfaces delete web --project stowzilla --yes
188
+
181
189
  # List requirements for a project
182
190
  fp list --project stowzilla
183
191
 
@@ -22,7 +22,16 @@ module Fp
22
22
  'change_proposed' => 'change proposed',
23
23
  'removal_proposed' => 'deletion proposed',
24
24
  'change_approved' => 'change approved',
25
- 'change_rejected' => 'change rejected'
25
+ 'change_rejected' => 'change rejected',
26
+ 'decision_recorded' => 'decision recorded'
27
+ }.freeze
28
+
29
+ # A member's own stance on a requirement that already has a status (Fizzy #1478),
30
+ # including a request for changes (Fizzy #1481) — its note says what to change.
31
+ DECISION_LABELS = {
32
+ 'approve' => 'approves',
33
+ 'request_changes' => 'requests changes',
34
+ 'reject' => 'rejects'
26
35
  }.freeze
27
36
 
28
37
  def run(args)
@@ -86,11 +95,13 @@ module Fp
86
95
 
87
96
  def print_entry(change)
88
97
  label = TYPE_LABELS[change['type']] || change['type']
98
+ label = DECISION_LABELS.fetch(change['decision'], label) if change['type'] == 'decision_recorded'
89
99
  actor = change['actor'] || 'unknown'
90
100
  kind = change['actor_kind'] == 'agent' ? 'agent' : 'human'
91
101
 
92
102
  puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
93
103
  puts " #{change['reason']}" if present?(change['reason'])
104
+ puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
94
105
  print_source(change['source'])
95
106
  print_diff(change['changes'])
96
107
  print_evidence_diff(change['evidence_diff'])
@@ -7,10 +7,17 @@ module Fp
7
7
  # fp surfaces --project <slug> - list available surfaces for a project
8
8
  # fp surfaces show <key> --project <slug> - view a single surface's details
9
9
  # fp surfaces add <key> --project <slug> - create a surface
10
+ # fp surfaces edit <key> --project <slug> - change an existing surface
11
+ # fp surfaces delete <key> --project <slug> - delete a surface, with confirmation
10
12
  #
11
13
  # `add` exists because a requirement can only name surfaces that already exist, so an
12
14
  # agent onboarding a project it hasn't seen before would otherwise be blocked waiting
13
15
  # on a human to click through the web app.
16
+ #
17
+ # `edit` and `delete` exist because `add` on an existing key used to make a second
18
+ # surface with the same key, and there was no way to fix that short of a raw API call
19
+ # (Fizzy #1480). The server now refuses a duplicate key with a 409; `add` turns that
20
+ # into a pointer at `edit`. `delete` is guarded like `fp projects delete`.
14
21
  class Surfaces < Base
15
22
  ADD_FLAGS = {
16
23
  project: :string,
@@ -21,6 +28,13 @@ module Fp
21
28
  default_branch: :string
22
29
  }.freeze
23
30
 
31
+ EDIT_FLAGS = ADD_FLAGS.merge(key: :string).freeze
32
+
33
+ DELETE_FLAGS = {
34
+ project: :string,
35
+ yes: :boolean
36
+ }.freeze
37
+
24
38
  VALID_KINDS = %w[api web android ios other].freeze
25
39
  VALID_AUDIENCES = %w[customer ops both internal].freeze
26
40
 
@@ -32,6 +46,10 @@ module Fp
32
46
  add(args[1..])
33
47
  when 'show'
34
48
  show(args[1..])
49
+ when 'edit'
50
+ edit(args[1..])
51
+ when 'delete'
52
+ delete(args[1..])
35
53
  else
36
54
  list(args)
37
55
  end
@@ -116,6 +134,15 @@ module Fp
116
134
 
117
135
  result = client.create_surface(project_id, params)
118
136
 
137
+ if !result[:ok] && result[:status] == 409
138
+ output.error(
139
+ "Surface '#{key}' already exists in project '#{opts[:project]}'. " \
140
+ "Change it with: fp surfaces edit #{key} --project #{opts[:project]} [--name ...] [--kind ...] [--repo ...]",
141
+ status: 409
142
+ )
143
+ exit 1
144
+ end
145
+
119
146
  unless result[:ok]
120
147
  output.error(result[:error], status: result[:status])
121
148
  exit 1
@@ -133,6 +160,124 @@ module Fp
133
160
  end
134
161
  end
135
162
 
163
+ def edit(args)
164
+ opts, positional = parse_flags(args, EDIT_FLAGS)
165
+ require_project!(opts)
166
+
167
+ key = positional.first
168
+ if key.nil? || key.strip.empty?
169
+ output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
170
+ '[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key]')
171
+ exit 1
172
+ end
173
+
174
+ validate_choice!(opts[:kind], VALID_KINDS, '--kind')
175
+ validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
176
+
177
+ # Only what was passed is sent, so an edit never blanks a field it wasn't asked about.
178
+ params = {}
179
+ %i[key name kind audience repo default_branch].each do |field|
180
+ params[field] = opts[field] if opts.key?(field)
181
+ end
182
+
183
+ if params.empty?
184
+ output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch, or --key.')
185
+ exit 1
186
+ end
187
+
188
+ project_id = resolve_project_id(opts[:project])
189
+ surface = find_surface!(project_id, key)
190
+
191
+ result = client.update_surface(project_id, surface['id'], params)
192
+
193
+ unless result[:ok]
194
+ output.error(result[:error], status: result[:status])
195
+ exit 1
196
+ end
197
+
198
+ # The update action answers with update!'s return value, not the record, so read
199
+ # the surface back rather than trusting the response body.
200
+ updated = client.get_surface(project_id, surface['id'])
201
+ record = (updated[:ok] && updated[:data]['surface']) || surface.merge(params.transform_keys(&:to_s))
202
+
203
+ output.success({ surface: record, project: opts[:project] }) do
204
+ puts "Updated surface '#{record['key']}' in project '#{opts[:project]}'."
205
+ puts
206
+ puts " Name: #{record['name']}"
207
+ puts " Kind: #{record['kind']}"
208
+ puts " Audience: #{record['audience']}"
209
+ puts " Repo: #{record['repo'] || '(none)'}"
210
+ puts " Branch: #{record['default_branch'] || '(none)'}"
211
+ end
212
+ end
213
+
214
+ def delete(args)
215
+ opts, positional = parse_flags(args, DELETE_FLAGS)
216
+ require_project!(opts)
217
+
218
+ key = positional.first
219
+ if key.nil? || key.strip.empty?
220
+ output.error('Usage: fp surfaces delete <key> --project <slug> [--yes]')
221
+ exit 1
222
+ end
223
+
224
+ project_id = resolve_project_id(opts[:project])
225
+ surface = find_surface!(project_id, key)
226
+ confirm_delete!(surface, opts)
227
+
228
+ result = client.delete_surface(project_id, surface['id'])
229
+
230
+ unless result[:ok]
231
+ output.error(result[:error], status: result[:status])
232
+ exit 1
233
+ end
234
+
235
+ output.success({ deleted: true, surface: { 'id' => surface['id'], 'key' => surface['key'] },
236
+ project: opts[:project] }) do
237
+ puts "Deleted surface '#{surface['key']}' from project '#{opts[:project]}'."
238
+ end
239
+ end
240
+
241
+ # Same guard as `fp projects delete`: --yes skips the prompt, an interactive
242
+ # terminal types the key back, and anything else is refused.
243
+ def confirm_delete!(surface, opts)
244
+ return if opts[:yes]
245
+
246
+ unless interactive?
247
+ output.error(
248
+ "Refusing to delete surface '#{surface['key']}' without confirmation. " \
249
+ 'Re-run with --yes to confirm from a non-interactive session.'
250
+ )
251
+ exit 1
252
+ end
253
+
254
+ warn "This will delete surface '#{surface['key']}' (#{surface['name']}) from project '#{opts[:project]}'."
255
+ warn 'Evidence filed against it stays behind with nothing to measure.'
256
+ $stderr.print "Type '#{surface['key']}' to confirm: "
257
+ $stderr.flush
258
+
259
+ typed = $stdin.gets&.strip
260
+ return if typed == surface['key']
261
+
262
+ output.error('Aborted. The key did not match; nothing was deleted.')
263
+ exit 1
264
+ end
265
+
266
+ def interactive?
267
+ $stdin.respond_to?(:tty?) && $stdin.tty?
268
+ end
269
+
270
+ def find_surface!(project_id, key)
271
+ result = client.find_surface_by_key(project_id, key)
272
+
273
+ unless result[:ok]
274
+ output.error(result[:error], status: result[:status])
275
+ exit 1
276
+ end
277
+
278
+ result[:data]['surface']
279
+ end
280
+
136
281
  def validate_choice!(value, allowed, flag)
137
282
  return if value.nil? || allowed.include?(value)
138
283
 
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.20'
4
+ VERSION = '0.0.21'
5
5
  end
@@ -54,13 +54,26 @@ owner — or a key minted by one — may delete; a member's key gets 403.
54
54
 
55
55
  ### fp surfaces
56
56
 
57
- List surfaces for a project.
57
+ List, view, create, edit and delete a project's surfaces.
58
58
 
59
59
  ```bash
60
60
  fp surfaces --project stowzilla
61
61
  fp surfaces --project stowzilla --json
62
+ fp surfaces show <key> --project stowzilla
63
+ fp surfaces add <key> --project stowzilla [--name "..."] [--kind api|web|android|ios|other] [--audience ...] [--repo org/repo] [--default-branch main]
64
+ fp surfaces edit <key> --project stowzilla [--name "..."] [--kind ...] [--audience ...] [--repo ...] [--default-branch ...] [--key new_key]
65
+ fp surfaces delete <key> --project stowzilla [--yes]
62
66
  ```
63
67
 
68
+ A key is unique within a project. `add` on a key that already exists fails and points
69
+ you at `edit` — use `edit` to fill in the kind or repo of a surface that `fp projects
70
+ create --surfaces` made with defaults. `edit` only sends the flags you pass. `delete`
71
+ asks you to type the key back in a terminal and refuses without `--yes` otherwise.
72
+
73
+ A surface that requirements still require can't be deleted or re-keyed — the server
74
+ refuses and names them. Taking a surface off a requirement is a human's call, so ask
75
+ one rather than working around it.
76
+
64
77
  ### fp list
65
78
 
66
79
  List requirements.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.20
4
+ version: 0.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http