featureparity 0.0.3 → 0.0.5
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 +14 -0
- data/lib/fp/commands/config_cmd.rb +10 -1
- data/lib/fp/commands/help.rb +24 -0
- data/lib/fp/commands/profile.rb +28 -7
- data/lib/fp/commands/propose.rb +13 -1
- data/lib/fp/commands/setup.rb +45 -6
- data/lib/fp/commands/show.rb +55 -22
- data/lib/fp/commands/surfaces.rb +81 -2
- data/lib/fp/commands/whoami.rb +55 -0
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/config.rb +165 -12
- data/lib/fp/junit.rb +49 -5
- data/lib/fp/version.rb +1 -1
- data/skills/fp/references/cli.md +30 -4
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1208887c7ed99691e476438cdcb50d36f09e3ee3b22931c7d8d9ea246438fb89
|
|
4
|
+
data.tar.gz: b2fb9b9d7bad99bde7e2f8b0789105072cd19647ed46f0ea4d4d6e43a28fd319
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d70c2ae5afb585a1f54da0c2efb244965a0f419001c9ed8f46d1895a5f37ee12ece8a1195627859eeeaff2fd3910cf4dfdb593fc41c69440bc97020ff31a17a
|
|
7
|
+
data.tar.gz: 23d9038f6850138f295501a56ea00f872ff828eab5a2822482c306a56ba39cdec2a1016761af8f4f87932a3d9f264faf0c9dad3302d2a96ea5adceb86b3e29c6
|
data/lib/fp/cli.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Fp
|
|
|
15
15
|
'config' => Commands::ConfigCmd,
|
|
16
16
|
'repos' => Commands::Repos,
|
|
17
17
|
'setup' => Commands::Setup,
|
|
18
|
+
'whoami' => Commands::Whoami,
|
|
18
19
|
'version' => Commands::Version,
|
|
19
20
|
'help' => Commands::Help
|
|
20
21
|
}.freeze
|
|
@@ -35,7 +36,7 @@ module Fp
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# Profile/config commands don't need auth
|
|
38
|
-
needs_auth = !%w[profile config repos setup version help].include?(command_name)
|
|
39
|
+
needs_auth = !%w[profile config repos setup whoami version help].include?(command_name)
|
|
39
40
|
|
|
40
41
|
if needs_auth
|
|
41
42
|
config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
|
data/lib/fp/client.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Fp
|
|
|
27
27
|
get("/api/projects/#{project_id}/surfaces")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# POST /projects/:id/surfaces
|
|
31
|
+
def create_surface(project_id, params)
|
|
32
|
+
post("/api/projects/#{project_id}/surfaces", params)
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
# GET /requirements
|
|
31
36
|
def list_requirements(project_id: nil, status: nil, category: nil)
|
|
32
37
|
params = {}
|
|
@@ -67,6 +72,15 @@ module Fp
|
|
|
67
72
|
put("/api/requirements/#{id}", params)
|
|
68
73
|
end
|
|
69
74
|
|
|
75
|
+
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
76
|
+
# optionally narrowed to one requirement slug and/or surface.
|
|
77
|
+
def list_evidence(project_id, slug: nil, surface: nil)
|
|
78
|
+
params = {}
|
|
79
|
+
params[:slug] = slug if slug
|
|
80
|
+
params[:surface] = surface if surface
|
|
81
|
+
get("/api/projects/#{project_id}/evidence", params)
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
# POST /projects/:project_id/evidence (direct reporting)
|
|
71
85
|
def report_evidence(params)
|
|
72
86
|
project_id = params.delete(:project_id)
|
|
@@ -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
|
@@ -14,6 +14,7 @@ module Fp
|
|
|
14
14
|
COMMANDS
|
|
15
15
|
projects List projects you have access to
|
|
16
16
|
surfaces --project <slug> List surfaces for a project
|
|
17
|
+
surfaces add <key> --project ... Create a surface
|
|
17
18
|
list --project <slug> List active requirements
|
|
18
19
|
show <slug> --project <slug> Show requirement details
|
|
19
20
|
propose --project <slug> ... Propose a new requirement (as draft)
|
|
@@ -22,6 +23,7 @@ module Fp
|
|
|
22
23
|
matrix --project <slug> Show the parity matrix
|
|
23
24
|
profile add|list Manage named profiles
|
|
24
25
|
config set|get|unset|list Manage global settings
|
|
26
|
+
whoami Show the resolved config (profile, env, API URL, key)
|
|
25
27
|
repos set|get|list|unset Map surfaces to local repo paths
|
|
26
28
|
setup Interactive first-time setup
|
|
27
29
|
version Show version
|
|
@@ -56,12 +58,26 @@ module Fp
|
|
|
56
58
|
# First-time setup (interactive)
|
|
57
59
|
fp setup
|
|
58
60
|
|
|
61
|
+
# Show the resolved configuration (which profile/env/API URL/key is in effect)
|
|
62
|
+
fp whoami
|
|
63
|
+
fp whoami --json
|
|
64
|
+
fp --profile staging whoami
|
|
65
|
+
|
|
59
66
|
# Non-interactive setup for CI/automation
|
|
60
67
|
fp setup --api-key fp_... --profile ci-agent --non-interactive
|
|
61
68
|
|
|
62
69
|
# List projects
|
|
63
70
|
fp projects
|
|
64
71
|
|
|
72
|
+
# List the surfaces a project tracks (requirements may only name these)
|
|
73
|
+
fp surfaces --project stowzilla
|
|
74
|
+
|
|
75
|
+
# Create a surface (agents can do this — no need to wait on the web app)
|
|
76
|
+
fp surfaces add customer_android --project stowzilla \\
|
|
77
|
+
--name "Customer Android App" \\
|
|
78
|
+
--kind android \\
|
|
79
|
+
--repo stowzilla/customer-android
|
|
80
|
+
|
|
65
81
|
# List requirements for a project
|
|
66
82
|
fp list --project stowzilla
|
|
67
83
|
|
|
@@ -83,6 +99,14 @@ module Fp
|
|
|
83
99
|
--acceptance "QR code is printed and scannable" \\
|
|
84
100
|
--category functional
|
|
85
101
|
|
|
102
|
+
# Nest a requirement under a parent (--parent takes the parent's SLUG).
|
|
103
|
+
# A child's --required surfaces must be a subset of its parent's.
|
|
104
|
+
fp propose --project stowzilla \\
|
|
105
|
+
--slug print_container_qr_ios \\
|
|
106
|
+
--name "Print QR on iOS" \\
|
|
107
|
+
--parent print_container_qr \\
|
|
108
|
+
--required customer_ios
|
|
109
|
+
|
|
86
110
|
# Report evidence for a requirement
|
|
87
111
|
fp report print_container_qr --project stowzilla \\
|
|
88
112
|
--surface customer_android \\
|
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/propose.rb
CHANGED
|
@@ -13,10 +13,14 @@ module Fp
|
|
|
13
13
|
required: :string,
|
|
14
14
|
acceptance: :string,
|
|
15
15
|
parent: :string,
|
|
16
|
-
category: :string
|
|
16
|
+
category: :string,
|
|
17
|
+
source_type: :string,
|
|
18
|
+
source_url: :string,
|
|
19
|
+
source_context: :string
|
|
17
20
|
}.freeze
|
|
18
21
|
|
|
19
22
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
23
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
20
24
|
|
|
21
25
|
def run(args)
|
|
22
26
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
@@ -31,6 +35,11 @@ module Fp
|
|
|
31
35
|
exit 1
|
|
32
36
|
end
|
|
33
37
|
|
|
38
|
+
if opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
|
|
39
|
+
output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
project_id = resolve_project_id(opts[:project])
|
|
35
44
|
|
|
36
45
|
# Parse required surfaces (comma-separated)
|
|
@@ -49,6 +58,9 @@ module Fp
|
|
|
49
58
|
params[:why] = opts[:why] if opts[:why]
|
|
50
59
|
params[:acceptance] = opts[:acceptance] if opts[:acceptance]
|
|
51
60
|
params[:category] = opts[:category] if opts[:category]
|
|
61
|
+
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
62
|
+
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
63
|
+
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
52
64
|
|
|
53
65
|
# Resolve parent slug to parent_id if provided
|
|
54
66
|
if opts[:parent]
|
data/lib/fp/commands/setup.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Fp
|
|
|
10
10
|
api_key: :string,
|
|
11
11
|
profile: :string,
|
|
12
12
|
api_url: :string,
|
|
13
|
+
environment: :string,
|
|
13
14
|
non_interactive: :boolean
|
|
14
15
|
}.freeze
|
|
15
16
|
|
|
@@ -22,19 +23,22 @@ module Fp
|
|
|
22
23
|
|
|
23
24
|
api_key = resolve_api_key(opts)
|
|
24
25
|
profile_name = resolve_profile_name(opts)
|
|
25
|
-
api_url = opts
|
|
26
|
+
api_url = resolve_api_url(opts)
|
|
27
|
+
environment = opts[:environment] || Config.environment_for(api_url || Config::DEFAULT_API_URL)
|
|
26
28
|
|
|
27
29
|
puts "Validating API key..."
|
|
28
30
|
validate_key!(api_key, api_url)
|
|
29
31
|
|
|
30
|
-
save_profile!(profile_name, api_key, api_url)
|
|
32
|
+
save_profile!(profile_name, api_key, api_url, environment)
|
|
31
33
|
|
|
32
34
|
effective_url = api_url || Config::DEFAULT_API_URL
|
|
33
|
-
domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
|
|
34
35
|
|
|
35
36
|
puts
|
|
36
37
|
puts "✅ Setup complete!"
|
|
37
38
|
puts
|
|
39
|
+
puts " API URL: #{effective_url}"
|
|
40
|
+
puts " Environment: #{environment}" if environment
|
|
41
|
+
puts
|
|
38
42
|
puts "Your profile '#{profile_name}' is ready. Usage:"
|
|
39
43
|
puts
|
|
40
44
|
puts " fp --profile #{profile_name} projects"
|
|
@@ -42,6 +46,9 @@ module Fp
|
|
|
42
46
|
puts
|
|
43
47
|
puts "Or set FP_API_KEY to skip profiles:"
|
|
44
48
|
puts " export FP_API_KEY=#{api_key[0..6]}..."
|
|
49
|
+
puts
|
|
50
|
+
puts "Verify the resolved configuration any time with:"
|
|
51
|
+
puts " fp whoami"
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
private
|
|
@@ -57,7 +64,7 @@ module Fp
|
|
|
57
64
|
exit 1
|
|
58
65
|
end
|
|
59
66
|
|
|
60
|
-
api_url = opts[:api_url] || Config.get_setting('api_url')
|
|
67
|
+
api_url = opts[:api_url] || config&.explicit_api_url || Config.get_setting('api_url')
|
|
61
68
|
effective_url = api_url || Config::DEFAULT_API_URL
|
|
62
69
|
domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
|
|
63
70
|
|
|
@@ -78,10 +85,17 @@ module Fp
|
|
|
78
85
|
end
|
|
79
86
|
|
|
80
87
|
def resolve_profile_name(opts)
|
|
88
|
+
# --profile / FP_PROFILE may be consumed by the global parser; honor it
|
|
89
|
+
# via config, but only when explicitly provided (not the implicit
|
|
90
|
+
# "default" profile fallback) so interactive setup still prompts.
|
|
81
91
|
if opts[:profile]
|
|
82
92
|
return opts[:profile]
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
if config && %w[flag env\ (FP_PROFILE)].include?(config.profile_source)
|
|
96
|
+
return config.profile_name
|
|
97
|
+
end
|
|
98
|
+
|
|
85
99
|
if opts[:non_interactive]
|
|
86
100
|
return 'default'
|
|
87
101
|
end
|
|
@@ -93,6 +107,31 @@ module Fp
|
|
|
93
107
|
name.nil? || name.empty? ? 'default' : name
|
|
94
108
|
end
|
|
95
109
|
|
|
110
|
+
# Resolve the API URL for setup. Priority: --api-url flag > global setting.
|
|
111
|
+
# When neither is set and we're interactive, prompt the user so they can
|
|
112
|
+
# target a non-default environment (self-hosted, staging, ephemeral PR env)
|
|
113
|
+
# instead of silently defaulting to production.
|
|
114
|
+
def resolve_api_url(opts)
|
|
115
|
+
return opts[:api_url] if opts[:api_url] && !opts[:api_url].empty?
|
|
116
|
+
|
|
117
|
+
# The global CLI parser consumes --api-url before setup runs; honor it.
|
|
118
|
+
flag_url = config&.explicit_api_url
|
|
119
|
+
return flag_url if flag_url
|
|
120
|
+
|
|
121
|
+
existing = Config.get_setting('api_url')
|
|
122
|
+
return existing if existing && !existing.empty?
|
|
123
|
+
|
|
124
|
+
# Non-interactive: fall back to the default (nil => DEFAULT_API_URL).
|
|
125
|
+
return nil if opts[:non_interactive]
|
|
126
|
+
|
|
127
|
+
default = Config::DEFAULT_API_URL
|
|
128
|
+
print "API URL (default: #{default}): "
|
|
129
|
+
$stdout.flush
|
|
130
|
+
url = $stdin.gets&.strip
|
|
131
|
+
|
|
132
|
+
url.nil? || url.empty? ? nil : url
|
|
133
|
+
end
|
|
134
|
+
|
|
96
135
|
def validate_key_format!(key)
|
|
97
136
|
unless key.start_with?('fp_')
|
|
98
137
|
output.error("API key must start with 'fp_' prefix. Got: #{key[0..3]}...")
|
|
@@ -146,8 +185,8 @@ module Fp
|
|
|
146
185
|
exit 1
|
|
147
186
|
end
|
|
148
187
|
|
|
149
|
-
def save_profile!(name, api_key, api_url)
|
|
150
|
-
Config.save_profile(name, api_key: api_key, api_url: api_url)
|
|
188
|
+
def save_profile!(name, api_key, api_url, environment = nil)
|
|
189
|
+
Config.save_profile(name, api_key: api_key, api_url: api_url, environment: environment)
|
|
151
190
|
puts " ✓ Profile '#{name}' saved to ~/.config/fp/config.yml"
|
|
152
191
|
end
|
|
153
192
|
end
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -25,21 +25,49 @@ module Fp
|
|
|
25
25
|
|
|
26
26
|
requirement = result[:data]['requirement']
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# Evidence is not embedded in the requirement payload — it lives on the project's
|
|
29
|
+
# evidence collection, keyed by slug. Fetch it, or `fp show` reports "(none)" for
|
|
30
|
+
# every requirement no matter how many receipts have been filed.
|
|
31
|
+
evidence = fetch_evidence(project_id, slug)
|
|
32
|
+
|
|
33
|
+
# Agents only ever speak slugs, so translate the parent's ID back into one.
|
|
34
|
+
parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
|
|
35
|
+
|
|
36
|
+
payload = result[:data].merge('evidence' => evidence)
|
|
37
|
+
payload['parent_slug'] = parent_slug if parent_slug
|
|
38
|
+
|
|
39
|
+
output.success(payload) do
|
|
40
|
+
print_requirement(requirement, evidence: evidence, parent_slug: parent_slug)
|
|
30
41
|
end
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
private
|
|
34
45
|
|
|
35
|
-
def
|
|
46
|
+
def fetch_evidence(project_id, slug)
|
|
47
|
+
result = client.list_evidence(project_id, slug: slug)
|
|
48
|
+
return [] unless result[:ok]
|
|
49
|
+
|
|
50
|
+
result[:data]['evidence'] || []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_parent_slug(project_id, parent_id)
|
|
54
|
+
return nil if parent_id.nil? || parent_id.to_s.empty?
|
|
55
|
+
|
|
56
|
+
result = client.list_requirements(project_id: project_id)
|
|
57
|
+
return nil unless result[:ok]
|
|
58
|
+
|
|
59
|
+
parent = (result[:data]['requirements'] || []).find { |r| r['id'] == parent_id }
|
|
60
|
+
parent && parent['slug']
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_requirement(req, evidence: [], parent_slug: nil)
|
|
36
64
|
puts "#{req['title']}"
|
|
37
65
|
puts "=" * req['title'].length
|
|
38
66
|
puts
|
|
39
67
|
puts "Slug: #{req['slug']}"
|
|
40
68
|
puts "Status: #{req['status']}"
|
|
41
69
|
puts "Category: #{req['category'] || '(none)'}"
|
|
42
|
-
puts "Parent: #{req['parent_id'] || '(none)'}"
|
|
70
|
+
puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
|
|
43
71
|
puts
|
|
44
72
|
|
|
45
73
|
surfaces = req['required_surfaces'] || []
|
|
@@ -63,26 +91,31 @@ module Fp
|
|
|
63
91
|
puts
|
|
64
92
|
end
|
|
65
93
|
|
|
66
|
-
# Evidence receipts
|
|
67
|
-
|
|
68
|
-
if evidence.
|
|
69
|
-
puts 'Evidence:'
|
|
70
|
-
evidence.each do |e|
|
|
71
|
-
puts " #{e['surface']}:"
|
|
72
|
-
puts " Status: #{e['status']}"
|
|
73
|
-
puts " File: #{e['file']}" if e['file']
|
|
74
|
-
puts " Repo: #{e['repo']}" if e['repo']
|
|
75
|
-
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
76
|
-
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
77
|
-
puts " CI: #{e['ci_url']}" if e['ci_url']
|
|
78
|
-
puts " Title: #{e['example_title']}" if e['example_title']
|
|
79
|
-
puts " By: #{e['reported_by']}" if e['reported_by']
|
|
80
|
-
puts
|
|
81
|
-
end
|
|
82
|
-
else
|
|
83
|
-
puts 'Evidence:'
|
|
94
|
+
# Evidence receipts, newest first, grouped per surface.
|
|
95
|
+
puts 'Evidence:'
|
|
96
|
+
if evidence.empty?
|
|
84
97
|
puts ' (none reported)'
|
|
85
98
|
puts
|
|
99
|
+
else
|
|
100
|
+
evidence
|
|
101
|
+
.sort_by { |e| e['reported_at'].to_s }
|
|
102
|
+
.reverse
|
|
103
|
+
.group_by { |e| e['surface'] }
|
|
104
|
+
.each do |surface, records|
|
|
105
|
+
puts " #{surface}:"
|
|
106
|
+
records.each do |e|
|
|
107
|
+
puts " Status: #{e['status']}"
|
|
108
|
+
puts " File: #{e['source_file']}" if e['source_file']
|
|
109
|
+
puts " Repo: #{e['repo']}" if e['repo']
|
|
110
|
+
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
111
|
+
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
112
|
+
puts " CI: #{e['ci_url']}" if e['ci_url']
|
|
113
|
+
puts " Title: #{e['example_title']}" if e['example_title']
|
|
114
|
+
puts " By: #{e['source']}" if e['source']
|
|
115
|
+
puts " At: #{e['reported_at']}" if e['reported_at']
|
|
116
|
+
puts
|
|
117
|
+
end
|
|
118
|
+
end
|
|
86
119
|
end
|
|
87
120
|
|
|
88
121
|
puts "Created: #{req['created_at']}"
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -2,9 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp surfaces --project <slug>
|
|
5
|
+
# fp surfaces --project <slug> - list available surfaces for a project
|
|
6
|
+
# fp surfaces add <key> --project <slug> - create a surface
|
|
7
|
+
#
|
|
8
|
+
# `add` exists because a requirement can only name surfaces that already exist, so an
|
|
9
|
+
# agent onboarding a project it hasn't seen before would otherwise be blocked waiting
|
|
10
|
+
# on a human to click through the web app.
|
|
6
11
|
class Surfaces < Base
|
|
12
|
+
ADD_FLAGS = {
|
|
13
|
+
project: :string,
|
|
14
|
+
name: :string,
|
|
15
|
+
kind: :string,
|
|
16
|
+
audience: :string,
|
|
17
|
+
repo: :string,
|
|
18
|
+
default_branch: :string
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
VALID_KINDS = %w[api web android ios other].freeze
|
|
22
|
+
VALID_AUDIENCES = %w[customer ops both internal].freeze
|
|
23
|
+
|
|
7
24
|
def run(args)
|
|
25
|
+
# Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
|
|
26
|
+
# callers (and the docs) keep working.
|
|
27
|
+
if args.first == 'add'
|
|
28
|
+
add(args[1..])
|
|
29
|
+
else
|
|
30
|
+
list(args)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def list(args)
|
|
8
37
|
opts, = parse_flags(args)
|
|
9
38
|
require_flag(opts, :project)
|
|
10
39
|
|
|
@@ -22,13 +51,63 @@ module Fp
|
|
|
22
51
|
output.success({ surfaces: surfaces, project: opts[:project] }) do
|
|
23
52
|
if surfaces.empty?
|
|
24
53
|
puts "No surfaces configured for project '#{opts[:project]}'."
|
|
25
|
-
puts
|
|
54
|
+
puts "Add one with: fp surfaces add <key> --project #{opts[:project]} --name \"<Name>\""
|
|
26
55
|
else
|
|
27
56
|
puts "Surfaces for '#{opts[:project]}':"
|
|
28
57
|
surfaces.each { |s| puts " - #{s}" }
|
|
29
58
|
end
|
|
30
59
|
end
|
|
31
60
|
end
|
|
61
|
+
|
|
62
|
+
def add(args)
|
|
63
|
+
opts, positional = parse_flags(args, ADD_FLAGS)
|
|
64
|
+
require_flag(opts, :project)
|
|
65
|
+
|
|
66
|
+
key = positional.first
|
|
67
|
+
if key.nil? || key.strip.empty?
|
|
68
|
+
output.error('Usage: fp surfaces add <key> --project <slug> [--name "Name"] [--kind api|web|android|ios|other]')
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
validate_choice!(opts[:kind], VALID_KINDS, '--kind')
|
|
73
|
+
validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
|
|
74
|
+
|
|
75
|
+
project_id = resolve_project_id(opts[:project])
|
|
76
|
+
|
|
77
|
+
# The API derives key from name and defaults kind/audience/default_branch, but we
|
|
78
|
+
# send the key explicitly because here the key is what the caller actually typed —
|
|
79
|
+
# `add api --name "Backend API"` must produce `api`, not `backend_api`.
|
|
80
|
+
params = { key: key, name: opts[:name] || key }
|
|
81
|
+
params[:kind] = opts[:kind] if opts[:kind]
|
|
82
|
+
params[:audience] = opts[:audience] if opts[:audience]
|
|
83
|
+
params[:repo] = opts[:repo] if opts[:repo]
|
|
84
|
+
params[:default_branch] = opts[:default_branch] if opts[:default_branch]
|
|
85
|
+
|
|
86
|
+
result = client.create_surface(project_id, params)
|
|
87
|
+
|
|
88
|
+
unless result[:ok]
|
|
89
|
+
output.error(result[:error], status: result[:status])
|
|
90
|
+
exit 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
surface = result[:data]['surface'] || {}
|
|
94
|
+
|
|
95
|
+
output.success(result[:data]) do
|
|
96
|
+
puts "Created surface '#{surface['key'] || key}' in project '#{opts[:project]}'."
|
|
97
|
+
puts
|
|
98
|
+
puts " Name: #{surface['name']}"
|
|
99
|
+
puts " Kind: #{surface['kind']}"
|
|
100
|
+
puts " Audience: #{surface['audience']}"
|
|
101
|
+
puts " Repo: #{surface['repo'] || '(none)'}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_choice!(value, allowed, flag)
|
|
106
|
+
return if value.nil? || allowed.include?(value)
|
|
107
|
+
|
|
108
|
+
output.error("Invalid #{flag} '#{value}'. Must be one of: #{allowed.join(', ')}")
|
|
109
|
+
exit 1
|
|
110
|
+
end
|
|
32
111
|
end
|
|
33
112
|
end
|
|
34
113
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp whoami
|
|
6
|
+
#
|
|
7
|
+
# Show the *resolved* configuration the CLI would actually use for this
|
|
8
|
+
# invocation: which profile is active, the API URL and where it came from,
|
|
9
|
+
# the environment that URL targets, and whether an API key is present
|
|
10
|
+
# (masked — never the full token). Purely local; makes no API calls, so it
|
|
11
|
+
# works even without a valid key. Honors --profile / --api-url / --json.
|
|
12
|
+
class Whoami < Base
|
|
13
|
+
def run(_args)
|
|
14
|
+
r = config.resolved
|
|
15
|
+
|
|
16
|
+
output.success(r) do
|
|
17
|
+
puts 'Resolved FeatureParity CLI configuration:'
|
|
18
|
+
puts
|
|
19
|
+
puts " Profile: #{format_value(r[:profile], r[:profile_source])}"
|
|
20
|
+
puts " Environment: #{r[:environment] || '(unknown)'}"
|
|
21
|
+
puts " API URL: #{format_value(r[:api_url], r[:api_url_source])}"
|
|
22
|
+
puts " API key: #{key_line(r)}"
|
|
23
|
+
puts " Config file: #{r[:config_file]}"
|
|
24
|
+
|
|
25
|
+
if r[:profile_environment] && r[:profile_environment] != r[:environment]
|
|
26
|
+
puts
|
|
27
|
+
puts " ⚠ Profile is tagged for environment '#{r[:profile_environment]}' " \
|
|
28
|
+
"but the resolved API URL points at '#{r[:environment]}'."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless r[:authenticated]
|
|
32
|
+
puts
|
|
33
|
+
puts 'No API key resolved. Set one with:'
|
|
34
|
+
puts ' export FP_API_KEY=fp_...'
|
|
35
|
+
puts ' fp setup'
|
|
36
|
+
puts ' fp profile add <name> --api-key fp_...'
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def format_value(value, source)
|
|
44
|
+
display = value.nil? || value.to_s.empty? ? '(none)' : value
|
|
45
|
+
source && source != 'none' ? "#{display} [from #{source}]" : display.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def key_line(r)
|
|
49
|
+
return '(not set)' unless r[:api_key_present]
|
|
50
|
+
|
|
51
|
+
"#{r[:api_key_hint]} [from #{r[:api_key_source]}]"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/fp/commands.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative 'commands/profile'
|
|
|
12
12
|
require_relative 'commands/config_cmd'
|
|
13
13
|
require_relative 'commands/repos'
|
|
14
14
|
require_relative 'commands/setup'
|
|
15
|
+
require_relative 'commands/whoami'
|
|
15
16
|
require_relative 'commands/version'
|
|
16
17
|
require_relative 'commands/help'
|
|
17
18
|
|
data/lib/fp/config.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'uri'
|
|
5
6
|
|
|
6
7
|
module Fp
|
|
7
8
|
# Handles configuration: profiles, API keys, API URL.
|
|
@@ -29,9 +30,12 @@ module Fp
|
|
|
29
30
|
|
|
30
31
|
attr_reader :api_key, :api_url, :profile_name
|
|
31
32
|
|
|
33
|
+
# Where a resolved value came from, for `fp whoami` transparency.
|
|
34
|
+
attr_reader :api_key_source, :api_url_source, :profile_source
|
|
35
|
+
|
|
32
36
|
def initialize(profile: nil, api_url: nil)
|
|
33
|
-
@profile_name = resolve_profile_name(profile)
|
|
34
37
|
@explicit_api_url = api_url
|
|
38
|
+
@profile_name = resolve_profile_name(profile)
|
|
35
39
|
@api_key = resolve_api_key
|
|
36
40
|
@api_url = resolve_api_url
|
|
37
41
|
end
|
|
@@ -40,6 +44,56 @@ module Fp
|
|
|
40
44
|
!@api_key.nil? && !@api_key.empty?
|
|
41
45
|
end
|
|
42
46
|
|
|
47
|
+
# The API URL passed via the global --api-url flag, if any (nil otherwise).
|
|
48
|
+
# `profile add` / `setup` use this so `fp profile add x --api-url URL` still
|
|
49
|
+
# records URL even though the global parser consumes the flag first.
|
|
50
|
+
def explicit_api_url
|
|
51
|
+
return nil if @explicit_api_url.nil? || @explicit_api_url.empty?
|
|
52
|
+
|
|
53
|
+
@explicit_api_url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Short, human-readable environment label derived from the API host, e.g.
|
|
57
|
+
# https://api.dev.featureparity.dev -> "dev"
|
|
58
|
+
# https://api.staging.featureparity.dev -> "staging"
|
|
59
|
+
# https://api.featureparity.dev -> "production"
|
|
60
|
+
# http://localhost:9292 -> "local"
|
|
61
|
+
# This is what "tags" a profile/key to an environment: the API URL it targets.
|
|
62
|
+
def environment
|
|
63
|
+
self.class.environment_for(@api_url)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The environment explicitly recorded on the active profile, if any.
|
|
67
|
+
# Falls back to nil when unset (callers derive from api_url instead).
|
|
68
|
+
def profile_environment
|
|
69
|
+
return nil unless @profile_name
|
|
70
|
+
|
|
71
|
+
profile = self.class.load_profiles[@profile_name]
|
|
72
|
+
profile && profile['environment']
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Masked hint for display — never reveal the full key.
|
|
76
|
+
def masked_api_key
|
|
77
|
+
self.class.mask_key(@api_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Full resolved snapshot for `fp whoami` (safe to print / JSON-encode).
|
|
81
|
+
def resolved
|
|
82
|
+
{
|
|
83
|
+
profile: @profile_name,
|
|
84
|
+
profile_source: @profile_source,
|
|
85
|
+
api_url: @api_url,
|
|
86
|
+
api_url_source: @api_url_source,
|
|
87
|
+
environment: environment,
|
|
88
|
+
profile_environment: profile_environment,
|
|
89
|
+
authenticated: valid?,
|
|
90
|
+
api_key_present: valid?,
|
|
91
|
+
api_key_hint: masked_api_key,
|
|
92
|
+
api_key_source: @api_key_source,
|
|
93
|
+
config_file: self.class::CONFIG_FILE
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
43
97
|
def validation_error
|
|
44
98
|
return nil if valid?
|
|
45
99
|
|
|
@@ -64,7 +118,7 @@ module Fp
|
|
|
64
118
|
config['profiles'] || {}
|
|
65
119
|
end
|
|
66
120
|
|
|
67
|
-
def save_profile(name, api_key:, api_url: nil)
|
|
121
|
+
def save_profile(name, api_key:, api_url: nil, environment: nil)
|
|
68
122
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
69
123
|
ensure_config_file!
|
|
70
124
|
|
|
@@ -72,6 +126,10 @@ module Fp
|
|
|
72
126
|
config['profiles'] ||= {}
|
|
73
127
|
config['profiles'][name] = { 'api_key' => api_key }
|
|
74
128
|
config['profiles'][name]['api_url'] = api_url if api_url
|
|
129
|
+
# Tag the key/profile with the environment it targets. If not given
|
|
130
|
+
# explicitly, derive it from the api_url so keys are always tagged.
|
|
131
|
+
env = environment || environment_for(api_url || get_setting('api_url') || DEFAULT_API_URL)
|
|
132
|
+
config['profiles'][name]['environment'] = env if env
|
|
75
133
|
|
|
76
134
|
write_config!(config)
|
|
77
135
|
end
|
|
@@ -82,6 +140,67 @@ module Fp
|
|
|
82
140
|
config[key.to_s]
|
|
83
141
|
end
|
|
84
142
|
|
|
143
|
+
# Derive a short environment label from an API URL's host. This mirrors
|
|
144
|
+
# the Belt DNS convention (see infrastructure/modules/app/dns.tf), which is
|
|
145
|
+
# what actually "tags" a profile/key to an environment: the API host tells
|
|
146
|
+
# you which FeatureParity environment the key talks to.
|
|
147
|
+
#
|
|
148
|
+
# Standalone environments use a dotted "api." prefix:
|
|
149
|
+
# api.featureparity.dev -> "production"
|
|
150
|
+
# api.dev.featureparity.dev -> "dev"
|
|
151
|
+
# api.staging.featureparity.dev -> "staging"
|
|
152
|
+
#
|
|
153
|
+
# Nested / ephemeral (per-branch) environments use a dashed "api-" prefix,
|
|
154
|
+
# because SSL wildcards are single-level (*.dev.featureparity.dev covers
|
|
155
|
+
# api-<env>.dev.featureparity.dev but not api.<env>.dev.featureparity.dev).
|
|
156
|
+
# The env slug is everything after "api-" up to the first dot, and may
|
|
157
|
+
# itself contain dashes:
|
|
158
|
+
# api-abc-1234.dev.featureparity.dev -> "abc-1234"
|
|
159
|
+
# api-fizzy123.dev.featureparity.dev -> "fizzy123"
|
|
160
|
+
#
|
|
161
|
+
# Anything local:
|
|
162
|
+
# localhost / 127.0.0.1 / 0.0.0.0 -> "local"
|
|
163
|
+
def environment_for(url)
|
|
164
|
+
return nil if url.nil? || url.to_s.empty?
|
|
165
|
+
|
|
166
|
+
host = begin
|
|
167
|
+
URI.parse(url.to_s).host
|
|
168
|
+
rescue URI::InvalidURIError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
return nil if host.nil? || host.empty?
|
|
172
|
+
|
|
173
|
+
return 'local' if host == 'localhost' || host.start_with?('127.') || host == '0.0.0.0'
|
|
174
|
+
|
|
175
|
+
first, *rest = host.split('.')
|
|
176
|
+
|
|
177
|
+
# Nested/ephemeral: "api-<env>" where <env> may contain dashes.
|
|
178
|
+
return first.sub(/\Aapi-/, '') if first.start_with?('api-')
|
|
179
|
+
|
|
180
|
+
# Standalone dotted "api." prefix: the env is the next label.
|
|
181
|
+
if first == 'api'
|
|
182
|
+
# api.featureparity.dev -> ["featureparity", "dev"] -> production
|
|
183
|
+
return 'production' if rest.length <= 2
|
|
184
|
+
|
|
185
|
+
return rest.first
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# No recognizable "api" prefix — best effort: treat a bare apex
|
|
189
|
+
# (two labels, e.g. featureparity.dev) as production, else the left label.
|
|
190
|
+
return 'production' if rest.length <= 1
|
|
191
|
+
|
|
192
|
+
first
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Mask an API key for display: keep a short prefix + last 4 chars.
|
|
196
|
+
# fp_abcdefgh1234 -> fp_…1234
|
|
197
|
+
def mask_key(key)
|
|
198
|
+
return nil if key.nil? || key.empty?
|
|
199
|
+
return '****' if key.length <= 8
|
|
200
|
+
|
|
201
|
+
"#{key[0, 3]}…#{key[-4..]}"
|
|
202
|
+
end
|
|
203
|
+
|
|
85
204
|
# Set a global setting (top-level key in config.yml)
|
|
86
205
|
def set_setting(key, value)
|
|
87
206
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
@@ -190,50 +309,84 @@ module Fp
|
|
|
190
309
|
|
|
191
310
|
def resolve_profile_name(explicit_profile)
|
|
192
311
|
# 1. Explicit --profile flag 2. FP_PROFILE env var
|
|
193
|
-
|
|
194
|
-
|
|
312
|
+
if explicit_profile && !explicit_profile.empty?
|
|
313
|
+
@profile_source = 'flag'
|
|
314
|
+
return explicit_profile
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if ENV['FP_PROFILE'] && !ENV['FP_PROFILE'].empty?
|
|
318
|
+
@profile_source = 'env (FP_PROFILE)'
|
|
319
|
+
return ENV['FP_PROFILE']
|
|
320
|
+
end
|
|
195
321
|
|
|
196
322
|
# 3. Fall back to a profile literally named "default", if one exists.
|
|
197
323
|
# This mirrors AWS/git/gcloud, which all consult a default profile when
|
|
198
324
|
# nothing else is specified.
|
|
199
|
-
|
|
325
|
+
if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
|
|
326
|
+
@profile_source = 'default profile'
|
|
327
|
+
return DEFAULT_PROFILE_NAME
|
|
328
|
+
end
|
|
200
329
|
|
|
330
|
+
@profile_source = 'none'
|
|
201
331
|
nil
|
|
202
332
|
end
|
|
203
333
|
|
|
204
334
|
def resolve_api_key
|
|
205
335
|
# FP_API_KEY env var always wins
|
|
206
|
-
|
|
336
|
+
if ENV['FP_API_KEY'] && !ENV['FP_API_KEY'].empty?
|
|
337
|
+
@api_key_source = 'env (FP_API_KEY)'
|
|
338
|
+
return ENV['FP_API_KEY']
|
|
339
|
+
end
|
|
207
340
|
|
|
208
341
|
# Otherwise, use profile if specified
|
|
209
|
-
|
|
342
|
+
unless @profile_name
|
|
343
|
+
@api_key_source = 'none'
|
|
344
|
+
return nil
|
|
345
|
+
end
|
|
210
346
|
|
|
211
347
|
profiles = self.class.load_profiles
|
|
212
348
|
profile = profiles[@profile_name]
|
|
213
|
-
|
|
349
|
+
unless profile
|
|
350
|
+
@api_key_source = 'none'
|
|
351
|
+
return nil
|
|
352
|
+
end
|
|
214
353
|
|
|
354
|
+
@api_key_source = "profile (#{@profile_name})"
|
|
215
355
|
profile['api_key']
|
|
216
356
|
end
|
|
217
357
|
|
|
218
358
|
def resolve_api_url
|
|
219
359
|
# 1. Explicit --api-url flag
|
|
220
|
-
|
|
360
|
+
if @explicit_api_url && !@explicit_api_url.empty?
|
|
361
|
+
@api_url_source = 'flag'
|
|
362
|
+
return @explicit_api_url
|
|
363
|
+
end
|
|
221
364
|
|
|
222
365
|
# 2. FP_API_URL env var
|
|
223
|
-
|
|
366
|
+
if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
|
|
367
|
+
@api_url_source = 'env (FP_API_URL)'
|
|
368
|
+
return ENV['FP_API_URL']
|
|
369
|
+
end
|
|
224
370
|
|
|
225
371
|
# 3. Profile-specific URL
|
|
226
372
|
if @profile_name
|
|
227
373
|
profiles = self.class.load_profiles
|
|
228
374
|
profile = profiles[@profile_name]
|
|
229
|
-
|
|
375
|
+
if profile && profile['api_url']
|
|
376
|
+
@api_url_source = "profile (#{@profile_name})"
|
|
377
|
+
return profile['api_url']
|
|
378
|
+
end
|
|
230
379
|
end
|
|
231
380
|
|
|
232
381
|
# 4. Global api_url setting
|
|
233
382
|
global_url = self.class.get_setting('api_url')
|
|
234
|
-
|
|
383
|
+
if global_url && !global_url.empty?
|
|
384
|
+
@api_url_source = 'global setting'
|
|
385
|
+
return global_url
|
|
386
|
+
end
|
|
235
387
|
|
|
236
388
|
# 5. Default
|
|
389
|
+
@api_url_source = 'default'
|
|
237
390
|
DEFAULT_API_URL
|
|
238
391
|
end
|
|
239
392
|
end
|
data/lib/fp/junit.rb
CHANGED
|
@@ -34,6 +34,16 @@ module Fp
|
|
|
34
34
|
# (matching the propose/report convention).
|
|
35
35
|
MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
|
|
36
36
|
|
|
37
|
+
# A marker only counts when it is a comment on its own line — the documented
|
|
38
|
+
# convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
|
|
39
|
+
# line-leading comment token is what keeps marker-shaped text inside string literals,
|
|
40
|
+
# fixtures and docs from being mistaken for a real annotation. Without it, any test
|
|
41
|
+
# suite that has tests *about* fp markers reports evidence for its own fixtures.
|
|
42
|
+
#
|
|
43
|
+
# Covers #, //, --, and both the opening and continuation lines of /* ... */ blocks,
|
|
44
|
+
# which spans every language the marker convention documents.
|
|
45
|
+
COMMENT_LINE_RE = %r{\A\s*(?:\#|//|--|/\*|\*)}.freeze
|
|
46
|
+
|
|
37
47
|
module_function
|
|
38
48
|
|
|
39
49
|
# Parse a JUnit XML file into an array of TestCase structs.
|
|
@@ -52,10 +62,14 @@ module Fp
|
|
|
52
62
|
cases = []
|
|
53
63
|
|
|
54
64
|
doc.each_element('//testcase') do |el|
|
|
65
|
+
parent = el.parent
|
|
66
|
+
suite_name = parent && parent.expanded_name == 'testsuite' ? parent.attributes['name'] : nil
|
|
67
|
+
classname = el.attributes['classname']
|
|
68
|
+
|
|
55
69
|
cases << TestCase.new(
|
|
56
70
|
name: el.attributes['name'],
|
|
57
|
-
classname:
|
|
58
|
-
file: el.attributes['file'],
|
|
71
|
+
classname: classname,
|
|
72
|
+
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
59
73
|
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
60
74
|
status: testcase_status(el)
|
|
61
75
|
)
|
|
@@ -64,6 +78,26 @@ module Fp
|
|
|
64
78
|
cases
|
|
65
79
|
end
|
|
66
80
|
|
|
81
|
+
# Runners disagree about where the source file goes. Minitest- and RSpec-style reports
|
|
82
|
+
# put it on the testcase's `file` attribute; vitest and jest omit `file` entirely and
|
|
83
|
+
# carry the path in `classname` and the enclosing `<testsuite name>`. Markers live in
|
|
84
|
+
# the file, so fall back through the alternatives instead of silently finding nothing.
|
|
85
|
+
def resolve_file(file, classname, suite_name)
|
|
86
|
+
return file if file && !file.empty?
|
|
87
|
+
|
|
88
|
+
[classname, suite_name].find { |candidate| path_like?(candidate) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Distinguish a source path from a bare test-class name. Deliberately conservative:
|
|
92
|
+
# a Java-style `com.example.FooTest` classname must not be mistaken for a file.
|
|
93
|
+
TEST_FILE_EXT_RE = /\.(rb|js|jsx|mjs|cjs|ts|tsx|kt|kts|swift|java|py|go|cs|php|rs|scala)\z/.freeze
|
|
94
|
+
|
|
95
|
+
def path_like?(value)
|
|
96
|
+
return false if value.nil? || value.empty?
|
|
97
|
+
|
|
98
|
+
value.include?('/') || value.match?(TEST_FILE_EXT_RE)
|
|
99
|
+
end
|
|
100
|
+
|
|
67
101
|
# Determine present/skipped for a testcase element.
|
|
68
102
|
# Failures/errors still count as "present" for agent evidence — the test
|
|
69
103
|
# exists and was executed. Only skipped/pending tests become stubs.
|
|
@@ -113,6 +147,12 @@ module Fp
|
|
|
113
147
|
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
114
148
|
next if markers.empty?
|
|
115
149
|
|
|
150
|
+
# A title is only trustworthy when we know which test the marker annotates: either
|
|
151
|
+
# the report carried line numbers, or the file holds a single test. Otherwise the
|
|
152
|
+
# marker was matched file-wide and naming any one test would be a guess — vitest
|
|
153
|
+
# and jest emit no line numbers, so this is the common case, not an edge one.
|
|
154
|
+
titles_reliable = cases.any?(&:line) || cases.length == 1
|
|
155
|
+
|
|
116
156
|
cases.each do |tc|
|
|
117
157
|
marker = marker_for(tc, cases, markers)
|
|
118
158
|
next unless marker
|
|
@@ -126,7 +166,7 @@ module Fp
|
|
|
126
166
|
if surface.nil? || surface.empty?
|
|
127
167
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
128
168
|
else
|
|
129
|
-
record_binding(bindings, marker[:slug], surface, tc)
|
|
169
|
+
record_binding(bindings, marker[:slug], surface, tc, titles_reliable: titles_reliable)
|
|
130
170
|
end
|
|
131
171
|
end
|
|
132
172
|
end
|
|
@@ -163,7 +203,7 @@ module Fp
|
|
|
163
203
|
candidate
|
|
164
204
|
end
|
|
165
205
|
|
|
166
|
-
def record_binding(bindings, slug, surface, testcase)
|
|
206
|
+
def record_binding(bindings, slug, surface, testcase, titles_reliable: true)
|
|
167
207
|
rel_file = testcase.file
|
|
168
208
|
key = [slug, surface, rel_file]
|
|
169
209
|
state = testcase.stub? ? 'stub' : 'present'
|
|
@@ -173,7 +213,7 @@ module Fp
|
|
|
173
213
|
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
174
214
|
|
|
175
215
|
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
|
|
176
|
-
title: testcase.name, state: state)
|
|
216
|
+
title: (testcase.name if titles_reliable), state: state)
|
|
177
217
|
end
|
|
178
218
|
|
|
179
219
|
# Load and cache the fp:<slug> markers (with line numbers) for a testcase's
|
|
@@ -198,9 +238,13 @@ module Fp
|
|
|
198
238
|
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
199
239
|
# [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
|
|
200
240
|
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
241
|
+
#
|
|
242
|
+
# Only comment lines are considered — see COMMENT_LINE_RE.
|
|
201
243
|
def scan_markers(contents)
|
|
202
244
|
markers = []
|
|
203
245
|
contents.each_line.with_index(1) do |line, num|
|
|
246
|
+
next unless line.match?(COMMENT_LINE_RE)
|
|
247
|
+
|
|
204
248
|
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
205
249
|
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
206
250
|
markers << { slug: slug, surfaces: surfaces, line: num }
|
data/lib/fp/version.rb
CHANGED
data/skills/fp/references/cli.md
CHANGED
|
@@ -159,15 +159,26 @@ fp matrix --project stowzilla --json # JSON
|
|
|
159
159
|
|
|
160
160
|
### fp profile
|
|
161
161
|
|
|
162
|
-
Manage named profiles.
|
|
162
|
+
Manage named profiles. Each profile stores an API key and (optionally) the
|
|
163
|
+
API URL and environment it targets, so a key is always tagged with the
|
|
164
|
+
environment it talks to.
|
|
163
165
|
|
|
164
166
|
```bash
|
|
165
167
|
fp profile list
|
|
166
|
-
fp profile add staging --api-key fp_...
|
|
167
|
-
fp profile
|
|
168
|
-
fp profile show staging
|
|
168
|
+
fp profile add staging --api-key fp_... --api-url https://api.staging.featureparity.dev
|
|
169
|
+
fp profile add staging --api-key fp_... --environment staging
|
|
169
170
|
```
|
|
170
171
|
|
|
172
|
+
If `--environment` is omitted it is derived from the API URL's host, following
|
|
173
|
+
the Belt DNS convention (`infrastructure/modules/app/dns.tf`):
|
|
174
|
+
|
|
175
|
+
- `api.featureparity.dev` → `production`
|
|
176
|
+
- `api.staging.featureparity.dev` → `staging`
|
|
177
|
+
- `api-abc-1234.dev.featureparity.dev` → `abc-1234` (nested/ephemeral env; `api-` prefix)
|
|
178
|
+
- `localhost` / `127.0.0.1` → `local`
|
|
179
|
+
|
|
180
|
+
`fp profile list` shows the tag.
|
|
181
|
+
|
|
171
182
|
### fp config
|
|
172
183
|
|
|
173
184
|
Manage global settings.
|
|
@@ -177,6 +188,21 @@ fp config list
|
|
|
177
188
|
fp config get api_url
|
|
178
189
|
fp config set api_url https://api.dev.featureparity.dev
|
|
179
190
|
fp config unset api_url
|
|
191
|
+
fp config whoami # alias of `fp whoami`
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### fp whoami
|
|
195
|
+
|
|
196
|
+
Show the *resolved* configuration this invocation would use — the active
|
|
197
|
+
profile, the environment, the API URL (and where each value came from), and
|
|
198
|
+
whether an API key is present (masked, never the full token). Makes no API
|
|
199
|
+
calls, so it works even without a valid key.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
fp whoami
|
|
203
|
+
fp whoami --json
|
|
204
|
+
fp --profile staging whoami # inspect a specific profile
|
|
205
|
+
fp --api-url http://localhost:9292 whoami
|
|
180
206
|
```
|
|
181
207
|
|
|
182
208
|
### fp repos
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureparity
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|
|
@@ -108,6 +108,7 @@ files:
|
|
|
108
108
|
- lib/fp/commands/show.rb
|
|
109
109
|
- lib/fp/commands/surfaces.rb
|
|
110
110
|
- lib/fp/commands/version.rb
|
|
111
|
+
- lib/fp/commands/whoami.rb
|
|
111
112
|
- lib/fp/config.rb
|
|
112
113
|
- lib/fp/junit.rb
|
|
113
114
|
- lib/fp/output.rb
|