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
data/lib/fp/commands/report.rb
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Single requirement:
|
|
6
|
+
# fp report <slug> --project <slug> --surface X --file path --repo org/repo
|
|
7
|
+
# [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--title "..."] [--state present|stub]
|
|
8
|
+
#
|
|
9
|
+
# Batch from a JUnit XML report (upload evidence without CI):
|
|
10
|
+
# fp report --junit <path> --project <slug> --surface X --repo org/repo
|
|
11
|
+
# [--base-dir DIR] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
|
|
12
|
+
#
|
|
13
|
+
# In --junit mode the CLI parses the report, discovers the fp:<slug> markers
|
|
14
|
+
# in the referenced test files, and uploads evidence for every requirement
|
|
15
|
+
# the suite covered. This lets an agent run the suite locally and report
|
|
16
|
+
# evidence without waiting on CI.
|
|
8
17
|
class Report < Base
|
|
9
18
|
KNOWN_FLAGS = {
|
|
10
19
|
project: :string,
|
|
@@ -16,7 +25,9 @@ module Fp
|
|
|
16
25
|
work_item: :string,
|
|
17
26
|
ci_url: :string,
|
|
18
27
|
title: :string,
|
|
19
|
-
state: :string
|
|
28
|
+
state: :string,
|
|
29
|
+
junit: :string,
|
|
30
|
+
base_dir: :string
|
|
20
31
|
}.freeze
|
|
21
32
|
|
|
22
33
|
VALID_STATES = %w[present stub].freeze
|
|
@@ -24,6 +35,8 @@ module Fp
|
|
|
24
35
|
def run(args)
|
|
25
36
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
26
37
|
|
|
38
|
+
return run_junit(opts) if opts[:junit]
|
|
39
|
+
|
|
27
40
|
slug = positional.first
|
|
28
41
|
unless slug
|
|
29
42
|
output.error('Requirement slug is required. Usage: fp report <slug> --project <project> --surface <surface> --file <path> --repo <org/repo>')
|
|
@@ -43,20 +56,19 @@ module Fp
|
|
|
43
56
|
exit 1
|
|
44
57
|
end
|
|
45
58
|
|
|
46
|
-
|
|
59
|
+
project_id = resolve_project_id(opts[:project])
|
|
47
60
|
|
|
48
|
-
#
|
|
49
|
-
req_result = client.find_requirement_by_slug(
|
|
61
|
+
# Verify the requirement exists before reporting evidence
|
|
62
|
+
req_result = client.find_requirement_by_slug(project_id, slug)
|
|
50
63
|
unless req_result[:ok]
|
|
51
64
|
output.error(req_result[:error], status: req_result[:status])
|
|
52
65
|
exit 1
|
|
53
66
|
end
|
|
54
67
|
|
|
55
|
-
requirement = req_result[:data]['requirement']
|
|
56
|
-
|
|
57
68
|
# Build evidence payload
|
|
58
69
|
params = {
|
|
59
|
-
|
|
70
|
+
project_id: project_id,
|
|
71
|
+
slug: slug,
|
|
60
72
|
surface: opts[:surface],
|
|
61
73
|
file: opts[:file],
|
|
62
74
|
repo: opts[:repo],
|
|
@@ -66,13 +78,14 @@ module Fp
|
|
|
66
78
|
params[:sha] = opts[:sha] if opts[:sha]
|
|
67
79
|
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
68
80
|
params[:ci_url] = opts[:ci_url] if opts[:ci_url]
|
|
69
|
-
params[:
|
|
81
|
+
params[:title] = opts[:title] if opts[:title]
|
|
70
82
|
|
|
71
83
|
# Try to report evidence
|
|
72
84
|
result = client.report_evidence(params)
|
|
73
85
|
|
|
74
86
|
# Evidence API may not exist yet (#1214)
|
|
75
|
-
|
|
87
|
+
# API Gateway returns 403 (not 404) for unmatched routes
|
|
88
|
+
if !result[:ok] && [403, 404].include?(result[:status])
|
|
76
89
|
output.success(params, summary: nil) do
|
|
77
90
|
puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
|
|
78
91
|
puts " State: #{state}"
|
|
@@ -112,6 +125,142 @@ module Fp
|
|
|
112
125
|
end
|
|
113
126
|
end
|
|
114
127
|
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Batch-upload evidence from a JUnit XML report.
|
|
132
|
+
def run_junit(opts)
|
|
133
|
+
require_flag(opts, :project)
|
|
134
|
+
require_flag(opts, :repo)
|
|
135
|
+
# --surface is the default for markers that don't pin their own surface
|
|
136
|
+
# (e.g. `fp:slug@api,web`). It's only strictly required when at least one
|
|
137
|
+
# matched marker omits a surface — validated below once we know.
|
|
138
|
+
|
|
139
|
+
# State override applies to every non-skipped case. Skipped tests always
|
|
140
|
+
# become stubs regardless. Default lets the report decide per-testcase.
|
|
141
|
+
state_override = opts[:state]
|
|
142
|
+
if state_override && !VALID_STATES.include?(state_override)
|
|
143
|
+
output.error("Invalid state '#{state_override}'. Must be one of: #{VALID_STATES.join(', ')}")
|
|
144
|
+
output.error('Note: Agents report present or stub. Only CI (#1222) can report passing or failing.')
|
|
145
|
+
exit 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
testcases = Fp::JUnit.parse_file(opts[:junit])
|
|
150
|
+
rescue ArgumentError => e
|
|
151
|
+
output.error(e.message)
|
|
152
|
+
exit 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if testcases.empty?
|
|
156
|
+
output.error("No <testcase> entries found in #{opts[:junit]}")
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
base_dir = opts[:base_dir] || Dir.pwd
|
|
161
|
+
result = Fp::JUnit.bind_markers(testcases, base_dir: base_dir, default_surface: opts[:surface])
|
|
162
|
+
bindings = result[:bindings]
|
|
163
|
+
unmatched = result[:unmatched]
|
|
164
|
+
missing_surface = result[:missing_surface] || []
|
|
165
|
+
|
|
166
|
+
# Markers that pinned no surface AND no --surface default was given.
|
|
167
|
+
unless missing_surface.empty?
|
|
168
|
+
slugs = missing_surface.map { |m| m[:slug] }.uniq.join(', ')
|
|
169
|
+
output.error("--surface is required: these markers pin no surface of their own: #{slugs}")
|
|
170
|
+
output.error('Either pass --surface, or annotate the marker (e.g. fp:my_slug@api,web).')
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if bindings.empty?
|
|
175
|
+
output.error("No fp:<slug> markers found for any of the #{testcases.length} testcase(s) in #{opts[:junit]}.")
|
|
176
|
+
output.error('Ensure your test files contain fp:<slug> markers and that the JUnit report includes file paths.')
|
|
177
|
+
exit 1
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
project_id = resolve_project_id(opts[:project])
|
|
181
|
+
|
|
182
|
+
reported = []
|
|
183
|
+
skipped_unknown = []
|
|
184
|
+
api_unavailable = false
|
|
185
|
+
|
|
186
|
+
bindings.each do |binding|
|
|
187
|
+
state = state_override || binding.state
|
|
188
|
+
|
|
189
|
+
# Verify the requirement exists before reporting (skip unknown slugs
|
|
190
|
+
# with a warning rather than aborting the whole batch).
|
|
191
|
+
req_result = client.find_requirement_by_slug(project_id, binding.slug)
|
|
192
|
+
unless req_result[:ok]
|
|
193
|
+
skipped_unknown << binding
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
params = {
|
|
198
|
+
project_id: project_id,
|
|
199
|
+
slug: binding.slug,
|
|
200
|
+
surface: binding.surface,
|
|
201
|
+
file: binding.file,
|
|
202
|
+
repo: opts[:repo],
|
|
203
|
+
state: state
|
|
204
|
+
}
|
|
205
|
+
params[:pr_url] = opts[:pr] if opts[:pr]
|
|
206
|
+
params[:sha] = opts[:sha] if opts[:sha]
|
|
207
|
+
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
208
|
+
params[:ci_url] = opts[:ci_url] if opts[:ci_url]
|
|
209
|
+
params[:title] = binding.title if binding.title
|
|
210
|
+
|
|
211
|
+
report_result = client.report_evidence(params)
|
|
212
|
+
|
|
213
|
+
# Evidence API may not exist yet (#1214). API Gateway returns 403
|
|
214
|
+
# (not 404) for unmatched routes.
|
|
215
|
+
if !report_result[:ok] && [403, 404].include?(report_result[:status])
|
|
216
|
+
api_unavailable = true
|
|
217
|
+
reported << { binding: binding, state: state, recorded: false }
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
unless report_result[:ok]
|
|
222
|
+
output.error("Failed to report '#{binding.slug}' (#{binding.surface}): #{report_result[:error]}", status: report_result[:status])
|
|
223
|
+
exit 1
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
reported << { binding: binding, state: state, recorded: true }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
|
|
233
|
+
data = {
|
|
234
|
+
repo: opts[:repo],
|
|
235
|
+
reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, file: r[:binding].file, state: r[:state], recorded: r[:recorded] } },
|
|
236
|
+
skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
|
|
237
|
+
unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
output.success(data) do
|
|
241
|
+
puts "Reported evidence from #{opts[:junit]}:"
|
|
242
|
+
reported.each do |r|
|
|
243
|
+
marker = r[:recorded] ? '✓' : '•'
|
|
244
|
+
puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface} (#{r[:state]}) #{r[:binding].file}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
unless skipped_unknown.empty?
|
|
248
|
+
puts
|
|
249
|
+
puts "⚠️ Skipped #{skipped_unknown.length} marker(s) with no matching requirement in this project:"
|
|
250
|
+
skipped_unknown.each { |b| puts " - #{b.slug} → #{b.surface} (#{b.file})" }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
unless unmatched.empty?
|
|
254
|
+
puts
|
|
255
|
+
puts "ℹ️ #{unmatched.length} testcase(s) had no fp:<slug> marker and were skipped."
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if api_unavailable
|
|
259
|
+
puts
|
|
260
|
+
puts '⚠️ Evidence API not available yet. These will be recorded when #1214 is complete.'
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
115
264
|
end
|
|
116
265
|
end
|
|
117
266
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp repos set|get|list|unset
|
|
6
|
+
#
|
|
7
|
+
# Maps FeatureParity surfaces to local repo paths on THIS machine. Stored in
|
|
8
|
+
# ~/.config/fp/config.yml under `repos`, scoped by project slug. This is a
|
|
9
|
+
# per-installation convenience (not stored in the FP web app): when an agent
|
|
10
|
+
# picks up a requirement affecting, say, the `customer_android` surface, it can
|
|
11
|
+
# run `fp repos get customer_android --project stowzilla` to learn which local
|
|
12
|
+
# directory to work in. Analogous to how `brainiac projects` maps repos to names.
|
|
13
|
+
#
|
|
14
|
+
# fp repos set <surface> <path> --project <slug> [--repo org/repo]
|
|
15
|
+
# fp repos get <surface> --project <slug>
|
|
16
|
+
# fp repos list [--project <slug>]
|
|
17
|
+
# fp repos unset <surface> --project <slug>
|
|
18
|
+
class Repos < Base
|
|
19
|
+
KNOWN_FLAGS = {
|
|
20
|
+
project: :string,
|
|
21
|
+
repo: :string
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def run(args)
|
|
25
|
+
subcommand = args.shift
|
|
26
|
+
|
|
27
|
+
case subcommand
|
|
28
|
+
when 'set' then set_repo(args)
|
|
29
|
+
when 'get' then get_repo(args)
|
|
30
|
+
when 'list' then list_repos(args)
|
|
31
|
+
when 'unset' then unset_repo(args)
|
|
32
|
+
else show_usage
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def set_repo(args)
|
|
39
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
40
|
+
require_flag(opts, :project)
|
|
41
|
+
|
|
42
|
+
surface = positional[0]
|
|
43
|
+
path = positional[1]
|
|
44
|
+
|
|
45
|
+
unless surface && path
|
|
46
|
+
output.error('Usage: fp repos set <surface> <path> --project <slug> [--repo org/repo]')
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Store an absolute, tilde-expanded path so agents get a directly usable location.
|
|
51
|
+
expanded = File.expand_path(path)
|
|
52
|
+
unless File.directory?(expanded)
|
|
53
|
+
output.error("Path does not exist or is not a directory: #{expanded}")
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
entry = Config.set_repo(opts[:project], surface, path: expanded, repo: opts[:repo])
|
|
58
|
+
|
|
59
|
+
output.success({ project: opts[:project], surface: surface }.merge(entry)) do
|
|
60
|
+
puts "Mapped surface '#{surface}' (project '#{opts[:project]}') to:"
|
|
61
|
+
puts " path: #{entry['path']}"
|
|
62
|
+
puts " repo: #{entry['repo']}" if entry['repo']
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get_repo(args)
|
|
67
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
68
|
+
require_flag(opts, :project)
|
|
69
|
+
|
|
70
|
+
surface = positional[0]
|
|
71
|
+
unless surface
|
|
72
|
+
output.error('Usage: fp repos get <surface> --project <slug>')
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
entry = Config.repo_for_surface(opts[:project], surface)
|
|
77
|
+
|
|
78
|
+
unless entry
|
|
79
|
+
output.error("No repo mapped for surface '#{surface}' in project '#{opts[:project]}'.")
|
|
80
|
+
output.error("Map one with: fp repos set #{surface} /path/to/repo --project #{opts[:project]}")
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
output.success({ project: opts[:project], surface: surface }.merge(entry)) do
|
|
85
|
+
# Print just the path so this composes in shell: cd "$(fp repos get ... )"
|
|
86
|
+
puts entry['path']
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list_repos(args)
|
|
91
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
92
|
+
|
|
93
|
+
repos_map = if opts[:project]
|
|
94
|
+
{ opts[:project] => Config.repos_for_project(opts[:project]) }
|
|
95
|
+
else
|
|
96
|
+
Config.repos_map
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Drop empty projects (e.g. an explicit --project with no mappings yet).
|
|
100
|
+
repos_map = repos_map.reject { |_project, surfaces| surfaces.nil? || surfaces.empty? }
|
|
101
|
+
|
|
102
|
+
output.success({ repos: repos_map }) do
|
|
103
|
+
if repos_map.empty?
|
|
104
|
+
if opts[:project]
|
|
105
|
+
puts "No repos mapped for project '#{opts[:project]}'."
|
|
106
|
+
else
|
|
107
|
+
puts 'No repos mapped on this machine.'
|
|
108
|
+
end
|
|
109
|
+
puts
|
|
110
|
+
puts 'Map one with:'
|
|
111
|
+
puts ' fp repos set <surface> /path/to/repo --project <slug> [--repo org/repo]'
|
|
112
|
+
else
|
|
113
|
+
repos_map.each do |project, surfaces|
|
|
114
|
+
puts "#{project}:"
|
|
115
|
+
surfaces.sort.each do |surface, entry|
|
|
116
|
+
repo = entry['repo'] ? " (#{entry['repo']})" : ''
|
|
117
|
+
puts " #{surface} -> #{entry['path']}#{repo}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unset_repo(args)
|
|
125
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
126
|
+
require_flag(opts, :project)
|
|
127
|
+
|
|
128
|
+
surface = positional[0]
|
|
129
|
+
unless surface
|
|
130
|
+
output.error('Usage: fp repos unset <surface> --project <slug>')
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
removed = Config.unset_repo(opts[:project], surface)
|
|
135
|
+
|
|
136
|
+
unless removed
|
|
137
|
+
output.error("No repo mapped for surface '#{surface}' in project '#{opts[:project]}'.")
|
|
138
|
+
exit 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
output.success({ project: opts[:project], surface: surface, removed: true }) do
|
|
142
|
+
puts "Unmapped surface '#{surface}' from project '#{opts[:project]}'."
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def show_usage
|
|
147
|
+
output.error('Usage: fp repos <set|get|list|unset>')
|
|
148
|
+
puts
|
|
149
|
+
puts 'Commands:'
|
|
150
|
+
puts ' fp repos set <surface> <path> --project <slug> [--repo org/repo]'
|
|
151
|
+
puts ' Map a surface to a local repo path'
|
|
152
|
+
puts ' fp repos get <surface> --project <slug>'
|
|
153
|
+
puts ' Print the local path for a surface'
|
|
154
|
+
puts ' fp repos list [--project <slug>] List surface -> path mappings'
|
|
155
|
+
puts ' fp repos unset <surface> --project <slug>'
|
|
156
|
+
puts ' Remove a mapping'
|
|
157
|
+
puts
|
|
158
|
+
puts 'Mappings are local to this machine (stored in ~/.config/fp/config.yml).'
|
|
159
|
+
puts
|
|
160
|
+
puts 'Examples:'
|
|
161
|
+
puts ' fp repos set customer_android ~/code/customer-android \\'
|
|
162
|
+
puts ' --project stowzilla --repo stowzilla/customer-android'
|
|
163
|
+
puts ' fp repos get customer_android --project stowzilla'
|
|
164
|
+
puts ' cd "$(fp repos get customer_android --project stowzilla)"'
|
|
165
|
+
exit 1
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/fp/commands/setup.rb
CHANGED
|
@@ -62,7 +62,7 @@ module Fp
|
|
|
62
62
|
domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
|
|
63
63
|
|
|
64
64
|
puts "Get your API key from: https://#{domain}"
|
|
65
|
-
puts "(
|
|
65
|
+
puts "(Project Settings → API Keys → Mint Key)"
|
|
66
66
|
puts
|
|
67
67
|
print "Paste your API key (starts with fp_): "
|
|
68
68
|
$stdout.flush
|
|
@@ -107,7 +107,7 @@ module Fp
|
|
|
107
107
|
|
|
108
108
|
def validate_key!(api_key, api_url)
|
|
109
109
|
url = (api_url || Config::DEFAULT_API_URL).chomp('/')
|
|
110
|
-
uri = URI.parse("#{url}/api/
|
|
110
|
+
uri = URI.parse("#{url}/api/projects")
|
|
111
111
|
|
|
112
112
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
113
|
http.use_ssl = uri.scheme == 'https'
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Fp
|
|
|
15
15
|
exit 1
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
result = client.find_requirement_by_slug(
|
|
18
|
+
project_id = resolve_project_id(opts[:project])
|
|
19
|
+
result = client.find_requirement_by_slug(project_id, slug)
|
|
20
20
|
|
|
21
21
|
unless result[:ok]
|
|
22
22
|
output.error(result[:error], status: result[:status])
|
|
@@ -38,6 +38,7 @@ module Fp
|
|
|
38
38
|
puts
|
|
39
39
|
puts "Slug: #{req['slug']}"
|
|
40
40
|
puts "Status: #{req['status']}"
|
|
41
|
+
puts "Category: #{req['category'] || '(none)'}"
|
|
41
42
|
puts "Parent: #{req['parent_id'] || '(none)'}"
|
|
42
43
|
puts
|
|
43
44
|
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -8,21 +8,21 @@ module Fp
|
|
|
8
8
|
opts, = parse_flags(args)
|
|
9
9
|
require_flag(opts, :project)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
result = client.
|
|
11
|
+
project_id = resolve_project_id(opts[:project])
|
|
12
|
+
result = client.list_surfaces(project_id)
|
|
13
13
|
|
|
14
14
|
unless result[:ok]
|
|
15
15
|
output.error(result[:error], status: result[:status])
|
|
16
16
|
exit 1
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
surfaces =
|
|
19
|
+
surface_records = result[:data]['surfaces'] || []
|
|
20
|
+
surfaces = surface_records.map { |s| s['key'] }
|
|
21
21
|
|
|
22
22
|
output.success({ surfaces: surfaces, project: opts[:project] }) do
|
|
23
23
|
if surfaces.empty?
|
|
24
24
|
puts "No surfaces configured for project '#{opts[:project]}'."
|
|
25
|
-
puts 'Configure
|
|
25
|
+
puts 'Configure surfaces in the web app.'
|
|
26
26
|
else
|
|
27
27
|
puts "Surfaces for '#{opts[:project]}':"
|
|
28
28
|
surfaces.each { |s| puts " - #{s}" }
|
data/lib/fp/commands.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative 'commands/report'
|
|
|
10
10
|
require_relative 'commands/matrix'
|
|
11
11
|
require_relative 'commands/profile'
|
|
12
12
|
require_relative 'commands/config_cmd'
|
|
13
|
+
require_relative 'commands/repos'
|
|
13
14
|
require_relative 'commands/setup'
|
|
14
15
|
require_relative 'commands/version'
|
|
15
16
|
require_relative 'commands/help'
|
data/lib/fp/config.rb
CHANGED
|
@@ -15,9 +15,15 @@ module Fp
|
|
|
15
15
|
#
|
|
16
16
|
# API key resolution priority:
|
|
17
17
|
# 1. FP_API_KEY env var
|
|
18
|
-
# 2. Profile api_key (--profile flag > FP_PROFILE env var)
|
|
18
|
+
# 2. Profile api_key (--profile flag > FP_PROFILE env var > "default" profile)
|
|
19
|
+
#
|
|
20
|
+
# Profile name resolution priority:
|
|
21
|
+
# 1. --profile CLI flag
|
|
22
|
+
# 2. FP_PROFILE env var
|
|
23
|
+
# 3. A profile literally named "default" (if one exists in config.yml)
|
|
19
24
|
class Config
|
|
20
25
|
DEFAULT_API_URL = 'https://api.featureparity.dev'
|
|
26
|
+
DEFAULT_PROFILE_NAME = 'default'
|
|
21
27
|
CONFIG_DIR = File.expand_path('~/.config/fp')
|
|
22
28
|
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
|
|
23
29
|
|
|
@@ -99,6 +105,72 @@ module Fp
|
|
|
99
105
|
load_profiles.key?(name)
|
|
100
106
|
end
|
|
101
107
|
|
|
108
|
+
# --- Repo → surface mappings (per-machine, local) ---
|
|
109
|
+
#
|
|
110
|
+
# Stored under a top-level `repos` key in config.yml, scoped by project slug:
|
|
111
|
+
#
|
|
112
|
+
# repos:
|
|
113
|
+
# stowzilla:
|
|
114
|
+
# customer_android:
|
|
115
|
+
# path: /home/dev/code/customer-android
|
|
116
|
+
# repo: stowzilla/customer-android
|
|
117
|
+
#
|
|
118
|
+
# This is intentionally local (per fp installation) — it maps FeatureParity
|
|
119
|
+
# surfaces to wherever the code lives on THIS machine, so an agent working a
|
|
120
|
+
# requirement for a given surface knows which directory to open. Analogous to
|
|
121
|
+
# how `brainiac projects` maps repos to project names.
|
|
122
|
+
|
|
123
|
+
# Return the full repos mapping ({ project => { surface => {path, repo} } }).
|
|
124
|
+
def repos_map
|
|
125
|
+
config = load_config
|
|
126
|
+
config['repos'] || {}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Return the surface => {path, repo} mapping for a single project.
|
|
130
|
+
def repos_for_project(project)
|
|
131
|
+
repos_map[project.to_s] || {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Look up the mapping for a single surface within a project.
|
|
135
|
+
# Returns a hash like { 'path' => ..., 'repo' => ... } or nil if unmapped.
|
|
136
|
+
def repo_for_surface(project, surface)
|
|
137
|
+
repos_for_project(project)[surface.to_s]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Map a surface (within a project) to a local path and optional org/repo.
|
|
141
|
+
def set_repo(project, surface, path:, repo: nil)
|
|
142
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
143
|
+
ensure_config_file!
|
|
144
|
+
|
|
145
|
+
config = load_config
|
|
146
|
+
config['repos'] ||= {}
|
|
147
|
+
config['repos'][project.to_s] ||= {}
|
|
148
|
+
|
|
149
|
+
entry = { 'path' => path }
|
|
150
|
+
entry['repo'] = repo if repo && !repo.to_s.empty?
|
|
151
|
+
config['repos'][project.to_s][surface.to_s] = entry
|
|
152
|
+
|
|
153
|
+
write_config!(config)
|
|
154
|
+
entry
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Remove a surface mapping within a project. Prunes empty project hashes.
|
|
158
|
+
# Returns true if something was removed, false otherwise.
|
|
159
|
+
def unset_repo(project, surface)
|
|
160
|
+
config = load_config
|
|
161
|
+
return false unless config['repos'].is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
project_map = config['repos'][project.to_s]
|
|
164
|
+
return false unless project_map.is_a?(Hash) && project_map.key?(surface.to_s)
|
|
165
|
+
|
|
166
|
+
project_map.delete(surface.to_s)
|
|
167
|
+
config['repos'].delete(project.to_s) if project_map.empty?
|
|
168
|
+
config.delete('repos') if config['repos'].empty?
|
|
169
|
+
|
|
170
|
+
write_config!(config)
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
102
174
|
private
|
|
103
175
|
|
|
104
176
|
def ensure_config_file!
|
|
@@ -117,7 +189,16 @@ module Fp
|
|
|
117
189
|
private
|
|
118
190
|
|
|
119
191
|
def resolve_profile_name(explicit_profile)
|
|
120
|
-
|
|
192
|
+
# 1. Explicit --profile flag 2. FP_PROFILE env var
|
|
193
|
+
name = explicit_profile || ENV['FP_PROFILE']
|
|
194
|
+
return name if name && !name.empty?
|
|
195
|
+
|
|
196
|
+
# 3. Fall back to a profile literally named "default", if one exists.
|
|
197
|
+
# This mirrors AWS/git/gcloud, which all consult a default profile when
|
|
198
|
+
# nothing else is specified.
|
|
199
|
+
return DEFAULT_PROFILE_NAME if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
|
|
200
|
+
|
|
201
|
+
nil
|
|
121
202
|
end
|
|
122
203
|
|
|
123
204
|
def resolve_api_key
|