featureparity 0.0.2 → 0.0.4

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: 7fec4ef9bda94cba09b19e5209ed867ff87d6a5312c6c6441234691e406bd2a2
4
- data.tar.gz: 7fd7a58df29f9a69ce342ed60e5b7ff60b796f286404b81a56108be1f5c83f1b
3
+ metadata.gz: 0b5b8ef2f8e259e3d8d7ecd0c547ad8bef6f6202e0b3564bd8d789de905661ed
4
+ data.tar.gz: 79d02f1bfdd3c95b1d94a40b2cda406d656c4970a0c6b3f60b792415b4417c62
5
5
  SHA512:
6
- metadata.gz: b198114877fb8ca6695f14c38796334b2cf4269e4164509a97a7b0d12cf4fa7f86ecbd4036028fad15fb80eb00d95da5a410542eda6cdcbd7366b33e6c0dab11
7
- data.tar.gz: 7a6a248d554a4fa13bc5b33591be0868f2714dfc483c81da0ceafaa4fa951f5f915c229ac8096fbda59efae8319c39e135e4b36e4403944c6ceec80cc7f74832
6
+ metadata.gz: e737b8e544deaa42e6ada2f6af065c5afdeaa46afa51d5365c5f8a92fa77572e1f5c1a13715a11eb0655416a08e487f4b8e9bb7f7ffa9efc3a6042abcc1c45ad
7
+ data.tar.gz: 247e22500f20fcd047a617232020f29a87987ac2376a78176a69969a467b8d8164b4dd6a427c73c558965b7aa9543ffb2e0b659416974ec5272f76d2618c415e
data/lib/fp/cli.rb CHANGED
@@ -13,7 +13,9 @@ module Fp
13
13
  'matrix' => Commands::Matrix,
14
14
  'profile' => Commands::Profile,
15
15
  'config' => Commands::ConfigCmd,
16
+ 'repos' => Commands::Repos,
16
17
  'setup' => Commands::Setup,
18
+ 'whoami' => Commands::Whoami,
17
19
  'version' => Commands::Version,
18
20
  'help' => Commands::Help
19
21
  }.freeze
@@ -34,7 +36,7 @@ module Fp
34
36
  end
35
37
 
36
38
  # Profile/config commands don't need auth
37
- needs_auth = !%w[profile config setup version help].include?(command_name)
39
+ needs_auth = !%w[profile config repos setup whoami version help].include?(command_name)
38
40
 
39
41
  if needs_auth
40
42
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
data/lib/fp/client.rb CHANGED
@@ -12,21 +12,27 @@ module Fp
12
12
  @base_url = config.api_url.chomp('/')
13
13
  end
14
14
 
15
- # GET /workspaces (called "projects" in the CLI)
15
+ # GET /projects
16
16
  def list_projects
17
- get('/api/workspaces')
17
+ get('/api/projects')
18
18
  end
19
19
 
20
- # GET /workspaces/:id
20
+ # GET /projects/:id
21
21
  def get_project(id)
22
- get("/api/workspaces/#{id}")
22
+ get("/api/projects/#{id}")
23
+ end
24
+
25
+ # GET /projects/:id/surfaces
26
+ def list_surfaces(project_id)
27
+ get("/api/projects/#{project_id}/surfaces")
23
28
  end
24
29
 
25
30
  # GET /requirements
26
- def list_requirements(workspace_id: nil, status: nil)
31
+ def list_requirements(project_id: nil, status: nil, category: nil)
27
32
  params = {}
28
- params[:workspace_id] = workspace_id if workspace_id
33
+ params[:project_id] = project_id if project_id
29
34
  params[:status] = status if status
35
+ params[:category] = category if category
30
36
  get('/api/requirements', params)
31
37
  end
32
38
 
@@ -35,10 +41,10 @@ module Fp
35
41
  get("/api/requirements/#{id}")
36
42
  end
37
43
 
