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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fp/cli.rb +14 -2
  3. data/lib/fp/client.rb +79 -2
  4. data/lib/fp/commands/analyze.rb +17 -0
  5. data/lib/fp/commands/base.rb +16 -0
  6. data/lib/fp/commands/check.rb +38 -4
  7. data/lib/fp/commands/ci_report.rb +2 -2
  8. data/lib/fp/commands/comment.rb +1 -1
  9. data/lib/fp/commands/comments.rb +146 -0
  10. data/lib/fp/commands/coverage.rb +149 -0
  11. data/lib/fp/commands/doctor.rb +228 -0
  12. data/lib/fp/commands/help.rb +41 -1
  13. data/lib/fp/commands/history.rb +16 -1
  14. data/lib/fp/commands/init.rb +181 -117
  15. data/lib/fp/commands/list.rb +58 -4
  16. data/lib/fp/commands/mark.rb +167 -0
  17. data/lib/fp/commands/projects.rb +6 -1
  18. data/lib/fp/commands/propose.rb +435 -53
  19. data/lib/fp/commands/propose_change.rb +13 -7
  20. data/lib/fp/commands/question.rb +137 -0
  21. data/lib/fp/commands/report.rb +40 -4
  22. data/lib/fp/commands/run.rb +147 -0
  23. data/lib/fp/commands/show.rb +41 -3
  24. data/lib/fp/commands/spot_check.rb +458 -0
  25. data/lib/fp/commands/tutorial.rb +95 -0
  26. data/lib/fp/commands/whoami.rb +2 -0
  27. data/lib/fp/commands.rb +8 -0
  28. data/lib/fp/config.rb +12 -0
  29. data/lib/fp/junit.rb +74 -11
  30. data/lib/fp/repo_detector.rb +488 -0
  31. data/lib/fp/requirement_lint.rb +105 -0
  32. data/lib/fp/rspec_json.rb +54 -0
  33. data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
  34. data/lib/fp/tutorial/hexhaven/README.md +29 -0
  35. data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
  36. data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
  37. data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
  38. data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
  39. data/lib/fp/version.rb +1 -1
  40. data/lib/fp.rb +1 -0
  41. data/skills/fp/SKILL.md +244 -105
  42. data/skills/fp/references/cli.md +304 -3
  43. metadata +19 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b09491a1b44b4ba693dfd567cdec5068d3410c577066435b541b2e9520ae04f
4
- data.tar.gz: c9b7fb84bee3dbb3e8dfd8ee29f7c4d878747c0157efaa63fb020ead7934b2e7
3
+ metadata.gz: 5cef61c9093573a494f9d27b203554e51dfc2f0a6d60ae43e666564c8e2b1740
4
+ data.tar.gz: 7162500244354be8c93a78499e479ac30a7df2d25ab4f818ce3451a6819ce960
5
5
  SHA512:
6
- metadata.gz: cdf6cc31e230d20555285da1b44ee1816e94a88d3a5e3c02b22569cc63034d2f038d5183c12191f9ba446e5fdbc78a44ebe0b485e476f0dd5b9e021675338500
7
- data.tar.gz: c9b9337383f9e7aecfa8caec130d99e3a1e93452481d14ffe1e0f95bd63c495480241d3b1578f0ff01ca5c7f81cc3b969a78cf232947a563cd307ade34b255c1
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
@@ -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
@@ -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"
@@ -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| File.read(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: File.read(path), results: results, test_revisions: test_revisions }
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.).
@@ -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