diff --git a/.changeset/fast-rabbits-unite.md b/.changeset/fast-rabbits-unite.md new file mode 100644 index 0000000000..f4686b32cc --- /dev/null +++ b/.changeset/fast-rabbits-unite.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +**DEPRECATION**: The `getOctokitOptions` function signature with `repoUrl` option has been deprecated in favour of a function signature with individual `host`, `owner`, and `repo` parameters: + +```diff + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider, + token, +- repoUrl, ++ host, ++ owner, ++ repo, + }); +``` diff --git a/.changeset/wet-bees-heal.md b/.changeset/wet-bees-heal.md new file mode 100644 index 0000000000..6708b42d16 --- /dev/null +++ b/.changeset/wet-bees-heal.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Added support for autocompletion to GithubRepoPicker component diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 935886bf14..476e8fde1e 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -474,6 +474,16 @@ export const createPublishGithubPullRequestAction: ( >; // @public +export function getOctokitOptions(options: { + integrations: ScmIntegrationRegistry; + credentialsProvider?: GithubCredentialsProvider; + token?: string; + host: string; + owner?: string; + repo?: string; +}): Promise; + +// @public @deprecated export function getOctokitOptions(options: { integrations: ScmIntegrationRegistry; credentialsProvider?: GithubCredentialsProvider; diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.ts b/plugins/scaffolder-backend-module-github/src/actions/github.ts index e3a9ff3352..6c47a2dade 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.ts @@ -27,9 +27,9 @@ import { } from '@backstage/plugin-scaffolder-node'; import { createGithubRepoWithCollaboratorsAndTopics, - getOctokitOptions, initRepoPushAndProtect, } from './helpers'; +import { getOctokitOptions } from '../util'; import * as inputProps from './inputProperties'; import * as outputProps from './outputProperties'; import { examples } from './github.examples'; @@ -225,20 +225,22 @@ export function createPublishGithubAction(options: { requiredLinearHistory = false, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - credentialsProvider: githubCredentialsProvider, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); } + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); + const newRepo = await createGithubRepoWithCollaboratorsAndTopics( client, repo, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts index 0d24b74927..93f89a565c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts @@ -24,7 +24,7 @@ import { parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import { Octokit } from 'octokit'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { examples } from './githubActionsDispatch.examples'; /** @@ -96,7 +96,7 @@ export function createGithubActionsDispatchAction(options: { `Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`, ); - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); @@ -105,7 +105,9 @@ export function createGithubActionsDispatchAction(options: { const client = new Octokit( await getOctokitOptions({ integrations, - repoUrl, + host, + owner, + repo, credentialsProvider: githubCredentialsProvider, token: providedToken, }), diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.ts index 4b0d0e1e1f..b55811630b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.ts @@ -25,7 +25,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { Octokit } from 'octokit'; import { examples } from './githubAutolinks.examples'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; /** * Create an autolink reference for a repository @@ -89,7 +89,7 @@ export function createGithubAutolinksAction(options: { ctx.logger.info(`Creating autolink reference for repo ${repoUrl}`); - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); @@ -98,7 +98,9 @@ export function createGithubAutolinksAction(options: { const client = new Octokit( await getOctokitOptions({ integrations, - repoUrl, + host, + owner, + repo, credentialsProvider: githubCredentialsProvider, token, }), diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts index a7555d3af1..6de447541b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts @@ -22,7 +22,7 @@ import { import { ScmIntegrationRegistry } from '@backstage/integration'; import { examples } from './githubBranchProtection.examples'; import * as inputProps from './inputProperties'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { Octokit } from 'octokit'; import { enableBranchProtectionOnDefaultRepoBranch } from './gitHelpers'; @@ -115,19 +115,21 @@ export function createGithubBranchProtectionAction(options: { token: providedToken, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError(`No owner provided for repo ${repoUrl}`); } + const octokitOptions = await getOctokitOptions({ + integrations, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); + const repository = await client.rest.repos.get({ owner: owner, repo: repo, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.ts b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.ts index ba55b6a1fb..a91ac8ba5e 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.ts @@ -20,7 +20,7 @@ import { parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { Octokit } from 'octokit'; import Sodium from 'libsodium-wrappers'; import { examples } from './githubDeployKey.examples'; @@ -107,18 +107,20 @@ export function createGithubDeployKeyAction(options: { token: providedToken, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - token: providedToken, - repoUrl: repoUrl, - }); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError(`No owner provided for repo ${repoUrl}`); } + const octokitOptions = await getOctokitOptions({ + integrations, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); await client.rest.repos.createDeployKey({ diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts index ef59b611fe..49bd96db9d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts @@ -20,7 +20,7 @@ import { parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { Octokit } from 'octokit'; import Sodium from 'libsodium-wrappers'; import { examples } from './gitHubEnvironment.examples'; @@ -175,18 +175,20 @@ export function createGithubEnvironmentAction(options: { // Add a 2-second delay before initiating the steps in this action. await new Promise(resolve => setTimeout(resolve, 2000)); - const octokitOptions = await getOctokitOptions({ - integrations, - token: providedToken, - repoUrl: repoUrl, - }); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError(`No owner provided for repo ${repoUrl}`); } + const octokitOptions = await getOctokitOptions({ + integrations, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); const repository = await client.rest.repos.get({ owner: owner, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts index 75ac6025ce..4aee57130c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts @@ -25,9 +25,9 @@ import { import { createGithubIssuesLabelAction } from './githubIssuesLabel'; import yaml from 'yaml'; import { examples } from './githubIssuesLabel.examples'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; -jest.mock('./helpers', () => { +jest.mock('../util', () => { return { getOctokitOptions: jest.fn(), }; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts index 13c7df68cb..af1d7d552c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts @@ -23,9 +23,9 @@ import { import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; -jest.mock('./helpers', () => { +jest.mock('../util', () => { return { getOctokitOptions: jest.fn(), }; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.ts index 90f5a89dc7..0ce6642f39 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.ts @@ -24,7 +24,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { assertError, InputError } from '@backstage/errors'; import { Octokit } from 'octokit'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { examples } from './githubIssuesLabel.examples'; /** @@ -80,7 +80,7 @@ export function createGithubIssuesLabelAction(options: { async handler(ctx) { const { repoUrl, number, labels, token: providedToken } = ctx.input; - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); ctx.logger.info(`Adding labels to ${number} issue on repo ${repo}`); if (!owner) { @@ -91,7 +91,9 @@ export function createGithubIssuesLabelAction(options: { await getOctokitOptions({ integrations, credentialsProvider: githubCredentialsProvider, - repoUrl: repoUrl, + host, + owner, + repo, token: providedToken, }), ); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPagesEnable.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPagesEnable.ts index 42b9c47030..9212f77338 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPagesEnable.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPagesEnable.ts @@ -25,7 +25,7 @@ import { parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import { examples } from './githubPagesEnable.examples'; -import { getOctokitOptions } from '@backstage/plugin-scaffolder-backend-module-github'; +import { getOctokitOptions } from '../util'; /** * Creates a new action that enables GitHub Pages for a repository. @@ -92,20 +92,22 @@ export function createGithubPagesEnableAction(options: { token: providedToken, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - credentialsProvider: githubCredentialsProvider, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); } + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); + ctx.logger.info( `Attempting to enable GitHub Pages for ${owner}/${repo} with "${buildType}" build type, on source branch "${sourceBranch}" and source path "${sourcePath}"`, ); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts index 7fe9d8e401..44830b3047 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts @@ -28,7 +28,7 @@ import { import { Octokit } from 'octokit'; import { CustomErrorBase, InputError } from '@backstage/errors'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { examples } from './githubPullRequest.examples'; import { LoggerService, @@ -49,14 +49,12 @@ export const defaultClientFactory: CreateGithubPullRequestActionOptions['clientF host = 'github.com', token: providedToken, }) => { - const [encodedHost, encodedOwner, encodedRepo] = [host, owner, repo].map( - encodeURIComponent, - ); - const octokitOptions = await getOctokitOptions({ integrations, credentialsProvider: githubCredentialsProvider, - repoUrl: `${encodedHost}?owner=${encodedOwner}&repo=${encodedRepo}`, + host, + owner, + repo, token: providedToken, }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.ts index b4608e57de..d32f7447a6 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.ts @@ -24,10 +24,8 @@ import { createTemplateAction, parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; -import { - createGithubRepoWithCollaboratorsAndTopics, - getOctokitOptions, -} from './helpers'; +import { createGithubRepoWithCollaboratorsAndTopics } from './helpers'; +import { getOctokitOptions } from '../util'; import * as inputProps from './inputProperties'; import * as outputProps from './outputProperties'; import { examples } from './githubRepoCreate.examples'; @@ -182,20 +180,22 @@ export function createGithubRepoCreateAction(options: { token: providedToken, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - credentialsProvider: githubCredentialsProvider, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); - - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); } + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + token: providedToken, + host, + owner, + repo, + }); + const client = new Octokit(octokitOptions); + const newRepo = await createGithubRepoWithCollaboratorsAndTopics( client, repo, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.ts index 62624057c8..dcb159806c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.ts @@ -25,7 +25,8 @@ import { createTemplateAction, parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; -import { getOctokitOptions, initRepoPushAndProtect } from './helpers'; +import { initRepoPushAndProtect } from './helpers'; +import { getOctokitOptions } from '../util'; import * as inputProps from './inputProperties'; import * as outputProps from './outputProperties'; import { examples } from './githubRepoPush.examples'; @@ -142,7 +143,7 @@ export function createGithubRepoPushAction(options: { requiredLinearHistory = false, } = ctx.input; - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); @@ -152,7 +153,9 @@ export function createGithubRepoPushAction(options: { integrations, credentialsProvider: githubCredentialsProvider, token: providedToken, - repoUrl, + host, + owner, + repo, }); const client = new Octokit(octokitOptions); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.ts b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.ts index e188cdf0cb..13200a56d0 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.ts @@ -25,7 +25,7 @@ import { import { emitterEventNames } from '@octokit/webhooks'; import { assertError, InputError } from '@backstage/errors'; import { Octokit } from 'octokit'; -import { getOctokitOptions } from './helpers'; +import { getOctokitOptions } from '../util'; import { examples } from './githubWebhook.examples'; /** @@ -134,7 +134,7 @@ export function createGithubWebhookAction(options: { } = ctx.input; ctx.logger.info(`Creating webhook ${webhookUrl} for repo ${repoUrl}`); - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); if (!owner) { throw new InputError('Invalid repository owner provided in repoUrl'); @@ -144,7 +144,9 @@ export function createGithubWebhookAction(options: { await getOctokitOptions({ integrations, credentialsProvider: githubCredentialsProvider, - repoUrl: repoUrl, + host, + owner, + repo, token: providedToken, }), ); diff --git a/plugins/scaffolder-backend-module-github/src/actions/helpers.ts b/plugins/scaffolder-backend-module-github/src/actions/helpers.ts index 242f95ce34..8e6e521c29 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/helpers.ts @@ -15,19 +15,12 @@ */ import { Config } from '@backstage/config'; -import { assertError, InputError, NotFoundError } from '@backstage/errors'; -import { - DefaultGithubCredentialsProvider, - GithubCredentialsProvider, - ScmIntegrationRegistry, -} from '@backstage/integration'; -import { OctokitOptions } from '@octokit/core/dist-types/types'; +import { assertError, NotFoundError } from '@backstage/errors'; import { Octokit } from 'octokit'; import { getRepoSourceDirectory, initRepoAndPush, - parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import Sodium from 'libsodium-wrappers'; @@ -37,73 +30,6 @@ import { } from './gitHelpers'; import { LoggerService } from '@backstage/backend-plugin-api'; -const DEFAULT_TIMEOUT_MS = 60_000; - -/** - * Helper for generating octokit configuration options for given repoUrl. - * If no token is provided, it will attempt to get a token from the credentials provider. - * @public - */ -export async function getOctokitOptions(options: { - integrations: ScmIntegrationRegistry; - credentialsProvider?: GithubCredentialsProvider; - token?: string; - repoUrl: string; -}): Promise { - const { integrations, credentialsProvider, repoUrl, token } = options; - const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); - - const requestOptions = { - // set timeout to 60 seconds - timeout: DEFAULT_TIMEOUT_MS, - }; - - if (!owner) { - throw new InputError(`No owner provided for repo ${repoUrl}`); - } - - const integrationConfig = integrations.github.byHost(host)?.config; - - if (!integrationConfig) { - throw new InputError(`No integration for host ${host}`); - } - - // short circuit the `githubCredentialsProvider` if there is a token provided by the caller already - if (token) { - return { - auth: token, - baseUrl: integrationConfig.apiBaseUrl, - previews: ['nebula-preview'], - request: requestOptions, - }; - } - - const githubCredentialsProvider = - credentialsProvider ?? - DefaultGithubCredentialsProvider.fromIntegrations(integrations); - - // TODO(blam): Consider changing this API to take host and repo instead of repoUrl, as we end up parsing in this function - // and then parsing in the `getCredentials` function too the other side - const { token: credentialProviderToken } = - await githubCredentialsProvider.getCredentials({ - url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent( - repo, - )}`, - }); - - if (!credentialProviderToken) { - throw new InputError( - `No token available for host: ${host}, with owner ${owner}, and repo ${repo}. Make sure GitHub auth is configured correctly. See https://backstage.io/docs/auth/github/provider for more details.`, - ); - } - - return { - auth: credentialProviderToken, - baseUrl: integrationConfig.apiBaseUrl, - previews: ['nebula-preview'], - }; -} - export async function createGithubRepoWithCollaboratorsAndTopics( client: Octokit, repo: string, diff --git a/plugins/scaffolder-backend-module-github/src/actions/index.ts b/plugins/scaffolder-backend-module-github/src/actions/index.ts index c758415e1d..7cf4109e6b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/index.ts @@ -29,5 +29,3 @@ export { createPublishGithubAction } from './github'; export { createGithubAutolinksAction } from './githubAutolinks'; export { createGithubPagesEnableAction } from './githubPagesEnable'; export { createGithubBranchProtectionAction } from './githubBranchProtection'; - -export { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts new file mode 100644 index 0000000000..8556fc9428 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { createHandleAutocompleteRequest } from './autocomplete'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +jest.mock('../util', () => { + return { + getOctokitOptions: jest.fn(), + }; +}); + +const mockOctokit = { + paginate: async (fn: any) => (await fn()).data, + rest: { + repos: { + listForAuthenticatedUser: jest.fn(), + }, + }, +}; +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('handleAutocompleteRequest', () => { + const mockIntegrations = {} as ScmIntegrationRegistry; + + it('should return repositories with owner', async () => { + const handleAutocompleteRequest = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + + mockOctokit.rest.repos.listForAuthenticatedUser.mockResolvedValue({ + data: [ + { + full_name: 'backstage/backstage', + }, + ], + }); + + const result = await handleAutocompleteRequest({ + resource: 'repositoriesWithOwner', + token: 'token', + context: {}, + }); + + expect(result).toEqual({ + results: [{ id: 'backstage/backstage' }], + }); + }); + + it('should throw an error for invalid resource', async () => { + const handleAutocompleteRequest = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + + await expect( + handleAutocompleteRequest({ + resource: 'invalid', + token: 'token', + context: {}, + }), + ).rejects.toThrow(InputError); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts new file mode 100644 index 0000000000..08041640d9 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { getOctokitOptions } from '../util'; +import { Octokit } from 'octokit'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +export function createHandleAutocompleteRequest(options: { + integrations: ScmIntegrationRegistry; +}) { + return async function handleAutocompleteRequest({ + resource, + token, + context, + }: { + resource: string; + token: string; + context: Record; + }): Promise<{ results: { title?: string; id: string }[] }> { + const { integrations } = options; + const octokitOptions = await getOctokitOptions({ + integrations, + token, + host: context.host ?? 'github.com', + }); + const client = new Octokit(octokitOptions); + + switch (resource) { + case 'repositoriesWithOwner': { + const repositoriesWithOwner = await client.paginate( + client.rest.repos.listForAuthenticatedUser, + ); + + const results = repositoriesWithOwner.map(r => ({ id: r.full_name })); + + return { results }; + } + default: + throw new InputError(`Invalid resource: ${resource}`); + } + }; +} diff --git a/plugins/scaffolder-backend-module-github/src/index.ts b/plugins/scaffolder-backend-module-github/src/index.ts index 219ba54dbb..cf0bc003cb 100644 --- a/plugins/scaffolder-backend-module-github/src/index.ts +++ b/plugins/scaffolder-backend-module-github/src/index.ts @@ -22,3 +22,4 @@ export * from './actions'; export { githubModule as default } from './module'; +export { getOctokitOptions } from './util'; diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 0387473fdb..02dfc2d7f3 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -17,7 +17,10 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +import { + scaffolderActionsExtensionPoint, + scaffolderAutocompleteExtensionPoint, +} from '@backstage/plugin-scaffolder-node/alpha'; import { createGithubActionsDispatchAction, createGithubAutolinksAction, @@ -37,6 +40,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { CatalogClient } from '@backstage/catalog-client'; +import { createHandleAutocompleteRequest } from './autocomplete/autocomplete'; /** * @public @@ -52,8 +56,9 @@ export const githubModule = createBackendModule({ config: coreServices.rootConfig, discovery: coreServices.discovery, auth: coreServices.auth, + autocomplete: scaffolderAutocompleteExtensionPoint, }, - async init({ scaffolder, config, discovery, auth }) { + async init({ scaffolder, config, discovery, auth, autocomplete }) { const integrations = ScmIntegrations.fromConfig(config); const githubCredentialsProvider = DefaultGithubCredentialsProvider.fromIntegrations(integrations); @@ -109,6 +114,11 @@ export const githubModule = createBackendModule({ integrations, }), ); + + autocomplete.addAutocompleteProvider({ + id: 'github', + handler: createHandleAutocompleteRequest({ integrations }), + }); }, }); }, diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts new file mode 100644 index 0000000000..c25ca99f5f --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + DefaultGithubCredentialsProvider, + GithubCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { parseRepoUrl } from '@backstage/plugin-scaffolder-node'; +import { OctokitOptions } from '@octokit/core/dist-types/types'; + +const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Helper for generating octokit configuration options. + * If no token is provided, it will attempt to get a token from the credentials provider. + * @public + */ +export async function getOctokitOptions(options: { + integrations: ScmIntegrationRegistry; + credentialsProvider?: GithubCredentialsProvider; + token?: string; + host: string; + owner?: string; + repo?: string; +}): Promise; + +/** + * Helper for generating octokit configuration options for given repoUrl. + * If no token is provided, it will attempt to get a token from the credentials provider. + * @public + * @deprecated Use options `host`, `owner` and `repo` instead of `repoUrl`. + */ +export async function getOctokitOptions(options: { + integrations: ScmIntegrationRegistry; + credentialsProvider?: GithubCredentialsProvider; + token?: string; + repoUrl: string; +}): Promise; + +export async function getOctokitOptions(options: { + integrations: ScmIntegrationRegistry; + credentialsProvider?: GithubCredentialsProvider; + token?: string; + host?: string; + owner?: string; + repo?: string; + repoUrl?: string; +}): Promise { + const { integrations, credentialsProvider, token, repoUrl } = options; + const { host, owner, repo } = repoUrl + ? parseRepoUrl(repoUrl, integrations) + : options; + + const requestOptions = { + // set timeout to 60 seconds + timeout: DEFAULT_TIMEOUT_MS, + }; + + const integrationConfig = integrations.github.byHost(host!)?.config; + + if (!integrationConfig) { + throw new InputError(`No integration for host ${host}`); + } + + // short circuit the `githubCredentialsProvider` if there is a token provided by the caller already + if (token) { + return { + auth: token, + baseUrl: integrationConfig.apiBaseUrl, + previews: ['nebula-preview'], + request: requestOptions, + }; + } + + if (!owner || !repo) { + throw new InputError( + `No owner and/or owner provided, which is required if a token is not provided`, + ); + } + + const githubCredentialsProvider = + credentialsProvider ?? + DefaultGithubCredentialsProvider.fromIntegrations(integrations); + + const { token: credentialProviderToken } = + await githubCredentialsProvider.getCredentials({ + url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent( + repo, + )}`, + }); + + if (!credentialProviderToken) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repo}. Make sure GitHub auth is configured correctly. See https://backstage.io/docs/auth/github/provider for more details.`, + ); + } + + return { + auth: credentialProviderToken, + baseUrl: integrationConfig.apiBaseUrl, + previews: ['nebula-preview'], + }; +} diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 9d39f52d85..8fe17d26f8 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -9,14 +9,27 @@ import { ActionContext as ActionContext_2 } from '@backstage/plugin-scaffolder-n import { AuditorService } from '@backstage/backend-plugin-api'; import { AuthService } from '@backstage/backend-plugin-api'; import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha'; -import * as azure from '@backstage/plugin-scaffolder-backend-module-azure'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; -import * as bitbucket from '@backstage/plugin-scaffolder-backend-module-bitbucket'; -import * as bitbucketCloud from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; -import * as bitbucketServer from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { createGithubActionsDispatchAction as createGithubActionsDispatchAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubDeployKeyAction as createGithubDeployKeyAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubEnvironmentAction as createGithubEnvironmentAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubIssuesLabelAction as createGithubIssuesLabelAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { CreateGithubPullRequestActionOptions as CreateGithubPullRequestActionOptions_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubRepoCreateAction as createGithubRepoCreateAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubRepoPushAction as createGithubRepoPushAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createGithubWebhookAction as createGithubWebhookAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createPublishAzureAction as createPublishAzureAction_2 } from '@backstage/plugin-scaffolder-backend-module-azure'; +import { createPublishBitbucketAction as createPublishBitbucketAction_2 } from '@backstage/plugin-scaffolder-backend-module-bitbucket'; +import { createPublishBitbucketCloudAction as createPublishBitbucketCloudAction_2 } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; +import { createPublishBitbucketServerAction as createPublishBitbucketServerAction_2 } from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; +import { createPublishBitbucketServerPullRequestAction as createPublishBitbucketServerPullRequestAction_2 } from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; +import { createPublishGerritAction as createPublishGerritAction_2 } from '@backstage/plugin-scaffolder-backend-module-gerrit'; +import { createPublishGerritReviewAction as createPublishGerritReviewAction_2 } from '@backstage/plugin-scaffolder-backend-module-gerrit'; +import { createPublishGithubAction as createPublishGithubAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; +import { createPublishGitlabAction as createPublishGitlabAction_2 } from '@backstage/plugin-scaffolder-backend-module-gitlab'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { Duration } from 'luxon'; @@ -25,9 +38,6 @@ import { executeShellCommand as executeShellCommand_2 } from '@backstage/plugin- import { ExecuteShellCommandOptions } from '@backstage/plugin-scaffolder-node'; import express from 'express'; import { fetchContents as fetchContents_2 } from '@backstage/plugin-scaffolder-node'; -import * as gerrit from '@backstage/plugin-scaffolder-backend-module-gerrit'; -import * as github from '@backstage/plugin-scaffolder-backend-module-github'; -import * as gitlab from '@backstage/plugin-scaffolder-backend-module-gitlab'; import { HttpAuthService } from '@backstage/backend-plugin-api'; import { HumanDuration } from '@backstage/types'; import { IdentityApi } from '@backstage/plugin-auth-node'; @@ -265,57 +275,57 @@ export const createFilesystemRenameAction: () => TemplateAction_2< >; // @public @deprecated (undocumented) -export const createGithubActionsDispatchAction: typeof github.createGithubActionsDispatchAction; +export const createGithubActionsDispatchAction: typeof createGithubActionsDispatchAction_2; // @public @deprecated (undocumented) -export const createGithubDeployKeyAction: typeof github.createGithubDeployKeyAction; +export const createGithubDeployKeyAction: typeof createGithubDeployKeyAction_2; // @public @deprecated (undocumented) -export const createGithubEnvironmentAction: typeof github.createGithubEnvironmentAction; +export const createGithubEnvironmentAction: typeof createGithubEnvironmentAction_2; // @public @deprecated (undocumented) -export const createGithubIssuesLabelAction: typeof github.createGithubIssuesLabelAction; +export const createGithubIssuesLabelAction: typeof createGithubIssuesLabelAction_2; // @public @deprecated (undocumented) export type CreateGithubPullRequestActionOptions = - github.CreateGithubPullRequestActionOptions; + CreateGithubPullRequestActionOptions_2; // @public @deprecated (undocumented) -export const createGithubRepoCreateAction: typeof github.createGithubRepoCreateAction; +export const createGithubRepoCreateAction: typeof createGithubRepoCreateAction_2; // @public @deprecated (undocumented) -export const createGithubRepoPushAction: typeof github.createGithubRepoPushAction; +export const createGithubRepoPushAction: typeof createGithubRepoPushAction_2; // @public @deprecated (undocumented) -export const createGithubWebhookAction: typeof github.createGithubWebhookAction; +export const createGithubWebhookAction: typeof createGithubWebhookAction_2; // @public @deprecated (undocumented) -export const createPublishAzureAction: typeof azure.createPublishAzureAction; +export const createPublishAzureAction: typeof createPublishAzureAction_2; // @public @deprecated (undocumented) -export const createPublishBitbucketAction: typeof bitbucket.createPublishBitbucketAction; +export const createPublishBitbucketAction: typeof createPublishBitbucketAction_2; // @public @deprecated (undocumented) -export const createPublishBitbucketCloudAction: typeof bitbucketCloud.createPublishBitbucketCloudAction; +export const createPublishBitbucketCloudAction: typeof createPublishBitbucketCloudAction_2; // @public @deprecated (undocumented) -export const createPublishBitbucketServerAction: typeof bitbucketServer.createPublishBitbucketServerAction; +export const createPublishBitbucketServerAction: typeof createPublishBitbucketServerAction_2; // @public @deprecated (undocumented) -export const createPublishBitbucketServerPullRequestAction: typeof bitbucketServer.createPublishBitbucketServerPullRequestAction; +export const createPublishBitbucketServerPullRequestAction: typeof createPublishBitbucketServerPullRequestAction_2; // @public @deprecated (undocumented) -export const createPublishGerritAction: typeof gerrit.createPublishGerritAction; +export const createPublishGerritAction: typeof createPublishGerritAction_2; // @public @deprecated (undocumented) -export const createPublishGerritReviewAction: typeof gerrit.createPublishGerritReviewAction; +export const createPublishGerritReviewAction: typeof createPublishGerritReviewAction_2; // @public @deprecated (undocumented) -export const createPublishGithubAction: typeof github.createPublishGithubAction; +export const createPublishGithubAction: typeof createPublishGithubAction_2; // @public @deprecated (undocumented) export const createPublishGithubPullRequestAction: ( - options: github.CreateGithubPullRequestActionOptions, + options: CreateGithubPullRequestActionOptions_2, ) => TemplateAction_2< { title: string; @@ -340,7 +350,7 @@ export const createPublishGithubPullRequestAction: ( >; // @public @deprecated (undocumented) -export const createPublishGitlabAction: typeof gitlab.createPublishGitlabAction; +export const createPublishGitlabAction: typeof createPublishGitlabAction_2; // @public @deprecated (undocumented) export const createPublishGitlabMergeRequestAction: (options: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/deprecated.ts b/plugins/scaffolder-backend/src/scaffolder/actions/deprecated.ts index 5d037b44b6..324e9b43e1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/deprecated.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/deprecated.ts @@ -13,134 +13,151 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as github from '@backstage/plugin-scaffolder-backend-module-github'; -import * as gitlab from '@backstage/plugin-scaffolder-backend-module-gitlab'; -import * as azure from '@backstage/plugin-scaffolder-backend-module-azure'; -import * as bitbucket from '@backstage/plugin-scaffolder-backend-module-bitbucket'; -import * as bitbucketCloud from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; -import * as bitbucketServer from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; -import * as gerrit from '@backstage/plugin-scaffolder-backend-module-gerrit'; +import { + createGithubActionsDispatchAction as githubActionsDispatch, + createGithubDeployKeyAction as githubDeployKey, + createGithubEnvironmentAction as githubEnvironment, + createGithubIssuesLabelAction as githubIssuesLabel, + CreateGithubPullRequestActionOptions as GithubPullRequestActionOptions, + createGithubRepoCreateAction as githubRepoCreate, + createGithubRepoPushAction as githubRepoPush, + createGithubWebhookAction as githubWebhook, + createPublishGithubAction as publishGithub, + createPublishGithubPullRequestAction as publishGithubPullRequest, +} from '@backstage/plugin-scaffolder-backend-module-github'; + +import { + createPublishGitlabAction as publishGitlab, + createPublishGitlabMergeRequestAction as publishGitlabMergeRequest, +} from '@backstage/plugin-scaffolder-backend-module-gitlab'; + +import { createPublishAzureAction as publishAzure } from '@backstage/plugin-scaffolder-backend-module-azure'; + +import { createPublishBitbucketAction as publishBitbucket } from '@backstage/plugin-scaffolder-backend-module-bitbucket'; + +import { createPublishBitbucketCloudAction as publishBitbucketCloud } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; + +import { + createPublishBitbucketServerAction as publishBitbucketServer, + createPublishBitbucketServerPullRequestAction as publishBitbucketServerPullRequest, +} from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; + +import { + createPublishGerritAction as publishGerrit, + createPublishGerritReviewAction as publishGerritReview, +} from '@backstage/plugin-scaffolder-backend-module-gerrit'; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubActionsDispatchAction = - github.createGithubActionsDispatchAction; +export const createGithubActionsDispatchAction = githubActionsDispatch; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubDeployKeyAction = github.createGithubDeployKeyAction; +export const createGithubDeployKeyAction = githubDeployKey; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubEnvironmentAction = - github.createGithubEnvironmentAction; +export const createGithubEnvironmentAction = githubEnvironment; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubIssuesLabelAction = - github.createGithubIssuesLabelAction; +export const createGithubIssuesLabelAction = githubIssuesLabel; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ export type CreateGithubPullRequestActionOptions = - github.CreateGithubPullRequestActionOptions; + GithubPullRequestActionOptions; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubRepoCreateAction = github.createGithubRepoCreateAction; +export const createGithubRepoCreateAction = githubRepoCreate; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubRepoPushAction = github.createGithubRepoPushAction; +export const createGithubRepoPushAction = githubRepoPush; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createGithubWebhookAction = github.createGithubWebhookAction; +export const createGithubWebhookAction = githubWebhook; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createPublishGithubAction = github.createPublishGithubAction; +export const createPublishGithubAction = publishGithub; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-github` instead */ -export const createPublishGithubPullRequestAction = - github.createPublishGithubPullRequestAction; +export const createPublishGithubPullRequestAction = publishGithubPullRequest; /** * @public @deprecated use "createPublishBitbucketCloudAction" from \@backstage/plugin-scaffolder-backend-module-bitbucket-cloud or "createPublishBitbucketServerAction" from \@backstage/plugin-scaffolder-backend-module-bitbucket-server instead */ -export const createPublishBitbucketAction = - bitbucket.createPublishBitbucketAction; +export const createPublishBitbucketAction = publishBitbucket; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-bitbucket-cloud` instead */ -export const createPublishBitbucketCloudAction = - bitbucketCloud.createPublishBitbucketCloudAction; +export const createPublishBitbucketCloudAction = publishBitbucketCloud; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-bitbucket-server` instead */ -export const createPublishBitbucketServerAction = - bitbucketServer.createPublishBitbucketServerAction; +export const createPublishBitbucketServerAction = publishBitbucketServer; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-bitbucket-server` instead */ export const createPublishBitbucketServerPullRequestAction = - bitbucketServer.createPublishBitbucketServerPullRequestAction; + publishBitbucketServerPullRequest; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-azure` instead */ -export const createPublishAzureAction = azure.createPublishAzureAction; +export const createPublishAzureAction = publishAzure; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-gerrit` instead */ -export const createPublishGerritAction = gerrit.createPublishGerritAction; +export const createPublishGerritAction = publishGerrit; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-gerrit` instead */ -export const createPublishGerritReviewAction = - gerrit.createPublishGerritReviewAction; +export const createPublishGerritReviewAction = publishGerritReview; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-gitlab` instead */ -export const createPublishGitlabAction = gitlab.createPublishGitlabAction; +export const createPublishGitlabAction = publishGitlab; /** * @public * @deprecated use import from `@backstage/plugin-scaffolder-backend-module-gitlab` instead */ -export const createPublishGitlabMergeRequestAction = - gitlab.createPublishGitlabMergeRequestAction; +export const createPublishGitlabMergeRequestAction = publishGitlabMergeRequest; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx index 3a01c6de7c..d5657606a7 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx @@ -14,22 +14,43 @@ * limitations under the License. */ -import React from 'react'; +import React, { act } from 'react'; import { GithubRepoPicker } from './GithubRepoPicker'; -import { fireEvent } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { + ScaffolderApi, + scaffolderApiRef, +} from '@backstage/plugin-scaffolder-react'; +import userEvent from '@testing-library/user-event'; describe('GithubRepoPicker', () => { + const scaffolderApiMock: Partial = { + autocomplete: jest.fn().mockImplementation(opts => + Promise.resolve({ + results: [ + { + id: + opts.resource === 'repositoriesWithOwner' + ? 'spotify/backstage' + : `${opts.resource}_example`, + }, + ], + }), + ), + }; describe('owner field', () => { it('renders a select if there is a list of allowed owners', async () => { const allowedOwners = ['owner1', 'owner2']; const { findByText } = await renderInTestApp( - , + + + , ); expect(await findByText('owner1')).toBeInTheDocument(); @@ -40,12 +61,14 @@ describe('GithubRepoPicker', () => { const onChange = jest.fn(); const allowedOwners = ['owner1', 'owner2']; const { getByRole } = await renderInTestApp( - , + + + , ); await fireEvent.change(getByRole('combobox'), { @@ -59,12 +82,14 @@ describe('GithubRepoPicker', () => { const onChange = jest.fn(); const allowedOwners = ['owner1']; const { getByRole } = await renderInTestApp( - , + + + , ); expect(getByRole('combobox')).toBeDisabled(); @@ -73,16 +98,76 @@ describe('GithubRepoPicker', () => { it('should display free text if no allowed owners are passed', async () => { const onChange = jest.fn(); const { getAllByRole } = await renderInTestApp( - , + + + , ); const ownerField = getAllByRole('textbox')[0]; - fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } }); + act(() => { + ownerField.focus(); + fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } }); + ownerField.blur(); + }); expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); }); }); + + describe('autocompletion', () => { + it('should populate owners if accessToken is provided', async () => { + const onChange = jest.fn(); + + const { getAllByRole, getByText } = await renderInTestApp( + + + , + ); + + // Open the Autcomplete dropdown + const ownerInput = getAllByRole('textbox')[0]; + await userEvent.click(ownerInput); + + // Verify that the available owners are shown + await waitFor(() => expect(getByText('spotify')).toBeInTheDocument()); + + // Verify that selecting an option calls onChange + await userEvent.click(getByText('spotify')); + expect(onChange).toHaveBeenCalledWith({ + owner: 'spotify', + }); + }); + + it('should populate repositories if owner and accessToken are provided', async () => { + const onChange = jest.fn(); + + await renderInTestApp( + + + , + ); + + // Verify that the available repos are updated + await waitFor( + () => + expect(onChange).toHaveBeenCalledWith({ + availableRepos: [{ name: 'backstage' }], + }), + { timeout: 1500 }, + ); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx index 6f6cbfb355..66a662b530 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import TextField from '@material-ui/core/TextField'; @@ -21,19 +21,79 @@ import { Select, SelectItem } from '@backstage/core-components'; import { BaseRepoUrlPickerProps } from './types'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; +import { useApi } from '@backstage/core-plugin-api'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import useDebounce from 'react-use/esm/useDebounce'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import uniq from 'lodash/uniq'; +import map from 'lodash/map'; export const GithubRepoPicker = ( props: BaseRepoUrlPickerProps<{ allowedOwners?: string[]; + accessToken?: string; }>, ) => { - const { allowedOwners = [], rawErrors, state, onChange } = props; + const { allowedOwners = [], rawErrors, state, onChange, accessToken } = props; const { t } = useTranslationRef(scaffolderTranslationRef); const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) : [{ label: 'Loading...', value: 'loading' }]; - const { owner } = state; + const { host, owner } = state; + + const scaffolderApi = useApi(scaffolderApiRef); + + const [availableRepositoriesWithOwner, setAvailableRepositoriesWithOwner] = + useState<{ owner: string; name: string }[]>([]); + + // Update available repositories with owner when client is available + const updateAvailableRepositoriesWithOwner = useCallback(() => { + if (!scaffolderApi.autocomplete || !accessToken || !host) { + setAvailableRepositoriesWithOwner([]); + return; + } + + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'repositoriesWithOwner', + provider: 'github', + context: { host }, + }) + .then(({ results }) => { + setAvailableRepositoriesWithOwner( + results.map(r => { + const [rOwner, rName] = r.id.split('/'); + return { owner: rOwner, name: rName }; + }), + ); + }) + .catch(() => { + setAvailableRepositoriesWithOwner([]); + }); + }, [scaffolderApi, accessToken, host]); + + useDebounce(updateAvailableRepositoriesWithOwner, 500, [ + updateAvailableRepositoriesWithOwner, + ]); + + // Update available owners when available repositories with owner change + const availableOwners = useMemo( + () => uniq(map(availableRepositoriesWithOwner, 'owner')), + [availableRepositoriesWithOwner], + ); + + // Update available repositories when available repositories with owner change or when owner changes + const updateAvailableRepositories = useCallback(() => { + const availableRepos = availableRepositoriesWithOwner.flatMap(r => + r.owner === owner ? [{ name: r.name }] : [], + ); + + onChange({ availableRepos }); + }, [availableRepositoriesWithOwner, owner, onChange]); + + useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]); return ( <> @@ -54,19 +114,28 @@ export const GithubRepoPicker = ( selected={owner} items={ownerItems} /> - - {t('fields.githubRepoPicker.owner.description')} - ) : ( - onChange({ owner: e.target.value })} - helperText={t('fields.githubRepoPicker.owner.description')} + { + onChange({ owner: newValue || '' }); + }} + options={availableOwners} + renderInput={params => ( + + )} + freeSolo + autoSelect /> )} + + {t('fields.githubRepoPicker.owner.description')} + ); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index ef6654b60c..3c6eba1695 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -186,6 +186,10 @@ export const RepoUrlPicker = ( onChange={updateLocalState} rawErrors={rawErrors} state={state} + accessToken={ + uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey && + secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey] + } /> )} {hostType === 'gitea' && (