38
- # GET /requirements by slug within a workspace
39
- def find_requirement_by_slug(workspace_id, slug)
40
- # List all requirements for the workspace and find by slug
41
- result = list_requirements(workspace_id: workspace_id)
44
+ # GET /requirements by slug within a project
45
+ def find_requirement_by_slug(project_id, slug)
46
+ # List all requirements for the project and find by slug
47
+ result = list_requirements(project_id: project_id)
42
48
  return result unless result[:ok]
43
49
 
44
50
  requirements = result[:data]['requirements'] || []
@@ -47,7 +53,7 @@ module Fp
47
53
  if requirement
48
54
  { ok: true, data: { 'requirement' => requirement } }
49
55
  else
50
- { ok: false, error: "Requirement '#{slug}' not found in workspace", status: 404 }
56
+ { ok: false, error: "Requirement '#{slug}' not found in project", status: 404 }
51
57
  end
52
58
  end
53
59
 
@@ -61,20 +67,21 @@ module Fp
61
67
  put("/api/requirements/#{id}", params)
62
68
  end
63
69
 
64
- # POST /evidence (when API is ready)
70
+ # POST /projects/:project_id/evidence (direct reporting)
65
71
  def report_evidence(params)
66
- post('/api/evidence', params)
72
+ project_id = params.delete(:project_id)
73
+ post("/api/projects/#{project_id}/evidence", params)
67
74
  end
68
75
 
69
76
  # GET /matrix (when API is ready)
70
- def get_matrix(workspace_id, format: :json)
77
+ def get_matrix(project_id, format: :json)
71
78
  path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
72
- get(path, { workspace_id: workspace_id })
79
+ get(path, { project_id: project_id })
73
80
  end
74
81
 
75
82
  # GET /gaps (when API is ready)
76
- def get_gaps(workspace_id, surface: nil)
77
- params = { workspace_id: workspace_id }
83
+ def get_gaps(project_id, surface: nil)
84
+ params = { project_id: project_id }
78
85
  params[:surface] = surface if surface
79
86
  get('/api/gaps', params)
80
87
  end
@@ -50,26 +50,26 @@ module Fp
50
50
  [opts, positional]
51
51
  end
52
52
 
53
- # Resolve workspace ID from --project flag (can be slug or ID)
54
- def resolve_workspace_id(project_slug_or_id)
53
+ # Resolve project ID from --project flag (can be slug or ID)
54
+ def resolve_project_id(project_slug_or_id)
55
55
  return nil unless project_slug_or_id
56
56
 
57
- # First try to find by listing workspaces
57
+ # First try to find by listing projects
58
58
  result = client.list_projects
59
59
  unless result[:ok]
60
60
  output.error(result[:error], status: result[:status])
61
61
  exit 1
62
62
  end
63
63
 
64
- workspaces = result[:data]['workspaces'] || []
65
- workspace = workspaces.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
64
+ projects = result[:data]['projects'] || []
65
+ project = projects.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
66
66
 
67
- unless workspace
67
+ unless project
68
68
  output.error("Project '#{project_slug_or_id}' not found")
69
69
  exit 1
70
70
  end
71
71
 
72
- workspace['id']
72
+ project['id']
73
73
  end
74
74
 
75
75
  def require_flag(opts, flag, message = nil)
@@ -19,6 +19,9 @@ module Fp
19
19
  unset_setting(args)
20
20
  when 'list'
21
21
  list_settings
22
+ when 'whoami'
23
+ # Alias for `fp whoami` — show the fully resolved configuration.
24
+ Whoami.new(client: client, output: output, config: config).run(args)
22
25
  else
23
26
  show_usage
24
27
  end
@@ -105,17 +108,22 @@ module Fp
105
108
  puts "Global settings:"
106
109
  settings.each { |k, v| puts " #{k} = #{v}" }
107
110
  end
