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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # Shared by `fp question` and `fp questions`: find the requirement, print a question.
6
+ module QuestionSupport
7
+ private
8
+
9
+ def find_requirement!(project_id, slug)
10
+ result = client.find_requirement_by_slug(project_id, slug)
11
+ unless result[:ok]
12
+ output.error(result[:error], status: result[:status])
13
+ exit 1
14
+ end
15
+ result[:data]['requirement']
16
+ end
17
+
18
+ def print_question(question)
19
+ asker = question['asked_by'] || 'unknown'
20
+ kind = question['asked_by_kind'] == 'agent' ? 'agent' : 'human'
21
+ puts " [#{question['status']}] #{question['text']}"
22
+ puts " asked by #{asker} (#{kind}) at #{question['asked_at']}"
23
+ case question['status']
24
+ when 'answered'
25
+ puts " answer: #{question['answer']} (#{question['resolved_by']})"
26
+ when 'dismissed'
27
+ puts " dismissed by #{question['resolved_by']}"
28
+ end
29
+ end
30
+ end
31
+
32
+ # `fp question <slug> "<text>"` — attach an open question for a human to the draft it
33
+ # concerns (Fizzy #1786).
34
+ #
35
+ # An agent writing a batch of drafts usually has questions only a human can answer:
36
+ # a limit the source never set, a role it didn't name. Left in the chat, they're
37
+ # detached from the drafts. Asked here, the human reviewing the draft in Quick Review
38
+ # sees them beside it, and answers or dismisses each one there. Agents can ask but
39
+ # not answer; the web app is where a human resolves them.
40
+ class Question < Base
41
+ include QuestionSupport
42
+
43
+ KNOWN_FLAGS = { project: :string, body: :string }.freeze
44
+
45
+ help_summary 'Attach an open question for a human to a draft requirement'
46
+ help_usage 'fp question <slug> "<question>" [--project <slug>]'
47
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
48
+ help_flag :body, 'The question text (instead of the second argument)'
49
+ help_example 'fp question upload_limits "Is the 5 MB cap per file or per upload?"'
50
+ help_example 'fp questions upload_limits # list open and resolved questions'
51
+
52
+ def run(args)
53
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
54
+ require_project!(opts)
55
+
56
+ slug = positional.first
57
+ unless slug
58
+ output.error('Requirement slug is required. Usage: fp question <slug> "<question>" [--project <project>]')
59
+ exit 1
60
+ end
61
+
62
+ text = opts[:body] || positional[1..].join(' ')
63
+ if text.nil? || text.strip.empty?
64
+ output.error("A question needs its text. Usage: fp question #{slug} \"<question>\" " \
65
+ "(run `fp questions #{slug}` to list them)")
66
+ exit 1
67
+ end
68
+
69
+ project_id = resolve_project_id(opts[:project])
70
+ requirement = find_requirement!(project_id, slug)
71
+
72
+ result = client.ask_requirement_question(requirement['id'], text.strip)
73
+ unless result[:ok]
74
+ output.error(result[:error], status: result[:status])
75
+ exit 1
76
+ end
77
+
78
+ output.success(result[:data]) do
79
+ open_count = result[:data]['open_count']
80
+ puts "Asked on '#{slug}': #{result[:data].dig('question', 'text')}"
81
+ puts
82
+ puts "#{open_count} open question#{'s' unless open_count == 1} on this requirement." if open_count
83
+ puts 'A human reviewing it in Quick Review sees the question and answers or dismisses it there.'
84
+ puts "Run `fp questions #{slug}` to read the answers."
85
+ end
86
+ end
87
+ end
88
+
89
+ # `fp questions <slug>` — the questions asked on a requirement, open and resolved.
90
+ class Questions < Base
91
+ include QuestionSupport
92
+
93
+ KNOWN_FLAGS = { project: :string, open: :boolean }.freeze
94
+
95
+ help_summary "List a requirement's questions for a human, with their answers"
96
+ help_usage 'fp questions <slug> [--open] [--project <slug>]'
97
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
98
+ help_flag :open, 'Show only the questions still open'
99
+ help_example 'fp questions upload_limits'
100
+ help_example 'fp questions upload_limits --open --json'
101
+
102
+ def run(args)
103
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
104
+ require_project!(opts)
105
+
106
+ slug = positional.first
107
+ unless slug
108
+ output.error('Requirement slug is required. Usage: fp questions <slug> [--project <project>]')
109
+ exit 1
110
+ end
111
+
112
+ project_id = resolve_project_id(opts[:project])
113
+ requirement = find_requirement!(project_id, slug)
114
+
115
+ result = client.list_requirement_questions(requirement['id'])
116
+ unless result[:ok]
117
+ output.error(result[:error], status: result[:status])
118
+ exit 1
119
+ end
120
+
121
+ questions = result[:data]['questions'] || []
122
+ questions = questions.select { |q| q['status'] == 'open' } if opts[:open]
123
+ payload = { 'requirement' => requirement, 'questions' => questions,
124
+ 'open_count' => result[:data]['open_count'] }
125
+
126
+ output.success(payload) do
127
+ puts "Questions on '#{slug}':"
128
+ if questions.empty?
129
+ puts opts[:open] ? ' (none open)' : ' (none asked)'
130
+ else
131
+ questions.each { |q| print_question(q) }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -36,7 +36,7 @@ module Fp
36
36
  help_flag :ci_url, 'CI run URL'
