@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.
- package/CHANGELOG.md +31 -0
- package/dist/codemods/additions/additions.js +20 -7
- package/dist/codemods/additions/prompts/docs-content.md +119 -0
- package/dist/codemods/additions/prompts/example-prompt.md +56 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +250 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
- package/dist/codemods/agentic/definitions.js +31 -0
- package/dist/codemods/agentic/detect.js +57 -0
- package/dist/codemods/agentic/handoff.js +35 -0
- package/dist/codemods/agentic/index.js +143 -0
- package/dist/codemods/agentic/prompts.js +115 -0
- package/dist/codemods/agentic/resolve.js +120 -0
- package/dist/codemods/agentic/runner.js +79 -0
- package/dist/codemods/agentic/verify.js +55 -0
- package/dist/codemods/migrations/migrations.js +12 -0
- package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
- package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +51 -8
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/commands/add.command.js +74 -12
- package/dist/utils/utils.cli.js +7 -2
- package/dist/utils/utils.console.js +10 -1
- package/dist/utils/utils.git.js +10 -1
- package/dist/utils/utils.handlebars.js +9 -2
- package/dist/utils/utils.packageManager.js +2 -2
- package/dist/utils/utils.templates.js +38 -24
- package/package.json +1 -1
- package/src/codemods/AGENTS.md +25 -0
- package/src/codemods/additions/additions.test.ts +69 -4
- package/src/codemods/additions/additions.ts +50 -8
- package/src/codemods/additions/prompts/docs-content.md +119 -0
- package/src/codemods/additions/prompts/example-prompt.md +56 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1037 -0
- package/src/codemods/additions/scripts/docs.ts +353 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
- package/src/codemods/agentic/definitions.test.ts +67 -0
- package/src/codemods/agentic/definitions.ts +33 -0
- package/src/codemods/agentic/detect.test.ts +136 -0
- package/src/codemods/agentic/detect.ts +75 -0
- package/src/codemods/agentic/handoff.test.ts +102 -0
- package/src/codemods/agentic/handoff.ts +45 -0
- package/src/codemods/agentic/index.test.ts +342 -0
- package/src/codemods/agentic/index.ts +221 -0
- package/src/codemods/agentic/prompts.test.ts +233 -0
- package/src/codemods/agentic/prompts.ts +166 -0
- package/src/codemods/agentic/resolve.test.ts +220 -0
- package/src/codemods/agentic/resolve.ts +165 -0
- package/src/codemods/agentic/runner.test.ts +146 -0
- package/src/codemods/agentic/runner.ts +103 -0
- package/src/codemods/agentic/types.ts +50 -0
- package/src/codemods/agentic/verify.test.ts +106 -0
- package/src/codemods/agentic/verify.ts +93 -0
- package/src/codemods/migrations/migrations.ts +14 -0
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
- package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +131 -1
- package/src/codemods/utils.ts +79 -8
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/commands/add.command.ts +92 -9
- package/src/utils/utils.cli.ts +5 -0
- package/src/utils/utils.git.test.ts +57 -0
- package/src/utils/utils.git.ts +25 -0
- package/src/utils/utils.handlebars.ts +9 -0
- package/src/utils/utils.packageManager.test.ts +23 -0
- package/src/utils/utils.packageManager.ts +3 -2
- package/src/utils/utils.templates.ts +50 -25
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/common/.config/webpack/webpack.config.ts +4 -5
- package/templates/common/_package.json +9 -8
- package/templates/docs/common/workflows/validate-docs.yml +52 -0
- package/templates/docs/panel/docs/AGENTS.md +107 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- package/templates/docs/panel/docs/troubleshooting.md +35 -0
- package/templates/github/workflows/bundle-stats.yml +1 -1
- package/templates/github/workflows/ci.yml +7 -7
- package/templates/github/workflows/is-compatible.yml +3 -3
- 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', () => {
|
package/src/codemods/utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|