diff --git a/docs/sources/setup/datasource.md b/docs/sources/setup/datasource.md index c449a3ac..7b9da905 100644 --- a/docs/sources/setup/datasource.md +++ b/docs/sources/setup/datasource.md @@ -25,12 +25,12 @@ weight: 103 1. Click on the GitHub data source plugin which you have installed. -1. Go to its settings tab and at the bottom, you will find the **Connection** section. +1. Go to its settings tab and at the bottom, you will find the **Authentication** section. 1. Paste the access token. {{< figure alt="Configuring API Token" src="/media/docs/grafana/data-sources/github/github-plugin-confg-token.png" >}} - (_Optional_): If you using the GitHub Enterprise, then select the **Enterprise** option inside the **Additional Settings** section and paste the URL of your GitHub Enterprise. +1. (_Optional_): If you using the GitHub Enterprise Server, then select the **Enterprise Server** option inside the **Connection** section and paste the URL of your GitHub Enterprise Server. 1. Click **Save & Test** button and you should see a confirmation dialog box that says "Data source is working". diff --git a/docs/sources/setup/token.md b/docs/sources/setup/token.md index 4765fd79..a0a327f0 100644 --- a/docs/sources/setup/token.md +++ b/docs/sources/setup/token.md @@ -30,6 +30,17 @@ Read more about [personal access tokens](https://docs.github.com/en/authenticati 1. Define the permissions which you want to allow. 1. Click **Generate Token**. +## Creating a fine-grained personal access token + +This is an example when you want to use the fine-grained personal access token. \ +Read more about [fine-grained personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). + +1. Login to your GitHub account. +1. Navigate to [Personal access tokens](https://github.com/settings/tokens?type=beta) and click **Generate new token**. +1. Provide a name for the token. +1. Provide necessary permissions which you want to allow. Ensure you are providing `read-only` permissions. +1. Click **Generate token**. + ## Using GitHub App Authentication You can also authenticate using a GitHub App instead of a personal access token. This method allows for better security and fine-grained access to resources. diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index ff1b4c98..cedf52e5 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -2,6 +2,7 @@ package github import ( "context" + "fmt" "strings" "github.com/grafana/github-datasource/pkg/dfutil" @@ -187,9 +188,15 @@ func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRe }) if err != nil { if strings.Contains(err.Error(), "401 Unauthorized") { - return newHealthResult(backend.HealthStatusError, "401 Unauthorized. check your API key/Access token") + return newHealthResult(backend.HealthStatusError, "401 Unauthorized. Check your API key/Access token") } - return newHealthResult(backend.HealthStatusError, "Health check failed") + if strings.Contains(err.Error(), "404 Not Found") { + return newHealthResult(backend.HealthStatusError, "404 Not Found. Check the Github Enterprise Server URL") + } + if strings.HasSuffix(err.Error(), "no such host") { + return newHealthResult(backend.HealthStatusError, "Unable to reach the Github Enterprise Server URL from the Grafana server. Check the Github Enterprise Server URL and/or proxy settings") + } + return newHealthResult(backend.HealthStatusError, fmt.Sprintf("Health check failed. %s", err.Error())) } return newHealthResult(backend.HealthStatusOk, "Data source is working") diff --git a/src/DataSource.ts b/src/DataSource.ts index ec955f85..274c7d05 100644 --- a/src/DataSource.ts +++ b/src/DataSource.ts @@ -10,13 +10,14 @@ import { ScopedVars, } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; -import { GitHubDataSourceOptions, GitHubQuery, GitHubVariableQuery } from './types'; import { replaceVariables } from './variables'; import { isValid } from './validation'; import { getAnnotationsFromFrame } from 'common/annotationsFromDataFrame'; import { prepareAnnotation } from 'migrations'; import { Observable } from 'rxjs'; import { trackRequest } from 'tracking'; +import type { GitHubQuery, GitHubVariableQuery } from './types'; +import type { GitHubDataSourceOptions } from './types/config'; export class GitHubDataSource extends DataSourceWithBackend { templateSrv = getTemplateSrv(); diff --git a/src/module.ts b/src/module.ts index e8a3a0a2..aad8d169 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,7 +3,8 @@ import { GitHubDataSource } from './DataSource'; import ConfigEditor from './views/ConfigEditor'; import QueryEditor from './views/QueryEditor'; import VariableQueryEditor from './views/VariableQueryEditor'; -import { GitHubQuery, GitHubDataSourceOptions, GitHubSecureJsonData } from './types'; +import type { GitHubQuery } from './types'; +import type { GitHubDataSourceOptions, GitHubSecureJsonData } from './types/config'; export const plugin = new DataSourcePlugin< GitHubDataSource, diff --git a/src/types.ts b/src/types.ts index 30bb2db6..aeb5a1c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ -import { DataQuery, DataSourceJsonData } from '@grafana/data'; -import { Filter } from 'components/Filters'; +import type { DataQuery } from '@grafana/schema'; +import type { Filter } from 'components/Filters'; export interface Label { color: string; @@ -12,40 +12,6 @@ export interface RepositoryOptions { owner?: string; } -export interface GitHubEnterpriseOptions { - githubUrl?: string; -} - -export interface GitHubAppAuth { - appId?: string; - installationId?: string; -} - -export interface GitHubDataSourceOptions - extends DataSourceJsonData, - RepositoryOptions, - GitHubEnterpriseOptions, - GitHubAppAuth { - selectedAuthType?: GitHubAuthType; -} - -export interface GitHubSecureJsonData { - // accessToken is set if the user is using a Personal Access Token to connect to GitHub - accessToken?: string; - // privateKey is set if the user is using a GitHub App to connect to GitHub - privateKey?: string; -} - -export enum GitHubAuthType { - Personal = 'personal-access-token', - App = 'github-app', -} - -export enum GitHubLicenseType { - Basic = 'github-basic', - Enterprise = 'github-enterprise', -} - export enum QueryType { Commits = 'Commits', Issues = 'Issues', diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 00000000..c76f75dc --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,19 @@ +import type { DataSourceJsonData } from '@grafana/data'; + +export type GitHubLicenseType = 'github-basic' | 'github-enterprise-cloud' | 'github-enterprise-server'; + +export type GitHubAuthType = 'personal-access-token' | 'github-app'; + +export type GitHubDataSourceOptions = { + githubPlan?: GitHubLicenseType; + githubUrl?: string; + selectedAuthType?: GitHubAuthType; + appId?: string; + installationId?: string; +} & DataSourceJsonData; + +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 + +export type GitHubSecureJsonData = Partial>; diff --git a/src/views/ConfigEditor.spec.tsx b/src/views/ConfigEditor.spec.tsx index 91e8f05a..c9a878b5 100644 --- a/src/views/ConfigEditor.spec.tsx +++ b/src/views/ConfigEditor.spec.tsx @@ -8,28 +8,38 @@ describe('Config Editor', () => { const onOptionsChange = jest.fn(); const options = { jsonData: {}, secureJsonFields: {} } as any; render(); - await waitFor(() => expect(screen.getByText('Additional Settings')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Connection')).toBeInTheDocument()); onOptionsChange.mockClear(); // this is called on component render, so we need to clear it to avoid false positives - expect(screen.getByLabelText('Basic')).toBeChecked(); - expect(screen.getByLabelText('Enterprise')).not.toBeChecked(); - expect(screen.queryByText('GitHub Enterprise URL')).not.toBeInTheDocument(); - expect(onOptionsChange).toHaveBeenCalledTimes(0); - await userEvent.click(screen.getByLabelText('Enterprise')); + expect(screen.getByLabelText('Free, Pro & Team')).toBeChecked(); + expect(screen.getByLabelText('Enterprise Server')).not.toBeChecked(); + expect(screen.queryByText('GitHub Enterprise Server URL')).not.toBeInTheDocument(); expect(onOptionsChange).toHaveBeenCalledTimes(0); - expect(screen.queryByText('GitHub Enterprise URL')).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Enterprise Server')); + expect(onOptionsChange).toHaveBeenCalledTimes(1); + expect(screen.queryByText('GitHub Enterprise Server URL')).toBeInTheDocument(); }); it('should select enterprise license type as checked when the url is not empty', async () => { const onOptionsChange = jest.fn(); const options = { jsonData: { githubUrl: 'https://foo.bar' }, secureJsonFields: {} } as any; render(); - await waitFor(() => expect(screen.getByText('Additional Settings')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Connection')).toBeInTheDocument()); onOptionsChange.mockClear(); - expect(screen.getByLabelText('Basic')).not.toBeChecked(); - expect(screen.getByLabelText('Enterprise')).toBeChecked(); - expect(screen.queryByText('GitHub Enterprise URL')).toBeInTheDocument(); + expect(screen.getByLabelText('Free, Pro & Team')).not.toBeChecked(); + expect(screen.getByLabelText('Enterprise Server')).toBeChecked(); + expect(screen.queryByText('GitHub Enterprise Server URL')).toBeInTheDocument(); expect(onOptionsChange).toHaveBeenCalledTimes(0); - await userEvent.click(screen.getByLabelText('Basic')); - expect(onOptionsChange).toHaveBeenNthCalledWith(1, { jsonData: { githubUrl: '' }, secureJsonFields: {} }); + await userEvent.click(screen.getByLabelText('Free, Pro & Team')); + expect(onOptionsChange).toHaveBeenNthCalledWith(1, { + jsonData: { githubUrl: '', githubPlan: 'github-basic' }, + secureJsonFields: {}, + }); expect(screen.queryByText('GitHub Enterprise URL')).not.toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Enterprise Cloud')); + expect(onOptionsChange).toHaveBeenNthCalledWith(2, { + jsonData: { githubUrl: '', githubPlan: 'github-enterprise-cloud' }, + secureJsonFields: {}, + }); + await userEvent.click(screen.getByLabelText('Enterprise Server')); + expect(screen.queryByText('GitHub Enterprise Server URL')).toBeInTheDocument(); }); }); diff --git a/src/views/ConfigEditor.tsx b/src/views/ConfigEditor.tsx index 7cae76e9..6c200724 100644 --- a/src/views/ConfigEditor.tsx +++ b/src/views/ConfigEditor.tsx @@ -1,37 +1,49 @@ +import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { css } from '@emotion/css'; +import { Collapse, Field, Input, Label, RadioButtonGroup, SecretInput, SecretTextArea, useStyles2 } from '@grafana/ui'; +import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; +import { Divider } from 'components/Divider'; +import { components as selectors } from '../components/selectors'; import { - DataSourcePluginOptionsEditorProps, - GrafanaTheme2, onUpdateDatasourceJsonDataOption, onUpdateDatasourceSecureJsonDataOption, + type DataSourcePluginOptionsEditorProps, + type GrafanaTheme2, + type SelectableValue, } from '@grafana/data'; -import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; -import { Collapse, Field, Input, Label, RadioButtonGroup, SecretInput, SecretTextArea, useStyles2 } from '@grafana/ui'; -import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; -import { components } from '../components/selectors'; -import { GitHubAuthType, GitHubDataSourceOptions, GitHubLicenseType, GitHubSecureJsonData } from '../types'; -import { Divider } from 'components/Divider'; +import type { GitHubAuthType, GitHubLicenseType, GitHubDataSourceOptions, GitHubSecureJsonData } from 'types/config'; export type ConfigEditorProps = DataSourcePluginOptionsEditorProps; const ConfigEditor = (props: ConfigEditorProps) => { const { options, onOptionsChange } = props; const { jsonData, secureJsonData, secureJsonFields } = options; - const secureSettings = (secureJsonData || {}) as GitHubSecureJsonData; + const secureSettings = secureJsonData || {}; const styles = useStyles2(getStyles); const WIDTH_LONG = 40; - const authOptions = [ - { label: 'Personal Access Token', value: GitHubAuthType.Personal }, - { label: 'GitHub App', value: GitHubAuthType.App }, + + const authOptions: Array> = [ + { label: 'Personal Access Token', value: 'personal-access-token' }, + { label: 'GitHub App', value: 'github-app' }, ]; - const licenseOptions = [ - { label: 'Basic', value: GitHubLicenseType.Basic }, - { label: 'Enterprise', value: GitHubLicenseType.Enterprise }, + + const licenseOptions: Array> = [ + { label: 'Free, Pro & Team', value: 'github-basic' }, + { label: 'Enterprise Cloud', value: 'github-enterprise-cloud' }, + { label: 'Enterprise Server', value: 'github-enterprise-server' }, ]; const [isOpen, setIsOpen] = useState(true); - const [selectedLicense, setSelectedLicense] = useState( - jsonData.githubUrl ? GitHubLicenseType.Enterprise : GitHubLicenseType.Basic + + // Previously we used only githubUrl property to determine if the github plan is enterprise which is incorrect way + // Also only on prem github enterprise will be having their own base URLs where as cloud will be having common URL and this causes confusions to the user + // So we are adding a new prop called githubPlan to determine if the github instance is on-prem / cloud / basic plan + // Also if no plan exist and no url exist, we need to fallback to github-basic + // https://docs.github.com/en/get-started/using-github-docs/about-versions-of-github-docs + const [selectedLicense, setSelectedLicense] = useState( + jsonData.githubPlan === 'github-enterprise-server' || jsonData.githubUrl + ? 'github-enterprise-server' + : jsonData?.githubPlan || 'github-basic' ); const onSettingUpdate = (prop: string, set = true) => { @@ -62,19 +74,22 @@ const ConfigEditor = (props: ConfigEditorProps) => { [jsonData, onOptionsChange, options] ); - const onLicenseChange = (value: GitHubLicenseType) => { - // clear out githubUrl when switching to basic - if (value === GitHubLicenseType.Basic) { - onOptionsChange({ ...options, jsonData: { ...jsonData, githubUrl: '' } }); - } - - setSelectedLicense(value); + const onLicenseChange = (githubPlan: GitHubLicenseType) => { + onOptionsChange({ + ...options, + jsonData: { + ...jsonData, + githubPlan, + githubUrl: githubPlan === 'github-enterprise-server' ? jsonData.githubUrl : '', + }, + }); + setSelectedLicense(githubPlan); }; useEffect(() => { // set the default auth type if its a new datasource and nothing is set if (!jsonData.selectedAuthType) { - onAuthChange(GitHubAuthType.Personal); + onAuthChange('personal-access-token'); } }, [jsonData.selectedAuthType, onAuthChange]); @@ -82,48 +97,65 @@ const ConfigEditor = (props: ConfigEditorProps) => { <> - setIsOpen((x) => !x)}> + setIsOpen((x) => !x)}> +

How to create a access token

- To create a new Access Token, navigate to{' '} - + To create a new fine grained access token, navigate to{' '} + Personal Access Tokens {' '} - and create a click "Generate new token." + or refer the guidelines from{' '} + + the Github documentation. + +

+

Repository access

+

+ In the Repository access section, Select the required repositories you want to use with the plugin. +

+

Permissions

+

+ In the repository permissions, Ensure to provide read-only access to the necessary section which you + want to use with the plugin. The plugin does not require any write access.
+ Along with other permissions such as `Issues`, `Pull Requests`, ensure to provide read-only access to `Meta + data` section as well. +
+ This plugin does not require any org level permissions

- -

Ensure that your token has the following permissions:

- - For all repositories: -
public_repo, repo:status, repo_deployment, read:packages, read:user, user:email
- - For GitHub projects: -
read:org, read:project
- - An extra setting is required for private repositories: -
repo (Full control of private repositories)
- - - - - {jsonData.selectedAuthType === GitHubAuthType.Personal && ( + + + + options={authOptions} + value={jsonData.selectedAuthType} + onChange={onAuthChange} + className={styles.radioButton} + /> + + + {jsonData.selectedAuthType === 'personal-access-token' && ( { )} - {jsonData.selectedAuthType === GitHubAuthType.App && ( + {jsonData.selectedAuthType === 'github-app' && ( <> { - - + + { className={styles.radioButton} /> - {selectedLicense === GitHubLicenseType.Enterprise && ( - + {selectedLicense === 'github-enterprise-server' && ( + { queryTypes?: string[]; diff --git a/tests/ConfigEditor.spec.ts b/tests/ConfigEditor.spec.ts index 230a4e19..96d49385 100644 --- a/tests/ConfigEditor.spec.ts +++ b/tests/ConfigEditor.spec.ts @@ -15,11 +15,11 @@ test('ConfigEditor smoke test', async ({ createDataSourceConfigPage, page, selec await configPage.getByGrafanaSelector(components.ConfigEditor.AccessToken).fill('my-access-token'); // TODO: Move this logic to plugin-e2e if (semver.lt(grafanaVersion, '10.2.0')) { - await page.getByText('Enterprise', { exact: true }).click(); + await page.getByText('Enterprise Server', { exact: true }).click(); } else { - await page.getByRole('radio', { name: 'Enterprise' }).check(); + await page.getByRole('radio', { name: 'Enterprise Server' }).check(); } - await page.getByPlaceholder('URL of GitHub Enterprise').fill('https://github.mycompany.com'); + await page.getByPlaceholder('http(s)://HOSTNAME/').fill('https://github.mycompany.com'); // TODO: Move this logic to plugin-e2e if (semver.lt(grafanaVersion, '10.0.0')) { await page.getByLabel('Data source settings page Save and Test button').click();