37
37
  help_flag :title, 'Human-readable example/test name to display'
38
38
  help_flag :state, 'Evidence state: present or stub (default present)'
39
- help_flag :junit, 'JUnit XML report to batch-upload evidence from'
39
+ help_flag :junit, 'JUnit XML report (or RSpec `--format json` output) to batch-upload evidence from'
40
40
  help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
41
41
  help_flag :attach_media, 'With --junit: upload the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual evidence'
42
42
  help_flag :stamp_all, 'With --junit: put --pr/--work-item on every receipt, not only on tests in files this branch changed'
@@ -124,6 +124,7 @@ module Fp
124
124
  params[:line] = opts[:line].to_i if opts[:line]
125
125
 
126
126
  # Try to report evidence
127
+ warn_if_not_production!
127
128
  result = client.report_evidence(params)
128
129
 
129
130
  # Evidence API may not exist yet (#1214)
@@ -208,7 +209,10 @@ module Fp
208
209
 
209
210
  # Batch-upload evidence from a JUnit XML report.
210
211
  def run_junit(opts)
211
- require_project!(opts)
212
+ # The report's own checkout says which project it is: discovering from the
213
+ # report's directory (and up) means `fp report --junit hexhaven/junit.xml` run
214
+ # from a parent folder reads hexhaven's .featureparity.yml, not the parent's.
215
+ require_project!(opts, ProjectConfig.discover(File.dirname(File.expand_path(opts[:junit].to_s))))
212
216
  require_flag(opts, :repo)
213
217
  # --surface is the default for markers that don't pin their own surface
214
218
  # (e.g. `fp:slug@api,web`). It's only strictly required when at least one
@@ -244,6 +248,19 @@ module Fp
244
248
  result = Fp::JUnit.bind_markers(testcases, base_dir: base_dir,
245
249
  default_surface: opts[:surface],
246
250
  default_test_type: opts[:test_type])
251
+ # Run from outside the checkout, paths in the report resolve from nowhere. When
252
+ # nothing binds from here and no --base-dir was given, try the report's own
253
+ # directory before giving up.
254
+ report_dir = File.dirname(File.expand_path(opts[:junit].to_s))
255
+ if result[:bindings].empty? && opts[:base_dir].nil? && report_dir != File.expand_path(base_dir)
256
+ retried = Fp::JUnit.bind_markers(testcases, base_dir: report_dir,
257
+ default_surface: opts[:surface],
258
+ default_test_type: opts[:test_type])
259
+ if retried[:bindings].any?
260
+ base_dir = report_dir
261
+ result = retried
262
+ end
263
+ end
247
264
  bindings = result[:bindings]
248
265
  unmatched = result[:unmatched]
249
266
  missing_surface = result[:missing_surface] || []
@@ -272,6 +289,7 @@ module Fp
272
289
  # on requirements the branch never touched — "View Card" on an unrelated
273
290
  # requirement led to whichever card last ran the suite (Fizzy #1631). Only the
274
291
  # tests in files this branch changed get them; the rest keep their own.
292
+ warn_if_not_production!
275
293
  provenance = provenance_scope(opts, base_dir)
276
294
  reported = []
277
295
  skipped_unknown = []
@@ -488,7 +506,8 @@ module Fp
488
506
  stamping = !opts[:pr].nil? || !opts[:work_item].nil?
