flecto 3.0.1 → 3.0.2

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.
package/src/policy.js CHANGED
@@ -29,6 +29,7 @@ import { containsSecret } from './secrets.js';
29
29
  * beforeLooksSecret?: true,
30
30
  * afterLooksSecret?: true,
31
31
  * afterMatches?: string,
32
+ * afterAnyMatches?: string,
32
33
  * numericJump?: { minMultiple: number },
33
34
  * numericDelta?: { min: number },
34
35
  * allOf?: PolicyMatchClause[],
@@ -48,6 +49,7 @@ import { containsSecret } from './secrets.js';
48
49
  * beforeLooksSecret?: true,
49
50
  * afterLooksSecret?: true,
50
51
  * afterMatches?: string,
52
+ * afterAnyMatches?: string,
51
53
  * numericJump?: { minMultiple: number },
52
54
  * numericDelta?: { min: number }
53
55
  * }} PolicyMatchClause
@@ -74,6 +76,7 @@ import { containsSecret } from './secrets.js';
74
76
  * source?: 'watch' | 'ci' | 'diff',
75
77
  * policies?: string[],
76
78
  * plugins?: string[],
79
+ * packRoots?: string[],
77
80
  * severityRemap?: Record<string, PolicySeverity | 'off'>
78
81
  * }} PolicyEvalOptions
79
82
  */
@@ -85,12 +88,12 @@ const RULE_FIELDS = new Set([
85
88
  'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
86
89
  'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
87
90
  'beforeLooksSecret', 'afterLooksSecret',
88
- 'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
91
+ 'afterMatches', 'afterAnyMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
89
92
  ]);
90
93
  const CLAUSE_FIELDS = new Set([
91
94
  'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
92
95
  'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
93
- 'afterMatches', 'numericJump', 'numericDelta',
96
+ 'afterMatches', 'afterAnyMatches', 'numericJump', 'numericDelta',
94
97
  ]);
95
98
  const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
96
99
  // Community distribution convention: an npm package named flecto-pack-<id>
@@ -166,17 +169,34 @@ function validatePack(pack, path) {
166
169
  }
167
170
 
168
171
  /**
169
- * @param {string} cwd
172
+ * Every directory whose `policies/` is searched for a local pack, in order.
173
+ * The first entry is the primary cwd; later entries are fallbacks (the policy
174
+ * fixture harness runs from the fixture directory but packs are installed into
175
+ * the invoking project — see #114).
176
+ * @param {string | string[]} cwd
177
+ * @returns {string[]}
178
+ */
179
+ function packRoots(cwd) {
180
+ const roots = (Array.isArray(cwd) ? cwd : [cwd])
181
+ .filter((root) => typeof root === 'string' && root)
182
+ .map((root) => resolve(root));
183
+ // Distinct roots only: a fixture run from the project root would otherwise
184
+ // search the same policies/ directory twice and report it twice on failure.
185
+ return [...new Set(roots)];
186
+ }
187
+
188
+ /**
189
+ * @param {string | string[]} cwd
170
190
  * @param {string} packId
171
191
  * @returns {string | null}
172
192
  */
