featureparity 0.0.1 → 0.0.3
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 +19 -4
- data/lib/fp/client.rb +24 -17
- data/lib/fp/commands/base.rb +7 -7
- data/lib/fp/commands/config_cmd.rb +130 -0
- data/lib/fp/commands/help.rb +40 -1
- data/lib/fp/commands/list.rb +58 -17
- data/lib/fp/commands/matrix.rb +7 -10
- data/lib/fp/commands/projects.rb +11 -5
- data/lib/fp/commands/propose.rb +45 -4
- data/lib/fp/commands/report.rb +161 -12
- data/lib/fp/commands/repos.rb +169 -0
- data/lib/fp/commands/setup.rb +11 -4
- data/lib/fp/commands/show.rb +4 -2
- data/lib/fp/commands/surfaces.rb +5 -5
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/config.rb +151 -22
- data/lib/fp/junit.rb +212 -0
- data/lib/fp/output.rb +1 -1
- 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 +219 -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: 166fe16ad8636be840cc889d1104ad4e6a5f3c84cd046f0a94f291412083f344
|
|
4
|
+
data.tar.gz: e88add572a4cfd638768ae4d65de5a207b6a64c450ca0fdce426f48e8e51427e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3d5bee44367c02c8329c10bdb3c0615978c405c30d46fb002b1ca7fe6676a61075dc83bf4ba02ba01faf828d80fa7f56af2116fd5287f662166bf547d9c5468
|
|
7
|
+
data.tar.gz: 5b627165e1205cca331626fd46b35d7ad71b9ed666fd63ebe96569b2e2989b4b14ff35426d099a1e7dfd8afe7ac16c7984e53ec1052262a374914505c950404c
|
data/lib/fp/cli.rb
CHANGED
|
@@ -12,6 +12,8 @@ module Fp
|
|
|
12
12
|
'report' => Commands::Report,
|
|
13
13
|
'matrix' => Commands::Matrix,
|
|
14
14
|
'profile' => Commands::Profile,
|
|
15
|
+
'config' => Commands::ConfigCmd,
|
|
16
|
+
'repos' => Commands::Repos,
|
|
15
17
|
'setup' => Commands::Setup,
|
|
16
18
|
'version' => Commands::Version,
|
|
17
19
|
'help' => Commands::Help
|
|
@@ -32,17 +34,19 @@ module Fp
|
|
|
32
34
|
exit 1
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
# Profile commands don't need auth
|
|
36
|
-
needs_auth = !%w[profile setup version help].include?(command_name)
|
|
37
|
+
# Profile/config commands don't need auth
|
|
38
|
+
needs_auth = !%w[profile config repos setup version help].include?(command_name)
|
|
37
39
|
|
|
38
40
|
if needs_auth
|
|
39
|
-
config = Config.new(profile: global_opts[:profile])
|
|
41
|
+
config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
|
|
40
42
|
unless config.valid?
|
|
41
43
|
output = Output.new(json: global_opts[:json])
|
|
42
44
|
output.error(config.validation_error.strip)
|
|
43
45
|
exit 1
|
|
44
46
|
end
|
|
45
47
|
client = Client.new(config)
|
|
48
|
+
else
|
|
49
|
+
config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
output = Output.new(json: global_opts[:json])
|
|
@@ -60,7 +64,7 @@ module Fp
|
|
|
60
64
|
private
|
|
61
65
|
|
|
62
66
|
def extract_global_options(args)
|
|
63
|
-
opts = { json: false, profile: nil }
|
|
67
|
+
opts = { json: false, profile: nil, api_url: nil }
|
|
64
68
|
|
|
65
69
|
# Extract --json flag
|
|
66
70
|
if args.delete('--json')
|
|
@@ -89,6 +93,17 @@ module Fp
|
|
|
89
93
|
opts[:profile] = args.delete_at(idx)
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# Extract --api-url flag
|
|
97
|
+
if (idx = args.index('--api-url'))
|
|
98
|
+
args.delete_at(idx)
|
|
99
|
+
value = args[idx]
|
|
100
|
+
if value.nil? || value.start_with?('-')
|
|
101
|
+
$stderr.puts "Error: --api-url requires a value"
|
|
102
|
+
exit 1
|
|
103
|
+
end
|
|
104
|
+
opts[:api_url] = args.delete_at(idx)
|
|
105
|
+
end
|
|
106
|
+
|
|
92
107
|
opts
|
|
93
108
|
end
|
|
94
109
|
end
|
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)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp config get|set|unset|list
|
|
6
|
+
# Manage global CLI settings (stored in ~/.config/fp/config.yml)
|
|
7
|
+
class ConfigCmd < Base
|
|
8
|
+
ALLOWED_SETTINGS = %w[api_url].freeze
|
|
9
|
+
|
|
10
|
+
def run(args)
|
|
11
|
+
subcommand = args.shift
|
|
12
|
+
|
|
13
|
+
case subcommand
|
|
14
|
+
when 'set'
|
|
15
|
+
set_setting(args)
|
|
16
|
+
when 'get'
|
|
17
|
+
get_setting(args)
|
|
18
|
+
when 'unset'
|
|
19
|
+
unset_setting(args)
|
|
20
|
+
when 'list'
|
|
21
|
+
list_settings
|
|
22
|
+
else
|
|
23
|
+
show_usage
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_setting(args)
|
|
30
|
+
key = args.shift
|
|
31
|
+
value = args.shift
|
|
32
|
+
|
|
33
|
+
unless key
|
|
34
|
+
output.error('Usage: fp config set <key> <value>')
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless ALLOWED_SETTINGS.include?(key)
|
|
39
|
+
output.error("Unknown setting: '#{key}'. Allowed: #{ALLOWED_SETTINGS.join(', ')}")
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless value
|
|
44
|
+
output.error("Value is required. Usage: fp config set #{key} <value>")
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Config.set_setting(key, value)
|
|
49
|
+
|
|
50
|
+
output.success({ key: key, value: value }) do
|
|
51
|
+
puts "Set #{key} = #{value}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_setting(args)
|
|
56
|
+
key = args.shift
|
|
57
|
+
|
|
58
|
+
unless key
|
|
59
|
+
output.error('Usage: fp config get <key>')
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
value = Config.get_setting(key)
|
|
64
|
+
|
|
65
|
+
if value
|
|
66
|
+
output.success({ key: key, value: value }) do
|
|
67
|
+
puts value
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
output.success({ key: key, value: nil }) do
|
|
71
|
+
puts "(not set)"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def unset_setting(args)
|
|
77
|
+
key = args.shift
|
|
78
|
+
|
|
79
|
+
unless key
|
|
80
|
+
output.error('Usage: fp config unset <key>')
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Config.set_setting(key, nil)
|
|
85
|
+
|
|
86
|
+
output.success({ key: key }) do
|
|
87
|
+
puts "Unset #{key}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def list_settings
|
|
92
|
+
settings = {}
|
|
93
|
+
ALLOWED_SETTINGS.each do |key|
|
|
94
|
+
value = Config.get_setting(key)
|
|
95
|
+
settings[key] = value if value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
output.success({ settings: settings }) do
|
|
99
|
+
if settings.empty?
|
|
100
|
+
puts "No global settings configured."
|
|
101
|
+
puts
|
|
102
|
+
puts "Set one with:"
|
|
103
|
+
puts " fp config set api_url https://api.dev.featureparity.dev"
|
|
104
|
+
else
|
|
105
|
+
puts "Global settings:"
|
|
106
|
+
settings.each { |k, v| puts " #{k} = #{v}" }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def show_usage
|
|
112
|
+
output.error('Usage: fp config <set|get|unset|list>')
|
|
113
|
+
puts
|
|
114
|
+
puts 'Commands:'
|
|
115
|
+
puts ' fp config set <key> <value> Set a global setting'
|
|
116
|
+
puts ' fp config get <key> Get a setting value'
|
|
117
|
+
puts ' fp config unset <key> Remove a setting'
|
|
118
|
+
puts ' fp config list Show all global settings'
|
|
119
|
+
puts
|
|
120
|
+
puts 'Available settings:'
|
|
121
|
+
puts ' api_url Base URL for the FeatureParity API'
|
|
122
|
+
puts
|
|
123
|
+
puts 'Examples:'
|
|
124
|
+
puts ' fp config set api_url https://api.dev.featureparity.dev'
|
|
125
|
+
puts ' fp config get api_url'
|
|
126
|
+
exit 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -18,8 +18,11 @@ 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
|
|
24
|
+
config set|get|unset|list Manage global settings
|
|
25
|
+
repos set|get|list|unset Map surfaces to local repo paths
|
|
23
26
|
setup Interactive first-time setup
|
|
24
27
|
version Show version
|
|
25
28
|
help Show this help
|
|
@@ -28,6 +31,7 @@ module Fp
|
|
|
28
31
|
--json Output as JSON envelope
|
|
29
32
|
--profile <name> Use a named profile
|
|
30
33
|
-p <name> Alias for --profile
|
|
34
|
+
--api-url <url> Override API URL for this command
|
|
31
35
|
|
|
32
36
|
AUTHENTICATION
|
|
33
37
|
Set FP_API_KEY environment variable, or use a profile:
|
|
@@ -61,6 +65,9 @@ module Fp
|
|
|
61
65
|
# List requirements for a project
|
|
62
66
|
fp list --project stowzilla
|
|
63
67
|
|
|
68
|
+
# List requirements filtered by category
|
|
69
|
+
fp list --project stowzilla --category security
|
|
70
|
+
|
|
64
71
|
# Show gaps (requirements without evidence)
|
|
65
72
|
fp list --project stowzilla --gaps --surface customer_android
|
|
66
73
|
|
|
@@ -73,7 +80,8 @@ module Fp
|
|
|
73
80
|
--name "Print QR code on container label" \\
|
|
74
81
|
--why "Customers scan to track containers" \\
|
|
75
82
|
--required api,customer_android,customer_ios \\
|
|
76
|
-
--acceptance "QR code is printed and scannable"
|
|
83
|
+
--acceptance "QR code is printed and scannable" \\
|
|
84
|
+
--category functional
|
|
77
85
|
|
|
78
86
|
# Report evidence for a requirement
|
|
79
87
|
fp report print_container_qr --project stowzilla \\
|
|
@@ -84,12 +92,31 @@ module Fp
|
|
|
84
92
|
--work-item https://app.fizzy.do/123/cards/456 \\
|
|
85
93
|
--title "prints a QR code onto the container label"
|
|
86
94
|
|
|
95
|
+
# Upload evidence without CI: run your suite, emit JUnit XML, upload it.
|
|
96
|
+
# fp parses the report, finds the fp:<slug> markers in the test files,
|
|
97
|
+
# and reports evidence for every requirement the suite covered.
|
|
98
|
+
# rspec --format RspecJunitFormatter --out junit.xml # (or your runner's equivalent)
|
|
99
|
+
fp report --junit junit.xml --project stowzilla \\
|
|
100
|
+
--surface api \\
|
|
101
|
+
--repo stowzilla/marketplace \\
|
|
102
|
+
--work-item https://app.fizzy.do/123/cards/456
|
|
103
|
+
# --base-dir sets where relative test file paths in the report resolve from
|
|
104
|
+
# Skipped tests are reported as 'stub'; everything else as 'present'.
|
|
105
|
+
|
|
87
106
|
# Show the parity matrix
|
|
88
107
|
fp matrix --project stowzilla
|
|
89
108
|
|
|
90
109
|
# Export matrix as CSV
|
|
91
110
|
fp matrix --project stowzilla --csv > matrix.csv
|
|
92
111
|
|
|
112
|
+
# Map a surface to a local repo on this machine
|
|
113
|
+
fp repos set customer_android ~/code/customer-android \\
|
|
114
|
+
--project stowzilla --repo stowzilla/customer-android
|
|
115
|
+
|
|
116
|
+
# Find where a surface's code lives locally (for agents)
|
|
117
|
+
fp repos get customer_android --project stowzilla
|
|
118
|
+
cd "$(fp repos get customer_android --project stowzilla)"
|
|
119
|
+
|
|
93
120
|
MARKER CONVENTION
|
|
94
121
|
Tests are bound to requirements using the fp:<slug> marker in the test file.
|
|
95
122
|
The CLI does not rewrite test files - you add the marker yourself.
|
|
@@ -113,6 +140,18 @@ module Fp
|
|
|
113
140
|
...
|
|
114
141
|
}
|
|
115
142
|
|
|
143
|
+
PINNING SURFACES IN THE MARKER
|
|
144
|
+
A marker may target one or more surfaces with an @suffix. This is
|
|
145
|
+
handy when a single (e.g. backend) test satisfies several surfaces:
|
|
146
|
+
|
|
147
|
+
# fp:print_container_qr -> uses the --surface flag
|
|
148
|
+
# fp:print_container_qr@api -> reports for the api surface
|
|
149
|
+
# fp:print_container_qr@api,web -> reports for api AND web
|
|
150
|
+
|
|
151
|
+
With `fp report --junit`, each pinned surface produces its own
|
|
152
|
+
evidence upload. Markers without an @surface fall back to --surface;
|
|
153
|
+
if a marker pins none and no --surface is given, the run errors.
|
|
154
|
+
|
|
116
155
|
MORE INFORMATION
|
|
117
156
|
https://featureparity.dev/docs
|
|
118
157
|
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,22 +51,53 @@ 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
|
-
|
|
56
|
-
|
|
59
|
+
# Group into tree structure: parents first, then children indented
|
|
60
|
+
parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
|
|
61
|
+
children_by_parent = requirements
|
|
62
|
+
.select { |r| r['parent_id'] && r['parent_id'] != '' }
|
|
63
|
+
.group_by { |r| r['parent_id'] }
|
|
64
|
+
|
|
65
|
+
# Orphaned children (parent not in filtered set) show at top level
|
|
66
|
+
parent_ids = parents.map { |p| p['id'] }.to_set
|
|
67
|
+
orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
|
|
68
|
+
|
|
69
|
+
headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
|
|
70
|
+
rows = []
|
|
71
|
+
|
|
72
|
+
parents.each do |r|
|
|
57
73
|
surfaces = (r['required_surfaces'] || []).join(', ')
|
|
58
|
-
|
|
74
|
+
category = r['category'] || '-'
|
|
75
|
+
rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
|
|
76
|
+
|
|
77
|
+
# Add children indented
|
|
78
|
+
(children_by_parent[r['id']] || []).each do |child|
|
|
79
|
+
child_surfaces = (child['required_surfaces'] || []).join(', ')
|
|
80
|
+
child_category = child['category'] || '-'
|
|
81
|
+
rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, child['status']]
|
|
82
|
+
end
|
|
59
83
|
end
|
|
84
|
+
|
|
85
|
+
# Show orphans at top level
|
|
86
|
+
orphans.each do |r|
|
|
87
|
+
surfaces = (r['required_surfaces'] || []).join(', ')
|
|
88
|
+
category = r['category'] || '-'
|
|
89
|
+
rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
|
|
90
|
+
end
|
|
91
|
+
|
|
60
92
|
output.table(headers, rows)
|
|
61
93
|
end
|
|
62
94
|
end
|
|
63
95
|
end
|
|
64
96
|
|
|
65
|
-
def list_gaps(
|
|
97
|
+
def list_gaps(project_id, opts)
|
|
66
98
|
# Gaps endpoint may not exist yet - fall back to computing from requirements
|
|
67
99
|
# For now, list requirements that don't have evidence for a surface
|
|
68
|
-
result = client.list_requirements(
|
|
100
|
+
result = client.list_requirements(project_id: project_id, category: opts[:category])
|
|
69
101
|
|
|
70
102
|
unless result[:ok]
|
|
71
103
|
output.error(result[:error], status: result[:status])
|
|
@@ -84,15 +116,24 @@ module Fp
|
|
|
84
116
|
|
|
85
117
|
# For now, gaps = all requirements (evidence API is #1214)
|
|
86
118
|
# Once evidence API exists, filter to only those without evidence
|
|
87
|
-
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
|
|
88
120
|
if requirements.empty?
|
|
89
|
-
|
|
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}."
|
|
90
127
|
else
|
|
91
|
-
|
|
92
|
-
|
|
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]
|
|
93
133
|
rows = requirements.map do |r|
|
|
94
134
|
surfaces = (r['required_surfaces'] || []).join(', ')
|
|
95
|
-
|
|
135
|
+
category = r['category'] || '-'
|
|
136
|
+
[r['slug'], truncate(r['title'], 40), category, surfaces]
|
|
96
137
|
end
|
|
97
138
|
output.table(headers, rows)
|
|
98
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
|
|
@@ -68,8 +67,6 @@ module Fp
|
|
|
68
67
|
output.table(headers, rows)
|
|
69
68
|
puts
|
|
70
69
|
puts 'Legend: ✅ = passing, ⬜ = required (no evidence), - = not required'
|
|
71
|
-
puts
|
|
72
|
-
puts '(Evidence status will show when #1214 is complete)'
|
|
73
70
|
end
|
|
74
71
|
end
|
|
75
72
|
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)
|