489
507
  data = {
490
508
  repo: opts[:repo],
491
- reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stamped: (stamping ? r[:stamped] != false : nil), stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) }.compact },
509
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stamped: (stamping ? r[:stamped] != false : nil), stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]), tests: binding_tests(r[:binding]) }.compact },
510
+ tests_bound: reported.sum { |r| binding_tests(r[:binding]).length },
492
511
  provenance: (stamping ? { scoped: provenance.scoped?, mode: provenance.mode.to_s, stamped: reported.count { |r| r[:stamped] != false } } : nil),
493
512
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
494
513
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
@@ -499,7 +518,8 @@ module Fp
499
518
  }
500
519
 
501
520
  output.success(data) do
502
- puts "Reported evidence from #{opts[:junit]}:"
521
+ tests_bound = reported.sum { |r| binding_tests(r[:binding]).length }
522
+ puts "Reported evidence from #{opts[:junit]}: #{tests_bound} bound test(s) → #{reported.length} receipt(s)."
503
523
  reported.each do |r|
504
524
  superseded = ci_superseded?(r[:state], r[:stored_status], r[:stored_source])
505
525
  marker = if !r[:recorded]
@@ -514,6 +534,15 @@ module Fp
514
534
  shown_state = superseded ? "#{r[:stored_status]}, kept CI — #{r[:state]} ignored" : r[:state]
515
535
  stamp = stamping && provenance.scoped? && r[:stamped] ? ' ← this change' : ''
516
536
  puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}#{stamp}"
537
+ tests = binding_tests(r[:binding])
538
+ next if tests.length < 2
539
+
540
+ puts " #{tests.length} tests share this receipt (one per requirement, surface, test type and file):"
541
+ tests.each do |t|
542
+ at = t[:line] ? ":#{t[:line]}" : ''
543
+ skipped = t[:state] == 'stub' ? ' (skipped)' : ''
544
+ puts " - #{t[:name] || '(unnamed)'} #{r[:binding].file}#{at}#{skipped}"
545
+ end
517
546
  end
518
547
 
519
548
  if stamping
@@ -573,6 +602,13 @@ module Fp
573
602
  end
574
603
  end
575
604
 
605
+ # The testcases that fed a receipt. Several tests proving one requirement is normal;
606
+ # the server keeps one row per cell, so the summary names each of them rather than
607
+ # leaving the reader to guess whether the rest were dropped.
608
+ def binding_tests(binding)
609
+ binding.tests || [{ name: binding.title, line: binding.line, state: binding.state }]
610
+ end
611
+
576
612
  # Warn when two or more distinct test files in this run resolved to the *same*
577
613
  # evidence cell. Server-side identity is (project, slug, surface, repo, test_type) —
