featureparity 0.0.27 → 0.0.29
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 +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +304 -3
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5cef61c9093573a494f9d27b203554e51dfc2f0a6d60ae43e666564c8e2b1740
|
|
4
|
+
data.tar.gz: 7162500244354be8c93a78499e479ac30a7df2d25ab4f818ce3451a6819ce960
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4733078f1af140723dadb54a0ed5e3ba5c80b771a48ba9f8364c6dcfb088cb9e3c39c79fac27b0fd5083e2339e47b8b6fb76fe5c54fde772f68fdbc2a005e7d
|
|
7
|
+
data.tar.gz: 21330096e2f61cf6100dc54708c25818647f88581e0d0ef073bc84b2ec532556582012d899a663a0e96ce9e757ff6f5ae0ec51338be797bc19363f40e97d86af
|
data/lib/fp/cli.rb
CHANGED
|
@@ -7,6 +7,9 @@ module Fp
|
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
9
|
'init' => Commands::Init,
|
|
10
|
+
'tutorial' => Commands::Tutorial,
|
|
11
|
+
'doctor' => Commands::Doctor,
|
|
12
|
+
'run' => Commands::Run,
|
|
10
13
|
'projects' => Commands::Projects,
|
|
11
14
|
'surfaces' => Commands::Surfaces,
|
|
12
15
|
'showroom' => Commands::Showroom,
|
|
@@ -14,14 +17,20 @@ module Fp
|
|
|
14
17
|
'show' => Commands::Show,
|
|
15
18
|
'propose' => Commands::Propose,
|
|
16
19
|
'propose-change' => Commands::ProposeChange,
|
|
20
|
+
'mark' => Commands::Mark,
|
|
17
21
|
'comment' => Commands::Comment,
|
|
22
|
+
'question' => Commands::Question,
|
|
23
|
+
'questions' => Commands::Questions,
|
|
24
|
+
'comments' => Commands::Comments,
|
|
18
25
|
'history' => Commands::History,
|
|
19
26
|
'analyze' => Commands::Analyze,
|
|
20
27
|
'changes' => Commands::Changes,
|
|
21
28
|
'report' => Commands::Report,
|
|
22
29
|
'ci-report' => Commands::CiReport,
|
|
23
30
|
'changed-tests' => Commands::ChangedTests,
|
|
31
|
+
'spot-check' => Commands::SpotCheck,
|
|
24
32
|
'check' => Commands::Check,
|
|
33
|
+
'coverage' => Commands::Coverage,
|
|
25
34
|
'matrix' => Commands::Matrix,
|
|
26
35
|
'export' => Commands::Export,
|
|
27
36
|
'profile' => Commands::Profile,
|
|
@@ -39,6 +48,9 @@ module Fp
|
|
|
39
48
|
class << self
|
|
40
49
|
def run(argv)
|
|
41
50
|
args = argv.dup
|
|
51
|
+
# Everything after `--` belongs to another program (`fp run -- pytest -p no:cov`),
|
|
52
|
+
# so global flags and --help are only looked for before it.
|
|
53
|
+
passthrough = (split = args.index('--')) ? args.slice!(split..) : []
|
|
42
54
|
global_opts = extract_global_options(args)
|
|
43
55
|
|
|
44
56
|
command_name = args.shift || 'help'
|
|
@@ -68,7 +80,7 @@ module Fp
|
|
|
68
80
|
|
|
69
81
|
# Profile/config commands don't need auth. `init` is here too: it only writes
|
|
70
82
|
# files into the repo, and onboarding a repo must work before anyone has a key.
|
|
71
|
-
needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
|
|
83
|
+
needs_auth = !%w[init doctor profile config repos setup whoami version help mark].include?(command_name)
|
|
72
84
|
|
|
73
85
|
if needs_auth
|
|
74
86
|
config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
|
|
@@ -84,7 +96,7 @@ module Fp
|
|
|
84
96
|
|
|
85
97
|
output = Output.new(json: global_opts[:json])
|
|
86
98
|
command = command_class.new(client: client, output: output, config: config)
|
|
87
|
-
command.run(args)
|
|
99
|
+
command.run(args + passthrough)
|
|
88
100
|
rescue Interrupt
|
|
89
101
|
warn "\nAborted."
|
|
90
102
|
exit 130
|
data/lib/fp/client.rb
CHANGED
|
@@ -208,6 +208,12 @@ module Fp
|
|
|
208
208
|
post("/api/requirements/#{requirement_id}/analyze", body)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# POST /requirements/:id/test_review — Jev's read of whether these tests check what
|
|
212
|
+
# the requirement promises (`fp spot-check --jev`). Paid plans only (402 otherwise).
|
|
213
|
+
def review_spot_check_tests(requirement_id, tests)
|
|
214
|
+
post("/api/requirements/#{requirement_id}/test_review", { tests: tests })
|
|
215
|
+
end
|
|
216
|
+
|
|
211
217
|
# GET /requirements/:id/changes
|
|
212
218
|
#
|
|
213
219
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -241,6 +247,37 @@ module Fp
|
|
|
241
247
|
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
242
248
|
end
|
|
243
249
|
|
|
250
|
+
# GET /requirements/:id/questions
|
|
251
|
+
#
|
|
252
|
+
# The questions left for a human on a requirement (Fizzy #1786), open and resolved.
|
|
253
|
+
def list_requirement_questions(requirement_id)
|
|
254
|
+
get("/api/requirements/#{requirement_id}/questions")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# POST /requirements/:id/questions
|
|
258
|
+
#
|
|
259
|
+
# Attach an open question for a human to a draft (or active) requirement. There is
|
|
260
|
+
# no resolve method: only a human answers or dismisses, from the web app.
|
|
261
|
+
def ask_requirement_question(requirement_id, text)
|
|
262
|
+
post("/api/requirements/#{requirement_id}/questions", { text: text })
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# GET /requirements/:id/comments
|
|
266
|
+
#
|
|
267
|
+
# The team's comments on a requirement, oldest first, plus who can be tagged:
|
|
268
|
+
# `mentionable` (people) and `mentionable_agents` (Fizzy #1494).
|
|
269
|
+
def list_requirement_comments(requirement_id)
|
|
270
|
+
get("/api/requirements/#{requirement_id}/comments")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# POST /requirements/:id/comments
|
|
274
|
+
#
|
|
275
|
+
# Comment on a requirement. `mentions` are emails, `agent_mentions` agent key ids;
|
|
276
|
+
# tagged people are emailed and tagged agents fire the project's webhooks.
|
|
277
|
+
def comment_on_requirement(requirement_id, params)
|
|
278
|
+
post("/api/requirements/#{requirement_id}/comments", params)
|
|
279
|
+
end
|
|
280
|
+
|
|
244
281
|
# DELETE /requirements/:id/changes/:change_id
|
|
245
282
|
#
|
|
246
283
|
# Withdraw an open proposal. Only the proposal's author can withdraw it; the
|
|
@@ -345,6 +382,17 @@ module Fp
|
|
|
345
382
|
get("/api/projects/#{project_id}/parity", params)
|
|
346
383
|
end
|
|
347
384
|
|
|
385
|
+
# GET /projects/:project_id/parity?include_drafts=true — the same report plus a
|
|
386
|
+
# top-level `drafts` array: never-approved drafts with their cells resolved from
|
|
387
|
+
# evidence. Informational only; the server never gates on it (Fizzy #1786). Read by
|
|
388
|
+
# `fp coverage`, `fp list --status draft --gaps` and `fp check --include-drafts`.
|
|
389
|
+
def get_parity_with_drafts(project_id, level: nil, surface: nil)
|
|
390
|
+
params = { include_drafts: 'true' }
|
|
391
|
+
params[:level] = level if level
|
|
392
|
+
params[:surface] = surface if surface
|
|
393
|
+
get("/api/projects/#{project_id}/parity", params)
|
|
394
|
+
end
|
|
395
|
+
|
|
348
396
|
# POST /projects/:project_id/parity/preview — the pull-request gate.
|
|
349
397
|
#
|
|
350
398
|
# Evaluates JUnit results as an overlay on the stored grid *without persisting them*,
|
|
@@ -513,7 +561,7 @@ module Fp
|
|
|
513
561
|
http.read_timeout = 30
|
|
514
562
|
|
|
515
563
|
response = http.request(request)
|
|
516
|
-
parse_response(response)
|
|
564
|
+
parse_response(response, uri)
|
|
517
565
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
518
566
|
{ ok: false, error: "Request timed out: #{e.message}", status: 0 }
|
|
519
567
|
rescue SocketError, Errno::ECONNREFUSED => e
|
|
@@ -522,13 +570,20 @@ module Fp
|
|
|
522
570
|
{ ok: false, error: "Request failed: #{e.message}", status: 0 }
|
|
523
571
|
end
|
|
524
572
|
|
|
525
|
-
def parse_response(response)
|
|
573
|
+
def parse_response(response, uri = nil)
|
|
526
574
|
body = response.body
|
|
527
575
|
status = response.code.to_i
|
|
528
576
|
|
|
529
577
|
# Try to parse as JSON
|
|
530
578
|
data = JSON.parse(body) rescue nil
|
|
531
579
|
|
|
580
|
+
# A success that isn't JSON is almost always the web app answering in the API's
|
|
581
|
+
# place — FP_API_URL set to the site rather than its api host — and every command
|
|
582
|
+
# used to crash on it with "undefined method '[]' for nil" (Fizzy #1446).
|
|
583
|
+
if status >= 200 && status < 300 && data.nil? && !body.to_s.strip.empty?
|
|
584
|
+
return { ok: false, status: status, error: not_the_api_message(uri) }
|
|
585
|
+
end
|
|
586
|
+
|
|
532
587
|
if status >= 200 && status < 300
|
|
533
588
|
{ ok: true, data: data, status: status }
|
|
534
589
|
else
|
|
@@ -544,5 +599,27 @@ module Fp
|
|
|
544
599
|
result
|
|
545
600
|
end
|
|
546
601
|
end
|
|
602
|
+
|
|
603
|
+
def not_the_api_message(uri)
|
|
604
|
+
origin = uri ? "#{uri.scheme}://#{uri.host}" : @base_url
|
|
605
|
+
message = +"#{origin} answered with a web page, not the FeatureParity API. " \
|
|
606
|
+
'Point fp at the API host instead (check it with `fp whoami`).'
|
|
607
|
+
suggestion = api_host_for(uri&.host)
|
|
608
|
+
message << " Did you mean https://#{suggestion}? (export FP_API_URL=https://#{suggestion})" if suggestion
|
|
609
|
+
message
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# The API host that sits beside a web-app host: featureparity.dev → api.featureparity.dev,
|
|
613
|
+
# an ephemeral <env>.dev.featureparity.dev → api-<env>.dev.featureparity.dev.
|
|
614
|
+
def api_host_for(host)
|
|
615
|
+
return nil if host.nil? || host.start_with?('api.', 'api-')
|
|
616
|
+
return nil unless host.end_with?('featureparity.dev')
|
|
617
|
+
|
|
618
|
+
labels = host.split('.')
|
|
619
|
+
return "api.#{host}" if labels.length <= 2
|
|
620
|
+
return "api.#{host}" if labels.first == 'dev' || labels.first == 'staging'
|
|
621
|
+
|
|
622
|
+
"api-#{host}"
|
|
623
|
+
end
|
|
547
624
|
end
|
|
548
625
|
end
|
data/lib/fp/commands/analyze.rb
CHANGED
|
@@ -153,6 +153,7 @@ module Fp
|
|
|
153
153
|
'duplicate_of' => issues.find { |i| i['type'] == 'duplicate' }&.dig('duplicate_of'),
|
|
154
154
|
'conflicts' => (analysis && analysis['conflicts']) || [],
|
|
155
155
|
'suggestions' => (analysis && analysis['suggestions']) || [],
|
|
156
|
+
'warrants_requirement' => analysis && analysis['warrants_requirement'],
|
|
156
157
|
'error' => error
|
|
157
158
|
}
|
|
158
159
|
end
|
|
@@ -199,6 +200,8 @@ module Fp
|
|
|
199
200
|
end
|
|
200
201
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
201
202
|
puts " issues: #{entry['issues'].map { |i| i['type'] }.join(', ')}" unless entry['issues'].empty?
|
|
203
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
204
|
+
puts " #{warrants}" if warrants && entry['warrants_requirement']['judgment'] == false
|
|
202
205
|
puts " error: #{entry['error']}" if entry['error']
|
|
203
206
|
end
|
|
204
207
|
|
|
@@ -206,6 +209,8 @@ module Fp
|
|
|
206
209
|
puts "#{entry['slug']} — #{entry['title']}"
|
|
207
210
|
puts " verdict: #{entry['verdict'] || '(none)'} suggested: #{ACTIONS[entry['action']]}"
|
|
208
211
|
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
212
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
213
|
+
puts " #{warrants}" if warrants
|
|
209
214
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
210
215
|
entry['issues'].each { |i| puts " issue: #{i['type']}" }
|
|
211
216
|
entry['conflicts'].each do |c|
|
|
@@ -215,6 +220,18 @@ module Fp
|
|
|
215
220
|
puts " error: #{entry['error']}" if entry['error']
|
|
216
221
|
end
|
|
217
222
|
|
|
223
|
+
# The analyzer's yes/no on whether the concept deserves to be a requirement at all.
|
|
224
|
+
# A "no" carries its reason so a human can decide to withdraw it — the CLI only
|
|
225
|
+
# reports it; nothing is withdrawn or proposed on the strength of the judgment.
|
|
226
|
+
def warrants_line(warrants)
|
|
227
|
+
return nil unless warrants.is_a?(Hash) && [true, false].include?(warrants['judgment'])
|
|
228
|
+
|
|
229
|
+
line = "warrants a requirement: #{warrants['judgment'] ? 'yes' : 'no'}"
|
|
230
|
+
line += " (confidence #{(warrants['confidence'].to_f * 100).round}%)" unless warrants['confidence'].nil?
|
|
231
|
+
line += " — #{warrants['reason']}" if !warrants['judgment'] && !warrants['reason'].to_s.empty?
|
|
232
|
+
line
|
|
233
|
+
end
|
|
234
|
+
|
|
218
235
|
def progress(message)
|
|
219
236
|
warn message unless output.json?
|
|
220
237
|
end
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -202,12 +202,28 @@ module Fp
|
|
|
202
202
|
|
|
203
203
|
unless project
|
|
204
204
|
output.error("Project '#{project_slug_or_id}' not found")
|
|
205
|
+
hint = config&.environment_mismatch_hint
|
|
206
|
+
output.error(hint) if hint
|
|
205
207
|
exit 1
|
|
206
208
|
end
|
|
207
209
|
|
|
208
210
|
project['id']
|
|
209
211
|
end
|
|
210
212
|
|
|
213
|
+
# One line on stderr before a command writes to a project, when the API it's about
|
|
214
|
+
# to write to isn't production. FP_PROFILE / FP_API_URL left over in a shell put an
|
|
215
|
+
# agent's new project on a dev environment with nothing to say so (Fizzy #1786).
|
|
216
|
+
# Stderr keeps --json output parseable; it never stops the command.
|
|
217
|
+
def warn_if_not_production!
|
|
218
|
+
env = config&.environment
|
|
219
|
+
return if env.nil? || env == 'production'
|
|
220
|
+
|
|
221
|
+
profile = config.profile_name
|
|
222
|
+
via = profile ? " (profile '#{profile}')" : ''
|
|
223
|
+
$stderr.puts "⚠ Writing to the '#{env}' environment at #{config.api_url}#{via}, not production. " \
|
|
224
|
+
'Run `fp whoami` to see where that comes from.'
|
|
225
|
+
end
|
|
226
|
+
|
|
211
227
|
def require_flag(opts, flag, message = nil)
|
|
212
228
|
unless opts[flag]
|
|
213
229
|
message ||= "--#{flag.to_s.tr('_', '-')} is required"
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -51,15 +51,17 @@ module Fp
|
|
|
51
51
|
help_flag :surface, 'Gate a single surface'
|
|
52
52
|
help_flag :test_type, 'Gate a single test type'
|
|
53
53
|
help_flag :level, 'Override the enforcement level for this run (off/regression/covered/strict)'
|
|
54
|
-
help_flag :junit, "Gate the branch on this run's results, overlaid on the grid (repeatable, one per surface)"
|
|
54
|
+
help_flag :junit, "Gate the branch on this run's results (JUnit XML or RSpec JSON), overlaid on the grid (repeatable, one per surface)"
|
|
55
55
|
help_flag :base_dir, "Where the report's relative test-file paths resolve from"
|
|
56
56
|
help_flag :pr, 'With --junit: this run is a pull request — fail only on parity it makes worse, ' \
|
|
57
57
|
'not on what the default branch already owes (optionally the PR URL)'
|
|
58
58
|
help_flag :warn_only, 'Report violations but still exit 0'
|
|
59
|
+
help_flag :include_drafts, 'Also show draft coverage after the verdict (never changes the exit code)'
|
|
59
60
|
help_example 'fp check', 'Gate on recorded state (project from .featureparity.yml)'
|
|
60
61
|
help_example 'fp check --surface api --junit junit.xml', 'Gate a default-branch run on its own results'
|
|
61
62
|
help_example 'fp check --surface api --junit junit.xml --pr', 'Gate a pull request on what it changes'
|
|
62
63
|
help_example 'fp check --warn-only --json'
|
|
64
|
+
help_example 'fp check --include-drafts', 'Onboarding: which drafts have evidence, which have none'
|
|
63
65
|
help_note "Exit 0 = passed, 1 = violations (or request failed). The level lives on the project\n" \
|
|
64
66
|
'server-side; --level only overrides this one run.'
|
|
65
67
|
|
|
@@ -73,7 +75,8 @@ module Fp
|
|
|
73
75
|
# `--pr` alone is true; `--pr <url>` carries the URL. Not :boolean on purpose, so
|
|
74
76
|
# the parser takes a following value when one is given.
|
|
75
77
|
pr: :string,
|
|
76
|
-
warn_only: :boolean
|
|
78
|
+
warn_only: :boolean,
|
|
79
|
+
include_drafts: :boolean
|
|
77
80
|
}.freeze
|
|
78
81
|
|
|
79
82
|
# How many inherited violations to list before summarising the rest. They are
|
|
@@ -103,7 +106,9 @@ module Fp
|
|
|
103
106
|
project_id = resolve_project_id(project_slug)
|
|
104
107
|
|
|
105
108
|
result =
|
|
106
|
-
if junit_paths.empty?
|
|
109
|
+
if junit_paths.empty? && opts[:include_drafts]
|
|
110
|
+
client.get_parity_with_drafts(project_id, level: level, surface: surface)
|
|
111
|
+
elsif junit_paths.empty?
|
|
107
112
|
client.get_parity(project_id, level: level, surface: surface)
|
|
108
113
|
else
|
|
109
114
|
preview(project_id, junit_paths, surface, level, opts)
|
|
@@ -125,7 +130,12 @@ module Fp
|
|
|
125
130
|
'this verdict counts every violation, including those the default branch already has.'
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
# Draft coverage rides along when asked for. Informational only: it is added after
|
|
134
|
+
# the verdict was decided and never feeds `passed` (Fizzy #1786).
|
|
135
|
+
report = report.merge('drafts' => draft_rows(project_id, level, surface, report)) if opts[:include_drafts]
|
|
136
|
+
|
|
128
137
|
render(report, violations, passed, project_slug, opts)
|
|
138
|
+
print_drafts(report) if opts[:include_drafts] && !output.json?
|
|
129
139
|
|
|
130
140
|
# A failing gate is the whole point of the command, so it must be the exit code
|
|
131
141
|
# and not just something printed. --warn-only downgrades it to advisory.
|
|
@@ -134,6 +144,30 @@ module Fp
|
|
|
134
144
|
|
|
135
145
|
private
|
|
136
146
|
|
|
147
|
+
# The standing read already carries `drafts` when asked for. The preview endpoint
|
|
148
|
+
# doesn't, so a --junit run reads them from the standing report instead; drafts are
|
|
149
|
+
# recorded state either way. A failed or too-old read is a warning, never a failed
|
|
150
|
+
# gate, because drafts can't change the verdict.
|
|
151
|
+
def draft_rows(project_id, level, surface, report)
|
|
152
|
+
return report['drafts'] if report.key?('drafts')
|
|
153
|
+
|
|
154
|
+
result = client.get_parity_with_drafts(project_id, level: level, surface: surface)
|
|
155
|
+
drafts = result[:ok] ? (result[:data]['parity'] || {})['drafts'] : nil
|
|
156
|
+
if drafts.nil?
|
|
157
|
+
warn 'Warning: draft coverage is unavailable from this API; the verdict is unaffected.'
|
|
158
|
+
return []
|
|
159
|
+
end
|
|
160
|
+
drafts
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def print_drafts(report)
|
|
164
|
+
drafts = report['drafts'] || []
|
|
165
|
+
puts
|
|
166
|
+
puts "Draft coverage (#{drafts.size} draft(s), not gated):"
|
|
167
|
+
Coverage.print_drafts(output, drafts, report['surfaces'] || [])
|
|
168
|
+
Coverage.print_untested(Coverage.untested(drafts)) unless drafts.empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
137
171
|
# Preview mode needs to know which surface the results belong to, because the
|
|
138
172
|
# server uses it to decide which cells the run had a chance to cover — and
|
|
139
173
|
# therefore which vanished tests it may legitimately flag.
|
|
@@ -162,7 +196,7 @@ module Fp
|
|
|
162
196
|
client.preview_parity(
|
|
163
197
|
project_id,
|
|
164
198
|
surface: surface,
|
|
165
|
-
junit_xmls: junit_paths.map { |p|
|
|
199
|
+
junit_xmls: junit_paths.map { |p| Fp::JUnit.read_xml(p) },
|
|
166
200
|
level: level,
|
|
167
201
|
results: resolve_results(junit_paths, surface, opts),
|
|
168
202
|
pull_request: !!opts[:pr],
|
|
@@ -57,7 +57,7 @@ module Fp
|
|
|
57
57
|
help_flag :test_type, 'Default test kind for markers that pin none'
|
|
58
58
|
help_flag :ci_url, 'CI run URL to record on the evidence'
|
|
59
59
|
help_flag :pr, "Mark this as a PR's CI: passing tests recorded as pr_passing, no merged evidence demoted"
|
|
60
|
-
help_flag :junit, 'JUnit XML report (repeatable to combine a surface split across shards)'
|
|
60
|
+
help_flag :junit, 'JUnit XML report, or RSpec `--format json` output (repeatable to combine a surface split across shards)'
|
|
61
61
|
help_flag :base_dir, "Where the report's relative test-file paths resolve from (e.g. frontend)"
|
|
62
62
|
help_flag :repo, 'Repository the tests live in (org/repo)'
|
|
63
63
|
help_flag :sha, 'Checkout commit SHA'
|
|
@@ -127,7 +127,7 @@ module Fp
|
|
|
127
127
|
totals[:unmarked_failures].concat(resolved[:unmarked_failures])
|
|
128
128
|
totals[:test_revisions] += test_revisions.length
|
|
129
129
|
|
|
130
|
-
{ path: path, xml:
|
|
130
|
+
{ path: path, xml: Fp::JUnit.read_xml(path), results: results, test_revisions: test_revisions }
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# One surface run may be split across several reports (unit + e2e, shards, etc.).
|
data/lib/fp/commands/comment.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Fp
|
|
|
17
17
|
source_type: :string, source_url: :string, source_context: :string
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
|
-
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
20
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
|
|
21
21
|
|
|
22
22
|
help_summary "Comment on a requirement's open change proposal"
|
|
23
23
|
help_usage 'fp comment <slug> --body "..." --project <slug>'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp comments <slug>` — the team's conversation on a requirement (Fizzy #1494).
|
|
6
|
+
#
|
|
7
|
+
# Without --body it prints the comments, oldest first. With --body it posts one, and
|
|
8
|
+
# --tag names who to tag: a teammate by email or name, or one of the team's agents by
|
|
9
|
+
# name or key name. Tagged people are emailed; each tagged agent fires the project's
|
|
10
|
+
# `requirement.agent_mentioned` webhooks, so whatever runs that agent can pick it up.
|
|
11
|
+
#
|
|
12
|
+
# Not to be confused with `fp comment`, which threads a comment onto a requirement's
|
|
13
|
+
# open change *proposal*.
|
|
14
|
+
class Comments < Base
|
|
15
|
+
KNOWN_FLAGS = { project: :string, body: :string, tag: :string }.freeze
|
|
16
|
+
|
|
17
|
+
help_summary "Read or post the team's comments on a requirement, tagging people or agents"
|
|
18
|
+
help_usage 'fp comments <slug> [--body "..." [--tag <who>,<who>]] --project <slug>'
|
|
19
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
20
|
+
help_flag :body, 'Post this comment (omit to list the comments)'
|
|
21
|
+
help_flag :tag, 'Comma-separated people (email or name) or agents (name or key name) to tag'
|
|
22
|
+
help_example 'fp comments print_qr --project stowzilla'
|
|
23
|
+
help_example 'fp comments print_qr --body "The redesign dropped this screen — retire it?" --tag andy@example.com'
|
|
24
|
+
|
|
25
|
+
def run(args)
|
|
26
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
27
|
+
require_project!(opts)
|
|
28
|
+
|
|
29
|
+
slug = positional.first
|
|
30
|
+
unless slug
|
|
31
|
+
output.error('Requirement slug is required. Usage: fp comments <slug> [--body "..."] [--project <project>]')
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
project_id = resolve_project_id(opts[:project])
|
|
36
|
+
requirement = find_requirement!(project_id, slug)
|
|
37
|
+
listing = fetch!(client.list_requirement_comments(requirement['id']))
|
|
38
|
+
|
|
39
|
+
if opts[:body].nil?
|
|
40
|
+
print_comments(listing, slug)
|
|
41
|
+
else
|
|
42
|
+
post_comment(requirement, listing, opts, slug)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def post_comment(requirement, listing, opts, slug)
|
|
49
|
+
body = opts[:body].to_s
|
|
50
|
+
if body.strip.empty?
|
|
51
|
+
output.error('A comment needs some text. Pass --body "...".')
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
people, agents = resolve_tags(opts[:tag], listing)
|
|
56
|
+
data = fetch!(client.comment_on_requirement(requirement['id'],
|
|
57
|
+
body: body,
|
|
58
|
+
mentions: people.map { |p| p['email'] },
|
|
59
|
+
agent_mentions: agents.map { |a| a['id'] }))
|
|
60
|
+
comment = data['comment'] || {}
|
|
61
|
+
|
|
62
|
+
output.success(data) do
|
|
63
|
+
puts "Commented on '#{slug}'."
|
|
64
|
+
puts
|
|
65
|
+
print_comment(comment)
|
|
66
|
+
tagged_people = Array(comment['mentions']).map { |m| m['name'] }
|
|
67
|
+
tagged_agents = Array(comment['agent_mentions']).map { |m| m['name'] }
|
|
68
|
+
puts "#{tagged_people.join(', ')} will get an email." unless tagged_people.empty?
|
|
69
|
+
puts "#{tagged_agents.join(', ')} will be notified through the project's webhooks." unless tagged_agents.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Each --tag matches exactly one person (email or name) or agent (name or key
|
|
74
|
+
# name), case-insensitively. Unknown or ambiguous tags stop the command rather
|
|
75
|
+
# than post a comment that silently tags nobody.
|
|
76
|
+
def resolve_tags(raw, listing)
|
|
77
|
+
people = []
|
|
78
|
+
agents = []
|
|
79
|
+
raw.to_s.split(',').map(&:strip).reject(&:empty?).each do |tag|
|
|
80
|
+
wanted = tag.delete_prefix('@').downcase
|
|
81
|
+
person_hits = Array(listing['mentionable']).select do |p|
|
|
82
|
+
[p['email'], p['name']].compact.map(&:downcase).include?(wanted)
|
|
83
|
+
end
|
|
84
|
+
agent_hits = Array(listing['mentionable_agents']).select do |a|
|
|
85
|
+
[a['name'], a['key_name']].compact.map(&:downcase).include?(wanted)
|
|
86
|
+
end
|
|
87
|
+
hits = person_hits + agent_hits
|
|
88
|
+
if hits.empty?
|
|
89
|
+
output.error("Nobody on this project's team matches '#{tag}'. Run `fp comments <slug>` to see who can be tagged.")
|
|
90
|
+
exit 1
|
|
91
|
+
elsif hits.size > 1
|
|
92
|
+
output.error("'#{tag}' matches more than one person or agent; use an email or key name instead.")
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
person_hits.empty? ? agents << agent_hits.first : people << person_hits.first
|
|
96
|
+
end
|
|
97
|
+
[people, agents]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def print_comments(listing, slug)
|
|
101
|
+
comments = Array(listing['comments'])
|
|
102
|
+
output.success(listing) do
|
|
103
|
+
puts "Comments on '#{slug}'"
|
|
104
|
+
puts
|
|
105
|
+
if comments.empty?
|
|
106
|
+
puts ' (no comments yet)'
|
|
107
|
+
else
|
|
108
|
+
comments.each { |comment| print_comment(comment) }
|
|
109
|
+
end
|
|
110
|
+
print_taggable(listing)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print_comment(comment)
|
|
115
|
+
kind = comment['actor_kind'] == 'agent' ? ' (agent)' : ''
|
|
116
|
+
puts "* #{comment['actor_name'] || comment['actor']}#{kind} at #{comment['recorded_at']}"
|
|
117
|
+
comment['body'].to_s.each_line { |line| puts " > #{line.chomp}" }
|
|
118
|
+
tags = Array(comment['mentions']) + Array(comment['agent_mentions'])
|
|
119
|
+
puts " tagged: #{tags.map { |t| t['name'] }.join(', ')}" unless tags.empty?
|
|
120
|
+
puts
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_taggable(listing)
|
|
124
|
+
people = Array(listing['mentionable']).map { |p| "#{p['name']} <#{p['email']}>" }
|
|
125
|
+
agents = Array(listing['mentionable_agents']).map { |a| "#{a['name']} (#{a['key_name']}, #{a['owner_name']}'s agent)" }
|
|
126
|
+
return if people.empty? && agents.empty?
|
|
127
|
+
|
|
128
|
+
puts 'Can be tagged with --tag:'
|
|
129
|
+
people.each { |p| puts " #{p}" }
|
|
130
|
+
agents.each { |a| puts " #{a}" }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_requirement!(project_id, slug)
|
|
134
|
+
fetch!(client.find_requirement_by_slug(project_id, slug))['requirement']
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fetch!(result)
|
|
138
|
+
unless result[:ok]
|
|
139
|
+
output.error(result[:error], status: result[:status])
|
|
140
|
+
exit 1
|
|
141
|
+
end
|
|
142
|
+
result[:data]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|