featureparity 0.0.15 → 0.0.17
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 +3 -0
- data/lib/fp/client.rb +51 -3
- data/lib/fp/commands/changed_tests.rb +126 -0
- data/lib/fp/commands/changes.rb +359 -0
- data/lib/fp/commands/ci_report.rb +133 -28
- data/lib/fp/commands/comment.rb +99 -0
- data/lib/fp/commands/help.rb +34 -0
- data/lib/fp/commands/init.rb +71 -12
- data/lib/fp/commands/propose_change.rb +46 -1
- data/lib/fp/commands/report.rb +63 -6
- data/lib/fp/commands/show.rb +20 -2
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/junit.rb +50 -5
- data/lib/fp/source_fingerprint.rb +193 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +33 -0
- data/skills/fp/references/cli.md +86 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d20b326add84bbad1fc34a9b2b41c70216f7e9f2797f47cc9cba9f4293d27f1e
|
|
4
|
+
data.tar.gz: ae9721b86841174ee64f1925b0878e5ca5b5e34ea1f467ea0275887526515695
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87aa7b100433761979acec4c6a3f66c665757ebd77924cf1bab483861a1855be92acba93d3a6bd0e2f0cdc9daf9c887380a808eab96c467c3a41ee4e665f8bb0
|
|
7
|
+
data.tar.gz: 640c69700985e08ce0c6aec534d42d544db0d04771ae9f9be254e24aca3952cbbff7f52456c1c7b506c8bbf5e6a3d3b54c8c7959d6575d159d19cff5cad69c0b
|
data/lib/fp/cli.rb
CHANGED
|
@@ -11,9 +11,12 @@ module Fp
|
|
|
11
11
|
'show' => Commands::Show,
|
|
12
12
|
'propose' => Commands::Propose,
|
|
13
13
|
'propose-change' => Commands::ProposeChange,
|
|
14
|
+
'comment' => Commands::Comment,
|
|
14
15
|
'history' => Commands::History,
|
|
16
|
+
'changes' => Commands::Changes,
|
|
15
17
|
'report' => Commands::Report,
|
|
16
18
|
'ci-report' => Commands::CiReport,
|
|
19
|
+
'changed-tests' => Commands::ChangedTests,
|
|
17
20
|
'check' => Commands::Check,
|
|
18
21
|
'matrix' => Commands::Matrix,
|
|
19
22
|
'export' => Commands::Export,
|
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
|
|
@@ -122,6 +130,15 @@ module Fp
|
|
|
122
130
|
put("/api/requirements/#{requirement_id}/changes/#{change_id}", params)
|
|
123
131
|
end
|
|
124
132
|
|
|
133
|
+
# POST /requirements/:id/changes/:change_id/comment
|
|
134
|
+
#
|
|
135
|
+
# Thread a comment onto the open proposal. The single-open-proposal rule means a
|
|
136
|
+
# second agent can't open a rival change; this lets it raise its concern *on* the
|
|
137
|
+
# proposal a human is about to review instead of hitting a dead-end 409.
|
|
138
|
+
def comment_requirement_change(requirement_id, change_id, params)
|
|
139
|
+
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
140
|
+
end
|
|
141
|
+
|
|
125
142
|
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
126
143
|
# optionally narrowed to one requirement slug and/or surface.
|
|
127
144
|
def list_evidence(project_id, slug: nil, surface: nil)
|
|
@@ -152,7 +169,7 @@ module Fp
|
|
|
152
169
|
# only do that by fetching the test files from GitHub, which fails silently on a
|
|
153
170
|
# private repo, so resolved results are always sent when we have them.
|
|
154
171
|
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
|
|
155
|
-
unmarked_failures: nil)
|
|
172
|
+
test_revisions: nil, unmarked_failures: nil)
|
|
156
173
|
body = {
|
|
157
174
|
ci_url: ci_url,
|
|
158
175
|
surface: surface,
|
|
@@ -161,6 +178,9 @@ module Fp
|
|
|
161
178
|
# Only send pr_url when set, so a merged-branch run stays a plain ingest.
|
|
162
179
|
body[:pr_url] = pr_url if pr_url && !pr_url.empty?
|
|
163
180
|
body[:results] = results if results && !results.empty?
|
|
181
|
+
# Testcase-level source receipts stay separate from aggregate Evidence outcomes:
|
|
182
|
+
# several tests may back one cell, and each can change independently.
|
|
183
|
+
body[:test_revisions] = test_revisions if test_revisions && !test_revisions.empty?
|
|
164
184
|
# Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
|
|
165
185
|
# matrix can surface a red suite whose failures aren't bound to any requirement.
|
|
166
186
|
body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
|
|
@@ -168,6 +188,27 @@ module Fp
|
|
|
168
188
|
post("/api/projects/#{project_id}/ci_results", body)
|
|
169
189
|
end
|
|
170
190
|
|
|
191
|
+
# GET /projects/:project_id/changed_test_findings — every unresolved audit finding.
|
|
192
|
+
def list_changed_test_findings(project_id, surface: nil, test_type: nil)
|
|
193
|
+
params = {}
|
|
194
|
+
params[:surface] = surface if surface
|
|
195
|
+
params[:test_type] = test_type if test_type
|
|
196
|
+
get("/api/projects/#{project_id}/changed_test_findings", params)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# POST an assessment for the exact candidate fingerprint the agent inspected.
|
|
200
|
+
def assess_changed_test_finding(project_id, finding_id, classification:, explanation:,
|
|
201
|
+
candidate_fingerprint:)
|
|
202
|
+
post(
|
|
203
|
+
"/api/projects/#{project_id}/changed_test_findings/#{finding_id}/assessment",
|
|
204
|
+
{
|
|
205
|
+
classification: classification,
|
|
206
|
+
explanation: explanation,
|
|
207
|
+
candidate_fingerprint: candidate_fingerprint
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
171
212
|
# GET /projects/:project_id/parity — the computed parity grid and gate verdict.
|
|
172
213
|
#
|
|
173
214
|
# This replaces the old get_matrix/get_gaps stubs, which pointed at /api/matrix
|
|
@@ -197,7 +238,10 @@ module Fp
|
|
|
197
238
|
# ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
|
|
198
239
|
# — or a private one the server can't read — be previewed at all.
|
|
199
240
|
def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil)
|
|
200
|
-
|
|
241
|
+
# Older API deployments read `surface_filter` for report scoping while `surface`
|
|
242
|
+
# identifies the run's overlay. Send both during the rollout; current servers use
|
|
243
|
+
# `surface` for both meanings.
|
|
244
|
+
body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
|
|
201
245
|
body[:results] = results if results && !results.empty?
|
|
202
246
|
body[:level] = level if level
|
|
203
247
|
post("/api/projects/#{project_id}/parity/preview", body)
|
|
@@ -352,7 +396,11 @@ module Fp
|
|
|
352
396
|
else
|
|
353
397
|
body.to_s.empty? ? "HTTP #{status}" : body.to_s[0..200]
|
|
354
398
|
end
|
|
355
|
-
{ ok: false, error: error_message, status: status }
|
|
399
|
+
result = { ok: false, error: error_message, status: status }
|
|
400
|
+
# Carry structured error details (e.g. the open proposal behind a 409) so a
|
|
401
|
+
# command can act on them instead of only printing the message.
|
|
402
|
+
result[:details] = data['details'] if data.is_a?(Hash) && data.key?('details')
|
|
403
|
+
result
|
|
356
404
|
end
|
|
357
405
|
end
|
|
358
406
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# Lists CI-detected revisions to bound tests and records an agent's assessment of an
|
|
6
|
+
# exact candidate fingerprint.
|
|
7
|
+
class ChangedTests < Base
|
|
8
|
+
CLASSIFICATIONS = %w[still_proves no_longer_proves uncertain].freeze
|
|
9
|
+
KNOWN_FLAGS = {
|
|
10
|
+
project: :string,
|
|
11
|
+
surface: :string,
|
|
12
|
+
test_type: :string,
|
|
13
|
+
classification: :string,
|
|
14
|
+
explanation: :string,
|
|
15
|
+
candidate_fingerprint: :string
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def run(args)
|
|
19
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
20
|
+
require_project!(opts)
|
|
21
|
+
project_id = resolve_project_id(opts[:project])
|
|
22
|
+
|
|
23
|
+
if positional.first == 'assess'
|
|
24
|
+
assess(project_id, positional[1], opts)
|
|
25
|
+
else
|
|
26
|
+
list(project_id, opts)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def list(project_id, opts)
|
|
33
|
+
result = client.list_changed_test_findings(
|
|
34
|
+
project_id,
|
|
35
|
+
surface: opts[:surface],
|
|
36
|
+
test_type: opts[:test_type]
|
|
37
|
+
)
|
|
38
|
+
fail_result(result) unless result[:ok]
|
|
39
|
+
|
|
40
|
+
data = result[:data] || {}
|
|
41
|
+
findings = data['changed_test_findings'] || []
|
|
42
|
+
output.success(data) do
|
|
43
|
+
if findings.empty?
|
|
44
|
+
puts 'No unresolved changed-test findings.'
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
puts "#{findings.length} unresolved changed-test audit finding(s):"
|
|
49
|
+
findings.each { |finding| print_finding(finding) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assess(project_id, finding_id, opts)
|
|
54
|
+
if finding_id.nil? || finding_id.empty?
|
|
55
|
+
output.error('Finding id is required. Usage: fp changed-tests assess <id> ...')
|
|
56
|
+
exit 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
classification = opts[:classification].to_s.tr('-', '_')
|
|
60
|
+
unless CLASSIFICATIONS.include?(classification)
|
|
61
|
+
output.error("--classification must be one of: #{CLASSIFICATIONS.join(', ')}")
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
require_flag(opts, :candidate_fingerprint)
|
|
65
|
+
require_flag(opts, :explanation)
|
|
66
|
+
if opts[:explanation].to_s.strip.empty?
|
|
67
|
+
output.error('--explanation must not be blank')
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
result = client.assess_changed_test_finding(
|
|
72
|
+
project_id,
|
|
73
|
+
finding_id,
|
|
74
|
+
classification: classification,
|
|
75
|
+
explanation: opts[:explanation],
|
|
76
|
+
candidate_fingerprint: opts[:candidate_fingerprint]
|
|
77
|
+
)
|
|
78
|
+
fail_result(result) unless result[:ok]
|
|
79
|
+
|
|
80
|
+
data = result[:data] || {}
|
|
81
|
+
output.success(data) do
|
|
82
|
+
finding = data['changed_test_finding'] || {}
|
|
83
|
+
puts "Recorded #{classification.tr('_', ' ')} for changed-test finding #{finding_id}."
|
|
84
|
+
puts "Status: #{finding['status']}"
|
|
85
|
+
puts 'The CI outcome and requirement wording were not changed.'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_finding(finding)
|
|
90
|
+
requirement = finding['requirement'] || {}
|
|
91
|
+
testcase = finding['testcase'] || {}
|
|
92
|
+
accepted = finding['accepted'] || {}
|
|
93
|
+
candidate = finding['candidate'] || {}
|
|
94
|
+
assessment = finding['assessment'] || {}
|
|
95
|
+
|
|
96
|
+
puts
|
|
97
|
+
puts "#{finding['id']} #{requirement['slug']} [#{finding['surface']}#{type_suffix(finding)}]"
|
|
98
|
+
puts " Requirement: #{requirement['title']}"
|
|
99
|
+
puts " Acceptance: #{requirement['acceptance']}"
|
|
100
|
+
puts " Testcase: #{testcase['name']} (#{testcase['outcome']})"
|
|
101
|
+
puts " Scope: #{finding['fingerprint_scope']}"
|
|
102
|
+
puts " Accepted: #{revision_text(accepted)}"
|
|
103
|
+
puts " Candidate: #{revision_text(candidate)}"
|
|
104
|
+
puts " Assessment: #{assessment['classification']}: #{assessment['explanation']}" unless assessment.empty?
|
|
105
|
+
puts ' Assess: fp changed-tests assess ' \
|
|
106
|
+
"#{finding['id']} --candidate-fingerprint #{candidate['fingerprint']} " \
|
|
107
|
+
'--classification still_proves|no_longer_proves|uncertain --explanation "..."'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def type_suffix(finding)
|
|
111
|
+
type = finding['test_type'].to_s
|
|
112
|
+
type.empty? || type == 'unspecified' ? '' : ":#{type}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def revision_text(revision)
|
|
116
|
+
location = revision['source_url'] || [revision['source_file'], revision['start_line']].compact.join(':')
|
|
117
|
+
"#{revision['fingerprint']} @ #{revision['commit_sha']} #{location}".strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fail_result(result)
|
|
121
|
+
output.error(result[:error], status: result[:status])
|
|
122
|
+
exit 1
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -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
|