111
+ puts
112
+ puts "This shows only global settings. To see the fully resolved config"
113
+ puts "(profile, environment, API URL, and key — with the source of each):"
114
+ puts " fp whoami"
108
115
  end
109
116
  end
110
117
 
111
118
  def show_usage
112
- output.error('Usage: fp config <set|get|unset|list>')
119
+ output.error('Usage: fp config <set|get|unset|list|whoami>')
113
120
  puts
114
121
  puts 'Commands:'
115
122
  puts ' fp config set <key> <value> Set a global setting'
116
123
  puts ' fp config get <key> Get a setting value'
117
124
  puts ' fp config unset <key> Remove a setting'
118
125
  puts ' fp config list Show all global settings'
126
+ puts ' fp config whoami Show the resolved config (alias of `fp whoami`)'
119
127
  puts
120
128
  puts 'Available settings:'
121
129
  puts ' api_url Base URL for the FeatureParity API'
@@ -123,6 +131,7 @@ module Fp
123
131
  puts 'Examples:'
124
132
  puts ' fp config set api_url https://api.dev.featureparity.dev'
125
133
  puts ' fp config get api_url'
134
+ puts ' fp config whoami'
126
135
  exit 1
127
136
  end
128
137
  end
@@ -18,9 +18,12 @@ module Fp
18
18
  show <slug> --project <slug> Show requirement details
19
19
  propose --project <slug> ... Propose a new requirement (as draft)
20
20
  report <slug> --project <slug>... Report evidence for a requirement
21
+ report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
21
22
  matrix --project <slug> Show the parity matrix
22
23
  profile add|list Manage named profiles
23
24
  config set|get|unset|list Manage global settings
25
+ whoami Show the resolved config (profile, env, API URL, key)
26
+ repos set|get|list|unset Map surfaces to local repo paths
24
27
  setup Interactive first-time setup
25
28
  version Show version
26
29
  help Show this help
@@ -54,6 +57,11 @@ module Fp
54
57
  # First-time setup (interactive)
55
58
  fp setup
56
59
 
60
+ # Show the resolved configuration (which profile/env/API URL/key is in effect)
61
+ fp whoami
62
+ fp whoami --json
63
+ fp --profile staging whoami
64
+
57
65
  # Non-interactive setup for CI/automation
58
66
  fp setup --api-key fp_... --profile ci-agent --non-interactive
59
67
 
@@ -63,6 +71,9 @@ module Fp
63
71
  # List requirements for a project
64
72
  fp list --project stowzilla
65
73
 
74
+ # List requirements filtered by category
75
+ fp list --project stowzilla --category security
76
+
66
77
  # Show gaps (requirements without evidence)
67
78
  fp list --project stowzilla --gaps --surface customer_android
68
79
 
@@ -75,7 +86,8 @@ module Fp
75
86
  --name "Print QR code on container label" \\
76
87
  --why "Customers scan to track containers" \\
77
88
  --required api,customer_android,customer_ios \\
78
- --acceptance "QR code is printed and scannable"
89
+ --acceptance "QR code is printed and scannable" \\
90
+ --category functional
79
91
 
80
92
  # Report evidence for a requirement
81
93
  fp report print_container_qr --project stowzilla \\
@@ -86,12 +98,31 @@ module Fp
86
98
  --work-item https://app.fizzy.do/123/cards/456 \\
87
99
  --title "prints a QR code onto the container label"
88
100
 
101
+ # Upload evidence without CI: run your suite, emit JUnit XML, upload it.
102
+ # fp parses the report, finds the fp:<slug> markers in the test files,
103
+ # and reports evidence for every requirement the suite covered.
104
+ # rspec --format RspecJunitFormatter --out junit.xml # (or your runner's equivalent)
105
+ fp report --junit junit.xml --project stowzilla \\
106
+ --surface api \\
107
+ --repo stowzilla/marketplace \\
108
+ --work-item https://app.fizzy.do/123/cards/456
109
+ # --base-dir sets where relative test file paths in the report resolve from
110
+ # Skipped tests are reported as 'stub'; everything else as 'present'.
111
+
89
112
  # Show the parity matrix
