featureparity 0.0.1
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 +7 -0
- data/bin/fp +6 -0
- data/lib/fp/cli.rb +96 -0
- data/lib/fp/client.rb +151 -0
- data/lib/fp/commands/base.rb +90 -0
- data/lib/fp/commands/help.rb +122 -0
- data/lib/fp/commands/list.rb +104 -0
- data/lib/fp/commands/matrix.rb +79 -0
- data/lib/fp/commands/profile.rb +87 -0
- data/lib/fp/commands/projects.rb +32 -0
- data/lib/fp/commands/propose.rb +66 -0
- data/lib/fp/commands/report.rb +117 -0
- data/lib/fp/commands/setup.rb +148 -0
- data/lib/fp/commands/show.rb +92 -0
- data/lib/fp/commands/surfaces.rb +34 -0
- data/lib/fp/commands/version.rb +14 -0
- data/lib/fp/commands.rb +19 -0
- data/lib/fp/config.rb +111 -0
- data/lib/fp/output.rb +71 -0
- data/lib/fp/version.rb +5 -0
- data/lib/fp.rb +14 -0
- metadata +139 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp profile add|list
|
|
6
|
+
# Manage named profiles for API authentication
|
|
7
|
+
class Profile < Base
|
|
8
|
+
KNOWN_FLAGS = {
|
|
9
|
+
api_key: :string,
|
|
10
|
+
api_url: :string
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def run(args)
|
|
14
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
15
|
+
subcommand = positional.first
|
|
16
|
+
|
|
17
|
+
case subcommand
|
|
18
|
+
when 'add'
|
|
19
|
+
add_profile(positional[1], opts)
|
|
20
|
+
when 'list'
|
|
21
|
+
list_profiles
|
|
22
|
+
else
|
|
23
|
+
show_usage
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def add_profile(name, opts)
|
|
30
|
+
unless name
|
|
31
|
+
output.error('Profile name is required. Usage: fp profile add <name> --api-key fp_...')
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless opts[:api_key]
|
|
36
|
+
output.error('--api-key is required')
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless opts[:api_key].start_with?('fp_')
|
|
41
|
+
output.error("API key must start with 'fp_' prefix")
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Config.save_profile(name, api_key: opts[:api_key], api_url: opts[:api_url])
|
|
46
|
+
|
|
47
|
+
output.success({ profile: name, api_url: opts[:api_url] || Config::DEFAULT_API_URL }) do
|
|
48
|
+
puts "Profile '#{name}' saved."
|
|
49
|
+
puts " API URL: #{opts[:api_url] || Config::DEFAULT_API_URL}"
|
|
50
|
+
puts
|
|
51
|
+
puts 'Use this profile with:'
|
|
52
|
+
puts " fp --profile #{name} projects"
|
|
53
|
+
puts " FP_PROFILE=#{name} fp projects"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def list_profiles
|
|
58
|
+
profiles = Config.list_profiles
|
|
59
|
+
|
|
60
|
+
if profiles.empty?
|
|
61
|
+
output.success({ profiles: [] }) do
|
|
62
|
+
puts 'No profiles configured.'
|
|
63
|
+
puts
|
|
64
|
+
puts 'Add a profile with:'
|
|
65
|
+
puts ' fp profile add <name> --api-key fp_...'
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
output.success({ profiles: profiles }) do
|
|
69
|
+
puts 'Configured profiles:'
|
|
70
|
+
profiles.each { |name| puts " - #{name}" }
|
|
71
|
+
puts
|
|
72
|
+
puts 'Note: API keys are never displayed. Use --profile <name> to select.'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_usage
|
|
78
|
+
output.error('Usage: fp profile <add|list>')
|
|
79
|
+
puts
|
|
80
|
+
puts 'Commands:'
|
|
81
|
+
puts ' fp profile add <name> --api-key fp_... Add a named profile'
|
|
82
|
+
puts ' fp profile list List configured profiles'
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp projects - list projects/workspaces the API key has access to
|
|
6
|
+
class Projects < Base
|
|
7
|
+
def run(_args)
|
|
8
|
+
result = client.list_projects
|
|
9
|
+
|
|
10
|
+
unless result[:ok]
|
|
11
|
+
output.error(result[:error], status: result[:status])
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
workspaces = result[:data]['workspaces'] || []
|
|
16
|
+
|
|
17
|
+
output.success(result[:data]) do
|
|
18
|
+
if workspaces.empty?
|
|
19
|
+
puts 'No projects found.'
|
|
20
|
+
else
|
|
21
|
+
headers = %w[SLUG NAME SURFACES]
|
|
22
|
+
rows = workspaces.map do |w|
|
|
23
|
+
surfaces = (w['available_surfaces'] || []).join(', ')
|
|
24
|
+
[w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
|
|
25
|
+
end
|
|
26
|
+
output.table(headers, rows)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..."
|
|
6
|
+
# Create a new requirement as draft
|
|
7
|
+
class Propose < Base
|
|
8
|
+
KNOWN_FLAGS = {
|
|
9
|
+
project: :string,
|
|
10
|
+
slug: :string,
|
|
11
|
+
name: :string,
|
|
12
|
+
why: :string,
|
|
13
|
+
required: :string,
|
|
14
|
+
acceptance: :string
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def run(args)
|
|
18
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
19
|
+
|
|
20
|
+
require_flag(opts, :project)
|
|
21
|
+
require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
|
|
22
|
+
require_flag(opts, :name, '--name is required (human-readable title)')
|
|
23
|
+
|
|
24
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
25
|
+
|
|
26
|
+
# Parse required surfaces (comma-separated)
|
|
27
|
+
required_surfaces = if opts[:required]
|
|
28
|
+
opts[:required].split(',').map(&:strip)
|
|
29
|
+
else
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
params = {
|
|
34
|
+
slug: opts[:slug],
|
|
35
|
+
title: opts[:name],
|
|
36
|
+
workspace_id: workspace_id,
|
|
37
|
+
required_surfaces: required_surfaces
|
|
38
|
+
}
|
|
39
|
+
params[:why] = opts[:why] if opts[:why]
|
|
40
|
+
params[:acceptance] = opts[:acceptance] if opts[:acceptance]
|
|
41
|
+
# Note: status is NOT sent - the API enforces draft for agents
|
|
42
|
+
|
|
43
|
+
result = client.create_requirement(params)
|
|
44
|
+
|
|
45
|
+
unless result[:ok]
|
|
46
|
+
output.error(result[:error], status: result[:status])
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
requirement = result[:data]['requirement']
|
|
51
|
+
|
|
52
|
+
output.success(result[:data]) do
|
|
53
|
+
puts "Created requirement '#{requirement['slug']}' as DRAFT."
|
|
54
|
+
puts
|
|
55
|
+
puts " Title: #{requirement['title']}"
|
|
56
|
+
puts " Status: #{requirement['status']}"
|
|
57
|
+
puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
|
|
58
|
+
puts
|
|
59
|
+
puts '⚠️ This requirement is a DRAFT.'
|
|
60
|
+
puts ' A human must activate it in the web app before it appears in the parity matrix.'
|
|
61
|
+
puts ' Agents cannot activate requirements.'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp report <slug> --project <slug> --surface X --file path --repo org/repo
|
|
6
|
+
# [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--title "..."] [--state present|stub]
|
|
7
|
+
# Report evidence for a requirement
|
|
8
|
+
class Report < Base
|
|
9
|
+
KNOWN_FLAGS = {
|
|
10
|
+
project: :string,
|
|
11
|
+
surface: :string,
|
|
12
|
+
file: :string,
|
|
13
|
+
repo: :string,
|
|
14
|
+
pr: :string,
|
|
15
|
+
sha: :string,
|
|
16
|
+
work_item: :string,
|
|
17
|
+
ci_url: :string,
|
|
18
|
+
title: :string,
|
|
19
|
+
state: :string
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
VALID_STATES = %w[present stub].freeze
|
|
23
|
+
|
|
24
|
+
def run(args)
|
|
25
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
26
|
+
|
|
27
|
+
slug = positional.first
|
|
28
|
+
unless slug
|
|
29
|
+
output.error('Requirement slug is required. Usage: fp report <slug> --project <project> --surface <surface> --file <path> --repo <org/repo>')
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
require_flag(opts, :project)
|
|
34
|
+
require_flag(opts, :surface)
|
|
35
|
+
require_flag(opts, :file)
|
|
36
|
+
require_flag(opts, :repo)
|
|
37
|
+
|
|
38
|
+
# Validate state
|
|
39
|
+
state = opts[:state] || 'present'
|
|
40
|
+
unless VALID_STATES.include?(state)
|
|
41
|
+
output.error("Invalid state '#{state}'. Must be one of: #{VALID_STATES.join(', ')}")
|
|
42
|
+
output.error('Note: Agents report present or stub. Only CI (#1222) can report passing or failing.')
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
47
|
+
|
|
48
|
+
# Find the requirement by slug
|
|
49
|
+
req_result = client.find_requirement_by_slug(workspace_id, slug)
|
|
50
|
+
unless req_result[:ok]
|
|
51
|
+
output.error(req_result[:error], status: req_result[:status])
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
requirement = req_result[:data]['requirement']
|
|
56
|
+
|
|
57
|
+
# Build evidence payload
|
|
58
|
+
params = {
|
|
59
|
+
requirement_id: requirement['id'],
|
|
60
|
+
surface: opts[:surface],
|
|
61
|
+
file: opts[:file],
|
|
62
|
+
repo: opts[:repo],
|
|
63
|
+
state: state
|
|
64
|
+
}
|
|
65
|
+
params[:pr_url] = opts[:pr] if opts[:pr]
|
|
66
|
+
params[:sha] = opts[:sha] if opts[:sha]
|
|
67
|
+
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
68
|
+
params[:ci_url] = opts[:ci_url] if opts[:ci_url]
|
|
69
|
+
params[:example_title] = opts[:title] if opts[:title]
|
|
70
|
+
|
|
71
|
+
# Try to report evidence
|
|
72
|
+
result = client.report_evidence(params)
|
|
73
|
+
|
|
74
|
+
# Evidence API may not exist yet (#1214)
|
|
75
|
+
if !result[:ok] && result[:status] == 404
|
|
76
|
+
output.success(params, summary: nil) do
|
|
77
|
+
puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
|
|
78
|
+
puts " State: #{state}"
|
|
79
|
+
puts " File: #{opts[:file]}"
|
|
80
|
+
puts " Repo: #{opts[:repo]}"
|
|
81
|
+
puts " PR: #{opts[:pr]}" if opts[:pr]
|
|
82
|
+
puts " SHA: #{opts[:sha]}" if opts[:sha]
|
|
83
|
+
puts " Work Item: #{opts[:work_item]}" if opts[:work_item]
|
|
84
|
+
puts " CI URL: #{opts[:ci_url]}" if opts[:ci_url]
|
|
85
|
+
puts " Title: #{opts[:title]}" if opts[:title]
|
|
86
|
+
puts
|
|
87
|
+
puts '⚠️ Evidence API not available yet. This will be recorded when #1214 is complete.'
|
|
88
|
+
end
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless result[:ok]
|
|
93
|
+
output.error(result[:error], status: result[:status])
|
|
94
|
+
exit 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
output.success(result[:data]) do
|
|
98
|
+
puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
|
|
99
|
+
puts " State: #{state}"
|
|
100
|
+
puts " File: #{opts[:file]}"
|
|
101
|
+
if opts[:pr]
|
|
102
|
+
puts " PR: #{opts[:pr]}"
|
|
103
|
+
end
|
|
104
|
+
if opts[:work_item]
|
|
105
|
+
puts " Work: #{opts[:work_item]}"
|
|
106
|
+
end
|
|
107
|
+
if opts[:ci_url]
|
|
108
|
+
puts " CI: #{opts[:ci_url]}"
|
|
109
|
+
end
|
|
110
|
+
if opts[:title]
|
|
111
|
+
puts " Title: #{opts[:title]}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp setup
|
|
6
|
+
# Interactive guided setup for new users.
|
|
7
|
+
# Validates the API key, checks connectivity, and saves a profile.
|
|
8
|
+
class Setup < Base
|
|
9
|
+
KNOWN_FLAGS = {
|
|
10
|
+
api_key: :string,
|
|
11
|
+
profile: :string,
|
|
12
|
+
api_url: :string,
|
|
13
|
+
non_interactive: :boolean
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def run(args)
|
|
17
|
+
opts, _positional = parse_flags(args, KNOWN_FLAGS)
|
|
18
|
+
|
|
19
|
+
puts "🔧 FeatureParity CLI Setup"
|
|
20
|
+
puts "=" * 40
|
|
21
|
+
puts
|
|
22
|
+
|
|
23
|
+
api_key = resolve_api_key(opts)
|
|
24
|
+
profile_name = resolve_profile_name(opts)
|
|
25
|
+
api_url = opts[:api_url]
|
|
26
|
+
|
|
27
|
+
puts "Validating API key..."
|
|
28
|
+
validate_key!(api_key, api_url)
|
|
29
|
+
|
|
30
|
+
save_profile!(profile_name, api_key, api_url)
|
|
31
|
+
|
|
32
|
+
puts
|
|
33
|
+
puts "✅ Setup complete!"
|
|
34
|
+
puts
|
|
35
|
+
puts "Your profile '#{profile_name}' is ready. Usage:"
|
|
36
|
+
puts
|
|
37
|
+
puts " fp --profile #{profile_name} projects"
|
|
38
|
+
puts " FP_PROFILE=#{profile_name} fp projects"
|
|
39
|
+
puts
|
|
40
|
+
puts "Or set FP_API_KEY to skip profiles:"
|
|
41
|
+
puts " export FP_API_KEY=#{api_key[0..6]}..."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resolve_api_key(opts)
|
|
47
|
+
if opts[:api_key]
|
|
48
|
+
validate_key_format!(opts[:api_key])
|
|
49
|
+
return opts[:api_key]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if opts[:non_interactive]
|
|
53
|
+
output.error("--api-key is required in non-interactive mode")
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
puts "Get your API key from: https://featureparity.dev"
|
|
58
|
+
puts "(Workspace Settings → API Keys → Mint Key)"
|
|
59
|
+
puts
|
|
60
|
+
print "Paste your API key (starts with fp_): "
|
|
61
|
+
$stdout.flush
|
|
62
|
+
key = $stdin.gets&.strip
|
|
63
|
+
|
|
64
|
+
if key.nil? || key.empty?
|
|
65
|
+
output.error("No API key provided")
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
validate_key_format!(key)
|
|
70
|
+
key
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_profile_name(opts)
|
|
74
|
+
if opts[:profile]
|
|
75
|
+
return opts[:profile]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if opts[:non_interactive]
|
|
79
|
+
return 'default'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
print "Profile name (default: 'default'): "
|
|
83
|
+
$stdout.flush
|
|
84
|
+
name = $stdin.gets&.strip
|
|
85
|
+
|
|
86
|
+
name.nil? || name.empty? ? 'default' : name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_key_format!(key)
|
|
90
|
+
unless key.start_with?('fp_')
|
|
91
|
+
output.error("API key must start with 'fp_' prefix. Got: #{key[0..3]}...")
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if key.length < 10
|
|
96
|
+
output.error("API key looks too short. Check that you copied the full key.")
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_key!(api_key, api_url)
|
|
102
|
+
url = (api_url || Config::DEFAULT_API_URL).chomp('/')
|
|
103
|
+
uri = URI.parse("#{url}/api/workspaces")
|
|
104
|
+
|
|
105
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
106
|
+
http.use_ssl = uri.scheme == 'https'
|
|
107
|
+
http.open_timeout = 10
|
|
108
|
+
http.read_timeout = 10
|
|
109
|
+
|
|
110
|
+
request = Net::HTTP::Get.new(uri)
|
|
111
|
+
request['Authorization'] = "Bearer #{api_key}"
|
|
112
|
+
request['Accept'] = 'application/json'
|
|
113
|
+
request['User-Agent'] = "fp-cli/#{Fp::VERSION}"
|
|
114
|
+
|
|
115
|
+
response = http.request(request)
|
|
116
|
+
status = response.code.to_i
|
|
117
|
+
|
|
118
|
+
if status == 401 || status == 403
|
|
119
|
+
output.error("API key is invalid or expired (HTTP #{status}). Mint a new one at https://featureparity.dev")
|
|
120
|
+
exit 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if status >= 500
|
|
124
|
+
output.error("API server error (HTTP #{status}). Try again later.")
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless status >= 200 && status < 300
|
|
129
|
+
output.error("Unexpected response (HTTP #{status}). Check your API URL.")
|
|
130
|
+
exit 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
puts " ✓ API key is valid"
|
|
134
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
135
|
+
output.error("Connection timed out. Check your network and API URL.")
|
|
136
|
+
exit 1
|
|
137
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
138
|
+
output.error("Cannot connect to API: #{e.message}")
|
|
139
|
+
exit 1
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def save_profile!(name, api_key, api_url)
|
|
143
|
+
Config.save_profile(name, api_key: api_key, api_url: api_url)
|
|
144
|
+
puts " ✓ Profile '#{name}' saved to ~/.config/fp/config.yml"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp show <slug> --project <slug>
|
|
6
|
+
# Show requirement details including evidence receipts
|
|
7
|
+
class Show < Base
|
|
8
|
+
def run(args)
|
|
9
|
+
opts, positional = parse_flags(args)
|
|
10
|
+
require_flag(opts, :project)
|
|
11
|
+
|
|
12
|
+
slug = positional.first
|
|
13
|
+
unless slug
|
|
14
|
+
output.error('Requirement slug is required. Usage: fp show <slug> --project <project>')
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
19
|
+
result = client.find_requirement_by_slug(workspace_id, slug)
|
|
20
|
+
|
|
21
|
+
unless result[:ok]
|
|
22
|
+
output.error(result[:error], status: result[:status])
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
requirement = result[:data]['requirement']
|
|
27
|
+
|
|
28
|
+
output.success(result[:data]) do
|
|
29
|
+
print_requirement(requirement)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def print_requirement(req)
|
|
36
|
+
puts "#{req['title']}"
|
|
37
|
+
puts "=" * req['title'].length
|
|
38
|
+
puts
|
|
39
|
+
puts "Slug: #{req['slug']}"
|
|
40
|
+
puts "Status: #{req['status']}"
|
|
41
|
+
puts
|
|
42
|
+
|
|
43
|
+
surfaces = req['required_surfaces'] || []
|
|
44
|
+
puts 'Required Surfaces:'
|
|
45
|
+
if surfaces.empty?
|
|
46
|
+
puts ' (none)'
|
|
47
|
+
else
|
|
48
|
+
surfaces.each { |s| puts " - #{s}" }
|
|
49
|
+
end
|
|
50
|
+
puts
|
|
51
|
+
|
|
52
|
+
if req['why'] && !req['why'].empty?
|
|
53
|
+
puts 'Why:'
|
|
54
|
+
puts " #{req['why']}"
|
|
55
|
+
puts
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if req['acceptance'] && !req['acceptance'].empty?
|
|
59
|
+
puts 'Acceptance Criteria:'
|
|
60
|
+
puts " #{req['acceptance']}"
|
|
61
|
+
puts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Evidence receipts (will be populated when Evidence model exists)
|
|
65
|
+
evidence = req['evidence'] || []
|
|
66
|
+
if evidence.any?
|
|
67
|
+
puts 'Evidence:'
|
|
68
|
+
evidence.each do |e|
|
|
69
|
+
puts " #{e['surface']}:"
|
|
70
|
+
puts " Status: #{e['status']}"
|
|
71
|
+
puts " File: #{e['file']}" if e['file']
|
|
72
|
+
puts " Repo: #{e['repo']}" if e['repo']
|
|
73
|
+
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
74
|
+
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
75
|
+
puts " CI: #{e['ci_url']}" if e['ci_url']
|
|
76
|
+
puts " Title: #{e['example_title']}" if e['example_title']
|
|
77
|
+
puts " By: #{e['reported_by']}" if e['reported_by']
|
|
78
|
+
puts
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
puts 'Evidence:'
|
|
82
|
+
puts ' (none reported)'
|
|
83
|
+
puts
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "Created: #{req['created_at']}"
|
|
87
|
+
puts "Updated: #{req['updated_at']}"
|
|
88
|
+
puts "By: #{req['created_by']}" if req['created_by']
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp surfaces --project <slug> - list available surfaces for a project
|
|
6
|
+
class Surfaces < Base
|
|
7
|
+
def run(args)
|
|
8
|
+
opts, = parse_flags(args)
|
|
9
|
+
require_flag(opts, :project)
|
|
10
|
+
|
|
11
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
12
|
+
result = client.get_project(workspace_id)
|
|
13
|
+
|
|
14
|
+
unless result[:ok]
|
|
15
|
+
output.error(result[:error], status: result[:status])
|
|
16
|
+
exit 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
workspace = result[:data]['workspace']
|
|
20
|
+
surfaces = workspace['available_surfaces'] || []
|
|
21
|
+
|
|
22
|
+
output.success({ surfaces: surfaces, project: opts[:project] }) do
|
|
23
|
+
if surfaces.empty?
|
|
24
|
+
puts "No surfaces configured for project '#{opts[:project]}'."
|
|
25
|
+
puts 'Configure available_surfaces in the web app.'
|
|
26
|
+
else
|
|
27
|
+
puts "Surfaces for '#{opts[:project]}':"
|
|
28
|
+
surfaces.each { |s| puts " - #{s}" }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/fp/commands.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'commands/base'
|
|
4
|
+
require_relative 'commands/projects'
|
|
5
|
+
require_relative 'commands/surfaces'
|
|
6
|
+
require_relative 'commands/list'
|
|
7
|
+
require_relative 'commands/show'
|
|
8
|
+
require_relative 'commands/propose'
|
|
9
|
+
require_relative 'commands/report'
|
|
10
|
+
require_relative 'commands/matrix'
|
|
11
|
+
require_relative 'commands/profile'
|
|
12
|
+
require_relative 'commands/setup'
|
|
13
|
+
require_relative 'commands/version'
|
|
14
|
+
require_relative 'commands/help'
|
|
15
|
+
|
|
16
|
+
module Fp
|
|
17
|
+
module Commands
|
|
18
|
+
end
|
|
19
|
+
end
|