578
614
  # the file isn't part of it — so those reports upsert the same row and the last one
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ module Fp
7
+ module Commands
8
+ # fp run [report flags] -- <test command...>
9
+ #
10
+ # Runs the test suite with a report formatter fp adds itself, then reports it exactly
11
+ # as `fp report --junit <that report>` would (Fizzy #1786). Getting a report out of a
12
+ # suite usually meant adding a formatter gem or package to someone else's project
13
+ # (rspec_junit_formatter, say) before the first `fp report` could run; every runner
14
+ # here can write one with options it already has, so nothing in the project changes.
15
+ #
16
+ # rspec --format json --out <tmp> (+ --format progress when none was given)
17
+ # pytest -o junit_family=xunit1 --junitxml=<tmp> (xunit1 carries file and line)
18
+ # vitest --reporter=default --reporter=junit --outputFile.junit=<tmp>
19
+ # playwright --reporter=list,junit with PLAYWRIGHT_JUNIT_OUTPUT_NAME=<tmp>
20
+ #
21
+ # It *is* `fp report`: same flags, same present/stub-only rule. The test command's
22
+ # own exit status wins, after reporting, so a red suite still fails the caller.
23
+ class Run < Base
24
+ help_summary 'Run a test command with a report formatter added, then fp report its results'
25
+ help_usage 'fp run [--surface X] [--repo org/repo] [fp report flags] -- <test command...>'
26
+ help_flag :surface, 'Surface the evidence is for (default for markers that pin none)'
27
+ help_flag :repo, 'Repository the tests live in (org/repo)', required: true
28
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
29
+ help_flag :test_type, "Kind of test (project's vocabulary, e.g. unit, e2e)"
30
+ help_flag :work_item, 'Work-item URL that prompted the work'
31
+ help_flag :pr, 'Pull request URL'
32
+ help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
33
+ help_example 'fp run --surface api --repo acme/shop -- bundle exec rspec'
34
+ help_example 'fp run --surface web --repo acme/shop -- npx vitest run'
35
+ help_example 'fp run --surface api --repo acme/shop -- pytest tests/'
36
+ help_example 'fp run --surface web --repo acme/shop -- npx playwright test'
37
+ help_note "Supports rspec, pytest, vitest and playwright. Any other runner: emit JUnit XML yourself\n" \
38
+ "and use `fp report --junit <file>`. Takes every `fp report` flag; agents still report\n" \
39
+ "present/stub only. Exits with the test command's status when it failed."
40
+
41
+ RUNNERS = %w[rspec pytest vitest playwright].freeze
42
+
43
+ def run(args)
44
+ split = args.index('--')
45
+ report_args = split ? args[0...split] : args
46
+ command = split ? args[(split + 1)..] : []
47
+
48
+ if command.empty?
49
+ output.error('Give the test command after `--`, e.g. fp run --surface api --repo org/repo -- bundle exec rspec')
50
+ exit 1
51
+ end
52
+ if report_args.include?('--junit') || report_args.any? { |a| a.start_with?('--junit=') }
53
+ output.error('fp run writes its own report; drop --junit (or use `fp report --junit <file>` instead).')
54
+ exit 1
55
+ end
56
+
57
+ runner = self.class.detect_runner(command)
58
+ unless runner
59
+ output.error("fp run doesn't recognise the runner in `#{command.join(' ')}`. " \
60
+ "Supported: #{RUNNERS.join(', ')}. For anything else, make the runner write " \
61
+ 'JUnit XML and run `fp report --junit <file>`.')
62
+ exit 1
63
+ end
64
+
65
+ tmp = Dir.mktmpdir('fp-run-')
66
+ begin
67
+ report_path, argv, env = self.class.inject(runner, command, tmp)
68
+ warn "fp run: #{argv.join(' ')}"
69
+ test_status = execute(env, argv)
70
+ if test_status.nil?
71
+ output.error("Could not start `#{argv.first}`: is it installed and on PATH?")
72
+ exit 127
73
+ end
74
+
75
+ unless File.file?(report_path) && File.size(report_path).positive?
76
+ output.error("The test command exited #{test_status} without writing a report, so there is nothing to report.")
77
+ exit(test_status.zero? ? 1 : test_status)
78
+ end
79
+
80
+ report_status = report(report_args, report_path)
81
+ exit test_status unless test_status.zero?
82
+ exit report_status unless report_status.zero?
83
+ ensure
84
+ FileUtils.rm_rf(tmp)
85
+ end
86
+ end
87
+
88
+ # Which supported runner a command invokes, by its words: `bundle exec rspec`,
89
+ # `bin/rspec`, `python -m pytest`, `npx vitest run`, `pnpm exec playwright test`.
90
+ def self.detect_runner(command)
91
+ command.each_with_index do |word, i|
92
+ base = File.basename(word)
93
+ return 'rspec' if base == 'rspec'
94
+ return 'pytest' if %w[pytest py.test].include?(base)
95
+ return 'vitest' if base == 'vitest'
96
+ return 'playwright' if base == 'playwright' && command[i + 1] == 'test'
97
+ end
98
+ nil
99
+ end
100
+
101
+ # [report path, argv to run, extra env] for a runner.
102
+ def self.inject(runner, command, tmp)
103
+ case runner
104
+ when 'rspec'
105
+ path = File.join(tmp, 'rspec.json')
106
+ formatted = command.any? { |a| a.start_with?('--format') || a.start_with?('-f') }
107
+ extra = ['--format', 'json', '--out', path]
108
+ extra += ['--format', 'progress'] unless formatted
109
+ [path, command + extra, {}]
110
+ when 'pytest'
111
+ path = File.join(tmp, 'junit.xml')
112
+ [path, command + ['-o', 'junit_family=xunit1', "--junitxml=#{path}"], {}]
113
+ when 'vitest'
114
+ path = File.join(tmp, 'junit.xml')
115
+ [path, command + ['--reporter=default', '--reporter=junit', "--outputFile.junit=#{path}"], {}]
116
+ when 'playwright'
117
+ path = File.join(tmp, 'junit.xml')
118
+ [path, command + ['--reporter=list,junit'], { 'PLAYWRIGHT_JUNIT_OUTPUT_NAME' => path }]
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # The command's exit status, or nil when it couldn't be started.
125
+ def execute(env, argv)
126
+ ok = system(env, *argv)
127
+ return nil if ok.nil?
128
+
129
+ $?.exitstatus || 1
130
+ end
131
+
132
+ # `fp report --junit` with the caller's flags; its own exit status (0 on success).
133
+ def report(report_args, report_path)
134
+ # The report sits in a temp dir, so `fp report` can't find the project from it the
135
+ # way it does beside a checked-in report. The checkout we ran in says which it is.
136
+ unless report_args.include?('--project') || report_args.any? { |a| a.start_with?('--project=') }
137
+ project = ProjectConfig.discover(Dir.pwd).project
138
+ report_args += ['--project', project] if project
139
+ end
140
+ Report.new(client: client, output: output, config: config).run(report_args + ['--junit', report_path])
141
+ 0
142
+ rescue SystemExit => e
143
+ e.status
144
+ end
145
+ end
146
+ end
147
+ end
@@ -44,12 +44,17 @@ module Fp
44
44
  # Agents only ever speak slugs, so translate the parent's ID back into one.