90
113
  fp matrix --project stowzilla
91
114
 
92
115
  # Export matrix as CSV
93
116
  fp matrix --project stowzilla --csv > matrix.csv
94
117
 
118
+ # Map a surface to a local repo on this machine
119
+ fp repos set customer_android ~/code/customer-android \\
120
+ --project stowzilla --repo stowzilla/customer-android
121
+
122
+ # Find where a surface's code lives locally (for agents)
123
+ fp repos get customer_android --project stowzilla
124
+ cd "$(fp repos get customer_android --project stowzilla)"
125
+
95
126
  MARKER CONVENTION
96
127
  Tests are bound to requirements using the fp:<slug> marker in the test file.
97
128
  The CLI does not rewrite test files - you add the marker yourself.
@@ -115,6 +146,18 @@ module Fp
115
146
  ...
116
147
  }
117
148
 
149
+ PINNING SURFACES IN THE MARKER
150
+ A marker may target one or more surfaces with an @suffix. This is
151
+ handy when a single (e.g. backend) test satisfies several surfaces:
152
+
153
+ # fp:print_container_qr -> uses the --surface flag
154
+ # fp:print_container_qr@api -> reports for the api surface
155
+ # fp:print_container_qr@api,web -> reports for api AND web
156
+
157
+ With `fp report --junit`, each pinned surface produces its own
158
+ evidence upload. Markers without an @surface fall back to --surface;
159
+ if a marker pins none and no --surface is given, the run errors.
160
+
118
161
  MORE INFORMATION
119
162
  https://featureparity.dev/docs
120
163
  HELP
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp list --project <slug> [--surface X] [--gaps] [--status draft]
5
+ # fp list --project <slug> [--surface X] [--category Y] [--gaps] [--status draft]
6
6
  # List requirements, optionally filtered
7
7
  class List < Base
8
8
  KNOWN_FLAGS = {
9
9
  project: :string,
10
10
  surface: :string,
11
+ category: :string,
11
12
  status: :string,
12
13
  gaps: :boolean
13
14
  }.freeze
@@ -16,19 +17,19 @@ module Fp
16
17
  opts, = parse_flags(args, KNOWN_FLAGS)
17
18
  require_flag(opts, :project)
18
19
 
19
- workspace_id = resolve_workspace_id(opts[:project])
20
+ project_id = resolve_project_id(opts[:project])
20
21
 
21
22
  if opts[:gaps]
22
- list_gaps(workspace_id, opts)
23
+ list_gaps(project_id, opts)
23
24
  else
24
- list_requirements(workspace_id, opts)
25
+ list_requirements(project_id, opts)
25
26
  end
26
27
  end
27
28
 
28
29
  private
29
30
 
30
- def list_requirements(workspace_id, opts)
31
- result = client.list_requirements(workspace_id: workspace_id)
31
+ def list_requirements(project_id, opts)
32
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
32
33
 
33
34
  unless result[:ok]
34
35
  output.error(result[:error], status: result[:status])
@@ -50,7 +51,10 @@ module Fp
50
51
 
51
52
  output.success({ requirements: requirements, project: opts[:project] }) do
52
53
  if requirements.empty?
53
- puts "No #{status_filter} requirements found."
54
+ filter_desc = [status_filter]
55
+ filter_desc << "category: #{opts[:category]}" if opts[:category]
56
+ filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
57
+ puts "No requirements found (#{filter_desc.join(', ')})."
54
58
  else
55
59
  # Group into tree structure: parents first, then children indented
56
60
  parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
@@ -62,24 +66,27 @@ module Fp
62
66
  parent_ids = parents.map { |p| p['id'] }.to_set
63
67
  orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
64
68
 
65
- headers = %w[SLUG TITLE SURFACES STATUS]
69
+ headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
66
70
  rows = []
