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 +4 -4
- data/lib/fp/cli.rb +3 -1
- data/lib/fp/client.rb +24 -17
- data/lib/fp/commands/base.rb +7 -7
- data/lib/fp/commands/config_cmd.rb +10 -1
- data/lib/fp/commands/help.rb +44 -1
- data/lib/fp/commands/list.rb +34 -18
- data/lib/fp/commands/matrix.rb +7 -8
- data/lib/fp/commands/profile.rb +28 -7
- data/lib/fp/commands/projects.rb +11 -5
- data/lib/fp/commands/propose.rb +19 -8
- data/lib/fp/commands/report.rb +161 -12
- data/lib/fp/commands/repos.rb +169 -0
- data/lib/fp/commands/setup.rb +47 -8
- data/lib/fp/commands/show.rb +3 -2
- data/lib/fp/commands/surfaces.rb +5 -5
- data/lib/fp/commands/whoami.rb +55 -0
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/config.rb +245 -11
- data/lib/fp/junit.rb +212 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +140 -0
- data/skills/fp/references/cli.md +245 -0
- data/skills/fp/references/markers.md +149 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b5b8ef2f8e259e3d8d7ecd0c547ad8bef6f6202e0b3564bd8d789de905661ed
|
|
4
|
+
data.tar.gz: 79d02f1bfdd3c95b1d94a40b2cda406d656c4970a0c6b3f60b792415b4417c62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 /
|
|
15
|
+
# GET /projects
|
|
16
16
|
def list_projects
|
|
17
|
-
get('/api/
|
|
17
|
+
get('/api/projects')
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# GET /
|
|
20
|
+
# GET /projects/:id
|
|
21
21
|
def get_project(id)
|
|
22
|
-
get("/api/
|
|
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(
|
|
31
|
+
def list_requirements(project_id: nil, status: nil, category: nil)
|
|
27
32
|
params = {}
|
|
28
|
-
params[:
|
|
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
|
|
39
|
-
def find_requirement_by_slug(
|
|
40
|
-
# List all requirements for the
|
|
41
|
-
result = list_requirements(
|
|
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
|
|
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 (
|
|
70
|
+
# POST /projects/:project_id/evidence (direct reporting)
|
|
65
71
|
def report_evidence(params)
|
|
66
|
-
|
|
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(
|
|
77
|
+
def get_matrix(project_id, format: :json)
|
|
71
78
|
path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
|
|
72
|
-
get(path, {
|
|
79
|
+
get(path, { project_id: project_id })
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
# GET /gaps (when API is ready)
|
|
76
|
-
def get_gaps(
|
|
77
|
-
params = {
|
|
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
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -50,26 +50,26 @@ module Fp
|
|
|
50
50
|
[opts, positional]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# Resolve
|
|
54
|
-
def
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
67
|
+
unless project
|
|
68
68
|
output.error("Project '#{project_slug_or_id}' not found")
|
|
69
69
|
exit 1
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
|
|
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
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -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
|
data/lib/fp/commands/list.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
+
project_id = resolve_project_id(opts[:project])
|
|
20
21
|
|
|
21
22
|
if opts[:gaps]
|
|
22
|
-
list_gaps(
|
|
23
|
+
list_gaps(project_id, opts)
|
|
23
24
|
else
|
|
24
|
-
list_requirements(
|
|
25
|
+
list_requirements(project_id, opts)
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
private
|
|
29
30
|
|
|
30
|
-
def list_requirements(
|
|
31
|
-
result = client.list_requirements(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
data/lib/fp/commands/matrix.rb
CHANGED
|
@@ -14,20 +14,19 @@ module Fp
|
|
|
14
14
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
15
15
|
require_flag(opts, :project)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
project_id = resolve_project_id(opts[:project])
|
|
18
18
|
|
|
19
|
-
# Get
|
|
20
|
-
|
|
21
|
-
unless
|
|
22
|
-
output.error(
|
|
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
|
-
|
|
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(
|
|
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
|
data/lib/fp/commands/profile.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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_...
|
|
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
|
data/lib/fp/commands/projects.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp projects - list projects
|
|
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
|
-
|
|
15
|
+
projects = result[:data]['projects'] || []
|
|
16
16
|
|
|
17
17
|
output.success(result[:data]) do
|
|
18
|
-
if
|
|
18
|
+
if projects.empty?
|
|
19
19
|
puts 'No projects found.'
|
|
20
20
|
else
|
|
21
21
|
headers = %w[SLUG NAME SURFACES]
|
|
22
|
-
rows =
|
|
23
|
-
|
|
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)
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
78
|
-
result = client.list_requirements(
|
|
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
|
|
99
|
+
output.error("Parent requirement '#{parent_slug}' not found in this project")
|
|
89
100
|
exit 1
|
|
90
101
|
end
|
|
91
102
|
|