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.
@@ -2,9 +2,18 @@
2
2
 
3
3
  module Fp
4
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
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
- workspace_id = resolve_workspace_id(opts[:project])
59
+ project_id = resolve_project_id(opts[:project])
47
60
 
48
- # Find the requirement by slug
49
- req_result = client.find_requirement_by_slug(workspace_id, 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
- requirement_id: requirement['id'],
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[:example_title] = opts[:title] if opts[:title]
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
- if !result[:ok] && result[:status] == 404
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
@@ -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 "(Workspace Settings → API Keys → Mint Key)"
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/workspaces")
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'
@@ -15,8 +15,8 @@ module Fp
15
15
  exit 1
16
16
  end
17
17
 
18
- workspace_id = resolve_workspace_id(opts[:project])
19
- result = client.find_requirement_by_slug(workspace_id, 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
 
@@ -8,21 +8,21 @@ module Fp
8
8
  opts, = parse_flags(args)
9
9
  require_flag(opts, :project)
10
10
 
11
- workspace_id = resolve_workspace_id(opts[:project])
12
- result = client.get_project(workspace_id)
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
- workspace = result[:data]['workspace']
20
- surfaces = workspace['available_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 available_surfaces in the web app.'
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
- explicit_profile || ENV['FP_PROFILE']
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