featureparity 0.0.14 → 0.0.16
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 +5 -1
- data/lib/fp/client.rb +92 -0
- data/lib/fp/commands/attach.rb +190 -0
- data/lib/fp/commands/attachments.rb +122 -0
- data/lib/fp/commands/changes.rb +359 -0
- data/lib/fp/commands/check.rb +30 -7
- data/lib/fp/commands/ci_report.rb +5 -1
- data/lib/fp/commands/conversation.rb +92 -0
- data/lib/fp/commands/help.rb +65 -9
- data/lib/fp/commands/init.rb +106 -21
- data/lib/fp/commands/propose.rb +23 -2
- data/lib/fp/commands/propose_change.rb +13 -5
- data/lib/fp/commands/report.rb +19 -3
- data/lib/fp/commands/show.rb +34 -2
- data/lib/fp/commands.rb +4 -0
- data/lib/fp/junit.rb +114 -97
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +112 -25
- data/skills/fp/references/cli.md +88 -24
- data/skills/fp/references/markers.md +25 -0
- metadata +6 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module Fp
|
|
9
|
+
module Commands
|
|
10
|
+
# `fp changes` lists requirement activity associated with a work item. In GitHub
|
|
11
|
+
# Actions it can discover source URLs from the pull request body, append a job
|
|
12
|
+
# summary, and keep a managed table in the pull request description itself.
|
|
13
|
+
class Changes < Base
|
|
14
|
+
PR_SECTION_BEGIN = '<!-- feature-parity:requirement-changes:begin -->'
|
|
15
|
+
PR_SECTION_END = '<!-- feature-parity:requirement-changes:end -->'
|
|
16
|
+
|
|
17
|
+
KNOWN_FLAGS = {
|
|
18
|
+
project: :string,
|
|
19
|
+
source_url: :string,
|
|
20
|
+
github_summary: :boolean,
|
|
21
|
+
github_pr: :boolean
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
TYPE_LABELS = {
|
|
25
|
+
'created' => 'Created',
|
|
26
|
+
'updated' => 'Edited',
|
|
27
|
+
'activated' => 'Activated',
|
|
28
|
+
'archived' => 'Archived',
|
|
29
|
+
'rejected' => 'Rejected',
|
|
30
|
+
'destroyed' => 'Deleted',
|
|
31
|
+
'change_proposed' => 'Modification proposed',
|
|
32
|
+
'removal_proposed' => 'Removal proposed',
|
|
33
|
+
'change_approved' => 'Change approved',
|
|
34
|
+
'change_rejected' => 'Change rejected'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def run(args)
|
|
38
|
+
explicit_urls, rest = extract_repeated(args, '--source-url')
|
|
39
|
+
opts, = parse_flags(rest, KNOWN_FLAGS)
|
|
40
|
+
project_config = ProjectConfig.discover
|
|
41
|
+
project_slug = require_project!(opts, project_config)
|
|
42
|
+
@github_pull_request = fetch_github_pull_request if opts[:github_pr]
|
|
43
|
+
|
|
44
|
+
source_urls = explicit_urls.map(&:strip).reject(&:empty?).uniq
|
|
45
|
+
source_urls = github_source_urls if source_urls.empty?
|
|
46
|
+
|
|
47
|
+
data = {
|
|
48
|
+
project: project_slug,
|
|
49
|
+
source_urls: source_urls,
|
|
50
|
+
requirement_changes: [],
|
|
51
|
+
source_detected: !source_urls.empty?
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if source_urls.any?
|
|
55
|
+
project_id = resolve_project_id(project_slug)
|
|
56
|
+
data[:requirement_changes] = fetch_changes(project_id, source_urls)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
markdown = render_markdown(data)
|
|
60
|
+
destinations = []
|
|
61
|
+
|
|
62
|
+
if opts[:github_summary]
|
|
63
|
+
append_github_summary(markdown)
|
|
64
|
+
destinations << ENV['GITHUB_STEP_SUMMARY']
|
|
65
|
+
end
|
|
66
|
+
if opts[:github_pr]
|
|
67
|
+
update_github_pull_request(markdown)
|
|
68
|
+
destinations << 'the pull request description'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
output.success(data) do
|
|
72
|
+
if destinations.any?
|
|
73
|
+
puts "FeatureParity requirement-change table updated in #{destinations.join(' and ')}."
|
|
74
|
+
else
|
|
75
|
+
puts markdown
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def fetch_changes(project_id, source_urls)
|
|
83
|
+
changes = []
|
|
84
|
+
|
|
85
|
+
source_urls.each do |source_url|
|
|
86
|
+
result = client.list_project_requirement_changes(project_id, source_url: source_url)
|
|
87
|
+
if [400, 403, 404].include?(result[:status].to_i) && result[:error].to_s.match?(/bad request|not found/i)
|
|
88
|
+
return legacy_changes(project_id, source_urls)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
unless result[:ok]
|
|
92
|
+
output.error(result[:error], status: result[:status])
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
changes.concat(result[:data]['requirement_changes'] || [])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
collapse_to_latest_per_requirement(
|
|
100
|
+
changes.uniq { |change| change['id'] }
|
|
101
|
+
.sort_by { |change| change['recorded_at'].to_s }
|
|
102
|
+
.reverse
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Option A: one row per requirement. A requirement whose creation and a later
|
|
107
|
+
# modification proposal both trace to the same work item would otherwise appear
|
|
108
|
+
# twice, which reads as a duplicate. We keep only the most recent event per
|
|
109
|
+
# requirement, so the table answers "where does each requirement stand now?"
|
|
110
|
+
# rather than replaying its whole timeline (that lives in `fp history <slug>`).
|
|
111
|
+
def collapse_to_latest_per_requirement(changes)
|
|
112
|
+
changes.each_with_object({}) do |change, latest|
|
|
113
|
+
key = change['slug'] || change['requirement_path'] || change['id']
|
|
114
|
+
latest[key] ||= change
|
|
115
|
+
end.values
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Compatibility with an API that predates the project activity endpoint. This is
|
|
119
|
+
# especially useful while a CLI/API release is rolling out: requirements whose own
|
|
120
|
+
# source matches the work item, plus every open proposal, are inspected through the
|
|
121
|
+
# existing per-requirement history endpoint. Once the endpoint is deployed, its
|
|
122
|
+
# ProjectIndex also covers reviewed and deleted activity without this bounded scan.
|
|
123
|
+
def legacy_changes(project_id, source_urls)
|
|
124
|
+
result = client.list_requirements(project_id: project_id)
|
|
125
|
+
unless result[:ok]
|
|
126
|
+
output.error(result[:error], status: result[:status])
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
requirements = result[:data]['requirements'] || []
|
|
131
|
+
candidates = requirements.select do |requirement|
|
|
132
|
+
source_urls.include?(requirement['source_url']) || present?(requirement['pending_change_id'])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
collapsed = candidates.flat_map do |requirement|
|
|
136
|
+
history = client.list_requirement_changes(requirement['id'])
|
|
137
|
+
next [] unless history[:ok]
|
|
138
|
+
|
|
139
|
+
(history[:data]['changes'] || []).filter_map do |change|
|
|
140
|
+
next unless source_urls.include?(change.dig('source', 'url'))
|
|
141
|
+
|
|
142
|
+
change.merge(
|
|
143
|
+
'slug' => requirement['slug'],
|
|
144
|
+
'title' => requirement['title'],
|
|
145
|
+
'requirement_status' => requirement['status'],
|
|
146
|
+
'requirement_path' => "/projects/#{project_id}/requirements/#{requirement['id']}",
|
|
147
|
+
'review_state' => change.dig('review', 'decision') || (change['pending'] ? 'pending' : requirement['status'])
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end.uniq { |change| change['id'] }
|
|
151
|
+
.sort_by { |change| change['recorded_at'].to_s }
|
|
152
|
+
.reverse
|
|
153
|
+
|
|
154
|
+
collapse_to_latest_per_requirement(collapsed)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def github_source_urls
|
|
158
|
+
body = if @github_pull_request
|
|
159
|
+
@github_pull_request.fetch('body', '').to_s
|
|
160
|
+
else
|
|
161
|
+
github_event.dig('pull_request', 'body').to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
body = body.sub(/#{Regexp.escape(PR_SECTION_BEGIN)}.*?#{Regexp.escape(PR_SECTION_END)}/m, '')
|
|
165
|
+
|
|
166
|
+
# Option B: explicit fp-source markers take precedence
|
|
167
|
+
explicit = body.scan(/fp-source:\s*(https?:\/\/\S+)/i).flatten.map { |url| clean_url(url) }
|
|
168
|
+
return explicit.uniq if explicit.any?
|
|
169
|
+
|
|
170
|
+
# Option A: heuristic detection of ticket-tracker URLs
|
|
171
|
+
URI.extract(body, %w[http https])
|
|
172
|
+
.map { |url| clean_url(url) }
|
|
173
|
+
.select { |url| work_item_url?(url) }
|
|
174
|
+
.uniq
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def github_event
|
|
178
|
+
return @github_event if defined?(@github_event)
|
|
179
|
+
|
|
180
|
+
event_path = ENV['GITHUB_EVENT_PATH']
|
|
181
|
+
@github_event = if present?(event_path) && File.file?(event_path)
|
|
182
|
+
JSON.parse(File.read(event_path))
|
|
183
|
+
else
|
|
184
|
+
{}
|
|
185
|
+
end
|
|
186
|
+
rescue JSON::ParserError
|
|
187
|
+
@github_event = {}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def work_item_url?(url)
|
|
191
|
+
# Heuristic detection: accept URLs that look like ticket trackers.
|
|
192
|
+
# Matches paths containing common ticket-tracker segments.
|
|
193
|
+
url.match?(%r{
|
|
194
|
+
\Ahttps?://[^/]+ # scheme and host
|
|
195
|
+
(?:
|
|
196
|
+
.*/(?:issues|cards|browse|tickets|tasks|work[-_]?items?|bugs?|stories?|features?|pull|merge_requests?|issue|story|_workitems|channels|c|t)/[^\s/]+ |
|
|
197
|
+
.*/\d+(?:/|\z) # any path ending in /123 or /123/
|
|
198
|
+
)
|
|
199
|
+
}xi)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def clean_url(url)
|
|
203
|
+
url.sub(/[)\]}>.,;:]+\z/, '')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def append_github_summary(markdown)
|
|
207
|
+
path = ENV['GITHUB_STEP_SUMMARY']
|
|
208
|
+
unless present?(path)
|
|
209
|
+
output.error('--github-summary requires GITHUB_STEP_SUMMARY (it is set automatically by GitHub Actions)')
|
|
210
|
+
exit 1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
File.open(path, 'a') { |file| file.puts(markdown) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fetch_github_pull_request
|
|
217
|
+
token, uri = github_credentials
|
|
218
|
+
response = github_request(Net::HTTP::Get, uri, token)
|
|
219
|
+
fail_github_request!('read the pull request', response) unless response.is_a?(Net::HTTPSuccess)
|
|
220
|
+
|
|
221
|
+
JSON.parse(response.body)
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
output.error('GitHub returned an invalid response while reading the pull request')
|
|
224
|
+
exit 1
|
|
225
|
+
rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
|
|
226
|
+
output.error("Could not read the pull request from GitHub: #{e.message}")
|
|
227
|
+
exit 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def update_github_pull_request(markdown)
|
|
231
|
+
current_body = @github_pull_request.fetch('body', '').to_s
|
|
232
|
+
updated_body = managed_pull_request_body(current_body, markdown)
|
|
233
|
+
return if updated_body == current_body
|
|
234
|
+
|
|
235
|
+
token, uri = github_credentials
|
|
236
|
+
response = github_request(Net::HTTP::Patch, uri, token, body: { body: updated_body })
|
|
237
|
+
fail_github_request!('update the pull request description', response) unless response.is_a?(Net::HTTPSuccess)
|
|
238
|
+
rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
|
|
239
|
+
output.error("Could not update the pull request description on GitHub: #{e.message}")
|
|
240
|
+
exit 1
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def managed_pull_request_body(body, markdown)
|
|
244
|
+
section = "#{PR_SECTION_BEGIN}\n#{markdown}\n#{PR_SECTION_END}"
|
|
245
|
+
begin_count = body.scan(PR_SECTION_BEGIN).length
|
|
246
|
+
end_count = body.scan(PR_SECTION_END).length
|
|
247
|
+
|
|
248
|
+
unless [begin_count, end_count].all? { |count| [0, 1].include?(count) } && begin_count == end_count
|
|
249
|
+
output.error('The pull request description has malformed or duplicate FeatureParity requirement-change markers')
|
|
250
|
+
exit 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if begin_count == 1
|
|
254
|
+
body.sub(/#{Regexp.escape(PR_SECTION_BEGIN)}.*?#{Regexp.escape(PR_SECTION_END)}/m, section)
|
|
255
|
+
else
|
|
256
|
+
[body.rstrip, section].reject(&:empty?).join("\n\n") + "\n"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def github_credentials
|
|
261
|
+
token = ENV['GITHUB_TOKEN']
|
|
262
|
+
repository = ENV['GITHUB_REPOSITORY'].to_s
|
|
263
|
+
number = github_event.dig('pull_request', 'number') || github_event['number']
|
|
264
|
+
|
|
265
|
+
unless present?(token) && repository.match?(%r{\A[^/]+/[^/]+\z}) && number.to_i.positive?
|
|
266
|
+
output.error('--github-pr requires GITHUB_TOKEN, GITHUB_REPOSITORY, and a pull_request GITHUB_EVENT_PATH')
|
|
267
|
+
exit 1
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
owner, repo = repository.split('/', 2).map { |part| URI.encode_www_form_component(part) }
|
|
271
|
+
api_url = ENV.fetch('GITHUB_API_URL', 'https://api.github.com').sub(%r{/+\z}, '')
|
|
272
|
+
[token, URI.parse("#{api_url}/repos/#{owner}/#{repo}/pulls/#{number.to_i}")]
|
|
273
|
+
rescue URI::InvalidURIError
|
|
274
|
+
output.error('GITHUB_API_URL is not a valid URL')
|
|
275
|
+
exit 1
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def github_request(request_class, uri, token, body: nil)
|
|
279
|
+
request = request_class.new(uri)
|
|
280
|
+
request['Accept'] = 'application/vnd.github+json'
|
|
281
|
+
request['Authorization'] = "Bearer #{token}"
|
|
282
|
+
request['User-Agent'] = 'featureparity-cli'
|
|
283
|
+
request['X-GitHub-Api-Version'] = '2022-11-28'
|
|
284
|
+
if body
|
|
285
|
+
request['Content-Type'] = 'application/json'
|
|
286
|
+
request.body = JSON.generate(body)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
290
|
+
http.request(request)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def fail_github_request!(action, response)
|
|
295
|
+
details = JSON.parse(response.body).fetch('message', response.body.to_s)
|
|
296
|
+
output.error("GitHub could not #{action}: #{details}", status: response.code.to_i)
|
|
297
|
+
exit 1
|
|
298
|
+
rescue JSON::ParserError
|
|
299
|
+
output.error("GitHub could not #{action}: HTTP #{response.code}", status: response.code.to_i)
|
|
300
|
+
exit 1
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def render_markdown(data)
|
|
304
|
+
lines = ['## FeatureParity requirement changes', '']
|
|
305
|
+
|
|
306
|
+
unless data[:source_detected]
|
|
307
|
+
lines << '⚠️ No work-item URL was found in the pull request description, so FeatureParity cannot determine which requirement activity belongs to this change.'
|
|
308
|
+
return lines.join("\n")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
links = data[:source_urls].map { |url| "[#{markdown_escape(url)}](#{url})" }
|
|
312
|
+
lines << "Work item#{links.length == 1 ? '' : 's'}: #{links.join(', ')}"
|
|
313
|
+
lines << ''
|
|
314
|
+
|
|
315
|
+
changes = data[:requirement_changes]
|
|
316
|
+
if changes.empty?
|
|
317
|
+
lines << 'No requirement changes are recorded for the linked work item(s).'
|
|
318
|
+
return lines.join("\n")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
lines << '| Requirement | Change | Review | Actor |'
|
|
322
|
+
lines << '| --- | --- | --- | --- |'
|
|
323
|
+
changes.each do |change|
|
|
324
|
+
lines << "| #{requirement_cell(change)} | #{TYPE_LABELS.fetch(change['type'], change['type'])} | " \
|
|
325
|
+
"#{markdown_escape(change['review_state'] || change['requirement_status'] || '—')} | " \
|
|
326
|
+
"#{markdown_escape(change['actor'] || '—')} |"
|
|
327
|
+
end
|
|
328
|
+
lines << ''
|
|
329
|
+
lines << '_Open the linked requirement to review its wording, diff, evidence, and decision state._'
|
|
330
|
+
lines.join("\n")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def requirement_cell(change)
|
|
334
|
+
slug = change['slug'] || 'unknown'
|
|
335
|
+
title = markdown_escape(change['title'] || slug)
|
|
336
|
+
path = change['requirement_path']
|
|
337
|
+
return title unless present?(path)
|
|
338
|
+
|
|
339
|
+
"[#{title}](#{web_base_url}#{path})"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def web_base_url
|
|
343
|
+
uri = URI.parse(config.api_url)
|
|
344
|
+
host = uri.host.to_s.sub(/\Aapi\./, '')
|
|
345
|
+
"#{uri.scheme || 'https'}://#{host}"
|
|
346
|
+
rescue URI::InvalidURIError
|
|
347
|
+
'https://featureparity.dev'
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def markdown_escape(value)
|
|
351
|
+
value.to_s.gsub('|', '\\|').gsub("\n", ' ')
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def present?(value)
|
|
355
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -39,6 +39,7 @@ module Fp
|
|
|
39
39
|
KNOWN_FLAGS = {
|
|
40
40
|
project: :string,
|
|
41
41
|
surface: :string,
|
|
42
|
+
test_type: :string,
|
|
42
43
|
level: :string,
|
|
43
44
|
junit: :string,
|
|
44
45
|
base_dir: :string,
|
|
@@ -126,7 +127,8 @@ module Fp
|
|
|
126
127
|
|
|
127
128
|
results = junit_paths.flat_map do |path|
|
|
128
129
|
testcases = Fp::JUnit.parse_file(path)
|
|
129
|
-
Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface
|
|
130
|
+
Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface,
|
|
131
|
+
default_test_type: opts[:test_type])[:results]
|
|
130
132
|
end
|
|
131
133
|
|
|
132
134
|
if results.empty?
|
|
@@ -141,6 +143,7 @@ module Fp
|
|
|
141
143
|
{
|
|
142
144
|
slug: r.slug,
|
|
143
145
|
surface: r.surface,
|
|
146
|
+
test_type: r.test_type,
|
|
144
147
|
status: r.status,
|
|
145
148
|
source_file: r.file,
|
|
146
149
|
source_line: r.line,
|
|
@@ -219,15 +222,35 @@ module Fp
|
|
|
219
222
|
return if violations.empty?
|
|
220
223
|
|
|
221
224
|
puts "#{violations.size} violation(s):"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
# Show the TYPE column only when some requirement demands specific test types —
|
|
226
|
+
# otherwise it's an empty column of dashes on every project that doesn't use the
|
|
227
|
+
# feature.
|
|
228
|
+
if violations.any? { |v| presence(v['test_type']) }
|
|
229
|
+
output.table(
|
|
230
|
+
%w[SLUG SURFACE TYPE STATE WHY],
|
|
231
|
+
violations.map do |v|
|
|
232
|
+
[v['slug'], v['surface'], v['test_type'] || '—',
|
|
233
|
+
"#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
|
|
234
|
+
end
|
|
235
|
+
)
|
|
236
|
+
else
|
|
237
|
+
output.table(
|
|
238
|
+
%w[SLUG SURFACE STATE WHY],
|
|
239
|
+
violations.map do |v|
|
|
240
|
+
[v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
|
|
241
|
+
end
|
|
242
|
+
)
|
|
243
|
+
end
|
|
228
244
|
puts
|
|
229
245
|
end
|
|
230
246
|
|
|
247
|
+
# nil/blank → nil, so `presence(x)` reads as falsey in a guard.
|
|
248
|
+
def presence(value)
|
|
249
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
250
|
+
|
|
251
|
+
value
|
|
252
|
+
end
|
|
253
|
+
|
|
231
254
|
# Markers the run carried that bind to no requirement in this project — the test
|
|
232
255
|
# claims to cover something that doesn't exist. FP identifies them; it does not
|
|
233
256
|
# propose the requirement, because a slug and a test name aren't enough to write a
|
|
@@ -50,6 +50,7 @@ module Fp
|
|
|
50
50
|
KNOWN_FLAGS = {
|
|
51
51
|
project: :string,
|
|
52
52
|
surface: :string,
|
|
53
|
+
test_type: :string,
|
|
53
54
|
ci_url: :string,
|
|
54
55
|
pr: :string,
|
|
55
56
|
junit: :string,
|
|
@@ -214,7 +215,8 @@ module Fp
|
|
|
214
215
|
resolved = Fp::JUnit.ci_results(
|
|
215
216
|
testcases,
|
|
216
217
|
base_dir: opts[:base_dir] || Dir.pwd,
|
|
217
|
-
default_surface: opts[:surface]
|
|
218
|
+
default_surface: opts[:surface],
|
|
219
|
+
default_test_type: opts[:test_type]
|
|
218
220
|
)
|
|
219
221
|
|
|
220
222
|
{
|
|
@@ -222,9 +224,11 @@ module Fp
|
|
|
222
224
|
{
|
|
223
225
|
slug: r.slug,
|
|
224
226
|
surface: r.surface,
|
|
227
|
+
test_type: r.test_type,
|
|
225
228
|
status: r.status,
|
|
226
229
|
source_file: r.file,
|
|
227
230
|
source_line: r.line,
|
|
231
|
+
title: r.title,
|
|
228
232
|
test_count: r.test_count,
|
|
229
233
|
failure_count: r.failure_count
|
|
230
234
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp conversation <conversation_id>
|
|
6
|
+
# Read a Pari conversation so agents can review AI suggestions and implement them.
|
|
7
|
+
#
|
|
8
|
+
# A conversation is just a record identified by its id — the command doesn't care,
|
|
9
|
+
# and doesn't ask, whether it came from a project panel or the account-level one.
|
|
10
|
+
# The server resolves the owner and authorizes the caller. That's the whole point:
|
|
11
|
+
# a user copies a reference, hands it to an agent, and the agent reads it.
|
|
12
|
+
#
|
|
13
|
+
# Typical workflow:
|
|
14
|
+
# 1. User opens Pari, has a conversation that suggests requirements or code
|
|
15
|
+
# 2. User clicks "Copy Reference" (or "Copy Prompt") to get the lookup command
|
|
16
|
+
# 3. User gives it to an agent: "implement what Pari suggested in <id>"
|
|
17
|
+
# 4. Agent runs `fp conversation <id>` to read the full context
|
|
18
|
+
# 5. Agent implements based on Pari's suggestions
|
|
19
|
+
class Conversation < Base
|
|
20
|
+
def run(args)
|
|
21
|
+
_opts, positional = parse_flags(args)
|
|
22
|
+
conversation_id = positional.first
|
|
23
|
+
|
|
24
|
+
unless conversation_id
|
|
25
|
+
output.error(<<~MSG.strip)
|
|
26
|
+
Conversation ID is required.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
fp conversation <id> Read a Pari conversation
|
|
30
|
+
|
|
31
|
+
The conversation ID is a KSUID like "03h0abc123xyz...". Copy it from Pari's
|
|
32
|
+
header (the copy buttons next to the history icon), or use "Copy Reference"
|
|
33
|
+
to get this whole command ready to paste.
|
|
34
|
+
MSG
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result = client.get_conversation(conversation_id)
|
|
39
|
+
|
|
40
|
+
unless result[:ok]
|
|
41
|
+
output.error(result[:error], status: result[:status])
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
conversation = result[:data]['conversation']
|
|
46
|
+
messages = result[:data]['messages'] || []
|
|
47
|
+
|
|
48
|
+
output.success(result[:data]) do
|
|
49
|
+
print_conversation(conversation, messages)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def print_conversation(conversation, messages)
|
|
56
|
+
title = conversation['title'] || 'Untitled conversation'
|
|
57
|
+
puts title
|
|
58
|
+
puts '=' * title.length
|
|
59
|
+
puts
|
|
60
|
+
puts "ID: #{conversation['id']}"
|
|
61
|
+
puts "Project: #{conversation['project_id']}" if conversation['project_id'].to_s != ''
|
|
62
|
+
puts "Status: #{conversation['status']}"
|
|
63
|
+
puts "Created: #{conversation['created_at']}"
|
|
64
|
+
puts "Updated: #{conversation['updated_at']}"
|
|
65
|
+
puts
|
|
66
|
+
|
|
67
|
+
if messages.empty?
|
|
68
|
+
puts '(no messages)'
|
|
69
|
+
else
|
|
70
|
+
puts 'Messages:'
|
|
71
|
+
puts '-' * 40
|
|
72
|
+
messages.each_with_index do |msg, idx|
|
|
73
|
+
role = msg['role'] == 'user' ? 'USER' : 'PARI'
|
|
74
|
+
puts
|
|
75
|
+
puts "[#{idx + 1}] #{role}:"
|
|
76
|
+
puts msg['content']
|
|
77
|
+
|
|
78
|
+
# Show any requirements Pari created during this turn
|
|
79
|
+
created = msg['created_requirement_ids'] || []
|
|
80
|
+
unless created.empty?
|
|
81
|
+
puts
|
|
82
|
+
puts " → Created draft requirement(s): #{created.join(', ')}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts
|
|
86
|
+
puts '-' * 40
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -23,6 +23,12 @@ module Fp
|
|
|
23
23
|
propose-change <slug> ... Propose a change to an existing requirement
|
|
24
24
|
propose-change <slug> --removal Propose deleting a requirement
|
|
25
25
|
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
26
|
+
changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
|
|
27
|
+
attach <slug> <file> Upload a file attachment to a requirement
|
|
28
|
+
attachments <slug> List attachments for a requirement
|
|
29
|
+
attachments <slug> <id> Get download URL for an attachment
|
|
30
|
+
attach rm <slug> <id> Delete an attachment
|
|
31
|
+
conversation <id> Read a Pari conversation for agent review
|
|
26
32
|
report <slug> --project <slug>... Report evidence for a requirement
|
|
27
33
|
report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
|
|
28
34
|
ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
|
|
@@ -151,16 +157,45 @@ module Fp
|
|
|
151
157
|
# Show a specific requirement
|
|
152
158
|
fp show print_container_qr --project stowzilla
|
|
153
159
|
|
|
154
|
-
# Propose a new requirement (creates as draft)
|
|
160
|
+
# Propose a new requirement (creates as draft).
|
|
161
|
+
# A requirement records the current shared understanding, not a decree handed from
|
|
162
|
+
# product to engineering. Refine it through conversation with customers, domain
|
|
163
|
+
# experts, and builders. Humans decide value and priority; agents surface gaps,
|
|
164
|
+
# inconsistencies, alternatives, and useful splits. Prefer outcomes over feature lists.
|
|
165
|
+
# Use available evidence: what people attempt, abandon, actually use, and what
|
|
166
|
+
# experiments reveal. The first requested solution is input, not unquestionable scope.
|
|
167
|
+
# First inventory the whole request and check active AND draft requirements. Give
|
|
168
|
+
# each independently testable/failable product promise a focused requirement; use
|
|
169
|
+
# a parent plus children for a broad capability with distinct behaviours or policy.
|
|
170
|
+
# Record concrete decisions (prices, quotas, dates, roles, fallbacks) in acceptance,
|
|
171
|
+
# and propose changes to adjacent requirements the work makes inaccurate.
|
|
172
|
+
# Before adding or changing a promise, ask whether it and related active/draft
|
|
173
|
+
# requirements can both be true for the same user, state, and conditions. Treat a
|
|
174
|
+
# failing bound test or required requirement edit as a contradiction signal: decide
|
|
175
|
+
# which promise reflects product intent and record that decision with propose-change
|
|
176
|
+
# (or --removal). Never weaken the test or add a requirement around the conflict;
|
|
177
|
+
# ask a human if intent is unclear.
|
|
155
178
|
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
179
|
+
# Write the requirement as a product decision someone can approve, not a design
|
|
180
|
+
# task. It should sound like a capable teammate, not AI boilerplate.
|
|
181
|
+
# title: one product promise in present tense, not the implementation project
|
|
182
|
+
# why: one or two natural sentences naming the user need/problem and outcome
|
|
183
|
+
# acceptance: trigger or situation + observable result + material boundaries
|
|
184
|
+
# Use examples and tests to ground and double-check the general rule, not to replace
|
|
185
|
+
# it or ongoing conversation. Leave out components, data models, algorithms, endpoints,
|
|
186
|
+
# and UI controls unless they are themselves part of the user-visible contract.
|
|
187
|
+
# Include a price, quota, timeout, count, date,
|
|
188
|
+
# role, or fallback only when the source made it a real product decision; if an
|
|
189
|
+
# important policy is missing, ask instead of inventing a plausible value.
|
|
190
|
+
#
|
|
191
|
+
# good title: "Dispatchers see current shipment status"
|
|
192
|
+
# good why: "Dispatchers need to spot delayed shipments before they miss a delivery window."
|
|
193
|
+
# good acceptance: "When status changes, the dashboard shows it; when unavailable, it says so instead of showing stale data as current."
|
|
194
|
+
# bad: "Implement Redis polling every 30 seconds to ensure a seamless experience."
|
|
195
|
+
# (invented policy, implementation detail, and filler; no product outcome)
|
|
196
|
+
# Before saving, read it as a manager deciding scope: is the value clear, is the
|
|
197
|
+
# result observable, are decisions separated from guesses, and does every sentence
|
|
198
|
+
# earn its place?
|
|
164
199
|
fp propose --project stowzilla \\
|
|
165
200
|
--slug print_container_qr \\
|
|
166
201
|
--name "Print QR code on container label" \\
|
|
@@ -182,6 +217,8 @@ module Fp
|
|
|
182
217
|
# — the card, the thread, the issue. The requirement drops back to DRAFT and a
|
|
183
218
|
# human reviews the old-vs-new diff before it counts again.
|
|
184
219
|
fp propose-change print_container_qr --project stowzilla \\
|
|
220
|
+
--name "Print a scannable container QR" \\
|
|
221
|
+
--slug print_scannable_container_qr \\
|
|
185
222
|
--why "Labels must survive a freezer" \\
|
|
186
223
|
--acceptance "QR scans after 24h at -20C" \\
|
|
187
224
|
--reason "Cold-chain rollout" \\
|
|
@@ -197,6 +234,25 @@ module Fp
|
|
|
197
234
|
fp history print_container_qr --project stowzilla
|
|
198
235
|
fp history print_container_qr --project stowzilla --pending
|
|
199
236
|
|
|
237
|
+
# Upload a file attachment to a requirement (screenshot evidence, docs, etc.)
|
|
238
|
+
# Supported formats: png, jpg, gif, webp, svg, pdf, txt, md (max 10 MB)
|
|
239
|
+
fp attach print_container_qr screenshot.png --project stowzilla
|
|
240
|
+
|
|
241
|
+
# List attachments for a requirement
|
|
242
|
+
fp attachments print_container_qr --project stowzilla
|
|
243
|
+
|
|
244
|
+
# Get a download URL for a specific attachment
|
|
245
|
+
fp attachments print_container_qr <attachment-id> --project stowzilla
|
|
246
|
+
|
|
247
|
+
# Delete an attachment
|
|
248
|
+
fp attach rm print_container_qr <attachment-id> --project stowzilla
|
|
249
|
+
|
|
250
|
+
# Read a Pari conversation so an agent can review AI suggestions.
|
|
251
|
+
# A conversation is just a record identified by its id — no --project
|
|
252
|
+
# needed, and it works for both project and account-level conversations.
|
|
253
|
+
# Use "Copy Reference" in Pari's header to get this command ready to paste.
|
|
254
|
+
fp conversation 03h0abc123xyz
|
|
255
|
+
|
|
200
256
|
# Report evidence for a requirement
|
|
201
257
|
fp report print_container_qr --project stowzilla \\
|
|
202
258
|
--surface customer_android \\
|