featureparity 0.0.5 → 0.0.7
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 +9 -2
- data/lib/fp/client.rb +66 -10
- data/lib/fp/commands/base.rb +49 -0
- data/lib/fp/commands/check.rb +216 -0
- data/lib/fp/commands/ci_report.rb +115 -0
- data/lib/fp/commands/export.rb +144 -0
- data/lib/fp/commands/help.rb +79 -1
- data/lib/fp/commands/history.rb +188 -0
- data/lib/fp/commands/init.rb +384 -0
- data/lib/fp/commands/list.rb +111 -31
- data/lib/fp/commands/matrix.rb +82 -42
- data/lib/fp/commands/propose.rb +1 -1
- data/lib/fp/commands/propose_change.rb +181 -0
- data/lib/fp/commands/report.rb +10 -6
- data/lib/fp/commands/repos.rb +3 -3
- data/lib/fp/commands/show.rb +5 -2
- data/lib/fp/commands/surfaces.rb +2 -2
- data/lib/fp/commands.rb +6 -0
- data/lib/fp/junit.rb +13 -4
- data/lib/fp/parity.rb +80 -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 +109 -1
- data/skills/fp/references/cli.md +203 -6
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d668266132dc93de82fc60d7603f340ab05a8e5852fa4cde36ce9e6e4586ff18
|
|
4
|
+
data.tar.gz: 7d1b2691eeade5eabf110c518dca40808ce1beee2a0809b422376eaf914692c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dafb288e14865ffd01b3cf739a510b8c7c2cf103b5e0867b07ce8f79264a14e8ab34ef95a01a73a77c3566adc2ce792488a3581f8ca184f1cd9b0678f8a4bce2
|
|
7
|
+
data.tar.gz: eee75e4d691fbd6884b0d417a1e8a9ae8c88e15da95ad1919b64f9a7e6a1ed915a5a8baa2687603cbe9d41a3f27738d4b0ae360d3ec97efb0885c45e62b4bcaf
|
data/lib/fp/cli.rb
CHANGED
|
@@ -4,13 +4,19 @@ module Fp
|
|
|
4
4
|
# Main CLI entry point - parses global flags and dispatches to commands
|
|
5
5
|
class CLI
|
|
6
6
|
COMMANDS = {
|
|
7
|
+
'init' => Commands::Init,
|
|
7
8
|
'projects' => Commands::Projects,
|
|
8
9
|
'surfaces' => Commands::Surfaces,
|
|
9
10
|
'list' => Commands::List,
|
|
10
11
|
'show' => Commands::Show,
|
|
11
12
|
'propose' => Commands::Propose,
|
|
13
|
+
'propose-change' => Commands::ProposeChange,
|
|
14
|
+
'history' => Commands::History,
|
|
12
15
|
'report' => Commands::Report,
|
|
16
|
+
'ci-report' => Commands::CiReport,
|
|
17
|
+
'check' => Commands::Check,
|
|
13
18
|
'matrix' => Commands::Matrix,
|
|
19
|
+
'export' => Commands::Export,
|
|
14
20
|
'profile' => Commands::Profile,
|
|
15
21
|
'config' => Commands::ConfigCmd,
|
|
16
22
|
'repos' => Commands::Repos,
|
|
@@ -35,8 +41,9 @@ module Fp
|
|
|
35
41
|
exit 1
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# Profile/config commands don't need auth
|
|
39
|
-
|
|
44
|
+
# Profile/config commands don't need auth. `init` is here too: it only writes
|
|
45
|
+
# files into the repo, and onboarding a repo must work before anyone has a key.
|
|
46
|
+
needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
|
|
40
47
|
|
|
41
48
|
if needs_auth
|
|
42
49
|
config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
|
data/lib/fp/client.rb
CHANGED
|
@@ -72,6 +72,30 @@ module Fp
|
|
|
72
72
|
put("/api/requirements/#{id}", params)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# GET /requirements/:id/changes
|
|
76
|
+
#
|
|
77
|
+
# A requirement's change history: what it was, what changed, who changed it, and
|
|
78
|
+
# what prompted the change. Includes the open proposal (if any) broken out.
|
|
79
|
+
def list_requirement_changes(requirement_id)
|
|
80
|
+
get("/api/requirements/#{requirement_id}/changes")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# POST /requirements/:id/changes
|
|
84
|
+
#
|
|
85
|
+
# Propose a modification, or a deletion when params[:removal] is true. The
|
|
86
|
+
# requirement drops back to draft until a human reviews it.
|
|
87
|
+
def propose_requirement_change(requirement_id, params)
|
|
88
|
+
post("/api/requirements/#{requirement_id}/changes", params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# PUT /requirements/:id/changes/:change_id
|
|
92
|
+
#
|
|
93
|
+
# Approve or reject an open proposal. Humans only — an agent key gets a 403, which
|
|
94
|
+
# is deliberate and printed verbatim.
|
|
95
|
+
def review_requirement_change(requirement_id, change_id, params)
|
|
96
|
+
put("/api/requirements/#{requirement_id}/changes/#{change_id}", params)
|
|
97
|
+
end
|
|
98
|
+
|
|
75
99
|
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
76
100
|
# optionally narrowed to one requirement slug and/or surface.
|
|
77
101
|
def list_evidence(project_id, slug: nil, surface: nil)
|
|
@@ -87,17 +111,49 @@ module Fp
|
|
|
87
111
|
post("/api/projects/#{project_id}/evidence", params)
|
|
88
112
|
end
|
|
89
113
|
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
def
|
|
98
|
-
|
|
114
|
+
# POST /projects/:project_id/ci_results — the CI-only path.
|
|
115
|
+
#
|
|
116
|
+
# Unlike report_evidence (agents: present/stub), this hands the raw JUnit XML
|
|
117
|
+
# to the backend, which parses it, maps testcases to fp:<slug> markers, and
|
|
118
|
+
# writes passing/failing evidence per surface. It also marks previously
|
|
119
|
+
# passing slugs that have vanished from the results as failing. This is the
|
|
120
|
+
# half of the loop only CI is allowed to close (#1222).
|
|
121
|
+
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
|
|
122
|
+
post("/api/projects/#{project_id}/ci_results", {
|
|
123
|
+
ci_url: ci_url,
|
|
124
|
+
surface: surface,
|
|
125
|
+
junit_xml: junit_xml
|
|
126
|
+
})
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# GET /projects/:project_id/parity — the computed parity grid and gate verdict.
|
|
130
|
+
#
|
|
131
|
+
# This replaces the old get_matrix/get_gaps stubs, which pointed at /api/matrix
|
|
132
|
+
# and /api/gaps — routes that were never built. `fp matrix` and `fp list --gaps`
|
|
133
|
+
# never called them, and instead each guessed at coverage client-side from
|
|
134
|
+
# required_surfaces without ever reading Evidence. The server now does the
|
|
135
|
+
# cross-referencing so the matrix, the gaps list, and the CI gate agree.
|
|
136
|
+
#
|
|
137
|
+
# @param level [String, nil] enforcement level override; nil uses the project's own
|
|
138
|
+
# @param surface [String, nil] restrict the report to a single surface key
|
|
139
|
+
def get_parity(project_id, level: nil, surface: nil)
|
|
140
|
+
params = {}
|
|
141
|
+
params[:level] = level if level
|
|
99
142
|
params[:surface] = surface if surface
|
|
100
|
-
get(
|
|
143
|
+
get("/api/projects/#{project_id}/parity", params)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# POST /projects/:project_id/parity/preview — the pull-request gate.
|
|
147
|
+
#
|
|
148
|
+
# Evaluates JUnit results as an overlay on the stored grid *without persisting them*,
|
|
149
|
+
# answering "would parity still hold if this merged?". GET parity can only describe
|
|
150
|
+
# what has already been recorded, which on a PR is the default branch's state; this
|
|
151
|
+
# is how a branch gets gated on its own results without writing evidence that would
|
|
152
|
+
# churn the live matrix.
|
|
153
|
+
def preview_parity(project_id, surface:, junit_xmls:, level: nil)
|
|
154
|
+
body = { surface: surface, junit_xmls: Array(junit_xmls) }
|
|
155
|
+
body[:level] = level if level
|
|
156
|
+
post("/api/projects/#{project_id}/parity/preview", body)
|
|
101
157
|
end
|
|
102
158
|
|
|
103
159
|
private
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -80,11 +80,60 @@ module Fp
|
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Resolve which project a command should act on, filling in opts[:project] from the
|
|
84
|
+
# repo-local .featureparity.yml when --project wasn't given.
|
|
85
|
+
#
|
|
86
|
+
# That fallback is what makes enforcement workable in CI and for agents: a
|
|
87
|
+
# generated workflow, and an agent in a fresh clone, shouldn't have to know (or
|
|
88
|
+
# duplicate) the project slug when the repo already declares it.
|
|
89
|
+
#
|
|
90
|
+
# Mutates opts rather than returning a separate value so the existing
|
|
91
|
+
# resolve_project_id / display code in each command needs no changes.
|
|
92
|
+
# Returns the slug; exits 1 with guidance when neither source has one.
|
|
93
|
+
def require_project!(opts, project_config = nil)
|
|
94
|
+
opts[:project] ||= (project_config || ProjectConfig.discover).project
|
|
95
|
+
|
|
96
|
+
unless opts[:project]
|
|
97
|
+
output.error(
|
|
98
|
+
'--project is required (or add `project: <slug>` to .featureparity.yml — see `fp init`)'
|
|
99
|
+
)
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
opts[:project]
|
|
104
|
+
end
|
|
105
|
+
|
|
83
106
|
def truncate(str, max)
|
|
84
107
|
return str if str.nil? || str.length <= max
|
|
85
108
|
|
|
86
109
|
str[0...max - 3] + '...'
|
|
87
110
|
end
|
|
111
|
+
|
|
112
|
+
# Pull every occurrence of a repeatable flag out of args, returning
|
|
113
|
+
# [values, remaining_args].
|
|
114
|
+
#
|
|
115
|
+
# parse_flags keeps only the last occurrence of a repeated flag, so a flag that may
|
|
116
|
+
# legitimately appear more than once (--junit, for a suite split across shards) has
|
|
117
|
+
# to be extracted before the normal parse. Shared by `fp ci-report` and `fp check`.
|
|
118
|
+
def extract_repeated(args, flag)
|
|
119
|
+
values = []
|
|
120
|
+
rest = []
|
|
121
|
+
i = 0
|
|
122
|
+
while i < args.length
|
|
123
|
+
arg = args[i]
|
|
124
|
+
if arg == flag && i + 1 < args.length
|
|
125
|
+
values << args[i + 1]
|
|
126
|
+
i += 2
|
|
127
|
+
elsif arg.start_with?("#{flag}=")
|
|
128
|
+
values << arg.split('=', 2)[1]
|
|
129
|
+
i += 1
|
|
130
|
+
else
|
|
131
|
+
rest << arg
|
|
132
|
+
i += 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
[values, rest]
|
|
136
|
+
end
|
|
88
137
|
end
|
|
89
138
|
end
|
|
90
139
|
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp check [--project X] [--surface api] [--level covered] [--junit FILE] [--json]
|
|
6
|
+
#
|
|
7
|
+
# The enforcement gate. Fetches the parity report and exits non-zero when the
|
|
8
|
+
# project violates its enforcement level, which is what makes it usable as a
|
|
9
|
+
# required status check on a pull request.
|
|
10
|
+
#
|
|
11
|
+
# Two modes:
|
|
12
|
+
#
|
|
13
|
+
# fp check Gate on what has been recorded. Answers "is this
|
|
14
|
+
# project currently in violation?"
|
|
15
|
+
# fp check --junit r.xml Gate on *this run's* results, applied on top of the
|
|
16
|
+
# --surface api recorded grid but never saved. Answers "would parity
|
|
17
|
+
# still hold if this branch merged?"
|
|
18
|
+
#
|
|
19
|
+
# The second is the pull-request gate. Without it a PR run can only see the default
|
|
20
|
+
# branch's state, so it can't tell you that the branch in front of it deleted the only
|
|
21
|
+
# test covering a requirement. Reporting the branch's evidence for real would answer
|
|
22
|
+
# that too, but would let every in-progress branch churn the live matrix — so the
|
|
23
|
+
# results are evaluated and thrown away.
|
|
24
|
+
#
|
|
25
|
+
# Everything about *what counts as a violation* lives server-side in ParityReport —
|
|
26
|
+
# this command renders the verdict and turns it into an exit code. That split is
|
|
27
|
+
# deliberate: if the policy lived in the CLI, every repo would enforce whichever
|
|
28
|
+
# version of the gem its CI happened to install.
|
|
29
|
+
#
|
|
30
|
+
# Exit codes are the contract CI reads:
|
|
31
|
+
# 0 gate passed
|
|
32
|
+
# 1 gate failed (violations), or the request failed
|
|
33
|
+
#
|
|
34
|
+
# `--warn-only` reports violations and still exits 0, for landing the gate on a repo
|
|
35
|
+
# that isn't clean yet — you get the annotations without blocking every merge on day
|
|
36
|
+
# one. Prefer setting the project's level to `regression` over living on --warn-only
|
|
37
|
+
# forever, since a warn-only gate is one nobody reads.
|
|
38
|
+
class Check < Base
|
|
39
|
+
KNOWN_FLAGS = {
|
|
40
|
+
project: :string,
|
|
41
|
+
surface: :string,
|
|
42
|
+
level: :string,
|
|
43
|
+
junit: :string,
|
|
44
|
+
warn_only: :boolean
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def run(args)
|
|
48
|
+
# Collect every --junit occurrence (parse_flags keeps only the last of a repeated
|
|
49
|
+
# flag), matching `fp ci-report`.
|
|
50
|
+
junit_paths, rest = extract_repeated(args, '--junit')
|
|
51
|
+
opts, = parse_flags(rest, KNOWN_FLAGS)
|
|
52
|
+
|
|
53
|
+
project_config = ProjectConfig.discover
|
|
54
|
+
project_slug = require_project!(opts, project_config)
|
|
55
|
+
|
|
56
|
+
surface = opts[:surface] || project_config.surface
|
|
57
|
+
level = opts[:level] || project_config.enforcement
|
|
58
|
+
|
|
59
|
+
project_id = resolve_project_id(project_slug)
|
|
60
|
+
|
|
61
|
+
result =
|
|
62
|
+
if junit_paths.empty?
|
|
63
|
+
client.get_parity(project_id, level: level, surface: surface)
|
|
64
|
+
else
|
|
65
|
+
preview(project_id, junit_paths, surface, level)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless result[:ok]
|
|
69
|
+
output.error(result[:error], status: result[:status])
|
|
70
|
+
exit 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
report = result[:data]['parity'] || {}
|
|
74
|
+
violations = report['violations'] || []
|
|
75
|
+
passed = report['passing']
|
|
76
|
+
|
|
77
|
+
render(report, violations, passed, project_slug, opts)
|
|
78
|
+
|
|
79
|
+
# A failing gate is the whole point of the command, so it must be the exit code
|
|
80
|
+
# and not just something printed. --warn-only downgrades it to advisory.
|
|
81
|
+
exit 1 if !passed && !opts[:warn_only]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Preview mode needs to know which surface the results belong to, because the
|
|
87
|
+
# server uses it to decide which cells the run had a chance to cover — and
|
|
88
|
+
# therefore which vanished tests it may legitimately flag.
|
|
89
|
+
def preview(project_id, junit_paths, surface, level)
|
|
90
|
+
unless surface
|
|
91
|
+
output.error(
|
|
92
|
+
'--surface is required with --junit (the surface these results are for). ' \
|
|
93
|
+
'Set it via --surface or `surface:` in .featureparity.yml.'
|
|
94
|
+
)
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
missing = junit_paths.reject { |p| File.file?(p) }
|
|
99
|
+
unless missing.empty?
|
|
100
|
+
output.error("JUnit file(s) not found: #{missing.join(', ')}")
|
|
101
|
+
exit 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
client.preview_parity(
|
|
105
|
+
project_id,
|
|
106
|
+
surface: surface,
|
|
107
|
+
junit_xmls: junit_paths.map { |p| File.read(p) },
|
|
108
|
+
level: level
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def render(report, violations, passed, project_slug, opts)
|
|
113
|
+
payload = report.merge('project' => project_slug, 'warn_only' => !!opts[:warn_only])
|
|
114
|
+
|
|
115
|
+
output.success(payload) do
|
|
116
|
+
summary = report['summary'] || {}
|
|
117
|
+
scope = report['surface'] ? " (surface: #{report['surface']})" : ''
|
|
118
|
+
puts "FeatureParity gate: #{project_slug}#{scope}"
|
|
119
|
+
puts "Enforcement level: #{report['level']}"
|
|
120
|
+
if report['preview']
|
|
121
|
+
reported = Array(report['preview_surfaces']).join(', ')
|
|
122
|
+
puts "Mode: preview — evaluating this run's results#{reported.empty? ? '' : " for #{reported}"} (nothing saved)"
|
|
123
|
+
end
|
|
124
|
+
puts
|
|
125
|
+
|
|
126
|
+
if (report['surfaces'] || []).empty?
|
|
127
|
+
puts 'This project has no surfaces configured, so there is nothing to enforce.'
|
|
128
|
+
puts "Add one with: fp surfaces add <key> --project #{project_slug}"
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if summary['requirements'].to_i.zero?
|
|
133
|
+
puts 'No requirements to measure. Nothing to enforce yet.'
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
print_state_counts(summary)
|
|
138
|
+
print_violations(violations)
|
|
139
|
+
print_pending_reviews(report, project_slug)
|
|
140
|
+
print_unscoped_warning(summary, project_slug)
|
|
141
|
+
print_verdict(passed, violations, opts)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def print_state_counts(summary)
|
|
146
|
+
counts = Parity.count_labels(summary)
|
|
147
|
+
|
|
148
|
+
puts "#{summary['requirements']} requirement(s) measured, #{summary['cells']} required surface(s)"
|
|
149
|
+
puts " #{counts.join(' ')}" unless counts.empty?
|
|
150
|
+
puts
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Requirements a proposed change has knocked back to draft. Informational, never a
|
|
154
|
+
# violation — a human's review latency is not something to fail an agent's build
|
|
155
|
+
# over. But they stay in the grid (proposing a change must not be a way out of the
|
|
156
|
+
# gate), and proposing marks their evidence `suspect`, so saying nothing here would
|
|
157
|
+
# leave a reader guessing why a row that was green went ⚠.
|
|
158
|
+
def print_pending_reviews(report, project_slug)
|
|
159
|
+
pending = report['pending_reviews'] || []
|
|
160
|
+
return if pending.empty?
|
|
161
|
+
|
|
162
|
+
puts "#{pending.size} requirement(s) awaiting review of a proposed change " \
|
|
163
|
+
"(#{Parity.pending_summary(pending)}):"
|
|
164
|
+
output.table(
|
|
165
|
+
%w[SLUG PROPOSED TITLE],
|
|
166
|
+
pending.map { |p| [p['slug'], p['kind'], truncate(p['title'].to_s, 48)] }
|
|
167
|
+
)
|
|
168
|
+
puts 'These still count toward the gate — proposing a change is not a way out of it.'
|
|
169
|
+
puts "Their evidence is suspect until the change is reviewed: fp history <slug>#{project_scope(project_slug)}"
|
|
170
|
+
puts
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def print_violations(violations)
|
|
174
|
+
return if violations.empty?
|
|
175
|
+
|
|
176
|
+
puts "#{violations.size} violation(s):"
|
|
177
|
+
output.table(
|
|
178
|
+
%w[SLUG SURFACE STATE WHY],
|
|
179
|
+
violations.map do |v|
|
|
180
|
+
[v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
|
|
181
|
+
end
|
|
182
|
+
)
|
|
183
|
+
puts
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Informational, never a violation: an active requirement naming no surface this
|
|
187
|
+
# project has can't be satisfied, so it would pass any gate silently. Worth
|
|
188
|
+
# saying out loud without blocking a merge on someone else's data-entry gap.
|
|
189
|
+
# `--project` is only worth echoing in a suggested command when the slug didn't
|
|
190
|
+
# come from .featureparity.yml — otherwise it's noise the agent would copy.
|
|
191
|
+
def project_scope(project_slug)
|
|
192
|
+
ProjectConfig.discover.project ? '' : " --project #{project_slug}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print_unscoped_warning(summary, project_slug)
|
|
196
|
+
unscoped = summary['unscoped'].to_i
|
|
197
|
+
return unless unscoped.positive?
|
|
198
|
+
|
|
199
|
+
puts "Note: #{unscoped} requirement(s) name no surface this project has, so " \
|
|
200
|
+
'nothing can satisfy them.'
|
|
201
|
+
puts " Review with: fp list --project #{project_slug}"
|
|
202
|
+
puts
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def print_verdict(passed, violations, opts)
|
|
206
|
+
if passed
|
|
207
|
+
puts 'Gate passed.'
|
|
208
|
+
elsif opts[:warn_only]
|
|
209
|
+
puts "Gate FAILED with #{violations.size} violation(s) — exiting 0 because --warn-only was given."
|
|
210
|
+
else
|
|
211
|
+
puts "Gate FAILED with #{violations.size} violation(s)."
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# CI-only evidence reporting from a JUnit XML report.
|
|
6
|
+
#
|
|
7
|
+
# fp ci-report --junit <path> --project <slug> --surface X --ci-url URL
|
|
8
|
+
#
|
|
9
|
+
# This is the CI counterpart to `fp report --junit`. Where `fp report`
|
|
10
|
+
# writes agent evidence (present/stub) and never claims a test passed, this
|
|
11
|
+
# command hands the raw JUnit XML to the backend's CI ingest endpoint, which:
|
|
12
|
+
#
|
|
13
|
+
# - maps each testcase to its fp:<slug> marker (server-side, same
|
|
14
|
+
# file-scanning convention as the CLI),
|
|
15
|
+
# - writes passing / failing evidence per requirement for the surface,
|
|
16
|
+
# - and marks any slug that was passing but has now vanished from the
|
|
17
|
+
# report as failing.
|
|
18
|
+
#
|
|
19
|
+
# That last part is why the whole report goes up as one document rather than
|
|
20
|
+
# per-slug: the server can only detect a disappeared test by diffing the full
|
|
21
|
+
# result set against what it had. Reporting passing/failing is a privilege of
|
|
22
|
+
# CI (#1222) — agents can't reach this path, they only ever report evidence
|
|
23
|
+
# via `fp report`.
|
|
24
|
+
#
|
|
25
|
+
# Multiple --junit flags may be passed to ingest several reports for the same
|
|
26
|
+
# surface in one invocation (e.g. a suite split across shards). Each is
|
|
27
|
+
# uploaded in turn.
|
|
28
|
+
class CiReport < Base
|
|
29
|
+
KNOWN_FLAGS = {
|
|
30
|
+
project: :string,
|
|
31
|
+
surface: :string,
|
|
32
|
+
ci_url: :string,
|
|
33
|
+
junit: :string
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def run(args)
|
|
37
|
+
# Collect every --junit occurrence (parse_flags keeps only the last of a
|
|
38
|
+
# repeated flag, so pull them out first).
|
|
39
|
+
junit_paths, rest = extract_repeated(args, '--junit')
|
|
40
|
+
opts, _positional = parse_flags(rest, KNOWN_FLAGS)
|
|
41
|
+
|
|
42
|
+
require_project!(opts)
|
|
43
|
+
require_flag(opts, :surface)
|
|
44
|
+
require_flag(opts, :ci_url)
|
|
45
|
+
|
|
46
|
+
if junit_paths.empty?
|
|
47
|
+
output.error('--junit <path> is required (may be given more than once).')
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
missing = junit_paths.reject { |p| File.file?(p) }
|
|
52
|
+
unless missing.empty?
|
|
53
|
+
output.error("JUnit file(s) not found: #{missing.join(', ')}")
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
project_id = resolve_project_id(opts[:project])
|
|
58
|
+
|
|
59
|
+
totals = { created: 0, updated: 0, missing_failing: 0, reports: [] }
|
|
60
|
+
|
|
61
|
+
junit_paths.each do |path|
|
|
62
|
+
xml = File.read(path)
|
|
63
|
+
result = client.ingest_ci_results(
|
|
64
|
+
project_id,
|
|
65
|
+
ci_url: opts[:ci_url],
|
|
66
|
+
surface: opts[:surface],
|
|
67
|
+
junit_xml: xml
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
unless result[:ok]
|
|
71
|
+
output.error("Failed to ingest #{path}: #{result[:error]}", status: result[:status])
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
data = result[:data] || {}
|
|
76
|
+
totals[:created] += data['evidence_created'].to_i
|
|
77
|
+
totals[:updated] += data['evidence_updated'].to_i
|
|
78
|
+
totals[:missing_failing] += data['missing_marked_failing'].to_i
|
|
79
|
+
totals[:reports] << { path: path, results: data['results'] || [] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
emit_summary(opts, totals)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Pull every occurrence of a repeatable flag (and its value) out of args,
|
|
88
|
+
# returning [collected_values, remaining_args]. Leaves all other flags for
|
|
89
|
+
# the normal parser.
|
|
90
|
+
def emit_summary(opts, totals)
|
|
91
|
+
data = {
|
|
92
|
+
project: opts[:project],
|
|
93
|
+
surface: opts[:surface],
|
|
94
|
+
ci_url: opts[:ci_url],
|
|
95
|
+
evidence_created: totals[:created],
|
|
96
|
+
evidence_updated: totals[:updated],
|
|
97
|
+
missing_marked_failing: totals[:missing_failing],
|
|
98
|
+
reports: totals[:reports]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
output.success(data) do
|
|
102
|
+
puts "Ingested CI results for surface '#{opts[:surface]}':"
|
|
103
|
+
totals[:reports].each do |report|
|
|
104
|
+
report[:results].each do |r|
|
|
105
|
+
glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
|
|
106
|
+
puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
puts
|
|
110
|
+
puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# Renders every requirement in a project as a single Markdown document —
|
|
6
|
+
# title, status, category, surfaces, why/acceptance criteria, and an
|
|
7
|
+
# evidence summary per surface — nested parent/child like `fp list`.
|
|
8
|
+
#
|
|
9
|
+
# Unlike `fp matrix --csv` (a parity spreadsheet) this is meant to be read
|
|
10
|
+
# top to bottom: handed to a stakeholder, dropped in a repo's docs/, or
|
|
11
|
+
# attached to a release as a point-in-time record of what the product is
|
|
12
|
+
# supposed to do.
|
|
13
|
+
class Export < Base
|
|
14
|
+
KNOWN_FLAGS = {
|
|
15
|
+
project: :string,
|
|
16
|
+
status: :string,
|
|
17
|
+
category: :string,
|
|
18
|
+
surface: :string,
|
|
19
|
+
output: :string
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def run(args)
|
|
23
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
24
|
+
require_project!(opts)
|
|
25
|
+
|
|
26
|
+
project_id = resolve_project_id(opts[:project])
|
|
27
|
+
|
|
28
|
+
result = client.list_requirements(project_id: project_id, category: opts[:category])
|
|
29
|
+
unless result[:ok]
|
|
30
|
+
output.error(result[:error], status: result[:status])
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
requirements = result[:data]['requirements'] || []
|
|
35
|
+
|
|
36
|
+
status_filter = opts[:status]
|
|
37
|
+
requirements = requirements.select { |r| r['status'] == status_filter } if status_filter
|
|
38
|
+
|
|
39
|
+
if opts[:surface]
|
|
40
|
+
requirements = requirements.select { |r| (r['required_surfaces'] || []).include?(opts[:surface]) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
document = render_document(opts[:project], requirements, project_id)
|
|
44
|
+
|
|
45
|
+
if opts[:output]
|
|
46
|
+
File.write(opts[:output], document)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
output.success({ project: opts[:project], requirement_count: requirements.length, document: document }) do
|
|
50
|
+
if opts[:output]
|
|
51
|
+
puts "Wrote #{requirements.length} requirement(s) to #{opts[:output]}"
|
|
52
|
+
else
|
|
53
|
+
puts document
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def render_document(project_slug, requirements, project_id)
|
|
61
|
+
lines = []
|
|
62
|
+
lines << "# Requirements: #{project_slug}"
|
|
63
|
+
lines << ''
|
|
64
|
+
lines << "_Generated #{Time.now.utc.strftime('%Y-%m-%d %H:%M UTC')} — #{requirements.length} requirement(s)._"
|
|
65
|
+
lines << ''
|
|
66
|
+
|
|
67
|
+
if requirements.empty?
|
|
68
|
+
lines << 'No requirements match the given filters.'
|
|
69
|
+
return lines.join("\n") + "\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
|
|
73
|
+
children_by_parent = requirements
|
|
74
|
+
.select { |r| r['parent_id'] && r['parent_id'] != '' }
|
|
75
|
+
.group_by { |r| r['parent_id'] }
|
|
76
|
+
|
|
77
|
+
parent_ids = parents.map { |p| p['id'] }.to_set
|
|
78
|
+
orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
|
|
79
|
+
|
|
80
|
+
evidence_by_slug = fetch_evidence_by_slug(project_id, requirements)
|
|
81
|
+
|
|
82
|
+
parents.each do |req|
|
|
83
|
+
render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2)
|
|
84
|
+
(children_by_parent[req['id']] || []).each do |child|
|
|
85
|
+
render_requirement(lines, child, evidence_by_slug[child['slug']], level: 3)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
orphans.each { |req| render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2) }
|
|
90
|
+
|
|
91
|
+
lines.join("\n") + "\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_requirement(lines, req, evidence, level:)
|
|
95
|
+
heading = '#' * level
|
|
96
|
+
lines << "#{heading} #{req['title']}"
|
|
97
|
+
lines << ''
|
|
98
|
+
lines << "- **Slug:** `#{req['slug']}`"
|
|
99
|
+
lines << "- **Status:** #{req['status']}"
|
|
100
|
+
lines << "- **Category:** #{req['category'] || '(none)'}"
|
|
101
|
+
|
|
102
|
+
surfaces = req['required_surfaces'] || []
|
|
103
|
+
lines << "- **Required Surfaces:** #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
|
|
104
|
+
lines << ''
|
|
105
|
+
|
|
106
|
+
if req['why'] && !req['why'].empty?
|
|
107
|
+
lines << '**Why:**'
|
|
108
|
+
lines << ''
|
|
109
|
+
lines << req['why']
|
|
110
|
+
lines << ''
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if req['acceptance'] && !req['acceptance'].empty?
|
|
114
|
+
lines << '**Acceptance Criteria:**'
|
|
115
|
+
lines << ''
|
|
116
|
+
lines << req['acceptance']
|
|
117
|
+
lines << ''
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
lines << '**Evidence:**'
|
|
121
|
+
lines << ''
|
|
122
|
+
if evidence.nil? || evidence.empty?
|
|
123
|
+
lines << '_(none reported)_'
|
|
124
|
+
else
|
|
125
|
+
evidence.group_by { |e| e['surface'] }.each do |surface, records|
|
|
126
|
+
latest = records.max_by { |e| e['reported_at'].to_s }
|
|
127
|
+
lines << "- `#{surface}`: #{latest['status']}#{latest['reported_at'] ? " (as of #{latest['reported_at']})" : ''}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
lines << ''
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Evidence is keyed per-slug on the API, so fetch each requirement's records
|
|
134
|
+
# individually. Fine for a document export (infrequent, human-facing) rather
|
|
135
|
+
# than a hot path like `fp matrix`.
|
|
136
|
+
def fetch_evidence_by_slug(project_id, requirements)
|
|
137
|
+
requirements.each_with_object({}) do |req, memo|
|
|
138
|
+
result = client.list_evidence(project_id, slug: req['slug'])
|
|
139
|
+
memo[req['slug']] = result[:ok] ? (result[:data]['evidence'] || []) : []
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|