@grafana/create-plugin 7.12.3 → 7.14.0

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/codemods/additions/additions.js +20 -7
  3. package/dist/codemods/additions/prompts/docs-content.md +119 -0
  4. package/dist/codemods/additions/prompts/example-prompt.md +56 -0
  5. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  6. package/dist/codemods/additions/scripts/docs.js +250 -0
  7. package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
  8. package/dist/codemods/agentic/definitions.js +31 -0
  9. package/dist/codemods/agentic/detect.js +57 -0
  10. package/dist/codemods/agentic/handoff.js +35 -0
  11. package/dist/codemods/agentic/index.js +143 -0
  12. package/dist/codemods/agentic/prompts.js +115 -0
  13. package/dist/codemods/agentic/resolve.js +120 -0
  14. package/dist/codemods/agentic/runner.js +79 -0
  15. package/dist/codemods/agentic/verify.js +55 -0
  16. package/dist/codemods/migrations/migrations.js +12 -0
  17. package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
  18. package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
  19. package/dist/codemods/utils.agents.js +64 -0
  20. package/dist/codemods/utils.js +51 -8
  21. package/dist/codemods/utils.workflows.js +94 -0
  22. package/dist/commands/add.command.js +74 -12
  23. package/dist/utils/utils.cli.js +7 -2
  24. package/dist/utils/utils.console.js +10 -1
  25. package/dist/utils/utils.git.js +10 -1
  26. package/dist/utils/utils.handlebars.js +9 -2
  27. package/dist/utils/utils.packageManager.js +2 -2
  28. package/dist/utils/utils.templates.js +38 -24
  29. package/package.json +1 -1
  30. package/src/codemods/AGENTS.md +25 -0
  31. package/src/codemods/additions/additions.test.ts +69 -4
  32. package/src/codemods/additions/additions.ts +50 -8
  33. package/src/codemods/additions/prompts/docs-content.md +119 -0
  34. package/src/codemods/additions/prompts/example-prompt.md +56 -0
  35. package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
  36. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  37. package/src/codemods/additions/scripts/docs.test.ts +1037 -0
  38. package/src/codemods/additions/scripts/docs.ts +353 -0
  39. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
  40. package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
  41. package/src/codemods/agentic/definitions.test.ts +67 -0
  42. package/src/codemods/agentic/definitions.ts +33 -0
  43. package/src/codemods/agentic/detect.test.ts +136 -0
  44. package/src/codemods/agentic/detect.ts +75 -0
  45. package/src/codemods/agentic/handoff.test.ts +102 -0
  46. package/src/codemods/agentic/handoff.ts +45 -0
  47. package/src/codemods/agentic/index.test.ts +342 -0
  48. package/src/codemods/agentic/index.ts +221 -0
  49. package/src/codemods/agentic/prompts.test.ts +233 -0
  50. package/src/codemods/agentic/prompts.ts +166 -0
  51. package/src/codemods/agentic/resolve.test.ts +220 -0
  52. package/src/codemods/agentic/resolve.ts +165 -0
  53. package/src/codemods/agentic/runner.test.ts +146 -0
  54. package/src/codemods/agentic/runner.ts +103 -0
  55. package/src/codemods/agentic/types.ts +50 -0
  56. package/src/codemods/agentic/verify.test.ts +106 -0
  57. package/src/codemods/agentic/verify.ts +93 -0
  58. package/src/codemods/migrations/migrations.ts +14 -0
  59. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  60. package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
  61. package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
  62. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
  63. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
  64. package/src/codemods/utils.agents.test.ts +140 -0
  65. package/src/codemods/utils.agents.ts +81 -0
  66. package/src/codemods/utils.test.ts +131 -1
  67. package/src/codemods/utils.ts +79 -8
  68. package/src/codemods/utils.workflows.test.ts +153 -0
  69. package/src/codemods/utils.workflows.ts +140 -0
  70. package/src/commands/add.command.ts +92 -9
  71. package/src/utils/utils.cli.ts +5 -0
  72. package/src/utils/utils.git.test.ts +57 -0
  73. package/src/utils/utils.git.ts +25 -0
  74. package/src/utils/utils.handlebars.ts +9 -0
  75. package/src/utils/utils.packageManager.test.ts +23 -0
  76. package/src/utils/utils.packageManager.ts +3 -2
  77. package/src/utils/utils.templates.ts +50 -25
  78. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  79. package/templates/common/.config/webpack/webpack.config.ts +4 -5
  80. package/templates/common/_package.json +9 -8
  81. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  82. package/templates/docs/panel/docs/AGENTS.md +107 -0
  83. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  84. package/templates/docs/panel/docs/README.md +20 -0
  85. package/templates/docs/panel/docs/data-formats.md +27 -0
  86. package/templates/docs/panel/docs/examples.md +35 -0
  87. package/templates/docs/panel/docs/index.md +26 -0
  88. package/templates/docs/panel/docs/options.md +53 -0
  89. package/templates/docs/panel/docs/troubleshooting.md +35 -0
  90. package/templates/github/workflows/bundle-stats.yml +1 -1
  91. package/templates/github/workflows/ci.yml +7 -7
  92. package/templates/github/workflows/is-compatible.yml +3 -3
  93. package/templates/github/workflows/release.yml +1 -1
