diff --git a/.github/workflows/check-config-schema.yml b/.github/workflows/check-config-schema.yml new file mode 100644 index 00000000..c2b2948f --- /dev/null +++ b/.github/workflows/check-config-schema.yml @@ -0,0 +1,45 @@ +name: Check Config Schema + +permissions: + contents: read + +on: + pull_request: + paths: + - 'src/types/config.ts' + - 'scripts/generate-config-schema.ts' + push: + branches: + - main + paths: + - 'src/types/config.ts' + - 'scripts/generate-config-schema.ts' + +jobs: + check-config-schema: + name: Verify config schema is up-to-date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Install dependencies + run: yarn install --immutable + + - name: Lint config types (import guard) + run: yarn lint --no-cache -- src/types/config.ts + + - name: Generate config schema + run: yarn generate:config-schema + + - name: Check for uncommitted schema changes + run: | + if ! git diff --exit-code pkg/schema/config.json; then + echo "::error::Config schema is out of date. Run 'yarn generate:config-schema' and commit the changes." + exit 1 + fi + echo "Config schema is up-to-date." diff --git a/eslint.config.mjs b/eslint.config.mjs index 39f4f1ca..3f5bc546 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,4 +28,21 @@ export default defineConfig([ ], }, ...baseConfig, + { + files: ['src/types/config.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['./*', '../*'], + message: + 'src/types/config.ts must be self-contained with no local imports to ensure reliable schema generation.', + }, + ], + }, + ], + }, + }, ]); diff --git a/package.json b/package.json index 72fa7a55..99e693fe 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "spellcheck": "cspell -c cspell.config.json \"**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}\"", "test": "jest --watch --onlyChanged", "test:ci": "jest --passWithNoTests --maxWorkers 4", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "generate:config-schema": "ts-node scripts/generate-config-schema.ts" }, "dependencies": { "@emotion/css": "11.13.5", @@ -89,7 +90,8 @@ "webpack-cli": "6.0.1", "webpack-livereload-plugin": "3.0.2", "webpack-subresource-integrity": "5.1.0", - "webpack-virtual-modules": "0.6.2" + "webpack-virtual-modules": "0.6.2", + "zod": "4.3.6" }, "resolutions": { "@remix-run/router": "1.23.2", diff --git a/pkg/plugin/instance.go b/pkg/plugin/instance.go index cdfe6c45..99048fe3 100644 --- a/pkg/plugin/instance.go +++ b/pkg/plugin/instance.go @@ -3,11 +3,13 @@ package plugin import ( "context" "fmt" + "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" schemas "github.com/grafana/schemads" + "github.com/grafana/github-datasource/pkg/schema" "github.com/grafana/github-datasource/pkg/github" "github.com/grafana/github-datasource/pkg/models" ) @@ -19,6 +21,13 @@ type GitHubInstanceWithSchema struct { } func (g *GitHubInstanceWithSchema) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + if req.Path == "schema/config" { + return sender.Send(&backend.CallResourceResponse{ + Status: http.StatusOK, + Headers: map[string][]string{"Content-Type": {"application/json"}}, + Body: schema.ConfigSchemaJSON, + }) + } return g.SchemaDatasource.CallResource(ctx, req, sender) } diff --git a/pkg/schema/config.go b/pkg/schema/config.go new file mode 100644 index 00000000..075f3f21 --- /dev/null +++ b/pkg/schema/config.go @@ -0,0 +1,8 @@ +package schema + +import ( + _ "embed" +) + +//go:embed config.json +var ConfigSchemaJSON []byte diff --git a/pkg/schema/config.json b/pkg/schema/config.json new file mode 100644 index 00000000..cbe6dcaf --- /dev/null +++ b/pkg/schema/config.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitHubDataSourceConfig", + "description": "Configuration schema for the Grafana GitHub data source plugin", + "type": "object", + "properties": { + "jsonData": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "githubPlan": { + "description": "GitHub plan type (basic)", + "type": "string", + "const": "github-basic" + }, + "githubUrl": { + "not": {}, + "description": "Not applicable for GitHub basic plan" + } + }, + "required": [ + "githubUrl" + ], + "additionalProperties": false, + "description": "Configuration for GitHub basic plan" + }, + { + "type": "object", + "properties": { + "githubPlan": { + "type": "string", + "const": "github-enterprise-cloud", + "description": "GitHub plan type (Enterprise Cloud)" + }, + "githubUrl": { + "not": {}, + "description": "Not applicable for GitHub Enterprise Cloud" + } + }, + "required": [ + "githubPlan", + "githubUrl" + ], + "additionalProperties": false, + "description": "Configuration for GitHub Enterprise Cloud plan" + } + ] + }, + { + "type": "object", + "properties": { + "githubPlan": { + "type": "string", + "const": "github-enterprise-server", + "description": "GitHub plan type (Enterprise Server)" + }, + "githubUrl": { + "type": "string", + "description": "The URL of the GitHub Enterprise Server instance" + } + }, + "required": [ + "githubPlan", + "githubUrl" + ], + "additionalProperties": false, + "description": "Configuration for GitHub Enterprise Server plan" + } + ] + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "selectedAuthType": { + "description": "Authentication type (Personal Access Token)", + "type": "string", + "const": "personal-access-token" + }, + "appId": { + "not": {}, + "description": "Not applicable for PAT authentication" + }, + "installationId": { + "not": {}, + "description": "Not applicable for PAT authentication" + } + }, + "required": [ + "appId", + "installationId" + ], + "additionalProperties": false, + "description": "Authentication options for Personal Access Token" + }, + { + "type": "object", + "properties": { + "selectedAuthType": { + "type": "string", + "const": "github-app", + "description": "Authentication type (GitHub App)" + }, + "appId": { + "type": "string", + "description": "The GitHub App ID" + }, + "installationId": { + "type": "string", + "description": "The GitHub App installation ID" + } + }, + "required": [ + "selectedAuthType", + "appId", + "installationId" + ], + "additionalProperties": false, + "description": "Authentication options for GitHub App" + } + ] + } + ], + "description": "GitHub data source configuration options (jsonData)" + }, + "secureJsonData": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "description": "Personal access token for GitHub API authentication" + }, + "privateKey": { + "not": {}, + "description": "Not applicable for PAT authentication" + } + }, + "required": [ + "accessToken", + "privateKey" + ], + "additionalProperties": false, + "description": "Secure data for Personal Access Token authentication" + }, + { + "type": "object", + "properties": { + "accessToken": { + "not": {}, + "description": "Not applicable for GitHub App authentication" + }, + "privateKey": { + "type": "string", + "description": "Private key for GitHub App authentication (PEM format)" + } + }, + "required": [ + "accessToken", + "privateKey" + ], + "additionalProperties": false, + "description": "Secure data for GitHub App authentication" + } + ], + "description": "Secure JSON data for GitHub data source authentication (secureJsonData)" + } + } +} diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts new file mode 100644 index 00000000..954e1074 --- /dev/null +++ b/scripts/generate-config-schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { GitHubDataSourceOptionsSchema, GitHubSecureJsonDataSchema } from '../src/types/config'; + +const configSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'GitHubDataSourceConfig', + description: 'Configuration schema for the Grafana GitHub data source plugin', + type: 'object' as const, + properties: { + jsonData: z.toJSONSchema(GitHubDataSourceOptionsSchema), + secureJsonData: z.toJSONSchema(GitHubSecureJsonDataSchema), + }, +}; + +const schemaJSON = JSON.stringify(configSchema, null, 2) + '\n'; + +const outPath = path.resolve(__dirname, '..', 'pkg', 'schema', 'config.json'); +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, schemaJSON); +console.log(`Config JSON schema written to ${outPath}`); diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 00000000..f19acee7 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,18 @@ +#!/bin/sh +# +# Git pre-commit hook that regenerates the config JSON schema +# when src/types/config.ts is modified. +# +# To install, run from the repository root: +# cp scripts/pre-commit .git/hooks/pre-commit +# chmod +x .git/hooks/pre-commit + +STAGED=$(git diff --cached --name-only) + +if echo "$STAGED" | grep -q "src/types/config.ts"; then + echo "Config types changed — regenerating config JSON schema..." + yarn generate:config-schema + + git add pkg/schema/config.json + echo "Config JSON schema updated and staged." +fi diff --git a/src/DataSource.test.ts b/src/DataSource.test.ts index 4b4d014d..8cd842d5 100644 --- a/src/DataSource.test.ts +++ b/src/DataSource.test.ts @@ -3,6 +3,7 @@ import { lastValueFrom, of } from 'rxjs'; import { GithubVariableSupport } from 'variables'; import { GitHubDataSource } from 'DataSource'; import type { GitHubVariableQuery } from 'types/query'; +import type { GitHubDataSourceOptions } from 'types/config'; describe('DataSource', () => { describe('GithubVariableSupport', () => { @@ -16,7 +17,7 @@ describe('DataSource', () => { }), ]; it('should return empty array if data in response is empty array', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = {} as GitHubVariableQuery; jest.spyOn(ds, 'query').mockReturnValue(of({ data: [] })); @@ -25,7 +26,7 @@ describe('DataSource', () => { expect(res?.data.map((d) => d.text)).toEqual([]); }); it('should return empty array if no data in response', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = {} as GitHubVariableQuery; jest.spyOn(ds, 'query').mockReturnValue(of({} as DataQueryResponse)); @@ -34,7 +35,7 @@ describe('DataSource', () => { expect(res?.data.map((d) => d.text)).toEqual([]); }); it('should return array with values if response has data', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = { key: 'test', field: 'test' } as GitHubVariableQuery; const data = [toDataFrame({ fields: [{ name: 'test', values: ['value1', 'value2'] }] })]; @@ -44,7 +45,7 @@ describe('DataSource', () => { expect(res?.data.map((d) => d.text)).toEqual(['value1', 'value2']); }); it('mapping of key', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = { key: 'foo' } as GitHubVariableQuery; const data = SAMPLE_RESPONSE_WITH_MULTIPLE_FIELDS; @@ -54,7 +55,7 @@ describe('DataSource', () => { expect(res?.data.map((d) => d.text)).toEqual(['foo1', 'foo2']); }); it('mapping of key and field', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = { key: 'bar', field: 'foo' } as GitHubVariableQuery; const data = SAMPLE_RESPONSE_WITH_MULTIPLE_FIELDS; @@ -64,7 +65,7 @@ describe('DataSource', () => { expect(res?.data.map((d) => d.text)).toEqual(['foo1', 'foo2']); }); it('mapping of field', async () => { - const ds = new GitHubDataSource({} as DataSourceInstanceSettings); + const ds = new GitHubDataSource({} as DataSourceInstanceSettings); const vs = new GithubVariableSupport(ds); const query = { field: 'foo' } as GitHubVariableQuery; const data = SAMPLE_RESPONSE_WITH_MULTIPLE_FIELDS; diff --git a/src/types/config.ts b/src/types/config.ts index c76f75dc..151eeac3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,19 +1,86 @@ -import type { DataSourceJsonData } from '@grafana/data'; +import { z } from 'zod'; +import type { DataSourceJsonData } from '@grafana/schema'; -export type GitHubLicenseType = 'github-basic' | 'github-enterprise-cloud' | 'github-enterprise-server'; +//#region jsonData -export type GitHubAuthType = 'personal-access-token' | 'github-app'; +//#region --- License / Plan schemas --- -export type GitHubDataSourceOptions = { - githubPlan?: GitHubLicenseType; - githubUrl?: string; - selectedAuthType?: GitHubAuthType; - appId?: string; - installationId?: string; -} & DataSourceJsonData; +const GitHubLicenseTypeSchema = z.enum(['github-basic', 'github-enterprise-cloud', 'github-enterprise-server']).describe('The GitHub license/plan type'); +export type GitHubLicenseType = z.infer; -export type GitHubSecureJsonDataKeys = - | 'accessToken' // accessToken is set if the user is using a Personal Access Token to connect to GitHub - | 'privateKey'; // privateKey is set if the user is using a GitHub App to connect to GitHub +const GitHubAuthTypeSchema = z.enum(['personal-access-token', 'github-app']).describe('The GitHub authentication method'); +export type GitHubAuthType = z.infer; -export type GitHubSecureJsonData = Partial>; +//#endregion + +//#region --- Plan option schemas --- + +const GitHubDataSourceBasicOptionsSchema = z.object({ + githubPlan: z.literal('github-basic').optional().describe('GitHub plan type (basic)'), + githubUrl: z.never().describe('Not applicable for GitHub basic plan'), +}).describe('Configuration for GitHub basic plan'); + +const GitHubDataSourceEnterpriseCloudOptionsSchema = z.object({ + githubPlan: z.literal('github-enterprise-cloud').describe('GitHub plan type (Enterprise Cloud)'), + githubUrl: z.never().describe('Not applicable for GitHub Enterprise Cloud'), +}).describe('Configuration for GitHub Enterprise Cloud plan'); + +const GitHubDataSourceEnterpriseServerOptionsSchema = z.object({ + githubPlan: z.literal('github-enterprise-server').describe('GitHub plan type (Enterprise Server)'), + githubUrl: z.string().describe('The URL of the GitHub Enterprise Server instance'), +}).describe('Configuration for GitHub Enterprise Server plan'); + +const GithubDataSourceCommonOptionsSchema = GitHubDataSourceBasicOptionsSchema + .or(GitHubDataSourceEnterpriseCloudOptionsSchema) + .or(GitHubDataSourceEnterpriseServerOptionsSchema); + +//#endregion + +//#region --- Auth option schemas --- + +const GitHubDataSourcePATAuthOptionsSchema = z.object({ + selectedAuthType: z.literal('personal-access-token').optional().describe('Authentication type (Personal Access Token)'), + appId: z.never().describe('Not applicable for PAT authentication'), + installationId: z.never().describe('Not applicable for PAT authentication'), +}).describe('Authentication options for Personal Access Token'); + +const GitHubDataSourceGHAppOptionsSchema = z.object({ + selectedAuthType: z.literal('github-app').describe('Authentication type (GitHub App)'), + appId: z.string().describe('The GitHub App ID'), + installationId: z.string().describe('The GitHub App installation ID'), +}).describe('Authentication options for GitHub App'); + +const GithubDataSourceAuthOptionsSchema = GitHubDataSourcePATAuthOptionsSchema + .or(GitHubDataSourceGHAppOptionsSchema) + +//#endregion + +export const GitHubDataSourceOptionsSchema = z.intersection(GithubDataSourceCommonOptionsSchema, GithubDataSourceAuthOptionsSchema).describe('GitHub data source configuration options (jsonData)'); + +export type GitHubDataSourceOptions = z.infer & DataSourceJsonData; + +//#endregion + +//#region secureJsonData + +//#region --- Secure JSON data schemas --- + +const GitHubSecureJsonDataAuthPATSchema = z.object({ + accessToken: z.string().describe('Personal access token for GitHub API authentication'), + privateKey: z.never().describe('Not applicable for PAT authentication'), +}).describe('Secure data for Personal Access Token authentication'); + +const GitHubSecureJsonDataAuthGHAppSchema = z.object({ + accessToken: z.never().describe('Not applicable for GitHub App authentication'), + privateKey: z.string().describe('Private key for GitHub App authentication (PEM format)'), +}).describe('Secure data for GitHub App authentication'); + +//#endregion + +export const GitHubSecureJsonDataSchema = GitHubSecureJsonDataAuthPATSchema + .or(GitHubSecureJsonDataAuthGHAppSchema) + .describe('Secure JSON data for GitHub data source authentication (secureJsonData)'); + +export type GitHubSecureJsonData = z.infer; + +//#endregion diff --git a/src/views/ConfigEditor.tsx b/src/views/ConfigEditor.tsx index c52f9fab..77a459ab 100644 --- a/src/views/ConfigEditor.tsx +++ b/src/views/ConfigEditor.tsx @@ -4,6 +4,7 @@ import { onUpdateDatasourceJsonDataOption, onUpdateDatasourceSecureJsonDataOption, type DataSourcePluginOptionsEditorProps, + type DataSourceSettings, type GrafanaTheme2, type SelectableValue, } from '@grafana/data'; @@ -29,7 +30,7 @@ export type ConfigEditorProps = DataSourcePluginOptionsEditorProps { const { options, onOptionsChange } = props; const { jsonData, secureJsonData, secureJsonFields } = options; - const secureSettings = secureJsonData || {}; + const secureSettings = (secureJsonData || {}) as Partial; const styles = useStyles2(getStyles); const WIDTH_LONG = 40; @@ -65,7 +66,7 @@ const ConfigEditor = (props: ConfigEditorProps) => { secureJsonData: { ...options.secureJsonData, [prop]: event.target.value, - }, + } as GitHubSecureJsonData, secureJsonFields: { ...options.secureJsonFields, [prop]: set, @@ -80,7 +81,7 @@ const ConfigEditor = (props: ConfigEditorProps) => { const onAuthChange = useCallback( (value: GitHubAuthType) => { - onOptionsChange({ ...options, jsonData: { ...jsonData, selectedAuthType: value } }); + onOptionsChange({ ...options, jsonData: { ...jsonData, selectedAuthType: value } as GitHubDataSourceOptions }); }, [jsonData, onOptionsChange, options] ); @@ -92,7 +93,7 @@ const ConfigEditor = (props: ConfigEditorProps) => { ...jsonData, githubPlan, githubUrl: githubPlan === 'github-enterprise-server' ? jsonData.githubUrl : '', - }, + } as GitHubDataSourceOptions, }); setSelectedLicense(githubPlan); }; @@ -114,7 +115,7 @@ const ConfigEditor = (props: ConfigEditorProps) => { - setIsOpen((x) => !x)}> + setIsOpen((x) => !x)}>

How to create a access token

To create a new fine grained access token, navigate to{' '} @@ -208,7 +209,10 @@ const ConfigEditor = (props: ConfigEditorProps) => { )} {config.secureSocksDSProxyEnabled && ( - + ) => void} + /> )} diff --git a/yarn.lock b/yarn.lock index 55536ea7..507b78c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8016,6 +8016,7 @@ __metadata: webpack-livereload-plugin: "npm:3.0.2" webpack-subresource-integrity: "npm:5.1.0" webpack-virtual-modules: "npm:0.6.2" + zod: "npm:4.3.6" languageName: unknown linkType: soft @@ -13989,7 +13990,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.3.0": +"zod@npm:4.3.6, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.3.0": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307