173
193
  function resolvePackPath(cwd, packId) {
174
- const localJson = resolve(cwd, 'policies', `${packId}.json`);
175
- const localYaml = resolve(cwd, 'policies', `${packId}.yaml`);
176
- const localYml = resolve(cwd, 'policies', `${packId}.yml`);
177
- if (existsSync(localJson)) return localJson;
178
- if (existsSync(localYaml)) return localYaml;
179
- if (existsSync(localYml)) return localYml;
194
+ for (const root of packRoots(cwd)) {
195
+ for (const ext of ['.json', '.yaml', '.yml']) {
196
+ const localPath = resolve(root, 'policies', `${packId}${ext}`);
197
+ if (existsSync(localPath)) return localPath;
198
+ }
199
+ }
180
200
 
181
201
  const builtinJson = join(PACKS_DIR, `${packId}.json`);
182
202
  if (existsSync(builtinJson)) return builtinJson;
@@ -240,6 +260,7 @@ function validateRule(candidate, location, isClause = false) {
240
260
  validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
241
261
  validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
242
262
  validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
263
+ validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location);
243
264
  validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
244
265
  validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
245
266
  for (const name of ['message', 'messageTemplate']) {
@@ -359,13 +380,16 @@ function validateComposition(clauses, name, location) {
359
380
  const packCache = new Map();
360
381
 
361
382
  /**
362
- * @param {string} cwd
383
+ * @param {string[]} roots already-resolved search roots, in order
363
384
  * @param {string} path
364
385
  * @param {number} mtimeMs
365
386
  * @returns {string}
366
387
  */
367
- function packCacheKey(cwd, path, mtimeMs) {
368
- return `${resolve(cwd)}\u0000${path}\u0000${mtimeMs}`;
388
+ function packCacheKey(roots, path, mtimeMs) {
389
+ // Roots are NUL-joined for the same reason the other fields are
390
+ // NUL-separated: two different root lists must never concatenate into the
391
+ // same key.
392
+ return `${roots.join('\u0000')}\u0000${path}\u0000${mtimeMs}`;
369
393
  }
370
394
 
371
395
  /**
@@ -385,11 +409,21 @@ export function loadPack(packId, cwd = process.cwd()) {
385
409
  if (!id) throw new Error('Policy pack id is required');
386
410
  const path = resolvePackPath(cwd, id);
387
411
  if (!path) {
388
- throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
412
+ // Name the directories actually searched. The old message always said
413
+ // "Add policies/<id>.json", which was actively misleading when the pack
414
+ // existed but in a directory this lookup never consulted (#114).
415
+ const searched = packRoots(cwd)
416
+ .map((root) => join(root, 'policies'))
417
+ .concat(PACKS_DIR)
418
+ .join(', ');
419
+ throw new Error(
420
+ `Unknown policy pack "${id}". Searched: ${searched}.`
421
+ + ` Add policies/${id}.json in one of these, or use a built-in pack.`,
422
+ );
389
423
  }
390
424
 
391
425
  const mtimeMs = statSync(path).mtimeMs;
392
- const cacheKey = packCacheKey(cwd, path, mtimeMs);
426
+ const cacheKey = packCacheKey(packRoots(cwd), path, mtimeMs);
393
427
  const cached = packCache.get(cacheKey);
394
428
  if (cached) return cached;
395
429
 
@@ -700,15 +734,16 @@ export function addPolicyPackFromPackage(name, options = {}) {
700
734
  }
701
735
 
702
736
  /**
703
- * Compile each rule's `match.path` and `afterMatches` regular expressions
704
- * once, at pack-load time, storing them as non-enumerable properties on the
705
- * loaded rule/clause objects. matchClause() runs per change event — for a
706
- * rule with a path match, once per event per rule — so compiling here instead
707
- * of inside that loop turns a `new RegExp(...)` call into a property read.
737
+ * Compile each rule's `match.path`, `afterMatches`, and `afterAnyMatches`
738
+ * regular expressions once, at pack-load time, storing them as non-enumerable
739
+ * properties on the loaded rule/clause objects. matchClause() runs per change
740
+ * event — for a rule with a path match, once per event per rule — so compiling
741
+ * here instead of inside that loop turns a `new RegExp(...)` call into a
742
+ * property read.
708
743
  *
709
- * validatePack() has already proven, by this point, that every `match.path`
710
- * and `afterMatches` string constructs a valid RegExp, so construction here
711
- * cannot throw a new error the caller hasn't already seen.
744
+ * validatePack() has already proven, by this point, that every `match.path`,
745
+ * `afterMatches`, and `afterAnyMatches` string constructs a valid RegExp, so
746
+ * construction here cannot throw a new error the caller hasn't already seen.
712
747
  * @param {PolicyPack} pack
713
748
  */
714
749
  function compilePackRegexes(pack) {
@@ -733,6 +768,12 @@ function compileClauseRegexes(clause) {
733
768
  enumerable: false,
734
769
  });
735
770
  }
771
+ if (clause.afterAnyMatches !== undefined) {
772
+ Object.defineProperty(clause, '_afterAnyMatchesRegex', {
773
+ value: new RegExp(clause.afterAnyMatches),
774
+ enumerable: false,
775
+ });
776
+ }
736
777
  }
737
778
 
738
779
  /**
@@ -754,6 +795,29 @@ function afterMatchesRegexFor(clause) {
754
795
  return clause._afterMatchesRegex ?? new RegExp(clause.afterMatches);
755
796
  }
756
797
 
798
+ /**
799
+ * @param {{ afterAnyMatches?: string, _afterAnyMatchesRegex?: RegExp }} clause
800
+ * @returns {RegExp}
801
+ */
802
+ function afterAnyMatchesRegexFor(clause) {
803
+ return clause._afterAnyMatchesRegex ?? new RegExp(clause.afterAnyMatches);
804
+ }
805
+
806
+ /**
807
+ * True when `value` is an array carrying at least one string element that
808
+ * matches. Deliberately a flat scan: it does not descend into nested arrays or
809
+ * objects, because a rule's text should say what it matches without the reader
810
+ * having to reason about how deep the walk goes. Non-array values never match,
811
+ * which is what keeps `afterMatches` and this predicate from overlapping.
812
+ * @param {unknown} value
813
+ * @param {RegExp} regex
814
+ * @returns {boolean}
815
+ */
816
+ function anyElementMatches(value, regex) {
817
+ return Array.isArray(value)
818
+ && value.some((element) => typeof element === 'string' && regex.test(element));
819
+ }
820
+
757
821
  /**
758
822
  * @param {PolicyRule} rule
759
823
  * @param {import('./differ.js').ChangeEvent} change
@@ -791,6 +855,10 @@ function matchClause(clause, change) {
791
855
  if (clause.beforeLooksSecret && !containsSecret(change.before)) return false;
792
856
  if (clause.afterLooksSecret && !containsSecret(change.after)) return false;
793
857
  if (clause.afterMatches && (typeof change.after !== 'string' || !afterMatchesRegexFor(clause).test(change.after))) return false;
858
+ // The list counterpart, for the change that turns a scalar into a list in
859
+ // one edit -- reported as a single `changed` event whose `after` is an array,
860
+ // which no scalar predicate can see into.
861
+ if (clause.afterAnyMatches && !anyElementMatches(change.after, afterAnyMatchesRegexFor(clause))) return false;
794
862
 
795
863
  if (clause.numericJump) {
796
864
  const before = change.before;
@@ -1020,10 +1088,15 @@ export async function evaluatePolicies(changes, options = {}) {
1020
1088
  const packIds = options.policies?.length ? options.policies : ['default'];
1021
1089
  const plugins = options.plugins ?? [];
1022
1090
  const severityRemap = options.severityRemap ?? {};
1091
+ // Packs may resolve from additional roots (the fixture harness adds the
1092
+ // invoking project, #114). Plugin resolution deliberately stays anchored to
1093
+ // the single `cwd` below: widening where executable code loads from is not
1094
+ // the same decision as widening where declarative packs load from.
1095
+ const packSearchRoots = options.packRoots?.length ? [cwd, ...options.packRoots] : cwd;
1023
1096
 
1024
1097
  /** @type {PolicyFinding[]} */
1025
1098
  const findings = [];
1026
- const packs = packIds.map((packId) => loadPack(packId, cwd));
1099
+ const packs = packIds.map((packId) => loadPack(packId, packSearchRoots));
1027
1100
  const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
1028
1101
  for (const ruleId of Object.keys(severityRemap)) {
1029
1102
  if (!knownRuleIds.has(ruleId)) {
package/src/pr-comment.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { isAbsolute, relative } from 'path';
3
3
 
4
+ import { PR_PROVIDERS, selectPrProvider } from './pr-providers.js';
5
+
4
6
  /**
5
7
  * Hidden marker embedded in every rendered body. Flecto finds its own comment by
6
8
  * searching for this string, so repeated runs update one sticky comment instead
@@ -8,21 +10,23 @@ import { isAbsolute, relative } from 'path';
8
10
  */
9
11
  export const PR_COMMENT_MARKER = '<!-- flecto:pr-comment -->';
10
12
 
11
- const DEFAULT_API_URL = 'https://api.github.com';
12
13
  const DEFAULT_TIMEOUT_MS = 10_000;
13
14
  /** GitHub rejects comment bodies longer than 65536 characters. */
14
15
  const MAX_BODY_CHARS = 60_000;
15
16
  const MAX_INLINE_CHANGES = 10;
16
17
  const MAX_VALUE_CHARS = 120;
17
18
  const MAX_COMMENT_PAGES = 10;
18
- const COMMENTS_PER_PAGE = 100;
19
19
  const SEVERITY_ORDER = ['error', 'warn', 'info'];
20
20
  const SEVERITY_HEADINGS = { error: 'Errors', warn: 'Warnings', info: 'Notices' };
21
21
  const SEVERITY_NOUNS = { error: 'error', warn: 'warning', info: 'notice' };
22
22
 
23
23
  /**
24
24
  * @typedef {{ file: string, envelope: import('./envelope.js').FlectoEnvelope, policies?: import('./policy.js').PolicyFinding[] }} CiResult
25
- * @typedef {{ repo: string, prNumber: number, token: string, apiUrl: string }} PrCommentContext
25
+ * @typedef {{ provider?: string, prNumber: number, token: string, apiUrl: string,
26
+ * repo?: string, projectId?: string, webUrl?: string, workspace?: string, repoSlug?: string
27
+ * }} PrCommentContext The fields a delivery adapter resolved; which ones are
28
+ * present depends on the provider. A context built by hand carries no
29
+ * `provider` and is treated as GitHub.
26
30
  */
27
31
 
28
32
  /**
@@ -233,70 +237,30 @@ export function renderPrComment(results, options = {}) {
233
237
  }
234
238
 
235
239
  /**
236
- * @param {Record<string, string | undefined>} env
237
- * @param {(path: string) => string} readEventFile
238
- * @returns {number | null}
239
- */
240
- function resolvePrNumber(env, readEventFile) {
241
- const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
242
- if (fromRef) return Number(fromRef[1]);
243
-
244
- const eventPath = env.GITHUB_EVENT_PATH;
245
- if (!eventPath) return null;
246
-
247
- let event;
248
- try {
249
- event = JSON.parse(readEventFile(eventPath));
250
- } catch {
251
- return null;
252
- }
253
-
254
- const candidates = [
255
- event?.pull_request?.number,
256
- // issue_comment events on a PR carry the PR number under `issue`, but a
257
- // plain issue must not be mistaken for one.
258
- event?.issue?.pull_request ? event?.issue?.number : undefined,
259
- event?.number,
260
- ];
261
- for (const candidate of candidates) {
262
- const value = Number(candidate);
263
- if (Number.isInteger(value) && value > 0) return value;
264
- }
265
- return null;
266
- }
267
-
268
- /**
269
- * Resolve the GitHub API context needed to post a pull request comment.
240
+ * Resolve the API context needed to post a merge request comment.
270
241
  *
271
- * Only `GITHUB_TOKEN` is honored `GH_TOKEN` is deliberately ignored because
272
- * `gh auth login` exports it on developer machines, where posting would be a
273
- * surprise.
242
+ * The provider is detected from CI environment variables, or forced with
243
+ * `provider`. GitHub is the fallback, so an unrecognized environment reports
244
+ * the message it always did rather than a new one about detection.
274
245
  * @param {Record<string, string | undefined>} [env]
275
- * @param {{ readEventFile?: (path: string) => string }} [options]
246
+ * @param {{ readEventFile?: (path: string) => string, provider?: string }} [options]
276
247
  * @returns {{ ok: true, context: PrCommentContext } | { ok: false, reason: string }}
277
248
  */
278
249
  export function resolvePrCommentContext(env = process.env, options = {}) {
279
250
  const readEventFile = options.readEventFile ?? ((path) => readFileSync(path, 'utf8'));
280
- const token = String(env.GITHUB_TOKEN ?? '').trim();
281
- if (!token) {
282
- return { ok: false, reason: 'GITHUB_TOKEN is not set' };
283
- }
284
-
285
- const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
286
- if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
287
- return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
288
- }
289
-
290
- const prNumber = resolvePrNumber(env, readEventFile);
291
- if (!prNumber) {
292
- return {
293
- ok: false,
294
- reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
295
- };
296
- }
251
+ const selected = selectPrProvider(env, options.provider);
252
+ if (!selected.ok) return selected;
253
+ return selected.provider.resolve(env, { readEventFile });
254
+ }
297
255
 
298
- const apiUrl = String(env.GITHUB_API_URL || DEFAULT_API_URL).replace(/\/+$/u, '');
299
- return { ok: true, context: { repo, prNumber, token, apiUrl } };
256
+ /**
257
+ * The adapter a resolved context belongs to. Contexts built by hand — as tests
258
+ * and embedders do — carry no provider and are GitHub, which is what they were
259
+ * before there was a choice.
260
+ * @param {{ provider?: string }} context
261
+ */
262
+ function providerFor(context) {
263
+ return PR_PROVIDERS.find((p) => p.id === context?.provider) ?? PR_PROVIDERS[0];
300
264
  }
301
265
 
302
266
  /**
@@ -333,10 +297,10 @@ async function errorDetail(response, token) {
333
297
  }
334
298
 
335
299
  /**
336
- * @param {{ fetchImpl: typeof fetch, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
300
+ * @param {{ fetchImpl: typeof fetch, provider: object, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
337
301
  * @returns {Promise<Response>}
338
302
  */
339
- async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs }) {
303
+ async function apiRequest({ fetchImpl, provider, url, method, token, body, timeoutMs }) {
340
304
  const controller = new AbortController();
341
305
  const timer = setTimeout(() => controller.abort(), timeoutMs);
342
306
  let response;
@@ -344,11 +308,10 @@ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs })
344
308
  response = await fetchImpl(url, {
345
309
  method,
346
310
  headers: {
347
- Accept: 'application/vnd.github+json',
348
- Authorization: `Bearer ${token}`,
311
+ Accept: provider.accept ?? 'application/json',
349
312
  'Content-Type': 'application/json',
350
313
  'User-Agent': 'flecto',
351
- 'X-GitHub-Api-Version': '2022-11-28',
314
+ ...provider.authHeaders(token),
352
315
  },
353
316
  body: body === undefined ? undefined : JSON.stringify(body),
354
317
  signal: controller.signal,
@@ -357,14 +320,14 @@ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs })
357
320
  const reason = err?.name === 'AbortError'
358
321
  ? `timed out after ${timeoutMs}ms`
359
322
  : redact(err?.message ?? String(err), token);
360
- throw new Error(`GitHub API ${method} failed: ${reason}`);
323
+ throw new Error(`${provider.label} API ${method} failed: ${reason}`);
361
324
  } finally {
362
325
  clearTimeout(timer);
363
326
  }
