featureparity 0.0.2 → 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 +2 -1
- data/lib/fp/client.rb +24 -17
- data/lib/fp/commands/base.rb +7 -7
- data/lib/fp/commands/help.rb +38 -1
- data/lib/fp/commands/list.rb +34 -18
- data/lib/fp/commands/matrix.rb +7 -8
- 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 +2 -2
- data/lib/fp/commands/show.rb +3 -2
- data/lib/fp/commands/surfaces.rb +5 -5
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/config.rb +83 -2
- 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 +219 -0
- data/skills/fp/references/markers.md +149 -0
- metadata +7 -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
|
@@ -13,6 +13,7 @@ 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,
|
|
17
18
|
'version' => Commands::Version,
|
|
18
19
|
'help' => Commands::Help
|
|
@@ -34,7 +35,7 @@ module Fp
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# Profile/config commands don't need auth
|
|
37
|
-
needs_auth = !%w[profile config setup version help].include?(command_name)
|
|
38
|
+
needs_auth = !%w[profile config repos setup version help].include?(command_name)
|
|
38
39
|
|
|
39
40
|
if needs_auth
|
|
40
41
|
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)
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -18,9 +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
|
|
23
24
|
config set|get|unset|list Manage global settings
|
|
25
|
+
repos set|get|list|unset Map surfaces to local repo paths
|
|
24
26
|
setup Interactive first-time setup
|
|
25
27
|
version Show version
|
|
26
28
|
help Show this help
|
|
@@ -63,6 +65,9 @@ module Fp
|
|
|
63
65
|
# List requirements for a project
|
|
64
66
|
fp list --project stowzilla
|
|
65
67
|
|
|
68
|
+
# List requirements filtered by category
|
|
69
|
+
fp list --project stowzilla --category security
|
|
70
|
+
|
|
66
71
|
# Show gaps (requirements without evidence)
|
|
67
72
|
fp list --project stowzilla --gaps --surface customer_android
|
|
68
73
|
|
|
@@ -75,7 +80,8 @@ module Fp
|
|
|
75
80
|
--name "Print QR code on container label" \\
|
|
76
81
|
--why "Customers scan to track containers" \\
|
|
77
82
|
--required api,customer_android,customer_ios \\
|
|
78
|
-
--acceptance "QR code is printed and scannable"
|
|
83
|
+
--acceptance "QR code is printed and scannable" \\
|
|
84
|
+
--category functional
|
|
79
85
|
|
|
80
86
|
# Report evidence for a requirement
|
|
81
87
|
fp report print_container_qr --project stowzilla \\
|
|
@@ -86,12 +92,31 @@ module Fp
|
|
|
86
92
|
--work-item https://app.fizzy.do/123/cards/456 \\
|
|
87
93
|
--title "prints a QR code onto the container label"
|
|
88
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
|
+
|
|
89
106
|
# Show the parity matrix
|
|
90
107
|
fp matrix --project stowzilla
|
|
91
108
|
|
|
92
109
|
# Export matrix as CSV
|
|
93
110
|
fp matrix --project stowzilla --csv > matrix.csv
|
|
94
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
|
+
|
|
95
120
|
MARKER CONVENTION
|
|
96
121
|
Tests are bound to requirements using the fp:<slug> marker in the test file.
|
|
97
122
|
The CLI does not rewrite test files - you add the marker yourself.
|
|
@@ -115,6 +140,18 @@ module Fp
|
|
|
115
140
|
...
|
|
116
141
|
}
|
|
117
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
|
+
|
|
118
155
|
MORE INFORMATION
|
|
119
156
|
https://featureparity.dev/docs
|
|
120
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,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/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
|
|