67
71
 
68
72
  parents.each do |r|
69
73
  surfaces = (r['required_surfaces'] || []).join(', ')
70
- rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
74
+ category = r['category'] || '-'
75
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
71
76
 
72
77
  # Add children indented
73
78
  (children_by_parent[r['id']] || []).each do |child|
74
79
  child_surfaces = (child['required_surfaces'] || []).join(', ')
75
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_surfaces.empty? ? '-' : child_surfaces, child['status']]
80
+ child_category = child['category'] || '-'
81
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, child['status']]
76
82
  end
77
83
  end
78
84
 
79
85
  # Show orphans at top level
80
86
  orphans.each do |r|
81
87
  surfaces = (r['required_surfaces'] || []).join(', ')
82
- rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
88
+ category = r['category'] || '-'
89
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
83
90
  end
84
91
 
85
92
  output.table(headers, rows)
@@ -87,10 +94,10 @@ module Fp
87
94
  end
88
95
  end
89
96
 
90
- def list_gaps(workspace_id, opts)
97
+ def list_gaps(project_id, opts)
91
98
  # Gaps endpoint may not exist yet - fall back to computing from requirements
92
99
  # For now, list requirements that don't have evidence for a surface
93
- result = client.list_requirements(workspace_id: workspace_id)
100
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
94
101
 
95
102
  unless result[:ok]
96
103
  output.error(result[:error], status: result[:status])
@@ -109,15 +116,24 @@ module Fp
109
116
 
110
117
  # For now, gaps = all requirements (evidence API is #1214)
111
118
  # Once evidence API exists, filter to only those without evidence
112
- output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface] }) do
119
+ output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface], category: opts[:category] }) do
113
120
  if requirements.empty?
114
- puts 'No gaps found.'
121
+ filter_desc = []
122
+ filter_desc << "category: #{opts[:category]}" if opts[:category]
123
+ filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
124
+ msg = 'No gaps found'
125
+ msg += " (#{filter_desc.join(', ')})" unless filter_desc.empty?
126
+ puts "#{msg}."
115
127
  else
116
- puts "Gaps for project '#{opts[:project]}'" + (opts[:surface] ? " (surface: #{opts[:surface]})" : '') + ':'
117
- headers = %w[SLUG TITLE SURFACES]
128
+ desc = "Gaps for project '#{opts[:project]}'"
129
+ desc += " (surface: #{opts[:surface]})" if opts[:surface]
130
+ desc += " (category: #{opts[:category]})" if opts[:category]
131
+ puts "#{desc}:"
132
+ headers = %w[SLUG TITLE CATEGORY SURFACES]
118
133
  rows = requirements.map do |r|
119
134
  surfaces = (r['required_surfaces'] || []).join(', ')
120
- [r['slug'], truncate(r['title'], 40), surfaces]
135
+ category = r['category'] || '-'
136
+ [r['slug'], truncate(r['title'], 40), category, surfaces]
121
137
  end
122
138
  output.table(headers, rows)
123
139
  end
@@ -14,20 +14,19 @@ module Fp
14
14
  opts, = parse_flags(args, KNOWN_FLAGS)
15
15
  require_flag(opts, :project)
16
16
 
17
- workspace_id = resolve_workspace_id(opts[:project])
17
+ project_id = resolve_project_id(opts[:project])
18
18
 
19
- # Get workspace for surfaces
20
- ws_result = client.get_project(workspace_id)
21
- unless ws_result[:ok]
22
- output.error(ws_result[:error], status: ws_result[:status])
19
+ # Get surfaces for the project
20
+ surfaces_result = client.list_surfaces(project_id)
21
+ unless surfaces_result[:ok]
22
+ output.error(surfaces_result[:error], status: surfaces_result[:status])
23
23
  exit 1
24
24
  end
25
25
 
