featureparity 0.0.15 → 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 +1 -0
- data/lib/fp/client.rb +8 -0
- data/lib/fp/commands/changes.rb +359 -0
- data/lib/fp/commands/help.rb +1 -0
- data/lib/fp/commands/init.rb +19 -0
- data/lib/fp/commands/show.rb +20 -2
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +2 -0
- data/skills/fp/references/cli.md +26 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd15c5892d88c0202433fd6e11993440f45cd3cb8c754c0c7138309008190cbb
|
|
4
|
+
data.tar.gz: 356c8c54af360a47639e2dfc6313774247ff93534eb02b6666656f738d69ce70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0d96204c4cf14ff9c7f5bbf1930f1ad6f170b11be67651df1cda342c2ad41e53e1a09ea93bed84db620d2c867f26ffbc313fa2d132f7b5b8b474026b4ea9ee8
|
|
7
|
+
data.tar.gz: c43e169274e9c6484b6e41c2bcb1c5748eaee9dabad8c39f523c7ed30a08f0edaeae3df7637edc799d2089165624108e72fdb517c95d580cc51c1b747064726b
|
data/lib/fp/cli.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Fp
|
|
|
12
12
|
'propose' => Commands::Propose,
|
|
13
13
|
'propose-change' => Commands::ProposeChange,
|
|
14
14
|
'history' => Commands::History,
|
|
15
|
+
'changes' => Commands::Changes,
|
|
15
16
|
'report' => Commands::Report,
|
|
16
17
|
'ci-report' => Commands::CiReport,
|
|
17
18
|
'check' => Commands::Check,
|
data/lib/fp/client.rb
CHANGED
|
@@ -98,6 +98,14 @@ module Fp
|
|
|
98
98
|
put("/api/requirements/#{id}", params)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# GET /projects/:project_id/requirement_changes — project-wide audit activity,
|
|
102
|
+
# optionally narrowed to the exact work-item URL that prompted it.
|
|
103
|
+
def list_project_requirement_changes(project_id, source_url: nil)
|
|
104
|
+
params = {}
|
|
105
|
+
params[:source_url] = source_url if source_url
|
|
106
|
+
get("/api/projects/#{project_id}/requirement_changes", params)
|
|
107
|
+
end
|
|
108
|
+
|
|
101
109
|
# GET /requirements/:id/changes
|
|
102
110
|
#
|
|
103
111
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -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/help.rb
CHANGED
|
@@ -23,6 +23,7 @@ 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
|
|
26
27
|
attach <slug> <file> Upload a file attachment to a requirement
|
|
27
28
|
attachments <slug> List attachments for a requirement
|
|
28
29
|
attachments <slug> <id> Get download URL for an attachment
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -213,6 +213,9 @@ module Fp
|
|
|
213
213
|
gate:
|
|
214
214
|
name: Parity gate
|
|
215
215
|
runs-on: ubuntu-latest
|
|
216
|
+
permissions:
|
|
217
|
+
contents: read
|
|
218
|
+
pull-requests: write
|
|
216
219
|
steps:
|
|
217
220
|
- uses: actions/checkout@v4
|
|
218
221
|
|
|
@@ -223,6 +226,21 @@ module Fp
|
|
|
223
226
|
- name: Install fp
|
|
224
227
|
run: gem install featureparity
|
|
225
228
|
|
|
229
|
+
- name: Update PR requirement changes
|
|
230
|
+
if: github.event_name == 'pull_request'
|
|
231
|
+
env:
|
|
232
|
+
FP_API_KEY: ${{ secrets.FP_API_KEY }}
|
|
233
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
234
|
+
run: |
|
|
235
|
+
if fp help | grep -q -- '--github-pr'; then
|
|
236
|
+
fp changes --github-pr --github-summary
|
|
237
|
+
else
|
|
238
|
+
echo '## FeatureParity requirement changes' >> "$GITHUB_STEP_SUMMARY"
|
|
239
|
+
echo '' >> "$GITHUB_STEP_SUMMARY"
|
|
240
|
+
echo 'The installed fp version cannot update the pull request description yet.' >> "$GITHUB_STEP_SUMMARY"
|
|
241
|
+
exit 1
|
|
242
|
+
fi
|
|
243
|
+
|
|
226
244
|
# Exits non-zero when the project violates its enforcement level.
|
|
227
245
|
# Add `--warn-only` while adopting if you need the annotations without
|
|
228
246
|
# blocking merges yet.
|
|
@@ -303,6 +321,7 @@ module Fp
|
|
|
303
321
|
fp list --gaps # the ones with no evidence yet — good places to start
|
|
304
322
|
fp show <slug> # details, acceptance criteria, and existing receipts
|
|
305
323
|
fp history <slug> # why it says what it says, and whether a change is pending
|
|
324
|
+
fp changes --source-url URL # requirement activity prompted by a work item
|
|
306
325
|
```
|
|
307
326
|
|
|
308
327
|
**The requirement catalog is the human-readable product specification, not a box to
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Fp
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
requirement = result[:data]['requirement']
|
|
27
|
+
source_history = result[:data]['source_history'] || []
|
|
27
28
|
|
|
28
29
|
# Evidence is not embedded in the requirement payload — it lives on the project's
|
|
29
30
|
# evidence collection, keyed by slug. Fetch it, or `fp show` reports "(none)" for
|
|
@@ -37,7 +38,7 @@ module Fp
|
|
|
37
38
|
payload['parent_slug'] = parent_slug if parent_slug
|
|
38
39
|
|
|
39
40
|
output.success(payload) do
|
|
40
|
-
print_requirement(requirement, evidence: evidence, parent_slug: parent_slug)
|
|
41
|
+
print_requirement(requirement, evidence: evidence, parent_slug: parent_slug, source_history: source_history)
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
@@ -60,7 +61,7 @@ module Fp
|
|
|
60
61
|
parent && parent['slug']
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
def print_requirement(req, evidence: [], parent_slug: nil)
|
|
64
|
+
def print_requirement(req, evidence: [], parent_slug: nil, source_history: [])
|
|
64
65
|
puts "#{req['title']}"
|
|
65
66
|
puts "=" * req['title'].length
|
|
66
67
|
puts
|
|
@@ -106,6 +107,23 @@ module Fp
|
|
|
106
107
|
puts
|
|
107
108
|
end
|
|
108
109
|
|
|
110
|
+
# Source trail: every source (Fizzy card, Discord thread, issue) that prompted a
|
|
111
|
+
# change to this requirement, oldest first. Derived from events so the history is
|
|
112
|
+
# additive — older sources aren't lost when a new one is recorded.
|
|
113
|
+
puts 'Source Trail:'
|
|
114
|
+
if source_history.empty?
|
|
115
|
+
puts ' (no sources recorded)'
|
|
116
|
+
else
|
|
117
|
+
source_history.each do |src|
|
|
118
|
+
line = []
|
|
119
|
+
line << src['type'] if src['type']
|
|
120
|
+
line << src['url'] if src['url']
|
|
121
|
+
puts " - #{line.join(' ')}"
|
|
122
|
+
puts " #{src['context']}" if src['context']
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
puts
|
|
126
|
+
|
|
109
127
|
# Evidence receipts, newest first, grouped per surface.
|
|
110
128
|
puts 'Evidence:'
|
|
111
129
|
if evidence.empty?
|
data/lib/fp/commands.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'commands/show'
|
|
|
8
8
|
require_relative 'commands/propose'
|
|
9
9
|
require_relative 'commands/propose_change'
|
|
10
10
|
require_relative 'commands/history'
|
|
11
|
+
require_relative 'commands/changes'
|
|
11
12
|
require_relative 'commands/report'
|
|
12
13
|
require_relative 'commands/ci_report'
|
|
13
14
|
require_relative 'commands/check'
|
data/lib/fp/version.rb
CHANGED
data/skills/fp/SKILL.md
CHANGED
|
@@ -49,6 +49,7 @@ fp list --project stowzilla --status draft # Proposed work; avoid duplicates
|
|
|
49
49
|
fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
|
|
50
50
|
fp show <slug> --project stowzilla # Requirement details and evidence
|
|
51
51
|
fp history <slug> --project stowzilla # Origins and pending changes
|
|
52
|
+
fp changes --source-url <work-item-url> # Activity prompted by one card, issue, or thread
|
|
52
53
|
fp matrix --project stowzilla # ASCII parity matrix
|
|
53
54
|
fp matrix --project stowzilla --csv # Export as CSV
|
|
54
55
|
fp check --project stowzilla # Enforce parity (exits 1 on violations)
|
|
@@ -332,6 +333,7 @@ fp init --project <slug> --surface <surface>
|
|
|
332
333
|
| Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
|
|
333
334
|
| Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
|
|
334
335
|
| View change history | `fp history <slug> --project X` |
|
|
336
|
+
| View work-item activity | `fp changes --project X --source-url URL` |
|
|
335
337
|
| Enforce parity | `fp check --project X` |
|
|
336
338
|
| Gate on this run | `fp check --project X --surface Y --junit file.xml` |
|
|
337
339
|
| View matrix | `fp matrix --project X` |
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -270,6 +270,31 @@ the review decision where one was made. An open proposal is called out at the en
|
|
|
270
270
|
|
|
271
271
|
`--project` is optional when the repo has a `.featureparity.yml`.
|
|
272
272
|
|
|
273
|
+
### fp changes
|
|
274
|
+
|
|
275
|
+
Show requirement activity prompted by a work item, rather than opening every requirement's
|
|
276
|
+
history separately.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
fp changes --source-url https://app.fizzy.do/6098707/cards/1434
|
|
280
|
+
fp changes --project stowzilla --source-url https://github.com/acme/app/issues/42
|
|
281
|
+
fp changes --github-pr --github-summary
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`--source-url` may be repeated. The API matches the URL exactly against the source recorded
|
|
285
|
+
when a requirement was created, directly edited, proposed for modification, or proposed for
|
|
286
|
+
removal. Results include the requirement, activity kind, review state, actor, and a link to the
|
|
287
|
+
requirement.
|
|
288
|
+
|
|
289
|
+
In a GitHub pull-request workflow, `--github-pr` reads the live PR through the GitHub API and
|
|
290
|
+
writes or replaces one marker-managed requirement table in its description. It requires
|
|
291
|
+
`GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH`, and `pull-requests: write`; inability
|
|
292
|
+
to read or update the PR is an error, so a missing table cannot silently pass. Add
|
|
293
|
+
`--github-summary` to keep the same table in `GITHUB_STEP_SUMMARY`. A PR with no linked work
|
|
294
|
+
item says FeatureParity cannot determine the activity; a linked work item with no events
|
|
295
|
+
explicitly says no requirement changes were recorded. Generated `fp init` workflows configure
|
|
296
|
+
both outputs before the parity gate.
|
|
297
|
+
|
|
273
298
|
### fp matrix
|
|
274
299
|
|
|
275
300
|
Display the parity matrix. Cells show the state of the evidence filed for each
|
|
@@ -373,7 +398,7 @@ fp init --project stowzilla --dir path/to/repo
|
|
|
373
398
|
| File | Purpose |
|
|
374
399
|
|------|---------|
|
|
375
400
|
| `.featureparity.yml` | declares the project slug, so `--project` becomes optional |
|
|
376
|
-
| `.github/workflows/feature-parity.yml` | the CI gate |
|
|
401
|
+
| `.github/workflows/feature-parity.yml` | the CI gate and PR requirement-change summary |
|
|
377
402
|
| `AGENTS.md` (appended) | instructions telling agents to mark and report their tests |
|
|
378
403
|
|
|
379
404
|
Existing files are skipped unless `--force`. The `AGENTS.md` section is appended between
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureparity
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|
|
@@ -97,6 +97,7 @@ files:
|
|
|
97
97
|
- lib/fp/commands/attach.rb
|
|
98
98
|
- lib/fp/commands/attachments.rb
|
|
99
99
|
- lib/fp/commands/base.rb
|
|
100
|
+
- lib/fp/commands/changes.rb
|
|
100
101
|
- lib/fp/commands/check.rb
|
|
101
102
|
- lib/fp/commands/ci_report.rb
|
|
102
103
|
- lib/fp/commands/config_cmd.rb
|