@grafana/create-plugin 7.13.0 → 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 +16 -0
- package/dist/codemods/additions/additions.js +12 -0
- package/dist/codemods/additions/prompts/docs-content.md +119 -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 +15 -14
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +40 -7
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/utils/utils.cli.js +3 -2
- package/dist/utils/utils.handlebars.js +9 -2
- package/package.json +1 -1
- package/src/codemods/additions/additions.ts +12 -0
- package/src/codemods/additions/prompts/docs-content.md +119 -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 +4 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +23 -0
- package/src/codemods/utils.ts +29 -7
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/utils/utils.cli.ts +2 -1
- package/src/utils/utils.handlebars.ts +9 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- 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
|
@@ -196,6 +196,12 @@ describe('utils', () => {
|
|
|
196
196
|
const context = new Context(tmpDir);
|
|
197
197
|
expect(() => readJsonFile(context, 'file.json')).toThrowError();
|
|
198
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
|
+
});
|
|
199
205
|
});
|
|
200
206
|
|
|
201
207
|
describe('getTemplateDataFromContext', () => {
|
|
@@ -263,6 +269,23 @@ describe('utils', () => {
|
|
|
263
269
|
expect(context.getFile('docs/nested/setup.md')).toBe('pnpm some-name\n');
|
|
264
270
|
});
|
|
265
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
|
+
|
|
266
289
|
it('strips .hbs and maps config file names like generate does', () => {
|
|
267
290
|
const context = new Context(tmpDir);
|
|
268
291
|
const folderPath = templateFolder({ 'page.md.hbs': 'x', npmrc: 'y' });
|
package/src/codemods/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -180,11 +180,16 @@ export function readJsonFile<T extends object = any>(context: Context, path: str
|
|
|
180
180
|
if (!context.doesFileExist(path)) {
|
|
181
181
|
throw new Error(`Cannot find ${path}`);
|
|
182
182
|
}
|
|
183
|
+
let parsed: unknown;
|
|
183
184
|
try {
|
|
184
|
-
|
|
185
|
+
parsed = JSON.parse(context.getFile(path) || '');
|
|
185
186
|
} catch (e) {
|
|
186
187
|
throw new Error(`Cannot parse ${path}: ${e}`);
|
|
187
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;
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
export function addDependenciesToPackageJson(
|
|
@@ -346,6 +351,8 @@ function sortObjectByKeys<T extends Record<string, any>>(obj: T): T {
|
|
|
346
351
|
|
|
347
352
|
export const migrationsDebug = debug.extend('migrations');
|
|
348
353
|
export const additionsDebug = debug.extend('additions');
|
|
354
|
+
// for helpers that migrations and additions share
|
|
355
|
+
export const codemodsDebug = debug.extend('codemods');
|
|
349
356
|
|
|
350
357
|
export function renderTemplate(
|
|
351
358
|
templatePath: string,
|
|
@@ -374,12 +381,23 @@ export function getTemplateDataFromContext(context: Context): TemplateData {
|
|
|
374
381
|
|
|
375
382
|
/**
|
|
376
383
|
* Renders every file under `folderPath` into `targetPath`, the way `generate` scaffolds a template folder:
|
|
377
|
-
* `.hbs` is stripped and config file names are mapped. Files that already exist are left alone
|
|
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.
|
|
378
386
|
* Returns the paths it added.
|
|
379
387
|
*/
|
|
380
388
|
export function addTemplateFolder(
|
|
381
389
|
context: Context,
|
|
382
|
-
{
|
|
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
|
+
}
|
|
383
401
|
): string[] {
|
|
384
402
|
if (!existsSync(folderPath)) {
|
|
385
403
|
throw new Error(
|
|
@@ -389,11 +407,15 @@ export function addTemplateFolder(
|
|
|
389
407
|
|
|
390
408
|
const added: string[] = [];
|
|
391
409
|
// relative to `cwd`, so glob characters in the install path (such as Windows backslashes) can't break the pattern
|
|
392
|
-
for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true })) {
|
|
410
|
+
for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true, posix: true })) {
|
|
393
411
|
const file = join(folderPath, relativeFile);
|
|
394
|
-
const
|
|
412
|
+
const outputFile = posix.join(posix.dirname(relativeFile), getExportFileName(file));
|
|
413
|
+
if (!include(outputFile)) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const filePath = join(targetPath, outputFile);
|
|
395
417
|
if (context.doesFileExist(filePath)) {
|
|
396
|
-
|
|
418
|
+
codemodsDebug(`${filePath} already exists, skipping`);
|
|
397
419
|
continue;
|
|
398
420
|
}
|
|
399
421
|
context.addFile(filePath, renderHandlebarsTemplate(readFileSync(file, 'utf-8'), data));
|
|
@@ -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
|
+
}
|
package/src/utils/utils.cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const argv = minimist(args, {
|
|
|
13
13
|
hasBackend: 'backend',
|
|
14
14
|
pluginName: 'plugin-name',
|
|
15
15
|
orgName: 'org-name',
|
|
16
|
+
docsPath: 'docs-path',
|
|
16
17
|
},
|
|
17
18
|
// tell minimist to parse boolean flags as true/false to prevent minimist from interpreting
|
|
18
19
|
// them as the command name if passed first. e.g. `create-plugin --force update`
|
|
@@ -20,7 +21,7 @@ export const argv = minimist(args, {
|
|
|
20
21
|
// --agent is tri-state: absent, bare (pick an agent), or a value (an agent id or a path to a binary).
|
|
21
22
|
// parsing it as a string keeps bare --agent as '' rather than true, so all three are distinguishable.
|
|
22
23
|
// --no-agent still arrives as false.
|
|
23
|
-
string: ['agent'],
|
|
24
|
+
string: ['agent', 'docsPath'],
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
export const commandName = argv._[0] || 'generate';
|
|
@@ -41,6 +41,14 @@ export const kebabToPascalKebab = (str: string) => {
|
|
|
41
41
|
.join('-');
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
// for text inside a double-quoted YAML scalar, e.g. `title: "{{{yamlEscape pluginName}}}"`
|
|
45
|
+
export const yamlEscape = (str: string) => {
|
|
46
|
+
if (typeof str !== 'string') {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
return str.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
50
|
+
};
|
|
51
|
+
|
|
44
52
|
// Register our helpers and partials with handlebars.
|
|
45
53
|
registerHandlebarsHelpers();
|
|
46
54
|
registerHandlebarsPartials();
|
|
@@ -63,6 +71,7 @@ function registerHandlebarsHelpers() {
|
|
|
63
71
|
properCase: pascalCase,
|
|
64
72
|
pascalCase: pascalCase,
|
|
65
73
|
if_eq: ifEq,
|
|
74
|
+
yamlEscape: yamlEscape,
|
|
66
75
|
};
|
|
67
76
|
|
|
68
77
|
Object.keys(helpers).forEach((helperName) =>
|
|
@@ -21,7 +21,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs
|
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
22
|
import { arch, platform, tmpdir } from 'node:os';
|
|
23
23
|
|
|
24
|
-
const VERSION = 'v0.60.
|
|
24
|
+
const VERSION = 'v0.60.16';
|
|
25
25
|
const REPO = 'grafana/grafana-app-sdk';
|
|
26
26
|
const BIN = 'grafana-app-sdk';
|
|
27
27
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Validate documentation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
- master
|
|
8
|
+
paths:
|
|
9
|
+
- '{{docsPath}}/**'
|
|
10
|
+
- 'src/plugin.json'
|
|
11
|
+
- 'package.json'
|
|
12
|
+
{{#if lockFile}}
|
|
13
|
+
- '{{lockFile}}'
|
|
14
|
+
{{/if}}
|
|
15
|
+
pull_request:
|
|
16
|
+
branches:
|
|
17
|
+
- main
|
|
18
|
+
- master
|
|
19
|
+
paths:
|
|
20
|
+
- '{{docsPath}}/**'
|
|
21
|
+
- 'src/plugin.json'
|
|
22
|
+
- 'package.json'
|
|
23
|
+
{{#if lockFile}}
|
|
24
|
+
- '{{lockFile}}'
|
|
25
|
+
{{/if}}
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
validate:
|
|
29
|
+
name: Validate plugin docs
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
permissions:
|
|
32
|
+
contents: read
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v7
|
|
35
|
+
with:
|
|
36
|
+
persist-credentials: false
|
|
37
|
+
{{#if_eq packageManagerName "pnpm"}}
|
|
38
|
+
# pnpm action uses the packageManager field in package.json to
|
|
39
|
+
# understand which version to install.
|
|
40
|
+
- uses: pnpm/action-setup@v6
|
|
41
|
+
{{/if_eq}}
|
|
42
|
+
- name: Setup Node.js environment
|
|
43
|
+
uses: actions/setup-node@v7
|
|
44
|
+
with:
|
|
45
|
+
node-version: '22'
|
|
46
|
+
cache: '{{packageManagerName}}'
|
|
47
|
+
|
|
48
|
+
- name: Install dependencies
|
|
49
|
+
run: {{packageManagerInstallCmd}}
|
|
50
|
+
|
|
51
|
+
- name: Validate plugin documentation
|
|
52
|
+
run: {{packageManagerName}} run docs:validate
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Plugin docs
|
|
2
|
+
|
|
3
|
+
This folder is the documentation for **{{{pluginName}}}**, published at
|
|
4
|
+
`grafana.com/grafana/plugins/{{pluginId}}/docs/`.
|
|
5
|
+
|
|
6
|
+
## Guides
|
|
7
|
+
|
|
8
|
+
Read these before you write or change any page. This docs format is new and your training
|
|
9
|
+
data does not cover it, so do not rely on what you know about other docs tools.
|
|
10
|
+
|
|
11
|
+
Run `{{packageManagerName}} run docs:validate` and open the guide on its last line, `Docs guides:`. It's
|
|
12
|
+
the guides' `index.md`, which says which guide to read for what, and the other guides sit next to it.
|
|
13
|
+
The guides match the version of the validator this plugin uses. The line is a local path, usually
|
|
14
|
+
`node_modules/@grafana/plugin-docs-cli/docs/index.md`, or a URL when the package isn't on disk, such as
|
|
15
|
+
with Yarn Plug'n'Play.
|
|
16
|
+
|
|
17
|
+
The guides name scripts such as `docs:validate`. This plugin uses **{{packageManagerName}}**, so run them
|
|
18
|
+
with `{{packageManagerName}} run`, as in the commands below.
|
|
19
|
+
|
|
20
|
+
## Pages and their source
|
|
21
|
+
|
|
22
|
+
When you change code, update the page that documents it:
|
|
23
|
+
|
|
24
|
+
| Page | Source of truth |
|
|
25
|
+
| -------------------- | -------------------------------------------------------------------------------------- |
|
|
26
|
+
| `index.md` | Nothing - it is a curated router. Update it when pages are added or removed. |
|
|
27
|
+
| `options.md` | `setPanelOptions` and `useFieldConfig` in `src/module.ts` |
|
|
28
|
+
| `data-formats.md` | The panel component's data handling, plus any field pickers in `src/module.ts` |
|
|
29
|
+
| `examples.md` | `provisioning/dashboards/*.json` if present, otherwise the panel's own option defaults |
|
|
30
|
+
| `troubleshooting.md` | Error and empty-state messages in source, known and reported issues. Never guess. |
|
|
31
|
+
|
|
32
|
+
Never guess failures in `troubleshooting.md`. Each symptom must come from an error, warning or empty-state
|
|
33
|
+
message in source, a known issue in the README or other docs or a reported issue.
|
|
34
|
+
|
|
35
|
+
## Optional pages
|
|
36
|
+
|
|
37
|
+
Add one of these pages when the plugin has the matching feature. Use the filename given here, give the page
|
|
38
|
+
a `sidebar_position` that puts it where it belongs and link it from `index.md`.
|
|
39
|
+
|
|
40
|
+
| Trigger | Page | Scope |
|
|
41
|
+
| ---------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------ |
|
|
42
|
+
| `setPresetsSupplier(...)` in source | `presets.md` | The presets the panel ships, when each applies, screenshots. |
|
|
43
|
+
| RBAC role declarations in `src/plugin.json` | `permissions.md` | What each role grants, default assignments. |
|
|
44
|
+
| Substantial accessibility considerations (keyboard shortcuts, screen reader notes) | `accessibility.md` | Keyboard interaction, ARIA, contrast, motion preferences. |
|
|
45
|
+
|
|
46
|
+
## Section briefs
|
|
47
|
+
|
|
48
|
+
Each section of a scaffolded page has a brief telling you what belongs there. A brief is an HTML comment
|
|
49
|
+
that starts with `section-brief`. Search the docs folder for `section-brief` to find what is left to write:
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
<!-- section-brief
|
|
55
|
+
Fill this in: Describe every option the plugin registers, one row per option.
|
|
56
|
+
|
|
57
|
+
If the plugin has no options, remove this section entirely.
|
|
58
|
+
-->
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Some sections also have fixed text, with a brief that builds on it:
|
|
62
|
+
|
|
63
|
+
```markdown
|
|
64
|
+
## Basic example
|
|
65
|
+
|
|
66
|
+
A panel excerpt in dashboard JSON has this shape:
|
|
67
|
+
|
|
68
|
+
<!-- section-brief
|
|
69
|
+
Fill this in: Complete the excerpt above with the options the example needs.
|
|
70
|
+
-->
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Text outside a brief is written for every panel and is part of the scaffold. Keep it, and change it
|
|
74
|
+
only where it's wrong for this plugin, such as a menu path or a step that differs.
|
|
75
|
+
|
|
76
|
+
Everything inside a brief is the instruction. A brief covers the section it sits under, unless it says
|
|
77
|
+
otherwise: it may tell you to extend the fixed text above it, to replace it with several sections or to
|
|
78
|
+
remove the page. When a brief says to remove a section, remove its heading and fixed text too.
|
|
79
|
+
|
|
80
|
+
To fill a section:
|
|
81
|
+
|
|
82
|
+
1. Read the brief.
|
|
83
|
+
2. Read the source the page documents, from the table above, then write the section.
|
|
84
|
+
3. Delete the whole comment, from `<!-- section-brief` to `-->`.
|
|
85
|
+
|
|
86
|
+
Briefs are not shown in the preview or the catalog, so a section you haven't written looks like a bare
|
|
87
|
+
heading. `docs:validate` lists every section that still has only its brief.
|
|
88
|
+
|
|
89
|
+
## Validate
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
{{packageManagerName}} run docs:validate
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Run `docs:validate` after every page you write or change and fix what it reports. A docs task is done when
|
|
96
|
+
it reports no warnings and no suggestions about section briefs. In particular, an `empty-section` or
|
|
97
|
+
`unfilled-section-brief` suggestion means a section is still unwritten or a brief is still in place. Neither
|
|
98
|
+
fails validation, so a passing exit code alone does not mean the docs are done.
|
|
99
|
+
|
|
100
|
+
## Preview
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
{{packageManagerName}} run docs:serve # local preview at http://localhost:3001, reloads on save
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`docs:serve` keeps running until you stop it. Start it in the background if you need to look at the
|
|
107
|
+
rendered pages, and stop it when you are done.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# {{{pluginName}}} documentation
|
|
2
|
+
|
|
3
|
+
This folder is the documentation for **{{{pluginName}}}**. It's published on the Documentation tab of
|
|
4
|
+
`grafana.com/grafana/plugins/{{pluginId}}/` with each plugin release. This README isn't published.
|
|
5
|
+
|
|
6
|
+
To learn how plugin docs work and how to write them, read the
|
|
7
|
+
[Add documentation to your plugin](https://grafana.com/developers/plugin-tools/how-to-guides/plugin-docs) guide.
|
|
8
|
+
|
|
9
|
+
## Next steps
|
|
10
|
+
|
|
11
|
+
1. Write the pages. Each section has a note that says what belongs there. **Or** let an AI agent draft them from your source code and README: commit your changes, then run `{{createPluginCmd}} add docs-content`. Review its work either way.
|
|
12
|
+
2. Add screenshots, videos and figures where they help readers. Save them inside this folder, for example in `img/`, and link to them with a relative path. `docs:validate` checks their formats and sizes.
|
|
13
|
+
3. Run `{{packageManagerName}} run docs:serve` to preview the docs. It also lists every section left to fill.
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
{{packageManagerName}} run docs:serve # local preview at http://localhost:3001
|
|
19
|
+
{{packageManagerName}} run docs:validate # check your docs, including sections left to fill
|
|
20
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Data formats
|
|
3
|
+
description: "Learn what data formats the {{{yamlEscape pluginName}}} panel accepts."
|
|
4
|
+
sidebar_position: 2
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This document explains the data the {{{pluginName}}} panel needs from a query, so you can build a matching query or check your test data.
|
|
8
|
+
|
|
9
|
+
## Supported data shape
|
|
10
|
+
|
|
11
|
+
<!-- section-brief
|
|
12
|
+
Fill this in: Describe the data frame shape {{{pluginName}}} expects: time series with N numeric fields, table with specific columns, single numeric value, geographic coordinates, log lines, etc. Be specific about field types and structure, because this is the contract dashboard authors rely on when building queries for the panel. Name the required field types (time, number, string, boolean) and any minimum/maximum field counts. After each shape, add a `#### Example` table whose headers are the field names the panel reads, with 3-5 rows of realistic values, so a reader can build a matching query or test data.
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
## Field mapping
|
|
16
|
+
|
|
17
|
+
<!-- section-brief
|
|
18
|
+
Fill this in: Explain which incoming field plays which role in the visualization - for example "the first time-typed field is the x-axis, all numeric fields become series". If {{{pluginName}}} lets users explicitly pick field roles in the editor, document those controls here. Remove the section if mapping is fully automatic and not user-configurable.
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
## Check what your query returns
|
|
22
|
+
|
|
23
|
+
To see the data the panel receives, open the panel editor, click **Query inspector** and select the **Data** tab. You can also turn on **Table view** at the top of the panel editor to see the data as a table.
|
|
24
|
+
|
|
25
|
+
## Reshape data that doesn't match
|
|
26
|
+
|
|
27
|
+
If a query returns data in a different shape, you can reshape it without changing the query. Select the **Transform data** tab in the panel editor and add a transformation. For more information, refer to [Transform data](https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/).
|