26
- workspace = ws_result[:data]['workspace']
27
- surfaces = workspace['available_surfaces'] || []
26
+ surfaces = (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
28
27
 
29
28
  # Get active requirements
30
- req_result = client.list_requirements(workspace_id: workspace_id)
29
+ req_result = client.list_requirements(project_id: project_id)
31
30
  unless req_result[:ok]
32
31
  output.error(req_result[:error], status: req_result[:status])
33
32
  exit 1
@@ -7,7 +7,8 @@ module Fp
7
7
  class Profile < Base
8
8
  KNOWN_FLAGS = {
9
9
  api_key: :string,
10
- api_url: :string
10
+ api_url: :string,
11
+ environment: :string
11
12
  }.freeze
12
13
 
13
14
  def run(args)
@@ -42,11 +43,18 @@ module Fp
42
43
  exit 1
43
44
  end
44
45
 
45
- Config.save_profile(name, api_key: opts[:api_key], api_url: opts[:api_url])
46
+ # The global CLI parser consumes --api-url before the command runs, so
47
+ # honor it via the config object when the local flag wasn't captured.
48
+ api_url = opts[:api_url] || config&.explicit_api_url
49
+ Config.save_profile(name, api_key: opts[:api_key], api_url: api_url, environment: opts[:environment])
46
50
 
47
- output.success({ profile: name, api_url: opts[:api_url] || Config::DEFAULT_API_URL }) do
51
+ effective_url = api_url || Config.get_setting('api_url') || Config::DEFAULT_API_URL
52
+ environment = opts[:environment] || Config.environment_for(effective_url)
53
+
54
+ output.success({ profile: name, api_url: effective_url, environment: environment }) do
48
55
  puts "Profile '#{name}' saved."
49
- puts " API URL: #{opts[:api_url] || Config::DEFAULT_API_URL}"
56
+ puts " API URL: #{effective_url}"
57
+ puts " Environment: #{environment}" if environment
50
58
  puts
51
59
  puts 'Use this profile with:'
52
60
  puts " fp --profile #{name} projects"
@@ -65,11 +73,23 @@ module Fp
65
73
  puts ' fp profile add <name> --api-key fp_...'
66
74
  end
67
75
  else
68
- output.success({ profiles: profiles }) do
76
+ raw = Config.load_profiles
77
+ detailed = profiles.map do |name|
78
+ p = raw[name] || {}
79
+ url = p['api_url'] || Config.get_setting('api_url') || Config::DEFAULT_API_URL
80
+ env = p['environment'] || Config.environment_for(url)
81
+ { name: name, environment: env, api_url: url }
82
+ end
83
+
84
+ output.success({ profiles: detailed }) do
69
85
  puts 'Configured profiles:'
70
- profiles.each { |name| puts " - #{name}" }
86
+ detailed.each do |d|
87
+ tag = d[:environment] ? " (#{d[:environment]})" : ''
88
+ puts " - #{d[:name]}#{tag}"
89
+ end
71
90
  puts
72
91
  puts 'Note: API keys are never displayed. Use --profile <name> to select.'
92
+ puts 'Run `fp whoami` to see the fully resolved configuration.'
73
93
  end
74
94
  end
75
95
  end
@@ -78,7 +98,8 @@ module Fp
78
98
  output.error('Usage: fp profile <add|list>')
79
99
  puts
80
100
  puts 'Commands:'
81
- puts ' fp profile add <name> --api-key fp_... Add a named profile'
101
+ puts ' fp profile add <name> --api-key fp_... [--api-url URL] [--environment ENV]'
102
+ puts ' Add a named profile'
82
103
  puts ' fp profile list List configured profiles'
83
104
  exit 1
84
105
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp projects - list projects/workspaces the API key has access to
5
+ # fp projects - list projects the API key has access to
6
6
  class Projects < Base
7
7
  def run(_args)
8
8
  result = client.list_projects
@@ -12,15 +12,21 @@ module Fp
12
12
  exit 1
13
13
  end
14
14
 
15
- workspaces = result[:data]['workspaces'] || []
15
+ projects = result[:data]['projects'] || []
16
16
 
17
17
  output.success(result[:data]) do
18
- if workspaces.empty?
18
+ if projects.empty?
19
19
  puts 'No projects found.'
20
20
  else
21
21
  headers = %w[SLUG NAME SURFACES]
22
- rows = workspaces.map do |w|
23
- surfaces = (w['available_surfaces'] || []).join(', ')
22
+ rows = projects.map do |w|
23
+ surfaces_result = client.list_surfaces(w['id'])
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(', ')
24
30
  [w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
25
31
  end
26
32
  output.table(headers, rows)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..." --parent <slug>
5
+ # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..." --parent <slug> --category <cat>
6
6
  # Create a new requirement as draft
7
7
  class Propose < Base
8
8
  KNOWN_FLAGS = {
@@ -12,9 +12,12 @@ module Fp
12
12
  why: :string,
13
13
  required: :string,
14
14
  acceptance: :string,
15
- parent: :string
15
+ parent: :string,
16
+ category: :string
16
17
  }.freeze
17
18
 
19
+ VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
20
+
18
21
  def run(args)
19
22
  opts, = parse_flags(args, KNOWN_FLAGS)
20
23
 
@@ -22,7 +25,13 @@ module Fp
22
25
  require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
23
26
  require_flag(opts, :name, '--name is required (human-readable title)')
24
27
 
25
- workspace_id = resolve_workspace_id(opts[:project])
28
+ # Validate category client-side for better UX (fail fast with helpful message)
29
+ if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
30
+ output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
31
+ exit 1
32
+ end
33
+
34
+ project_id = resolve_project_id(opts[:project])
26
35
 
27
36
  # Parse required surfaces (comma-separated)
28
37
  required_surfaces = if opts[:required]
@@ -34,15 +43,16 @@ module Fp
34
43
  params = {
35
44
  slug: opts[:slug],
36
45
  title: opts[:name],
37
- workspace_id: workspace_id,
46
+ project_id: project_id,
38
47
  required_surfaces: required_surfaces
39
48
  }
40
49
  params[:why] = opts[:why] if opts[:why]
41
50
  params[:acceptance] = opts[:acceptance] if opts[:acceptance]
51
+ params[:category] = opts[:category] if opts[:category]
42
52
 
43
53
  # Resolve parent slug to parent_id if provided
44
54
  if opts[:parent]
45
- parent_id = resolve_parent_id(workspace_id, opts[:parent])
55
+ parent_id = resolve_parent_id(project_id, opts[:parent])
46
56
  params[:parent_id] = parent_id
47
57
  end
48
58
 
@@ -62,6 +72,7 @@ module Fp
62
72
  puts
63
73
  puts " Title: #{requirement['title']}"
64
74
  puts " Status: #{requirement['status']}"
75
+ puts " Category: #{requirement['category'] || '(none)'}"
65
76
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
66
77
  puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
67
78
  puts
@@ -74,8 +85,8 @@ module Fp
74
85
  private
75
86
 
76
87
  # Resolve a parent requirement slug to its ID
77
- def resolve_parent_id(workspace_id, parent_slug)
78
- result = client.list_requirements(workspace_id: workspace_id)
88
+ def resolve_parent_id(project_id, parent_slug)
89
+ result = client.list_requirements(project_id: project_id)
79
90
  unless result[:ok]
80
91
  output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
81
92
  exit 1
@@ -85,7 +96,7 @@ module Fp
85
96
  parent = requirements.find { |r| r['slug'] == parent_slug }
86
97
 
87
98
  unless parent
88
- output.error("Parent requirement '#{parent_slug}' not found in this workspace")
99
+ output.error("Parent requirement '#{parent_slug}' not found in this project")
89
100
  exit 1
90
101
  end
91
102