@@ -2,6 +2,8 @@ import { dirSync } from 'tmp';
2
2
  import { Context } from './context.js';
3
3
  import {
4
4
  addDependenciesToPackageJson,
5
+ addTemplateFolder,
6
+ getTemplateDataFromContext,
5
7
  removeDependenciesFromPackageJson,
6
8
  flushChanges,
7
9
  formatFiles,
@@ -12,7 +14,8 @@ import {
12
14
  } from './utils.js';
13
15
  import { join } from 'node:path';
14
16
  import { mkdir, rm, writeFile } from 'node:fs/promises';
15
- import { readFileSync } from 'node:fs';
17
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
16
19
  import { execSync } from 'node:child_process';
17
20
  import which from 'which';
18
21
  import { output } from '../utils/utils.console.js';
@@ -23,6 +26,17 @@ vi.mock(import('node:child_process'), async (importOriginal) => ({
23
26
  execSync: vi.fn(),
24
27
  }));
25
28
 
29
+ // codemods run under `update` or `add`. Without a command, the config would use generate's defaults
30
+ vi.mock(import('../utils/utils.cli.js'), async (importOriginal) => ({
31
+ ...(await importOriginal()),
32
+ commandName: 'add',
33
+ }));
34
+
35
+ vi.mock(import('../utils/utils.packageManager.js'), async (importOriginal) => ({
36
+ ...(await importOriginal()),
37
+ getPackageManagerWithFallback: vi.fn(() => ({ packageManagerName: 'pnpm', packageManagerVersion: '10.4.1' })),
38
+ }));
39
+
26
40
  vi.mock('which', () => ({
27
41
  default: { sync: vi.fn() },
28
42
  }));
@@ -182,6 +196,122 @@ describe('utils', () => {
182
196
  const context = new Context(tmpDir);
183
197
  expect(() => readJsonFile(context, 'file.json')).toThrowError();
184
198
  });
199
+
200
+ it.each(['[]', 'null', '"text"'])('should throw when the file contains %s instead of an object', (content) => {
201
+ const context = new Context(tmpDir);
202
+ context.addFile('file.json', content);
203
+ expect(() => readJsonFile(context, 'file.json')).toThrowError('does not contain a JSON object');
204
+ });
205
+ });
206
+
207
+ describe('getTemplateDataFromContext', () => {
208
+ function pluginContext() {
209
+ const context = new Context(tmpDir);
210
+ context.addFile(
211
+ 'src/plugin.json',
212
+ JSON.stringify({ id: 'org-name-panel', name: 'Name', type: 'panel', info: { author: { name: 'Org' } } })
213
+ );
214
+ return context;
215
+ }
216
+
217
+ it('reads plugin.json from the context', () => {
218
+ expect(getTemplateDataFromContext(pluginContext())).toMatchObject({
219
+ pluginId: 'org-name-panel',
220
+ pluginName: 'Name',
221
+ pluginType: 'panel',
222
+ orgName: 'Org',
223
+ packageManagerName: 'pnpm',
224
+ packageManagerInstallCmd: 'pnpm install --frozen-lockfile --prefer-offline',
225
+ isAppType: false,
226
+ isNPM: false,
227
+ frontendBundler: 'webpack',
228
+ });
229
+ });
230
+
231
+ it('sees changes the codemod has not written yet', () => {
232
+ const context = pluginContext();
233
+ context.updateFile('src/plugin.json', JSON.stringify({ id: 'org-new-app', name: 'New', type: 'app' }));
234
+ expect(getTemplateDataFromContext(context)).toMatchObject({ pluginId: 'org-new-app', isAppType: true });
235
+ });
236
+
237
+ it('reads feature flags from the .cprc.json files', async () => {
238
+ await mkdir(join(tmpDir, '.config'));
239
+ await writeFile(join(tmpDir, '.config/.cprc.json'), JSON.stringify({ features: { bundleGrafanaUI: true } }));
240
+ await writeFile(join(tmpDir, '.cprc.json'), JSON.stringify({ features: { useExperimentalRspack: true } }));
241
+ expect(getTemplateDataFromContext(pluginContext())).toMatchObject({
242
+ bundleGrafanaUI: true,
243
+ frontendBundler: 'rspack',
244
+ });
245
+ });
246
+ });
247
+
248
+ describe('addTemplateFolder', () => {
249
+ function templateFolder(files: Record<string, string>): string {
250
+ const dir = mkdtempSync(join(tmpdir(), 'codemod-templates-'));
251
+ for (const [file, content] of Object.entries(files)) {
252
+ mkdirSync(join(dir, file, '..'), { recursive: true });
253
+ writeFileSync(join(dir, file), content);
254
+ }
255
+ return dir;
256
+ }
257
+
258
+ const data = { pluginName: "Erik's & Co", packageManagerName: 'pnpm' };
259
+
260
+ it('renders every file with Handlebars into the target folder', () => {
261
+ const context = new Context(tmpDir);
262
+ const folderPath = templateFolder({
263
+ 'index.md': '# {{{pluginName}}}\n',
264
+ 'nested/setup.md': '{{#if_eq packageManagerName "pnpm"}}pnpm{{/if_eq}} {{dashCase "Some Name"}}\n',
265
+ });
266
+ const added = addTemplateFolder(context, { folderPath, targetPath: 'docs', data });
267
+ expect(added.sort()).toEqual(['docs/index.md', 'docs/nested/setup.md']);
268
+ expect(context.getFile('docs/index.md')).toBe("# Erik's & Co\n");
269
+ expect(context.getFile('docs/nested/setup.md')).toBe('pnpm some-name\n');
270
+ });
271
+
272
+ it('skips files that include rejects, matching on the output path relative to the target', () => {
273
+ const context = new Context(tmpDir);
274
+ const folderPath = templateFolder({ 'README.md': 'readme\n', 'nested/page.md.hbs': 'page\n', npmrc: 'x' });
275
+ const seen: string[] = [];
276
+ const added = addTemplateFolder(context, {
277
+ folderPath,
278
+ targetPath: 'docs',
279
+ data,
280
+ include: (file) => {
281
+ seen.push(file);
282
+ return file === 'README.md';
283
+ },
284
+ });
285
+ expect(seen.sort()).toEqual(['.npmrc', 'README.md', 'nested/page.md']);
286
+ expect(added).toEqual(['docs/README.md']);
287
+ });
288
+
289
+ it('strips .hbs and maps config file names like generate does', () => {
290
+ const context = new Context(tmpDir);
291
+ const folderPath = templateFolder({ 'page.md.hbs': 'x', npmrc: 'y' });
292
+ addTemplateFolder(context, { folderPath, targetPath: '', data });
293
+ expect(context.doesFileExist('page.md')).toBe(true);
294
+ expect(context.doesFileExist('.npmrc')).toBe(true);
295
+ });
296
+
297
+ it('leaves existing files alone', () => {
298
+ const context = new Context(tmpDir);
299
+ context.addFile('docs/index.md', 'mine');
300
+ const added = addTemplateFolder(context, {
301
+ folderPath: templateFolder({ 'index.md': 'new' }),
302
+ targetPath: 'docs',
303
+ data,
304
+ });
305
+ expect(added).toEqual([]);
306
+ expect(context.getFile('docs/index.md')).toBe('mine');
307
+ });
308
+
309
+ it('throws when the template folder is missing', () => {
310
+ const context = new Context(tmpDir);
311
+ expect(() =>
312
+ addTemplateFolder(context, { folderPath: join(tmpdir(), 'missing-templates'), targetPath: 'docs', data })
313
+ ).toThrow('Cannot find templates at');
314
+ });
185
315
  });
