flecto 3.0.0 → 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/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
  */
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Delivery adapters for the sticky review comment.
3
+ *
4
+ * Everything upstream of delivery — the differ, the policy engine, the
5
+ * envelope, and the rendered comment body — is provider-agnostic markdown.
6
+ * Only the last step differs, so only the last step lives here: which
7
+ * environment variables identify a merge request, how the host authenticates,
8
+ * and the three URLs needed to list, create, and update a comment.
9
+ *
10
+ * Adding a provider means adding one object to {@link PR_PROVIDERS}. Nothing
11
+ * else in Flecto needs to know it exists.
12
+ */
13
+
14
+ const GITHUB_API_URL = 'https://api.github.com';
15
+ const GITLAB_API_URL = 'https://gitlab.com/api/v4';
16
+ const BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0';
17
+
18
+ /**
19
+ * @typedef {{
20
+ * id: string,
21
+ * label: string,
22
+ * perPage: number,
23
+ * detect: (env: Record<string, string | undefined>) => boolean,
24
+ * resolve: (
25
+ * env: Record<string, string | undefined>,
26
+ * helpers: { readEventFile: (path: string) => string },
27
+ * ) => { ok: true, context: object } | { ok: false, reason: string },
28
+ * authHeaders: (token: string) => Record<string, string>,
29
+ * accept?: string,
30
+ * listUrl: (context: any, page: number) => string,
31
+ * readList: (payload: unknown) => { id: string | number, body: string, url?: string }[],
32
+ * createUrl: (context: any) => string,
33
+ * updateUrl: (context: any, id: string | number) => string,
34
+ * updateMethod: string,
35
+ * payload: (body: string) => object,
36
+ * readOne: (payload: any, context: any) => { url?: string },
37
+ * }} PrProvider
38
+ */
39
+
40
+ /** Trim trailing slashes from a base URL. */
41
+ function trimUrl(value, fallback) {
42
+ return String(value || fallback).replace(/\/+$/u, '');
43
+ }
44
+
45
+ /**
46
+ * Pull request number from the GitHub ref, then the event payload.
47
+ * @param {Record<string, string | undefined>} env
48
+ * @param {(path: string) => string} readEventFile
49
+ * @returns {number | null}
50
+ */
51
+ function githubPrNumber(env, readEventFile) {
52
+ const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
53
+ if (fromRef) return Number(fromRef[1]);
54
+
55
+ const eventPath = env.GITHUB_EVENT_PATH;
56
+ if (!eventPath) return null;
57
+
58
+ let event;
59
+ try {
60
+ event = JSON.parse(readEventFile(eventPath));
61
+ } catch {
62
+ return null;
63
+ }
64
+
65
+ const candidates = [
66
+ event?.pull_request?.number,
67
+ // issue_comment events on a PR carry the PR number under `issue`, but a
68
+ // plain issue must not be mistaken for one.
69
+ event?.issue?.pull_request ? event?.issue?.number : undefined,
70
+ event?.number,
71
+ ];
72
+ for (const candidate of candidates) {
73
+ const value = Number(candidate);
74
+ if (Number.isInteger(value) && value > 0) return value;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** @type {PrProvider} */
80
+ const github = {
81
+ id: 'github',
82
+ label: 'GitHub',
83
+ perPage: 100,
84
+ accept: 'application/vnd.github+json',
85
+ detect: (env) => Boolean(env.GITHUB_ACTIONS || env.GITHUB_REPOSITORY || env.GITHUB_EVENT_PATH),
86
+
87
+ resolve(env, { readEventFile }) {
88
+ // Only GITHUB_TOKEN is honored — GH_TOKEN is deliberately ignored because
89
+ // `gh auth login` exports it on developer machines, where posting would be
90
+ // a surprise.
91
+ const token = String(env.GITHUB_TOKEN ?? '').trim();
92
+ if (!token) return { ok: false, reason: 'GITHUB_TOKEN is not set' };
93
+
94
+ const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
95
+ if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
96
+ return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
97
+ }
98
+
99
+ const prNumber = githubPrNumber(env, readEventFile);
100
+ if (!prNumber) {
101
+ return {
102
+ ok: false,
103
+ reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ context: { provider: 'github', repo, prNumber, token, apiUrl: trimUrl(env.GITHUB_API_URL, GITHUB_API_URL) },
110
+ };
111
+ },
112
+
113
+ authHeaders: (token) => ({ Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' }),
114
+ listUrl: (c, page) => `${c.apiUrl}/repos/${c.repo}/issues/${c.prNumber}/comments?per_page=${github.perPage}&page=${page}`,
115
+ readList: (payload) => (Array.isArray(payload) ? payload : []).map((c) => ({ id: c?.id, body: c?.body, url: c?.html_url })),
116
+ createUrl: (c) => `${c.apiUrl}/repos/${c.repo}/issues/${c.prNumber}/comments`,
117
+ updateUrl: (c, id) => `${c.apiUrl}/repos/${c.repo}/issues/comments/${id}`,
118
+ updateMethod: 'PATCH',
119
+ payload: (body) => ({ body }),
120
+ readOne: (payload) => ({ url: payload?.html_url }),
121
+ };
122
+
123
+ /** @type {PrProvider} */
124
+ const gitlab = {
125
+ id: 'gitlab',
126
+ label: 'GitLab',
127
+ perPage: 100,
128
+ detect: (env) => Boolean(env.GITLAB_CI || env.CI_MERGE_REQUEST_IID),
129
+
130
+ resolve(env) {
131
+ // CI_JOB_TOKEN is present on every GitLab job and cannot write notes, so
132
+ // silently trying it would produce a 401 that reads like a broken setup.
133
+ // Name the fix instead.
134
+ const token = String(env.FLECTO_GITLAB_TOKEN ?? env.GITLAB_TOKEN ?? '').trim();
135
+ if (!token) {
136
+ return {
137
+ ok: false,
138
+ reason: env.CI_JOB_TOKEN
139
+ ? 'no GitLab API token: CI_JOB_TOKEN cannot post merge request notes. '
140
+ + 'Set FLECTO_GITLAB_TOKEN to a project or group access token with the "api" scope.'
141
+ : 'FLECTO_GITLAB_TOKEN (or GITLAB_TOKEN) is not set',
142
+ };
143
+ }
144
+
145
+ const projectId = String(env.CI_PROJECT_ID ?? '').trim();
146
+ if (!projectId) return { ok: false, reason: 'CI_PROJECT_ID is not set' };
147
+
148
+ const iid = Number(env.CI_MERGE_REQUEST_IID);
149
+ if (!Number.isInteger(iid) || iid <= 0) {
150
+ return { ok: false, reason: 'CI_MERGE_REQUEST_IID is not set (not a merge request pipeline)' };
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ context: {
156
+ provider: 'gitlab',
157
+ projectId,
158
+ prNumber: iid,
159
+ token,
160
+ apiUrl: trimUrl(env.CI_API_V4_URL, GITLAB_API_URL),
161
+ webUrl: String(env.CI_MERGE_REQUEST_PROJECT_URL ?? '').replace(/\/+$/u, ''),
162
+ },
163
+ };
164
+ },
165
+
166
+ authHeaders: (token) => ({ 'PRIVATE-TOKEN': token }),
167
+ listUrl: (c, page) => `${gitlabNotesBase(c)}?per_page=${gitlab.perPage}&page=${page}`,
168
+ readList: (payload) => (Array.isArray(payload) ? payload : []).map((n) => ({ id: n?.id, body: n?.body })),
169
+ createUrl: (c) => gitlabNotesBase(c),
170
+ updateUrl: (c, id) => `${gitlabNotesBase(c)}/${id}`,
171
+ updateMethod: 'PUT',
172
+ payload: (body) => ({ body }),
173
+ readOne: (payload, c) => (c?.webUrl && payload?.id
174
+ ? { url: `${c.webUrl}/-/merge_requests/${c.prNumber}#note_${payload.id}` }
175
+ : {}),
176
+ };
177
+
178
+ /** Notes collection for the merge request. Project ids may be paths, so encode. */
179
+ function gitlabNotesBase(c) {
180
+ return `${c.apiUrl}/projects/${encodeURIComponent(c.projectId)}/merge_requests/${c.prNumber}/notes`;
181
+ }
182
+
183
+ /** @type {PrProvider} */
184
+ const bitbucket = {
185
+ id: 'bitbucket',
186
+ label: 'Bitbucket',
187
+ perPage: 100,
188
+ detect: (env) => Boolean(env.BITBUCKET_PR_ID || env.BITBUCKET_REPO_SLUG),
189
+
190
+ resolve(env) {
191
+ const token = String(env.FLECTO_BITBUCKET_TOKEN ?? env.BITBUCKET_TOKEN ?? '').trim();
192
+ if (!token) return { ok: false, reason: 'FLECTO_BITBUCKET_TOKEN (or BITBUCKET_TOKEN) is not set' };
193
+
194
+ const workspace = String(env.BITBUCKET_WORKSPACE ?? '').trim();
195
+ const repoSlug = String(env.BITBUCKET_REPO_SLUG ?? '').trim();
196
+ if (!workspace || !repoSlug) {
197
+ return { ok: false, reason: 'BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG must both be set' };
198
+ }
199
+
200
+ const prId = Number(env.BITBUCKET_PR_ID);
201
+ if (!Number.isInteger(prId) || prId <= 0) {
202
+ return { ok: false, reason: 'BITBUCKET_PR_ID is not set (not a pull request pipeline)' };
203
+ }
204
+
205
+ return {
206
+ ok: true,
207
+ context: {
208
+ provider: 'bitbucket',
209
+ workspace,
210
+ repoSlug,
211
+ prNumber: prId,
212
+ token,
213
+ apiUrl: trimUrl(env.BITBUCKET_API_URL, BITBUCKET_API_URL),
214
+ },
215
+ };
216
+ },
217
+
218
+ authHeaders: (token) => ({ Authorization: `Bearer ${token}` }),
219
+ listUrl: (c, page) => `${bitbucketCommentsBase(c)}?pagelen=${bitbucket.perPage}&page=${page}`,
220
+ readList: (payload) => (Array.isArray(payload?.values) ? payload.values : []).map((c) => ({
221
+ id: c?.id,
222
+ body: c?.content?.raw,
223
+ url: c?.links?.html?.href,
224
+ })),
225
+ createUrl: (c) => bitbucketCommentsBase(c),
226
+ updateUrl: (c, id) => `${bitbucketCommentsBase(c)}/${id}`,
227
+ updateMethod: 'PUT',
228
+ payload: (body) => ({ content: { raw: body } }),
229
+ readOne: (payload) => ({ url: payload?.links?.html?.href }),
230
+ };
231
+
232
+ function bitbucketCommentsBase(c) {
233
+ return `${c.apiUrl}/repositories/${c.workspace}/${c.repoSlug}/pullrequests/${c.prNumber}/comments`;
234
+ }
235
+
236
+ /** Detection order. GitHub stays first so its behavior is unchanged. */
237
+ export const PR_PROVIDERS = [github, gitlab, bitbucket];
238
+
239
+ export const PR_PROVIDER_IDS = PR_PROVIDERS.map((p) => p.id);
240
+
241
+ /**
242
+ * Pick the delivery adapter.
243
+ *
244
+ * An explicit id always wins. Otherwise the first provider whose CI variables
245
+ * are present is used, and GitHub is the fallback so an unrecognized
246
+ * environment produces the same message it always did rather than a new one
247
+ * about provider detection.
248
+ * @param {Record<string, string | undefined>} env
249
+ * @param {string} [explicit]
250
+ * @returns {{ ok: true, provider: PrProvider } | { ok: false, reason: string }}
251
+ */
252
+ export function selectPrProvider(env, explicit) {
253
+ if (explicit) {
254
+ const found = PR_PROVIDERS.find((p) => p.id === explicit);
255
+ if (!found) {
256
+ return { ok: false, reason: `unknown pr provider "${explicit}" (expected ${PR_PROVIDER_IDS.join(', ')})` };
257
+ }
258
+ return { ok: true, provider: found };
259
+ }
260
+ return { ok: true, provider: PR_PROVIDERS.find((p) => p.detect(env)) ?? github };
261
+ }
package/src/renderer.js CHANGED
@@ -211,13 +211,13 @@ export function maskSensitiveValue(value, path = '') {
211
211
  && typeof value === 'object'
212
212
  && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
213
213
  ) {
214
- /** @type {Record<string, unknown>} */
215
- const out = {};
216
- for (const [k, v] of Object.entries(value)) {
217
- const child = path ? `${path}.${k}` : k;
218
- out[k] = maskSensitiveValue(v, child);
219
- }
220
- return out;
214
+ // Object.fromEntries rather than `out[k] = ...`: assigning a key literally
215
+ // named "__proto__" runs the prototype setter instead of creating an own
216
+ // property, so that whole subtree would vanish from the masked output
217
+ // rather than being rendered masked.
218
+ return Object.fromEntries(
219
+ Object.entries(value).map(([k, v]) => [k, maskSensitiveValue(v, path ? `${path}.${k}` : k)]),
220
+ );
221
221
  }
222
222
  if (typeof value === 'string') return redactSecretString(value);
223
223
  return value;
package/src/report.js CHANGED
@@ -245,6 +245,13 @@ function snapshotCard(snapshot, index) {
245
245
  } else if (count > 0) {
246
246
  // changeCount without events: the caller summarized but did not diff.
247
247
  body.push(`<p class="empty">${escapeHtml(plural(count, 'change'))} recorded.</p>`);
248
+ } else if (!previous) {
249
+ // Nothing was compared here, so "no changes" would be a claim this card
250
+ // cannot support (#141). Say what actually happened instead.
251
+ body.push(
252
+ '<p class="empty">First snapshot of this file — there is no earlier state to'
253
+ + ' compare it against. That is <strong>no history</strong>, not no drift.</p>',
254
+ );
248
255
  } else {
249
256
  body.push('<p class="empty">No semantic changes from the previous snapshot.</p>');
250
257
  }
@@ -257,11 +264,14 @@ function snapshotCard(snapshot, index) {
257
264
  }
258
265
 
259
266
  const countClass = count > 0 ? 'count count-active' : 'count';
267
+ // A first snapshot is labelled "baseline" rather than "0 changes": the count
268
+ // is only meaningful once there is something on the other side of it.
269
+ const countLabel = previous ? plural(count, 'change') : 'baseline';
260
270
  return [
261
271
  `<details class="card" open id="snapshot-${escapeHtml(String(index))}">`,
262
272
  '<summary>',
263
273
  `<time class="stamp" datetime="${escapeHtml(time.iso)}">${escapeHtml(time.label)}</time>`,
264
- `<span class="${countClass}">${escapeHtml(plural(count, 'change'))}</span>`,
274
+ `<span class="${countClass}">${escapeHtml(countLabel)}</span>`,
265
275
  baselineNote,
266
276
  '</summary>',
267
277
  `<div class="card-body">${body.join('')}</div>`,
@@ -442,6 +452,14 @@ tr:last-child td { border-bottom: 0; }
442
452
  .sev-warn { color: var(--warn); }
443
453
  .sev-info { color: var(--info); }
444
454
  .empty { color: var(--muted); margin: 8px 0; }
455
+ .banner {
456
+ border: 1px solid var(--warn);
457
+ border-left-width: 4px;
458
+ border-radius: 6px;
459
+ background: var(--panel);
460
+ padding: 10px 14px;
461
+ margin: 0 0 24px;
462
+ }
445
463
  .no-matches { color: var(--muted); margin: 16px 0; }
446
464
  footer { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.82rem; }
447
465
  .hidden { display: none !important; }
@@ -513,8 +531,13 @@ export function renderReportHtml(data = {}) {
513
531
  /** @type {Array<{ file: string, snapshot: ReportSnapshot, finding: import('./policy.js').PolicyFinding }>} */
514
532
  const allFindings = [];
515
533
  let totalChanges = 0;
534
+ // How many of these snapshots actually had an earlier one to be compared
535
+ // against. Zero means the report compared nothing, which is a different
536
+ // statement from "compared everything and found nothing" (#141).
537
+ let comparisons = 0;
516
538
  for (const snapshot of snapshots) {
517
539
  const changes = changesOf(snapshot);
540
+ if (snapshot.previousCreatedAt) comparisons += 1;
518
541
  // Prefer the events actually carried; fall back to the count for callers
519
542
  // that summarized without diffing.
520
543
  totalChanges += Array.isArray(snapshot.changes)
@@ -556,12 +579,26 @@ export function renderReportHtml(data = {}) {
556
579
  '<section class="stats">',
557
580
  statTile('Snapshots', String(snapshots.length)),
558
581
  statTile('Files', String(groups.length)),
582
+ // "Changes" alone reads as an all-clear at 0 whether or not anything was
583
+ // ever compared, so the number of comparisons behind it sits next to it.
584
+ statTile('Comparisons', String(comparisons)),
559
585
  statTile('Changes', String(totalChanges)),
560
586
  statTile('Policy errors', String(severityCounts.error), 'error'),
561
587
  statTile('Policy warnings', String(severityCounts.warn), 'warn'),
562
588
  '</section>',
563
589
  ].join('');
564
590
 
591
+ // The failure mode this guards against: a CI job takes its first snapshot and
592
+ // renders a report from it, and the page reads as "nothing drifted" when the
593
+ // truth is that there was no history to look at. Say so above the fold.
594
+ const noHistoryBanner = comparisons === 0
595
+ ? '<p class="banner">Nothing in this report was compared. Every snapshot here is'
596
+ + ' the first one of its file, so there is no earlier state to measure drift'
597
+ + ' against — this is <strong>no history</strong>, not <strong>no drift</strong>.'
598
+ + ' Snapshot history is local to the working directory, so a fresh CI runner'
599
+ + ' starts with none of it.</p>'
600
+ : '';
601
+
565
602
  const findingsSection = allFindings.length === 0
566
603
  ? '<h2>Policy findings</h2><p class="empty">No policy findings across these snapshots.</p>'
567
604
  : [
@@ -603,6 +640,7 @@ export function renderReportHtml(data = {}) {
603
640
  return htmlDocument([
604
641
  head,
605
642
  stats,
643
+ noHistoryBanner,
606
644
  findingsSection,
607
645
  `<h2>Snapshot timeline (${escapeHtml(plural(snapshots.length, 'snapshot'))})</h2>`,
608
646
  controls,