45
45
  parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
46
46
 
47
+ # Open questions for a human (Fizzy #1786), read only when the row says it has some.
48
+ open_questions = fetch_open_questions(requirement)
49
+
47
50
  payload = result[:data].merge('evidence' => evidence, 'attachments' => attachments)
48
51
  payload['parent_slug'] = parent_slug if parent_slug
52
+ payload['open_questions'] = open_questions unless open_questions.empty?
49
53
 
50
54
  output.success(payload) do
51
55
  print_requirement(requirement, evidence: evidence, attachments: attachments,
52
56
  parent_slug: parent_slug, source_history: source_history)
57
+ print_open_questions(open_questions, requirement['slug'])
53
58
  end
54
59
  end
55
60
 
@@ -59,7 +64,34 @@ module Fp
59
64
  result = client.list_evidence(project_id, slug: slug)
60
65
  return [] unless result[:ok]
61
66
 
62
- result[:data]['evidence'] || []
67
+ # The server names the receipts CI has since spoken for; flag them on the rows so
68
+ # both the listing and --json say so, rather than a stale `present` reading as a
69
+ # second verdict beside CI's (Fizzy #1762).
70
+ superseded = Array(result[:data]['superseded_ids'])
71
+ (result[:data]['evidence'] || []).map do |e|
72
+ superseded.include?(e['id']) ? e.merge('superseded' => true) : e
73
+ end
74
+ end
75
+
76
+ def fetch_open_questions(requirement)
77
+ return [] unless requirement['open_question_count'].to_f.positive?
78
+
79
+ result = client.list_requirement_questions(requirement['id'])
80
+ return [] unless result[:ok]
81
+
82
+ (result[:data]['questions'] || []).select { |q| q['status'] == 'open' }
83
+ end
84
+
85
+ def print_open_questions(questions, slug)
86
+ return if questions.empty?
87
+
88
+ puts
89
+ puts 'Open Questions (for a human):'
90
+ questions.each do |q|
91
+ puts " - #{q['text']}"
92
+ puts " asked by #{q['asked_by']} at #{q['asked_at']}"
93
+ end
94
+ puts " (fp questions #{slug} lists answered ones too)"
63
95
  end
64
96
 
65
97
  def fetch_attachments(requirement_id)
@@ -140,6 +172,7 @@ module Fp
140
172
  line << src['url'] if src['url']
141
173
  puts " - #{line.join(' ')}"
142
174
  puts " #{src['context']}" if src['context']
175
+ Array(src['refs']).each { |ref| puts " read from #{ref}" }
143
176
  end
144
177
  end
145
178
  puts
@@ -156,8 +189,13 @@ module Fp
156
189
  .group_by { |e| e['surface'] }
157
190
  .each do |surface, records|
158
191
  puts " #{surface}:"
159
- records.each do |e|
160
- puts " Status: #{e['status']}"
192
+ # The receipts the matrix counts first; superseded ones after.
193
+ records.partition { |e| !e['superseded'] }.flatten(1).each do |e|
194
+ if e['superseded']
195
+ puts " Status: superseded by CI (was #{e['status']}; CI's latest run is what counts)"
196
+ else
197
+ puts " Status: #{e['status']}"
198
+ end
161
199
  # A pull request's CI may have written over the merged verdict; show both
162
200
  # when they differ so "failing" next to "PR: …" reads as the branch's
163
201
  # result, not the default branch's.