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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efbe901394a8f97be6d1340a877ecafa6a8151453ed5a78708e91b6e40af1fe1
4
- data.tar.gz: 51358f9c7907107f3744775e414f175a1206ace2a104b44dcfcee4723a13e8e4
3
+ metadata.gz: d20b326add84bbad1fc34a9b2b41c70216f7e9f2797f47cc9cba9f4293d27f1e
4
+ data.tar.gz: ae9721b86841174ee64f1925b0878e5ca5b5e34ea1f467ea0275887526515695
5
5
  SHA512:
6
- metadata.gz: 16067e82a07cac5e0bf5df3548950ff5d966e8f822ba6315740f474a9d50e17fa25f99adfc9fc4a58a2af66bcd03f3e0edce7e9ab143e05fb6f4b9c2fe2e013e
7
- data.tar.gz: 8f58e25f9465851931d0d763f1655b56112cd7899e919af8ee5bf0975fc6ec090c631fc42d993f76dce210468ce4d766cea2c1575b62282f35a969f2124e7530
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
- body = { surface: surface, junit_xmls: Array(junit_xmls) }
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