186
316
 
187
317
  describe('addDependenciesToPackageJson', () => {
@@ -1,7 +1,8 @@
1
- import { dirname, join } from 'node:path';
1
+ import { dirname, join, posix } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  import { Context } from './context.js';
4
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { glob } from 'glob';
5
6
  import { styleText } from 'node:util';
6
7
  import { output } from '../utils/utils.console.js';
7
8
  import { getPackageManagerSilentInstallCmd, getPackageManagerWithFallback } from '../utils/utils.packageManager.js';
@@ -10,7 +11,10 @@ import which from 'which';
10
11
  import { clean, coerce, gt, gte } from 'semver';
11
12
  import { debug } from '../utils/utils.cli.js';
12
13
  import { renderHandlebarsTemplate } from '../utils/utils.handlebars.js';
13
- import { getTemplateData } from '../utils/utils.templates.js';
14
+ import { buildTemplateData, getTemplateData } from '../utils/utils.templates.js';
15
+ import { getConfig } from '../utils/utils.config.js';
16
+ import { getExportFileName } from '../utils/utils.files.js';
17
+ import type { TemplateData } from '../types.js';
14
18
 
15
19
  export function printChanges(context: Context, key: string, description: string) {
16
20
  const changes = context.listChanges();
@@ -176,11 +180,16 @@ export function readJsonFile<T extends object = any>(context: Context, path: str
176
180
  if (!context.doesFileExist(path)) {
177
181
  throw new Error(`Cannot find ${path}`);
178
182
  }
183
+ let parsed: unknown;
179
184
  try {
180
- return JSON.parse(context.getFile(path) || '');
185
+ parsed = JSON.parse(context.getFile(path) || '');
181
186
  } catch (e) {
182
187
  throw new Error(`Cannot parse ${path}: ${e}`);
183
188
  }
189
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
190
+ throw new Error(`${path} does not contain a JSON object.`);
191
+ }
192
+ return parsed as T;
184
193
  }
185
194
 
186
195
  export function addDependenciesToPackageJson(
@@ -342,10 +351,15 @@ function sortObjectByKeys<T extends Record<string, any>>(obj: T): T {
342
351
 
343
352
  export const migrationsDebug = debug.extend('migrations');
344
353
  export const additionsDebug = debug.extend('additions');
345
-
346
- export function renderTemplate(templatePath: string, includeWarning = false): string {
347
- const templateData = getTemplateData();
348
- let rendered = renderHandlebarsTemplate(readFileSync(templatePath, 'utf-8'), templateData);
354
+ // for helpers that migrations and additions share
355
+ export const codemodsDebug = debug.extend('codemods');
356
+
357
+ export function renderTemplate(
358
+ templatePath: string,
359
+ includeWarning = false,
360
+ data: Record<string, unknown> = getTemplateData()
361
+ ): string {
362
+ let rendered = renderHandlebarsTemplate(readFileSync(templatePath, 'utf-8'), data);
349
363
  const regex = /DO NOT EDIT THIS FILE DIRECTLY\./g;
350
364
  if (includeWarning && !regex.test(rendered)) {
351
365
  rendered = `${warningComment}\n\n${rendered}`;
@@ -353,6 +367,63 @@ export function renderTemplate(templatePath: string, includeWarning = false): st
353
367
  return rendered;
354
368
  }
355
369
 
370
+ /**
371
+ * The same data `getTemplateData` gives scaffold templates, with plugin.json read from the context, so it
372
+ * includes changes the codemod has made but not yet written.
373
+ */
374
+ export function getTemplateDataFromContext(context: Context): TemplateData {
375
+ return buildTemplateData({
376
+ pluginJson: readJsonFile(context, 'src/plugin.json'),
377
+ packageManager: getPackageManagerWithFallback(),
378
+ features: getConfig(context.basePath).features,
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Renders every file under `folderPath` into `targetPath`, the way `generate` scaffolds a template folder:
384
+ * `.hbs` is stripped and config file names are mapped. Files that already exist are left alone, and so are
385
+ * files `include` rejects, given each file's output path relative to `targetPath` with `/` separators.
386
+ * Returns the paths it added.
387
+ */
388
+ export function addTemplateFolder(
389
+ context: Context,
390
+ {
391
+ folderPath,
392
+ targetPath,
393
+ data,
394
+ include = () => true,
395
+ }: {
396
+ folderPath: string;
397
+ targetPath: string;
398
+ data: Record<string, unknown>;
399
+ include?: (relativeFile: string) => boolean;
400
+ }
401
+ ): string[] {
402
+ if (!existsSync(folderPath)) {
403
+ throw new Error(
404
+ `Cannot find templates at ${folderPath}. This is a bug in @grafana/create-plugin - please report it.`
405
+ );
406
+ }
407
+
408
+ const added: string[] = [];
409
+ // relative to `cwd`, so glob characters in the install path (such as Windows backslashes) can't break the pattern
410
+ for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true, posix: true })) {
411
+ const file = join(folderPath, relativeFile);
412
+ const outputFile = posix.join(posix.dirname(relativeFile), getExportFileName(file));
413
+ if (!include(outputFile)) {
414
+ continue;
415
+ }
416
+ const filePath = join(targetPath, outputFile);
417
+ if (context.doesFileExist(filePath)) {
418
+ codemodsDebug(`${filePath} already exists, skipping`);
419
+ continue;
420
+ }
421
+ context.addFile(filePath, renderHandlebarsTemplate(readFileSync(file, 'utf-8'), data));
422
+ added.push(filePath);
423
+ }
424
+ return added;
425
+ }
426
+
356
427
  const warningComment = `/*
357
428
  * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY \`@grafana/create-plugin\`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
358
429
  *
@@ -0,0 +1,153 @@
1
+ import { Context } from './context.js';
2
+ import { bumpActionRefs } from './utils.workflows.js';
3
+
4
+ const WORKFLOW = '.github/workflows/release.yml';
5
+ const BUILD_PLUGIN = 'grafana/plugin-actions/build-plugin';
6
+ const PACKAGE_PLUGIN = 'grafana/plugin-actions/package-plugin';
7
+ const MINIMUMS = { [BUILD_PLUGIN]: 'build-plugin/v1.2.0', [PACKAGE_PLUGIN]: 'package-plugin/v1.3.3' };
8
+
9
+ // a release workflow with one step per `uses` value
10
+ function workflowWith(...uses: string[]): string {
11
+ return `name: Release\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n${uses.map((u) => ` - uses: ${u}\n`).join('')}`;
12
+ }
13
+
14
+ function bump(workflow: string, minimums: Record<string, string> = MINIMUMS) {
15
+ const context = new Context('/virtual');
16
+ context.addFile(WORKFLOW, workflow);
17
+ const result = bumpActionRefs(context, WORKFLOW, minimums);
18
+ return { result, content: context.getFile(WORKFLOW) };
19
+ }
20
+
21
+ describe('bumpActionRefs', () => {
22
+ it.each([
23
+ ['a prefixed tag', 'build-plugin/v1.0.2'],
24
+ ['a bare tag', 'v1.0.0'],
25
+ ])('raises %s to the minimum', (_, ref) => {
26
+ const { content, result } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
27
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`));
28
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
29
+ });
30
+
31
+ it.each([
32
+ ['a floating minor tag', 'build-plugin/v1.1', 'build-plugin/v1.2'],
33
+ ['a bare floating minor tag', 'v1.0', 'build-plugin/v1.2'],
34
+ ['a floating major tag', 'v0', 'build-plugin/v1'],
35
+ ])('raises %s and keeps it floating', (_, ref, raised) => {
36
+ const { content } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
37
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@${raised}`));
38
+ });
39
+
40
+ it.each(['build-plugin/v1.2.0', 'build-plugin/v2.0.0', 'build-plugin/v1.2', 'build-plugin/v1', 'v1', 'v2', 'main'])(
41
+ 'leaves %s alone',
42
+ (ref) => {
43
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
44
+ const { content, result } = bump(workflow);
45
+ expect(content).toBe(workflow);
46
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
47
+ }
48
+ );
49
+
50
+ it.each([
51
+ ['a branch', 'some-feature-branch'],
52
+ ['a commit SHA', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
53
+ ["another action's tag", 'package-plugin/v1.0.0'],
54
+ ['a tag with a longer prefix', 'foo/build-plugin/v1.0.0'],
55
+ ])('leaves %s alone and reports it', (_, ref) => {
56
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
57
+ const { content, result } = bump(workflow);
58
+ expect(content).toBe(workflow);
59
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [BUILD_PLUGIN] });
60
+ });
61
+
62
+ it('bumps each use independently, to its own action minimum', () => {
63
+ const { content, result } = bump(
64
+ workflowWith(
65
+ `${BUILD_PLUGIN}@v1.0.0`,
66
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
67
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.2`
68
+ )
69
+ );
70
+ expect(content).toBe(
71
+ workflowWith(
72
+ `${BUILD_PLUGIN}@build-plugin/v1.2.0`,
73
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
74
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.3`
75
+ )
76
+ );
77
+ expect(result.found).toEqual([BUILD_PLUGIN, PACKAGE_PLUGIN]);
78
+ });
79
+
80
+ it('bumps a quoted ref and keeps the quotes and any trailing comment', () => {
81
+ const { content } = bump(workflowWith(`'${BUILD_PLUGIN}@v1.0.0' # pinned`));
82
+ expect(content).toBe(workflowWith(`'${BUILD_PLUGIN}@build-plugin/v1.2.0' # pinned`));
83
+ });
84
+
85
+ it('only bumps `uses` values, not incidental mentions', () => {
86
+ const mention = `# see ${BUILD_PLUGIN}@v1.0.0 for reference\nenv:\n NOTE: ${BUILD_PLUGIN}@v1.0.0\n`;
87
+ const { content } = bump(`${mention}${workflowWith(`${BUILD_PLUGIN}@v1.0.0`)}`);
88
+ expect(content).toBe(`${mention}${workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`)}`);
89
+ });
90
+
91
+ it('keeps comments, blank lines and the rest of the workflow unchanged', () => {
92
+ const before = [
93
+ '# release on tag',
94
+ 'name: Release',
95
+ '',
96
+ 'on:',
97
+ ' push:',
98
+ " tags: ['v*']",
99
+ '',
100
+ 'jobs:',
101
+ ' release:',
102
+ ' runs-on: ubuntu-latest',
103
+ ' steps:',
104
+ ' - uses: actions/checkout@v6 # keep me',
105
+ ` - uses: ${BUILD_PLUGIN}@v1.0.0`,
106
+ ' with:',
107
+ ' policy_token: ${{ secrets.TOKEN }}',
108
+ '',
109
+ ].join('\n');
110
+ const { content } = bump(before);
111
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
112
+ });
113
+
114
+ it('leaves an indented commented-out block exactly as it was', () => {
115
+ const before = workflowWith(
116
+ `${BUILD_PLUGIN}@v1.0.0\n #with:\n # save the token in your repository secrets\n #policy_token: \${{ secrets.TOKEN }}`
117
+ );
118
+ const { content } = bump(before);
119
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
120
+ });
121
+
122
+ it('leaves a workflow it cannot parse alone', () => {
123
+ const workflow = `jobs: [\n uses: ${BUILD_PLUGIN}@v1.0.0\n`;
124
+ const { content, result } = bump(workflow);
125
+ expect(content).toBe(workflow);
126
+ expect(result).toEqual({ found: [], unrecognized: [] });
127
+ });
128
+
129
+ it('does not match an action whose name only starts with the given one', () => {
130
+ const workflow = workflowWith(`${BUILD_PLUGIN}-extra@v1.0.0`);
131
+ const { content, result } = bump(workflow);
132
+ expect(content).toBe(workflow);
133
+ expect(result.found).toEqual([]);
134
+ });
135
+
136
+ it('reports nothing found when the workflow uses none of the actions', () => {
137
+ const { content, result } = bump('name: Release\n');
138
+ expect(content).toBe('name: Release\n');
139
+ expect(result).toEqual({ found: [], unrecognized: [] });
140
+ });
141
+
142
+ it('reports nothing found when the workflow does not exist', () => {
143
+ const context = new Context('/virtual');
144
+ expect(bumpActionRefs(context, WORKFLOW, MINIMUMS)).toEqual({ found: [], unrecognized: [] });
145
+ expect(context.doesFileExist(WORKFLOW)).toBe(false);
146
+ });
147
+
148
+ it('throws when a minimum is not a version tag', () => {
149
+ expect(() => bump(workflowWith(`${BUILD_PLUGIN}@v1.0.0`), { [BUILD_PLUGIN]: 'main' })).toThrow(
150
+ "The minimum ref 'main' for grafana/plugin-actions/build-plugin is not a version tag."
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,140 @@
1
+ import { isScalar, parseDocument, visit } from 'yaml';
2
+ import type { Context } from './context.js';
3
+ import { codemodsDebug } from './utils.js';
4
+
5
+ export interface BumpActionRefsResult {
6
+ /** The actions used in the workflow. */
7
+ found: string[];
8
+ /** The actions pinned to something other than a version tag, such as a branch or a commit SHA. */
9
+ unrecognized: string[];
10
+ }
11
+
12
+ /**
13
+ * Raises each `uses: <action>@<ref>` in a workflow to at least the given minimum ref, the way
14
+ * addDependenciesToPackageJson raises dependency versions. A ref is never lowered.
15
+ *
16
+ * Refs are compared as `vX[.Y[.Z]]` tags, optionally prefixed with the action's own name as monorepo
17
+ * tags are (`build-plugin/v1.2.0`). A shorter tag floats, so `v1` already runs the latest 1.x and is
18
+ * left alone, and a floating tag that is too low is raised at its own precision (`v1.1` to `v1.2`).
19
+ * Other refs are left as-is and reported, except `main`, which always runs the latest release.
20
+ *
21
+ * The parser finds each `uses` value, then only that value's source text is replaced, so comments and
22
+ * formatting elsewhere stay byte-for-byte the same.
23
+ */
24
+ export function bumpActionRefs(
25
+ context: Context,
26
+ workflowPath: string,
27
+ minimumRefs: Record<string, string>
28
+ ): BumpActionRefsResult {
29
+ const minimums = new Map(
30
+ Object.entries(minimumRefs).map(([action, minimumRef]) => {
31
+ const minimum = parseTag(action, minimumRef);
32
+ if (!minimum) {
33
+ throw new Error(`The minimum ref '${minimumRef}' for ${action} is not a version tag.`);
34
+ }
35
+ return [action, minimum];
36
+ })
37
+ );
38
+
39
+ const content = context.getFile(workflowPath);
40
+ if (!content) {
41
+ codemodsDebug(`Could not find ${workflowPath}. Skipping action bumps.`);
42
+ return { found: [], unrecognized: [] };
43
+ }
44
+
45
+ const doc = parseDocument(content);
46
+ if (doc.errors.length > 0) {
47
+ codemodsDebug(`Failed to parse ${workflowPath}: ${doc.errors[0].message}. Skipping action bumps.`);
48
+ return { found: [], unrecognized: [] };
49
+ }
50
+
51
+ const found = new Set<string>();
52
+ const unrecognized = new Set<string>();
53
+ const edits: Array<{ start: number; end: number; text: string }> = [];
54
+
55
+ visit(doc, {
56
+ Pair: (_, pair) => {
57
+ if (!isScalar(pair.key) || pair.key.value !== 'uses' || !isScalar(pair.value)) {
58
+ return;
59
+ }
60
+ const uses = pair.value;
61
+ if (typeof uses.value !== 'string' || !uses.value.includes('@')) {
62
+ return;
63
+ }
64
+ const separator = uses.value.lastIndexOf('@');
65
+ const action = uses.value.slice(0, separator);
66
+ const ref = uses.value.slice(separator + 1);
67
+ const minimum = minimums.get(action);
68
+ if (!minimum) {
69
+ return;
70
+ }
71
+
72
+ found.add(action);
73
+ if (ref === 'main') {
74
+ return;
75
+ }
76
+ const tag = parseTag(action, ref);
77
+ if (!tag) {
78
+ codemodsDebug(`${workflowPath} pins ${action}@${ref}, which is not a version tag. Leaving it as-is.`);
79
+ unrecognized.add(action);
80
+ return;
81
+ }
82
+ if (meetsMinimum(tag.version, minimum.version)) {
83
+ codemodsDebug(`${workflowPath} already pins ${action}@${ref}. Skipping.`);
84
+ return;
85
+ }
86
+ if (!uses.range) {
87
+ return;
88
+ }
89
+ const [start, end] = uses.range;
90
+ edits.push({ start, end, text: content.slice(start, end).replace(`@${ref}`, `@${raisedRef(tag, minimum)}`) });
91
+ },
92
+ });
93
+
94
+ if (edits.length > 0) {
95
+ // apply from the end so earlier offsets stay valid
96
+ const updated = edits
97
+ .sort((a, b) => b.start - a.start)
98
+ .reduce((acc, { start, end, text }) => `${acc.slice(0, start)}${text}${acc.slice(end)}`, content);
99
+ context.updateFile(workflowPath, updated);
100
+ }
101
+ return { found: [...found], unrecognized: [...unrecognized] };
102
+ }
103
+
104
+ interface Tag {
105
+ /** The text before `v`, such as `build-plugin/`, or empty for a bare tag. */
106
+ prefix: string;
107
+ /** One to three numbers, fewer for a floating tag. */
108
+ version: number[];
109
+ }
110
+
111
+ // reads `vX[.Y[.Z]]`, or `<name>/vX[.Y[.Z]]` where name is the action's last path segment
112
+ function parseTag(action: string, ref: string): Tag | undefined {
113
+ const match = /^(?:([^/]+)\/)?v(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(ref);
114
+ if (!match || (match[1] !== undefined && match[1] !== action.split('/').at(-1))) {
115
+ return undefined;
116
+ }
117
+ return {
118
+ prefix: match[1] === undefined ? '' : `${match[1]}/`,
119
+ version: match
120
+ .slice(2)
121
+ .filter((part) => part !== undefined)
122
+ .map(Number),
123
+ };
124
+ }
125
+
126
+ // compares only the numbers the tag has, since a floating tag runs the latest release below it
127
+ function meetsMinimum(version: number[], minimum: number[]): boolean {
128
+ for (const [i, part] of version.entries()) {
129
+ const minimumPart = minimum[i] ?? 0;
130
+ if (part !== minimumPart) {
131
+ return part > minimumPart;
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ function raisedRef(tag: Tag, minimum: Tag): string {
138
+ const version = Array.from({ length: tag.version.length }, (_, i) => minimum.version[i] ?? 0);
139
+ return `${minimum.prefix}v${version.join('.')}`;
140
+ }