364
327
 
365
328
  if (!response.ok) {
366
329
  throw new Error(
367
- `GitHub API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
330
+ `${provider.label} API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
368
331
  );
369
332
  }
370
333
  return response;
@@ -374,20 +337,19 @@ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs })
374
337
  * Find the sticky Flecto comment on a pull request, if one exists.
375
338
  * @param {PrCommentContext} context
376
339
  * @param {{ fetchImpl: typeof fetch, marker: string, timeoutMs: number }} options
377
- * @returns {Promise<{ id: number, body?: string, html_url?: string } | null>}
340
+ * @returns {Promise<{ id: string | number, body?: string, url?: string } | null>}
378
341
  */
379
342
  async function findStickyComment(context, { fetchImpl, marker, timeoutMs }) {
343
+ const provider = providerFor(context);
380
344
  for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
381
- const url = `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}`
382
- + `/comments?per_page=${COMMENTS_PER_PAGE}&page=${page}`;
383
- const response = await githubRequest({
384
- fetchImpl, url, method: 'GET', token: context.token, timeoutMs,
345
+ const response = await apiRequest({
346
+ fetchImpl, provider, url: provider.listUrl(context, page), method: 'GET', token: context.token, timeoutMs,
385
347
  });
386
- const comments = await response.json();
387
- if (!Array.isArray(comments) || comments.length === 0) return null;
348
+ const comments = provider.readList(await response.json());
349
+ if (comments.length === 0) return null;
388
350
  const match = comments.find((c) => typeof c?.body === 'string' && c.body.includes(marker));
389
351
  if (match) return match;
390
- if (comments.length < COMMENTS_PER_PAGE) return null;
352
+ if (comments.length < provider.perPage) return null;
391
353
  }
392
354
  return null;
393
355
  }
@@ -404,6 +366,7 @@ export async function upsertPrComment(body, context, options = {}) {
404
366
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
405
367
  const marker = options.marker ?? PR_COMMENT_MARKER;
406
368
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
369
+ const provider = providerFor(context);
407
370
  if (typeof fetchImpl !== 'function') {
408
371
  throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
409
372
  }
@@ -411,39 +374,41 @@ export async function upsertPrComment(body, context, options = {}) {
411
374
  const existing = await findStickyComment(context, { fetchImpl, marker, timeoutMs });
412
375
 
413
376
  if (!existing) {
414
- const response = await githubRequest({
377
+ const response = await apiRequest({
415
378
  fetchImpl,
416
- url: `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}/comments`,
379
+ provider,
380
+ url: provider.createUrl(context),
417
381
  method: 'POST',
418
382
  token: context.token,
419
- body: { body },
383
+ body: provider.payload(body),
420
384
  timeoutMs,
421
385
  });
422
386
  const created = await response.json().catch(() => ({}));
423
- return { action: 'created', url: created?.html_url };
387
+ return { action: 'created', url: provider.readOne(created, context).url };
424
388
  }
425
389
 
426
390
  // Rendering is deterministic, so an identical body means nothing moved since
427
391
  // the last run — skip the write and the "edited" noise it creates.
428
392
  if (existing.body === body) {
429
- return { action: 'unchanged', url: existing.html_url };
393
+ return { action: 'unchanged', url: existing.url };
430
394
  }
431
395
 
432
- const response = await githubRequest({
396
+ const response = await apiRequest({
433
397
  fetchImpl,
434
- url: `${context.apiUrl}/repos/${context.repo}/issues/comments/${existing.id}`,
435
- method: 'PATCH',
398
+ provider,
399
+ url: provider.updateUrl(context, existing.id),
400
+ method: provider.updateMethod,
436
401
  token: context.token,
437
- body: { body },
402
+ body: provider.payload(body),
438
403
  timeoutMs,
439
404
  });
440
405
  const updated = await response.json().catch(() => ({}));
441
- return { action: 'updated', url: updated?.html_url ?? existing.html_url };
406
+ return { action: 'updated', url: provider.readOne(updated, context).url ?? existing.url };
442
407
  }
443
408
 
444
409
  /**
445
410
  * Post the sticky comment when — and only when — posting was explicitly enabled
446
- * and a complete GitHub pull request context is present.
411
+ * and a complete merge request context is present.
447
412
  *
448
413
  * Never throws and never reports the token: a delivery problem must not change
449
414
  * the CI exit code, which belongs to the diff and policy result alone.
@@ -454,7 +419,8 @@ export async function upsertPrComment(body, context, options = {}) {
454
419
  * fetchImpl?: typeof fetch,
455
420
  * marker?: string,
456
421
  * timeoutMs?: number,
457
- * readEventFile?: (path: string) => string
422
+ * readEventFile?: (path: string) => string,
423
+ * provider?: string
458
424
  * }} [options]
459
425
  * @returns {Promise<{ posted: boolean, action?: 'created' | 'updated' | 'unchanged', url?: string, reason?: string }>}
460
426
  */