featureparity 0.0.6 → 0.0.8
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 +8 -2
- data/lib/fp/client.rb +62 -16
- data/lib/fp/commands/base.rb +49 -0
- data/lib/fp/commands/check.rb +216 -0
- data/lib/fp/commands/ci_report.rb +14 -25
- data/lib/fp/commands/export.rb +144 -0
- data/lib/fp/commands/help.rb +93 -4
- data/lib/fp/commands/history.rb +188 -0
- data/lib/fp/commands/init.rb +397 -0
- data/lib/fp/commands/list.rb +118 -33
- data/lib/fp/commands/matrix.rb +82 -42
- data/lib/fp/commands/propose.rb +10 -2
- data/lib/fp/commands/propose_change.rb +181 -0
- data/lib/fp/commands/report.rb +13 -6
- data/lib/fp/commands/repos.rb +3 -3
- data/lib/fp/commands/show.rb +8 -2
- data/lib/fp/commands/surfaces.rb +2 -2
- data/lib/fp/commands.rb +5 -0
- data/lib/fp/junit.rb +13 -4
- data/lib/fp/parity.rb +81 -0
- data/lib/fp/project_config.rb +137 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +2 -0
- data/skills/fp/SKILL.md +124 -3
- data/skills/fp/references/cli.md +216 -10
- metadata +9 -2
data/lib/fp/commands/matrix.rb
CHANGED
|
@@ -2,75 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp matrix --project
|
|
6
|
-
#
|
|
5
|
+
# fp matrix [--project X] [--surface api] [--level covered] [--csv]
|
|
6
|
+
#
|
|
7
|
+
# The parity matrix: active requirements down the side, surfaces across the top,
|
|
8
|
+
# each cell showing the state of the evidence filed for that pair.
|
|
9
|
+
#
|
|
10
|
+
# This used to be computed here from `required_surfaces` alone and never read
|
|
11
|
+
# Evidence at all — every required cell rendered as "no evidence yet" and the legend
|
|
12
|
+
# advertised a ✅ the code could not produce. The grid now comes from the API's
|
|
13
|
+
# parity report, which is the same computation the CI gate blocks on, so what you
|
|
14
|
+
# see here is what CI will decide.
|
|
7
15
|
class Matrix < Base
|
|
8
16
|
KNOWN_FLAGS = {
|
|
9
17
|
project: :string,
|
|
18
|
+
surface: :string,
|
|
19
|
+
level: :string,
|
|
10
20
|
csv: :boolean
|
|
11
21
|
}.freeze
|
|
12
22
|
|
|
13
23
|
def run(args)
|
|
14
24
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
15
|
-
|
|
25
|
+
project_config = ProjectConfig.discover
|
|
26
|
+
project_slug = require_project!(opts, project_config)
|
|
27
|
+
project_id = resolve_project_id(project_slug)
|
|
16
28
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
surfaces = (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
|
|
27
|
-
|
|
28
|
-
# Get active requirements
|
|
29
|
-
req_result = client.list_requirements(project_id: project_id)
|
|
30
|
-
unless req_result[:ok]
|
|
31
|
-
output.error(req_result[:error], status: req_result[:status])
|
|
29
|
+
result = client.get_parity(
|
|
30
|
+
project_id,
|
|
31
|
+
level: opts[:level] || project_config.enforcement,
|
|
32
|
+
surface: opts[:surface] || project_config.surface
|
|
33
|
+
)
|
|
34
|
+
unless result[:ok]
|
|
35
|
+
output.error(result[:error], status: result[:status])
|
|
32
36
|
exit 1
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
report = result[:data]['parity'] || {}
|
|
40
|
+
surfaces = report['surfaces'] || []
|
|
41
|
+
requirements = report['requirements'] || []
|
|
36
42
|
|
|
37
43
|
if requirements.empty?
|
|
38
|
-
output.success(
|
|
44
|
+
output.success(report.merge('project' => project_slug)) do
|
|
39
45
|
puts 'No active requirements. The matrix is empty.'
|
|
46
|
+
puts "Surfaces: #{surfaces.join(', ')}" unless surfaces.empty?
|
|
40
47
|
end
|
|
41
48
|
return
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
# Build matrix data
|
|
45
|
-
# Columns: SLUG, TITLE, then one column per surface
|
|
46
51
|
headers = %w[SLUG TITLE] + surfaces
|
|
47
52
|
|
|
48
|
-
rows = requirements.map do |req|
|
|
49
|
-
required = req['required_surfaces'] || []
|
|
50
|
-
# Evidence status per surface (placeholder until Evidence API exists)
|
|
51
|
-
surface_cells = surfaces.map do |s|
|
|
52
|
-
if required.include?(s)
|
|
53
|
-
'⬜' # Required but no evidence yet
|
|
54
|
-
else
|
|
55
|
-
'-' # Not required
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
[req['slug'], truncate(req['title'], 30)] + surface_cells
|
|
59
|
-
end
|
|
60
|
-
|
|
61
53
|
if opts[:csv]
|
|
62
|
-
|
|
54
|
+
# CSV consumers want the state name, not a glyph they'd have to decode.
|
|
55
|
+
output.csv(headers, requirements.map { |req| build_row(req, surfaces, glyphs: false) })
|
|
63
56
|
else
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
rows = requirements.map { |req| build_row(req, surfaces) }
|
|
58
|
+
render_table(report, project_slug, headers, rows)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_row(requirement, surfaces, glyphs: true)
|
|
65
|
+
by_surface = (requirement['cells'] || []).to_h { |cell| [cell['surface'], cell['state']] }
|
|
66
|
+
|
|
67
|
+
cells = surfaces.map do |key|
|
|
68
|
+
state = by_surface[key]
|
|
69
|
+
if state.nil?
|
|
70
|
+
glyphs ? Parity::NOT_REQUIRED : 'not_required'
|
|
71
|
+
else
|
|
72
|
+
glyphs ? Parity.glyph(state) : state
|
|
70
73
|
end
|
|
71
74
|
end
|
|
75
|
+
|
|
76
|
+
[slug_cell(requirement, glyphs: glyphs), truncate(requirement['title'], 30)] + cells
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A requirement awaiting review of a proposed change is still measured, so it must
|
|
80
|
+
# still appear — but it reads as an ordinary row unless we say so, and its evidence
|
|
81
|
+
# has just been marked suspect. CSV gets a column-safe suffix rather than a glyph.
|
|
82
|
+
def slug_cell(requirement, glyphs: true)
|
|
83
|
+
return requirement['slug'] unless Parity.row_marker(requirement)
|
|
84
|
+
|
|
85
|
+
glyphs ? "#{requirement['slug']} #{Parity::UNDER_REVIEW}" : "#{requirement['slug']} (under review)"
|
|
72
86
|
end
|
|
73
87
|
|
|
88
|
+
def render_table(report, project_slug, headers, rows)
|
|
89
|
+
output.success(report.merge('project' => project_slug)) do
|
|
90
|
+
scope = report['surface'] ? " (surface: #{report['surface']})" : ''
|
|
91
|
+
puts "Parity Matrix: #{project_slug}#{scope}"
|
|
92
|
+
puts
|
|
93
|
+
output.table(headers, rows)
|
|
94
|
+
puts
|
|
95
|
+
legend = Parity::LEGEND
|
|
96
|
+
pending = report['pending_reviews'] || []
|
|
97
|
+
legend += " #{Parity::UNDER_REVIEW_LEGEND}" unless pending.empty?
|
|
98
|
+
puts legend
|
|
99
|
+
|
|
100
|
+
summary = report['summary'] || {}
|
|
101
|
+
counts = Parity.count_labels(summary)
|
|
102
|
+
puts counts.join(' ') unless counts.empty?
|
|
103
|
+
|
|
104
|
+
unless pending.empty?
|
|
105
|
+
puts "#{pending.size} awaiting review (#{Parity.pending_summary(pending)}) — fp history <slug>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The matrix is a report, not a gate — but say which way the gate would go, so
|
|
109
|
+
# nobody has to run `fp check` separately to find out.
|
|
110
|
+
verdict = report['passing'] ? 'passing' : "FAILING (#{summary['violations']} violation(s))"
|
|
111
|
+
puts "Gate at level '#{report['level']}': #{verdict}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
74
114
|
end
|
|
75
115
|
end
|
|
76
116
|
end
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -16,7 +16,8 @@ module Fp
|
|
|
16
16
|
category: :string,
|
|
17
17
|
source_type: :string,
|
|
18
18
|
source_url: :string,
|
|
19
|
-
source_context: :string
|
|
19
|
+
source_context: :string,
|
|
20
|
+
tags: :string
|
|
20
21
|
}.freeze
|
|
21
22
|
|
|
22
23
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
@@ -25,7 +26,7 @@ module Fp
|
|
|
25
26
|
def run(args)
|
|
26
27
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
require_project!(opts)
|
|
29
30
|
require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
|
|
30
31
|
require_flag(opts, :name, '--name is required (human-readable title)')
|
|
31
32
|
|
|
@@ -62,6 +63,12 @@ module Fp
|
|
|
62
63
|
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
63
64
|
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
64
65
|
|
|
66
|
+
# Tags are comma-separated. Send the raw list — the API normalizes
|
|
67
|
+
# (downcase/trim/dedupe) and validates.
|
|
68
|
+
if opts[:tags]
|
|
69
|
+
params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
|
|
70
|
+
end
|
|
71
|
+
|
|
65
72
|
# Resolve parent slug to parent_id if provided
|
|
66
73
|
if opts[:parent]
|
|
67
74
|
parent_id = resolve_parent_id(project_id, opts[:parent])
|
|
@@ -85,6 +92,7 @@ module Fp
|
|
|
85
92
|
puts " Title: #{requirement['title']}"
|
|
86
93
|
puts " Status: #{requirement['status']}"
|
|
87
94
|
puts " Category: #{requirement['category'] || '(none)'}"
|
|
95
|
+
puts " Tags: #{(requirement['tags'] || []).join(', ')}" unless (requirement['tags'] || []).empty?
|
|
88
96
|
puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
|
|
89
97
|
puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
|
|
90
98
|
puts
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp propose-change <slug>` — propose a modification to an existing requirement, or
|
|
6
|
+
# propose deleting it with --removal.
|
|
7
|
+
#
|
|
8
|
+
# Distinct from `fp propose`, which creates a brand new requirement. This one asks to
|
|
9
|
+
# change one that already exists, and the API records what it was, what is being
|
|
10
|
+
# asked for, and what prompted the ask. The requirement drops back to draft until a
|
|
11
|
+
# human reviews the diff — agents never approve their own changes.
|
|
12
|
+
class ProposeChange < Base
|
|
13
|
+
KNOWN_FLAGS = {
|
|
14
|
+
project: :string, name: :string, why: :string, acceptance: :string,
|
|
15
|
+
required: :string, category: :string, parent: :string, slug: :string,
|
|
16
|
+
reason: :string, source_type: :string, source_url: :string, source_context: :string,
|
|
17
|
+
removal: :boolean
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
21
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
22
|
+
|
|
23
|
+
# Flags that describe a proposed new value, mapped to their API field.
|
|
24
|
+
FIELD_FLAGS = {
|
|
25
|
+
name: :title,
|
|
26
|
+
why: :why,
|
|
27
|
+
acceptance: :acceptance,
|
|
28
|
+
category: :category,
|
|
29
|
+
slug: :slug
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def run(args)
|
|
33
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
34
|
+
require_project!(opts)
|
|
35
|
+
|
|
36
|
+
slug = positional.first
|
|
37
|
+
unless slug
|
|
38
|
+
output.error('Requirement slug is required. Usage: fp propose-change <slug> [--project <project>] [flags]')
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
validate_enums!(opts)
|
|
43
|
+
|
|
44
|
+
project_id = resolve_project_id(opts[:project])
|
|
45
|
+
requirement = find_requirement!(project_id, slug)
|
|
46
|
+
|
|
47
|
+
params = build_params(opts, project_id)
|
|
48
|
+
removal = opts[:removal] ? true : false
|
|
49
|
+
|
|
50
|
+
if !removal && params.keys.none? { |k| proposed_field?(k) }
|
|
51
|
+
output.error('Nothing to propose. Pass at least one of --name/--why/--acceptance/' \
|
|
52
|
+
'--required/--category/--parent, or --removal to propose deletion.')
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result = client.propose_requirement_change(requirement['id'], params)
|
|
57
|
+
unless result[:ok]
|
|
58
|
+
output.error(result[:error], status: result[:status])
|
|
59
|
+
exit 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
print_result(result[:data], slug: slug, removal: removal)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def validate_enums!(opts)
|
|
68
|
+
if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
|
|
69
|
+
output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
|
|
70
|
+
exit 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return unless opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
|
|
74
|
+
|
|
75
|
+
output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_requirement!(project_id, slug)
|
|
80
|
+
result = client.find_requirement_by_slug(project_id, slug)
|
|
81
|
+
unless result[:ok]
|
|
82
|
+
output.error(result[:error], status: result[:status])
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
result[:data]['requirement']
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_params(opts, project_id)
|
|
89
|
+
params = {}
|
|
90
|
+
|
|
91
|
+
FIELD_FLAGS.each { |flag, field| params[field] = opts[flag] if opts[flag] }
|
|
92
|
+
params[:required_surfaces] = opts[:required].split(',').map(&:strip) if opts[:required]
|
|
93
|
+
params[:parent_id] = resolve_parent_id(project_id, opts[:parent]) if opts[:parent]
|
|
94
|
+
|
|
95
|
+
params[:removal] = true if opts[:removal]
|
|
96
|
+
params[:reason] = opts[:reason] if opts[:reason]
|
|
97
|
+
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
98
|
+
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
99
|
+
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
100
|
+
|
|
101
|
+
params
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def proposed_field?(key)
|
|
105
|
+
%i[title why acceptance category slug required_surfaces parent_id].include?(key)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_parent_id(project_id, parent_slug)
|
|
109
|
+
result = client.list_requirements(project_id: project_id)
|
|
110
|
+
unless result[:ok]
|
|
111
|
+
output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
parent = (result[:data]['requirements'] || []).find { |r| r['slug'] == parent_slug }
|
|
116
|
+
unless parent
|
|
117
|
+
output.error("Parent requirement '#{parent_slug}' not found in this project")
|
|
118
|
+
exit 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parent['id']
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def print_result(data, slug:, removal:)
|
|
125
|
+
change = data['change'] || {}
|
|
126
|
+
requirement = data['requirement'] || {}
|
|
127
|
+
|
|
128
|
+
output.success(data) do
|
|
129
|
+
if removal
|
|
130
|
+
puts "Proposed DELETING requirement '#{slug}'."
|
|
131
|
+
else
|
|
132
|
+
puts "Proposed a change to requirement '#{slug}'."
|
|
133
|
+
end
|
|
134
|
+
puts
|
|
135
|
+
|
|
136
|
+
print_diff(change['changes'])
|
|
137
|
+
print_source(change)
|
|
138
|
+
|
|
139
|
+
puts " Status: #{requirement['status']}"
|
|
140
|
+
puts " Change: #{change['id']}"
|
|
141
|
+
puts
|
|
142
|
+
puts '⚠️ This requirement is back in DRAFT pending human review.'
|
|
143
|
+
puts " Run `fp history #{slug} --project <project>` to see the full diff."
|
|
144
|
+
puts ' A human must approve or reject it in the web app. Agents cannot review changes.'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def print_diff(changes)
|
|
149
|
+
return if changes.nil? || changes.empty?
|
|
150
|
+
|
|
151
|
+
puts 'Proposed changes:'
|
|
152
|
+
changes.each do |field, change|
|
|
153
|
+
puts " #{field}:"
|
|
154
|
+
puts " - #{format_value(change['from'])}"
|
|
155
|
+
puts " + #{format_value(change['to'])}"
|
|
156
|
+
end
|
|
157
|
+
puts
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def print_source(change)
|
|
161
|
+
source = change['source'] || {}
|
|
162
|
+
return if source.empty? && change['reason'].nil?
|
|
163
|
+
|
|
164
|
+
puts 'Prompted by:'
|
|
165
|
+
puts " Reason: #{change['reason']}" if change['reason']
|
|
166
|
+
puts " Source: #{source['type']}" if source['type']
|
|
167
|
+
puts " URL: #{source['url']}" if source['url']
|
|
168
|
+
puts " Context: #{source['context']}" if source['context']
|
|
169
|
+
puts
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def format_value(value)
|
|
173
|
+
case value
|
|
174
|
+
when nil then '(none)'
|
|
175
|
+
when Array then value.empty? ? '(none)' : value.join(', ')
|
|
176
|
+
else value.to_s
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Fp
|
|
|
19
19
|
project: :string,
|
|
20
20
|
surface: :string,
|
|
21
21
|
file: :string,
|
|
22
|
+
line: :string,
|
|
22
23
|
repo: :string,
|
|
23
24
|
pr: :string,
|
|
24
25
|
sha: :string,
|
|
@@ -43,7 +44,7 @@ module Fp
|
|
|
43
44
|
exit 1
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
require_project!(opts)
|
|
47
48
|
require_flag(opts, :surface)
|
|
48
49
|
require_flag(opts, :file)
|
|
49
50
|
require_flag(opts, :repo)
|
|
@@ -79,6 +80,7 @@ module Fp
|
|
|
79
80
|
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
80
81
|
params[:ci_url] = opts[:ci_url] if opts[:ci_url]
|
|
81
82
|
params[:title] = opts[:title] if opts[:title]
|
|
83
|
+
params[:line] = opts[:line] if opts[:line]
|
|
82
84
|
|
|
83
85
|
# Try to report evidence
|
|
84
86
|
result = client.report_evidence(params)
|
|
@@ -89,7 +91,7 @@ module Fp
|
|
|
89
91
|
output.success(params, summary: nil) do
|
|
90
92
|
puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
|
|
91
93
|
puts " State: #{state}"
|
|
92
|
-
puts " File: #{opts[:file]}"
|
|
94
|
+
puts " File: #{opts[:file]}#{opts[:line] ? ":#{opts[:line]}" : ''}"
|
|
93
95
|
puts " Repo: #{opts[:repo]}"
|
|
94
96
|
puts " PR: #{opts[:pr]}" if opts[:pr]
|
|
95
97
|
puts " SHA: #{opts[:sha]}" if opts[:sha]
|
|
@@ -110,10 +112,13 @@ module Fp
|
|
|
110
112
|
output.success(result[:data]) do
|
|
111
113
|
puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
|
|
112
114
|
puts " State: #{state}"
|
|
113
|
-
puts " File: #{opts[:file]}"
|
|
115
|
+
puts " File: #{opts[:file]}#{opts[:line] ? ":#{opts[:line]}" : ''}"
|
|
114
116
|
if opts[:pr]
|
|
115
117
|
puts " PR: #{opts[:pr]}"
|
|
116
118
|
end
|
|
119
|
+
if opts[:sha]
|
|
120
|
+
puts " SHA: #{opts[:sha]}"
|
|
121
|
+
end
|
|
117
122
|
if opts[:work_item]
|
|
118
123
|
puts " Work: #{opts[:work_item]}"
|
|
119
124
|
end
|
|
@@ -130,7 +135,7 @@ module Fp
|
|
|
130
135
|
|
|
131
136
|
# Batch-upload evidence from a JUnit XML report.
|
|
132
137
|
def run_junit(opts)
|
|
133
|
-
|
|
138
|
+
require_project!(opts)
|
|
134
139
|
require_flag(opts, :repo)
|
|
135
140
|
# --surface is the default for markers that don't pin their own surface
|
|
136
141
|
# (e.g. `fp:slug@api,web`). It's only strictly required when at least one
|
|
@@ -202,6 +207,7 @@ module Fp
|
|
|
202
207
|
repo: opts[:repo],
|
|
203
208
|
state: state
|
|
204
209
|
}
|
|
210
|
+
params[:line] = binding.line if binding.line
|
|
205
211
|
params[:pr_url] = opts[:pr] if opts[:pr]
|
|
206
212
|
params[:sha] = opts[:sha] if opts[:sha]
|
|
207
213
|
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
@@ -232,7 +238,7 @@ module Fp
|
|
|
232
238
|
def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
|
|
233
239
|
data = {
|
|
234
240
|
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] } },
|
|
241
|
+
reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded] } },
|
|
236
242
|
skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
|
|
237
243
|
unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
|
|
238
244
|
}
|
|
@@ -241,7 +247,8 @@ module Fp
|
|
|
241
247
|
puts "Reported evidence from #{opts[:junit]}:"
|
|
242
248
|
reported.each do |r|
|
|
243
249
|
marker = r[:recorded] ? '✓' : '•'
|
|
244
|
-
|
|
250
|
+
loc = r[:binding].line ? "#{r[:binding].file}:#{r[:binding].line}" : r[:binding].file
|
|
251
|
+
puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface} (#{r[:state]}) #{loc}"
|
|
245
252
|
end
|
|
246
253
|
|
|
247
254
|
unless skipped_unknown.empty?
|
data/lib/fp/commands/repos.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Fp
|
|
|
37
37
|
|
|
38
38
|
def set_repo(args)
|
|
39
39
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
40
|
-
|
|
40
|
+
require_project!(opts)
|
|
41
41
|
|
|
42
42
|
surface = positional[0]
|
|
43
43
|
path = positional[1]
|
|
@@ -65,7 +65,7 @@ module Fp
|
|
|
65
65
|
|
|
66
66
|
def get_repo(args)
|
|
67
67
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
68
|
-
|
|
68
|
+
require_project!(opts)
|
|
69
69
|
|
|
70
70
|
surface = positional[0]
|
|
71
71
|
unless surface
|
|
@@ -123,7 +123,7 @@ module Fp
|
|
|
123
123
|
|
|
124
124
|
def unset_repo(args)
|
|
125
125
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
126
|
-
|
|
126
|
+
require_project!(opts)
|
|
127
127
|
|
|
128
128
|
surface = positional[0]
|
|
129
129
|
unless surface
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Fp
|
|
|
7
7
|
class Show < Base
|
|
8
8
|
def run(args)
|
|
9
9
|
opts, positional = parse_flags(args)
|
|
10
|
-
|
|
10
|
+
require_project!(opts)
|
|
11
11
|
|
|
12
12
|
slug = positional.first
|
|
13
13
|
unless slug
|
|
@@ -67,6 +67,8 @@ module Fp
|
|
|
67
67
|
puts "Slug: #{req['slug']}"
|
|
68
68
|
puts "Status: #{req['status']}"
|
|
69
69
|
puts "Category: #{req['category'] || '(none)'}"
|
|
70
|
+
tags = req['tags'] || []
|
|
71
|
+
puts "Tags: #{tags.empty? ? '(none)' : tags.join(', ')}"
|
|
70
72
|
puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
|
|
71
73
|
puts
|
|
72
74
|
|
|
@@ -105,8 +107,12 @@ module Fp
|
|
|
105
107
|
puts " #{surface}:"
|
|
106
108
|
records.each do |e|
|
|
107
109
|
puts " Status: #{e['status']}"
|
|
108
|
-
|
|
110
|
+
if e['source_file']
|
|
111
|
+
loc = e['source_line'] ? "#{e['source_file']}:#{e['source_line']}" : e['source_file']
|
|
112
|
+
puts " File: #{loc}"
|
|
113
|
+
end
|
|
109
114
|
puts " Repo: #{e['repo']}" if e['repo']
|
|
115
|
+
puts " Commit: #{e['commit_sha']}" if e['commit_sha']
|
|
110
116
|
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
111
117
|
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
112
118
|
puts " CI: #{e['ci_url']}" if e['ci_url']
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Fp
|
|
|
35
35
|
|
|
36
36
|
def list(args)
|
|
37
37
|
opts, = parse_flags(args)
|
|
38
|
-
|
|
38
|
+
require_project!(opts)
|
|
39
39
|
|
|
40
40
|
project_id = resolve_project_id(opts[:project])
|
|
41
41
|
result = client.list_surfaces(project_id)
|
|
@@ -61,7 +61,7 @@ module Fp
|
|
|
61
61
|
|
|
62
62
|
def add(args)
|
|
63
63
|
opts, positional = parse_flags(args, ADD_FLAGS)
|
|
64
|
-
|
|
64
|
+
require_project!(opts)
|
|
65
65
|
|
|
66
66
|
key = positional.first
|
|
67
67
|
if key.nil? || key.strip.empty?
|
data/lib/fp/commands.rb
CHANGED
|
@@ -6,9 +6,14 @@ require_relative 'commands/surfaces'
|
|
|
6
6
|
require_relative 'commands/list'
|
|
7
7
|
require_relative 'commands/show'
|
|
8
8
|
require_relative 'commands/propose'
|
|
9
|
+
require_relative 'commands/propose_change'
|
|
10
|
+
require_relative 'commands/history'
|
|
9
11
|
require_relative 'commands/report'
|
|
10
12
|
require_relative 'commands/ci_report'
|
|
13
|
+
require_relative 'commands/check'
|
|
14
|
+
require_relative 'commands/init'
|
|
11
15
|
require_relative 'commands/matrix'
|
|
16
|
+
require_relative 'commands/export'
|
|
12
17
|
require_relative 'commands/profile'
|
|
13
18
|
require_relative 'commands/config_cmd'
|
|
14
19
|
require_relative 'commands/repos'
|
data/lib/fp/junit.rb
CHANGED
|
@@ -21,7 +21,11 @@ module Fp
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Evidence discovered by binding a testcase to a marker.
|
|
24
|
-
|
|
24
|
+
# `line` is the 1-based line number of the test in its source file: the
|
|
25
|
+
# testcase's own line when the runner reported one, otherwise the line of
|
|
26
|
+
# the fp:<slug> marker that annotates it (markers sit directly above the
|
|
27
|
+
# test, so the marker line is a close stand-in when the runner omits lines).
|
|
28
|
+
Binding = Struct.new(:slug, :surface, :file, :line, :title, :state, keyword_init: true)
|
|
25
29
|
|
|
26
30
|
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
27
31
|
# `@surface1,surface2`) suffix to target one or more specific surfaces:
|
|
@@ -166,7 +170,7 @@ module Fp
|
|
|
166
170
|
if surface.nil? || surface.empty?
|
|
167
171
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
168
172
|
else
|
|
169
|
-
record_binding(bindings, marker[:slug], surface, tc, titles_reliable: titles_reliable)
|
|
173
|
+
record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: titles_reliable)
|
|
170
174
|
end
|
|
171
175
|
end
|
|
172
176
|
end
|
|
@@ -203,16 +207,21 @@ module Fp
|
|
|
203
207
|
candidate
|
|
204
208
|
end
|
|
205
209
|
|
|
206
|
-
def record_binding(bindings, slug, surface, testcase, titles_reliable: true)
|
|
210
|
+
def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true)
|
|
207
211
|
rel_file = testcase.file
|
|
208
212
|
key = [slug, surface, rel_file]
|
|
209
213
|
state = testcase.stub? ? 'stub' : 'present'
|
|
210
214
|
|
|
215
|
+
# The test's line: prefer the runner-reported testcase line; fall back to
|
|
216
|
+
# the marker's line (it sits directly above the test) when the runner
|
|
217
|
+
# omitted line info.
|
|
218
|
+
line = testcase.line || (marker && marker[:line])
|
|
219
|
+
|
|
211
220
|
existing = bindings[key]
|
|
212
221
|
# Prefer a concrete 'present' state over 'stub' when a slug has both.
|
|
213
222
|
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
214
223
|
|
|
215
|
-
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
|
|
224
|
+
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file, line: line,
|
|
216
225
|
title: (testcase.name if titles_reliable), state: state)
|
|
217
226
|
end
|
|
218
227
|
|