featureparity 0.0.19 → 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 +4 -4
- data/lib/fp/client.rb +25 -0
- data/lib/fp/commands/help.rb +23 -0
- data/lib/fp/commands/history.rb +12 -1
- data/lib/fp/commands/projects.rb +275 -9
- data/lib/fp/commands/surfaces.rb +145 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +15 -0
- data/skills/fp/references/cli.md +32 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ae3f4be081207da9feb300fbdfaad6d2afddf2ffb021ffc8136508da964f0c2
|
|
4
|
+
data.tar.gz: 74d2cf3feb18ea77cac72f38be15c5eb6870d237d76f2a21a3b859853649b02d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f14f50d9f5e6d620341081a55ee98734bafd6893ade078cb424246c6ddb16a2f85a8aff6b5d28dc1d969505708b0e59806628699c47146736b024f90c0e0c6e6
|
|
7
|
+
data.tar.gz: 156d5c4e679017b8d5ec7825eb51d7eb64acec51daf02700a263f58e20251c2136efadab399244856f4ce943fe832825e89e45a75f57a814368e8dbc54a5189e
|
data/lib/fp/client.rb
CHANGED
|
@@ -22,6 +22,21 @@ module Fp
|
|
|
22
22
|
get("/api/projects/#{id}")
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# POST /projects
|
|
26
|
+
def create_project(params)
|
|
27
|
+
post('/api/projects', params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# PUT /projects/:id
|
|
31
|
+
def update_project(id, params)
|
|
32
|
+
put("/api/projects/#{id}", params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# DELETE /projects/:id
|
|
36
|
+
def delete_project(id)
|
|
37
|
+
delete("/api/projects/#{id}")
|
|
38
|
+
end
|
|
39
|
+
|
|
25
40
|
# GET /projects/:id/surfaces
|
|
26
41
|
def list_surfaces(project_id)
|
|
27
42
|
get("/api/projects/#{project_id}/surfaces")
|
|
@@ -57,6 +72,16 @@ module Fp
|
|
|
57
72
|
post("/api/projects/#{project_id}/surfaces", params)
|
|
58
73
|
end
|
|
59
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
|
+
|
|
60
85
|
# GET /requirements
|
|
61
86
|
def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
|
|
62
87
|
params = {}
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -14,9 +14,15 @@ module Fp
|
|
|
14
14
|
COMMANDS
|
|
15
15
|
init --project <slug> Onboard this repo: config, CI gate, agent docs
|
|
16
16
|
projects List projects you have access to
|
|
17
|
+
projects show <slug> View a project and its surfaces
|
|
18
|
+
projects create <slug> --name ... Create a project (owned by your key's owner)
|
|
19
|
+
projects edit <slug> --name ... Update a project's name or description
|
|
20
|
+
projects delete <slug> [--yes] Delete a project (asks you to type its slug)
|
|
17
21
|
surfaces --project <slug> List surfaces for a project
|
|
18
22
|
surfaces show <key> --project ... View a single surface's details
|
|
19
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)
|
|
20
26
|
list --project <slug> List active requirements
|
|
21
27
|
show <slug> --project <slug> Show requirement details
|
|
22
28
|
propose --project <slug> ... Propose a new requirement (as draft)
|
|
@@ -151,6 +157,17 @@ module Fp
|
|
|
151
157
|
# List projects
|
|
152
158
|
fp projects
|
|
153
159
|
|
|
160
|
+
# Create a project (agents can do this — the project lands on the account of
|
|
161
|
+
# whoever minted the key, and the same key reaches it immediately)
|
|
162
|
+
fp projects create calib-rate --name "Calib Rate" --surfaces api,web
|
|
163
|
+
fp projects show calib-rate
|
|
164
|
+
fp projects edit calib-rate --description "Calibration tooling"
|
|
165
|
+
|
|
166
|
+
# Delete a project. Interactive: type the slug to confirm. Non-interactive
|
|
167
|
+
# sessions must pass --yes, or the command refuses. Owners only.
|
|
168
|
+
fp projects delete calib-rate
|
|
169
|
+
fp projects delete calib-rate --yes
|
|
170
|
+
|
|
154
171
|
# List the surfaces a project tracks (requirements may only name these)
|
|
155
172
|
fp surfaces --project stowzilla
|
|
156
173
|
|
|
@@ -163,6 +180,12 @@ module Fp
|
|
|
163
180
|
--kind android \\
|
|
164
181
|
--repo stowzilla/customer-android
|
|
165
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
|
+
|
|
166
189
|
# List requirements for a project
|
|
167
190
|
fp list --project stowzilla
|
|
168
191
|
|
data/lib/fp/commands/history.rb
CHANGED
|
@@ -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'])
|
data/lib/fp/commands/projects.rb
CHANGED
|
@@ -2,9 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp projects
|
|
5
|
+
# fp projects - list projects the API key can reach
|
|
6
|
+
# fp projects show <slug> - view one project and its surfaces
|
|
7
|
+
# fp projects create <slug> --name "..." - create a project (owned by the key's owner)
|
|
8
|
+
# fp projects edit <slug> --name/--description - update a project's details
|
|
9
|
+
# fp projects delete <slug> [--yes] - delete a project, with confirmation
|
|
10
|
+
#
|
|
11
|
+
# `create` exists because the only way to start a project used to be the web app, so
|
|
12
|
+
# an agent onboarding a repo FeatureParity had never seen was stuck waiting on a human
|
|
13
|
+
# (Fizzy #1473). Keys are account-level (Fizzy #1472): the project lands on the key
|
|
14
|
+
# owner's account, and the same key reaches it immediately.
|
|
15
|
+
#
|
|
16
|
+
# `delete` is modelled on `gh repo delete`: it asks you to type the project's slug,
|
|
17
|
+
# or takes --yes to skip the prompt, and a non-interactive session without --yes is
|
|
18
|
+
# refused rather than guessed at. The server adds its own check — only an owner (or an
|
|
19
|
+
# owner's key) may delete.
|
|
6
20
|
class Projects < Base
|
|
7
|
-
|
|
21
|
+
CREATE_FLAGS = {
|
|
22
|
+
name: :string,
|
|
23
|
+
description: :string,
|
|
24
|
+
surfaces: :string
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
EDIT_FLAGS = {
|
|
28
|
+
name: :string,
|
|
29
|
+
description: :string
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
DELETE_FLAGS = {
|
|
33
|
+
yes: :boolean
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def run(args)
|
|
37
|
+
# Subcommand dispatch. Bare `fp projects` stays the list, so existing callers
|
|
38
|
+
# (the setup wizard's "test access with fp projects") keep working.
|
|
39
|
+
case args.first
|
|
40
|
+
when 'show'
|
|
41
|
+
show(args[1..])
|
|
42
|
+
when 'create'
|
|
43
|
+
create(args[1..])
|
|
44
|
+
when 'edit'
|
|
45
|
+
edit(args[1..])
|
|
46
|
+
when 'delete'
|
|
47
|
+
delete(args[1..])
|
|
48
|
+
else
|
|
49
|
+
list
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def list
|
|
8
56
|
result = client.list_projects
|
|
9
57
|
|
|
10
58
|
unless result[:ok]
|
|
@@ -17,22 +65,240 @@ module Fp
|
|
|
17
65
|
output.success(result[:data]) do
|
|
18
66
|
if projects.empty?
|
|
19
67
|
puts 'No projects found.'
|
|
68
|
+
puts 'Create one with: fp projects create <slug> --name "<Name>"'
|
|
20
69
|
else
|
|
21
70
|
headers = %w[SLUG NAME SURFACES]
|
|
22
71
|
rows = projects.map do |w|
|
|
23
|
-
|
|
24
|
-
surface_keys = if surfaces_result[:ok]
|
|
25
|
-
(surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
|
|
26
|
-
else
|
|
27
|
-
[]
|
|
28
|
-
end
|
|
29
|
-
surfaces = surface_keys.join(', ')
|
|
72
|
+
surfaces = surface_keys_for(w['id']).join(', ')
|
|
30
73
|
[w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
|
|
31
74
|
end
|
|
32
75
|
output.table(headers, rows)
|
|
33
76
|
end
|
|
34
77
|
end
|
|
35
78
|
end
|
|
79
|
+
|
|
80
|
+
def show(args)
|
|
81
|
+
_opts, positional = parse_flags(args)
|
|
82
|
+
slug = require_slug!(positional, 'fp projects show <slug>')
|
|
83
|
+
|
|
84
|
+
project = find_project!(slug)
|
|
85
|
+
result = client.get_project(project['id'])
|
|
86
|
+
|
|
87
|
+
unless result[:ok]
|
|
88
|
+
output.error(result[:error], status: result[:status])
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
record = result[:data]['project'] || project
|
|
93
|
+
surfaces = surface_keys_for(record['id'])
|
|
94
|
+
|
|
95
|
+
output.success({ project: record, surfaces: surfaces }) do
|
|
96
|
+
print_project(record, surfaces)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create(args)
|
|
101
|
+
opts, positional = parse_flags(args, CREATE_FLAGS)
|
|
102
|
+
slug = require_slug!(positional, 'fp projects create <slug> --name "<Name>" [--description "..."] [--surfaces api,web]')
|
|
103
|
+
|
|
104
|
+
# The slug is the project's permanent key and the name is what people see. Let
|
|
105
|
+
# `fp projects create calib-rate` work on its own: the name defaults to the slug
|
|
106
|
+
# and can be fixed up later with `fp projects edit`.
|
|
107
|
+
params = { slug: slug, name: opts[:name] || slug }
|
|
108
|
+
params[:description] = opts[:description] if opts[:description]
|
|
109
|
+
|
|
110
|
+
surfaces = split_list(opts[:surfaces])
|
|
111
|
+
params[:surfaces] = surfaces unless surfaces.empty?
|
|
112
|
+
|
|
113
|
+
result = client.create_project(params)
|
|
114
|
+
|
|
115
|
+
unless result[:ok]
|
|
116
|
+
output.error(result[:error], status: result[:status])
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
project = result[:data]['project'] || {}
|
|
121
|
+
created_surfaces = (result[:data]['surfaces'] || []).map { |s| s['key'] }
|
|
122
|
+
|
|
123
|
+
output.success(result[:data]) do
|
|
124
|
+
puts "Created project '#{project['slug'] || slug}'."
|
|
125
|
+
puts
|
|
126
|
+
puts " Name: #{project['name']}"
|
|
127
|
+
puts " Slug: #{project['slug'] || slug}"
|
|
128
|
+
puts " Surfaces: #{created_surfaces.empty? ? '(none yet)' : created_surfaces.join(', ')}"
|
|
129
|
+
puts
|
|
130
|
+
puts 'Next:'
|
|
131
|
+
puts " fp surfaces add <key> --project #{project['slug'] || slug} --name \"<Name>\"" if created_surfaces.empty?
|
|
132
|
+
puts " fp init --project #{project['slug'] || slug} --surface <key> # onboard a repo"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def edit(args)
|
|
137
|
+
opts, positional = parse_flags(args, EDIT_FLAGS)
|
|
138
|
+
slug = require_slug!(positional, 'fp projects edit <slug> [--name "..."] [--description "..."]')
|
|
139
|
+
|
|
140
|
+
params = {}
|
|
141
|
+
params[:name] = opts[:name] if opts[:name]
|
|
142
|
+
params[:description] = opts[:description] if opts.key?(:description)
|
|
143
|
+
|
|
144
|
+
if params.empty?
|
|
145
|
+
output.error('Nothing to change. Pass --name and/or --description.')
|
|
146
|
+
exit 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
project = find_project!(slug)
|
|
150
|
+
result = client.update_project(project['id'], params)
|
|
151
|
+
|
|
152
|
+
unless result[:ok]
|
|
153
|
+
output.error(result[:error], status: result[:status])
|
|
154
|
+
exit 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
record = result[:data]['project'] || {}
|
|
158
|
+
|
|
159
|
+
output.success(result[:data]) do
|
|
160
|
+
puts "Updated project '#{record['slug'] || slug}'."
|
|
161
|
+
puts
|
|
162
|
+
puts " Name: #{record['name']}"
|
|
163
|
+
puts " Description: #{blank?(record['description']) ? '(none)' : record['description']}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def delete(args)
|
|
168
|
+
opts, positional = parse_flags(args, DELETE_FLAGS)
|
|
169
|
+
slug = require_slug!(positional, 'fp projects delete <slug> [--yes]')
|
|
170
|
+
|
|
171
|
+
project = find_project!(slug)
|
|
172
|
+
confirm_delete!(project, opts)
|
|
173
|
+
|
|
174
|
+
result = client.delete_project(project['id'])
|
|
175
|
+
|
|
176
|
+
unless result[:ok]
|
|
177
|
+
output.error(result[:error], status: result[:status])
|
|
178
|
+
exit 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
output.success({ deleted: true, project: { 'id' => project['id'], 'slug' => project['slug'] } }) do
|
|
182
|
+
puts "Deleted project '#{project['slug']}'."
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The gh-style guard. Three ways through:
|
|
187
|
+
# --yes → no prompt (scripts, agents, CI)
|
|
188
|
+
# interactive terminal → type the slug back, exactly
|
|
189
|
+
# neither → refuse; a pipeline must say --yes out loud
|
|
190
|
+
#
|
|
191
|
+
# The prompt shows what is about to go with the project (surfaces, requirements)
|
|
192
|
+
# so "the wrong project" is caught by a human reading, not only by typing.
|
|
193
|
+
def confirm_delete!(project, opts)
|
|
194
|
+
return if opts[:yes]
|
|
195
|
+
|
|
196
|
+
unless interactive?
|
|
197
|
+
output.error(
|
|
198
|
+
"Refusing to delete '#{project['slug']}' without confirmation. " \
|
|
199
|
+
'Re-run with --yes to confirm from a non-interactive session.'
|
|
200
|
+
)
|
|
201
|
+
exit 1
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
surfaces = surface_keys_for(project['id'])
|
|
205
|
+
requirements = requirement_count_for(project['id'])
|
|
206
|
+
|
|
207
|
+
warn "This will permanently delete project '#{project['slug']}' (#{project['name']})."
|
|
208
|
+
warn " Surfaces: #{surfaces.empty? ? 'none' : surfaces.join(', ')}"
|
|
209
|
+
warn " Requirements: #{requirements.nil? ? 'unknown' : requirements}"
|
|
210
|
+
warn 'Its requirements, evidence, and memberships will no longer be reachable.'
|
|
211
|
+
$stderr.print "Type '#{project['slug']}' to confirm: "
|
|
212
|
+
$stderr.flush
|
|
213
|
+
|
|
214
|
+
typed = $stdin.gets&.strip
|
|
215
|
+
return if typed == project['slug']
|
|
216
|
+
|
|
217
|
+
output.error('Aborted. The slug did not match; nothing was deleted.')
|
|
218
|
+
exit 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def interactive?
|
|
222
|
+
$stdin.respond_to?(:tty?) && $stdin.tty?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# --- helpers ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def require_slug!(positional, usage)
|
|
228
|
+
slug = positional.first
|
|
229
|
+
if slug.nil? || slug.strip.empty?
|
|
230
|
+
output.error("Usage: #{usage}")
|
|
231
|
+
exit 1
|
|
232
|
+
end
|
|
233
|
+
slug.strip
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The project record (not just its id) by slug or id, or exit 1. Mirrors
|
|
237
|
+
# Base#resolve_project_id, but the commands here print name/slug too.
|
|
238
|
+
def find_project!(slug_or_id)
|
|
239
|
+
result = client.list_projects
|
|
240
|
+
unless result[:ok]
|
|
241
|
+
output.error(result[:error], status: result[:status])
|
|
242
|
+
exit 1
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
projects = result[:data]['projects'] || []
|
|
246
|
+
project = projects.find { |w| w['slug'] == slug_or_id || w['id'] == slug_or_id }
|
|
247
|
+
|
|
248
|
+
unless project
|
|
249
|
+
output.error("Project '#{slug_or_id}' not found")
|
|
250
|
+
exit 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
project
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def surface_keys_for(project_id)
|
|
257
|
+
result = client.list_surfaces(project_id)
|
|
258
|
+
return [] unless result[:ok]
|
|
259
|
+
|
|
260
|
+
(result[:data]['surfaces'] || []).map { |s| s['key'] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Nil when the read fails — the confirmation prompt says "unknown" rather than
|
|
264
|
+
# claiming a project is empty.
|
|
265
|
+
def requirement_count_for(project_id)
|
|
266
|
+
result = client.list_requirements(project_id: project_id)
|
|
267
|
+
return nil unless result[:ok]
|
|
268
|
+
|
|
269
|
+
(result[:data]['requirements'] || []).size
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def print_project(project, surfaces)
|
|
273
|
+
name = project['name'] || project['slug'].to_s
|
|
274
|
+
puts name
|
|
275
|
+
puts '=' * name.length
|
|
276
|
+
puts
|
|
277
|
+
puts "Slug: #{project['slug']}"
|
|
278
|
+
puts "Description: #{blank?(project['description']) ? '(none)' : project['description']}"
|
|
279
|
+
puts "Enforcement: #{project['enforcement_level']}" if project['enforcement_level']
|
|
280
|
+
puts "Surfaces: #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
|
|
281
|
+
|
|
282
|
+
vocabulary = Array(project['test_type_vocabulary'])
|
|
283
|
+
puts "Test types: #{vocabulary.join(', ')}" unless vocabulary.empty?
|
|
284
|
+
defaults = Array(project['default_test_types'])
|
|
285
|
+
puts "Default types: #{defaults.join(', ')}" unless defaults.empty?
|
|
286
|
+
|
|
287
|
+
puts
|
|
288
|
+
puts "ID: #{project['id']}" if project['id']
|
|
289
|
+
puts "Created: #{project['created_at']}" if project['created_at']
|
|
290
|
+
puts "Updated: #{project['updated_at']}" if project['updated_at']
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def split_list(value)
|
|
294
|
+
return [] if value.nil? || value == true
|
|
295
|
+
|
|
296
|
+
value.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def blank?(value)
|
|
300
|
+
value.nil? || value.to_s.strip.empty?
|
|
301
|
+
end
|
|
36
302
|
end
|
|
37
303
|
end
|
|
38
304
|
end
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -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
data/skills/fp/SKILL.md
CHANGED
|
@@ -352,6 +352,19 @@ FeatureParity section in `AGENTS.md`):
|
|
|
352
352
|
fp init --project <slug> --surface <surface>
|
|
353
353
|
```
|
|
354
354
|
|
|
355
|
+
If the project itself doesn't exist yet, create it first — you don't need a human to do
|
|
356
|
+
this in the web app. The project lands on the account of whoever minted your key, and
|
|
357
|
+
your key reaches it immediately:
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
fp projects create <slug> --name "<Name>" --surfaces api,web
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Creation is subject to the owner's plan allowance; a free account gets one project, and
|
|
364
|
+
the command relays the upgrade message if that's used up. `fp projects delete` exists but
|
|
365
|
+
asks a human to type the slug back (or `--yes` in scripts) and is owner-only — don't reach
|
|
366
|
+
for it to tidy up; a project's requirements, evidence, and history go with it.
|
|
367
|
+
|
|
355
368
|
### Triage requirement quality (`fp analyze`)
|
|
356
369
|
|
|
357
370
|
`fp analyze` checks how well requirements are written and whether they belong in the
|
|
@@ -404,6 +417,8 @@ not as the fix — you still write the proposal.
|
|
|
404
417
|
| View matrix | `fp matrix --project X` |
|
|
405
418
|
| List surfaces | `fp surfaces --project X` |
|
|
406
419
|
| List projects | `fp projects` |
|
|
420
|
+
| Create a project | `fp projects create <slug> --name "..." --surfaces api,web` |
|
|
421
|
+
| View / edit a project | `fp projects show <slug>`, `fp projects edit <slug> --name "..."` |
|
|
407
422
|
|
|
408
423
|
All commands support `--json` for machine-readable output.
|
|
409
424
|
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -29,22 +29,51 @@ fp setup --api-key fp_... --profile ci # Named profile
|
|
|
29
29
|
|
|
30
30
|
### fp projects
|
|
31
31
|
|
|
32
|
-
List accessible projects.
|
|
32
|
+
List accessible projects, or view, create, edit and delete one.
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
fp projects
|
|
35
|
+
fp projects # list
|
|
36
36
|
fp projects --json
|
|
37
|
+
fp projects show <slug> # details + surfaces
|
|
38
|
+
fp projects create <slug> --name "Name" [--description "..."] [--surfaces api,web]
|
|
39
|
+
fp projects edit <slug> [--name "..."] [--description "..."]
|
|
40
|
+
fp projects delete <slug> [--yes]
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
`create` is open to agents: the project is owned by the human who minted the key
|
|
44
|
+
(it counts against *their* plan allowance — a free account gets one project, and the
|
|
45
|
+
command relays the server's upgrade message if that's used up), and the same key reaches
|
|
46
|
+
the new project on its next call. `--name` defaults to the slug. `--surfaces` takes
|
|
47
|
+
surface names, comma-separated; each becomes a surface with a derived key and default
|
|
48
|
+
kind/audience, the same as `fp surfaces add` without flags.
|
|
49
|
+
|
|
50
|
+
`delete` is guarded the way `gh repo delete` is. In a terminal it shows the project's
|
|
51
|
+
surfaces and requirement count and asks you to type the slug back; a mismatch aborts.
|
|
52
|
+
Outside a terminal it refuses unless `--yes` is passed. Server-side, only a project
|
|
53
|
+
owner — or a key minted by one — may delete; a member's key gets 403.
|
|
54
|
+
|
|
39
55
|
### fp surfaces
|
|
40
56
|
|
|
41
|
-
List
|
|
57
|
+
List, view, create, edit and delete a project's surfaces.
|
|
42
58
|
|
|
43
59
|
```bash
|
|
44
60
|
fp surfaces --project stowzilla
|
|
45
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]
|
|
46
66
|
```
|
|
47
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
|
+
|
|
48
77
|
### fp list
|
|
49
78
|
|
|
50
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.
|
|
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-
|
|
11
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|