diff --git a/.changeset/unlucky-paws-greet.md b/.changeset/unlucky-paws-greet.md new file mode 100644 index 0000000000..92ace920e0 --- /dev/null +++ b/.changeset/unlucky-paws-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-octopus-deploy': patch +--- + +Added dropdown field extension for Octopus Deploy project groups diff --git a/plugins/octopus-deploy/README.md b/plugins/octopus-deploy/README.md index ee7d9d8543..745a1793e8 100644 --- a/plugins/octopus-deploy/README.md +++ b/plugins/octopus-deploy/README.md @@ -23,15 +23,16 @@ This plugin (currently) uses the Backstage proxy to securely communicate with th To use it, you will need to generate an [API Key](https://octopus.com/docs/octopus-rest-api/how-to-create-an-api-key) within Octopus Deploy. -1. Add the following to your app-config.yaml to enable the proxy: +Add the following to your app-config.yaml to enable the proxy: ``` // app-config.yaml proxy: - '/octopus-deploy': - target: 'https:///api' - headers: - X-Octopus-ApiKey: ${OCTOPUS_API_KEY} + endpoints: + '/octopus-deploy': + target: 'https:///api' + headers: + X-Octopus-ApiKey: ${OCTOPUS_API_KEY} ``` Optionally, also add the following section to your app-config.yaml if you wish to enable linking to the Project Release page in the Octopus Deploy UI from the footer of the Backstage Release Table. Typically this will be the server URL above without the /api postfix. @@ -41,7 +42,9 @@ octopusdeploy: webBaseUrl: "" ``` -2. Add the following to `EntityPage.tsx` to display Octopus Releases +#### Adding the Entities + +Add the following to `EntityPage.tsx` to display Octopus Releases ``` // In packages/app/src/components/catalog/EntityPage.tsx @@ -60,7 +63,7 @@ const cicdContent = ( ) ``` -3. Add `octopus.com/project-id` annotation in the catalog descriptor file. +Add `octopus.com/project-id` annotation in the catalog descriptor file. To obtain a projects ID you will have to query the Octopus API. In the future we'll add support for using a projects slug as well. @@ -93,3 +96,39 @@ spec: You can get the ID of the space from the URL in the Octopus Deploy UI. All set, you will be able to see the plugin in action! + +### Adding Scaffolder field extensions + +To add the Octopus Deploy custom fields extensions, add the following to your `App.tsx`: + +```tsx +// In packages/app/src/App.tsx +import { OctopusDeployDropdownFieldExtension } from '@backstage/plugin-octopus-deploy'; +const routes = ( + + ... + }> + + + + + ... + +``` + +To use the Octopus Deploy custom field extensions, add the following to your `template.yaml`: + +```yaml +# In the template.yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + # ... + parameters: + - title: Octopus Project Group + properties: + projectName: + title: Octopus Project Group + type: string + ui:field: OctopusDeployProjectGroupDropdown +``` diff --git a/plugins/octopus-deploy/api-report.md b/plugins/octopus-deploy/api-report.md index 094cc0a7b0..4a15bc1dfd 100644 --- a/plugins/octopus-deploy/api-report.md +++ b/plugins/octopus-deploy/api-report.md @@ -11,6 +11,7 @@ import { ConfigApi } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { FetchApi } from '@backstage/core-plugin-api'; +import { FieldExtensionComponent } from '@backstage/plugin-scaffolder-react'; import { JSX as JSX_2 } from 'react'; // @public (undocumented) @@ -29,6 +30,8 @@ export interface OctopusDeployApi { // (undocumented) getConfig(): Promise; // (undocumented) + getProjectGroups(): Promise; + // (undocumented) getProjectInfo(projectReference: ProjectReference): Promise; // (undocumented) getReleaseProgression(opts: { @@ -51,6 +54,8 @@ export class OctopusDeployClient implements OctopusDeployApi { // (undocumented) getConfig(): Promise; // (undocumented) + getProjectGroups(): Promise; + // (undocumented) getProjectInfo(projectReference: ProjectReference): Promise; // (undocumented) getReleaseProgression(opts: { @@ -59,6 +64,12 @@ export class OctopusDeployClient implements OctopusDeployApi { }): Promise; } +// @public (undocumented) +export const OctopusDeployDropdownFieldExtension: FieldExtensionComponent< + string, + {} +>; + // @public (undocumented) export type OctopusDeployment = { State: string; @@ -97,6 +108,13 @@ export type OctopusProject = { Links: OctopusLinks; }; +// @public (undocumented) +export type OctopusProjectGroup = { + Id: string; + Name: string; + Description: string; +}; + // @public (undocumented) export type OctopusRelease = { Id: string; diff --git a/plugins/octopus-deploy/package.json b/plugins/octopus-deploy/package.json index 7f76cdcd27..67aa6c5443 100644 --- a/plugins/octopus-deploy/package.json +++ b/plugins/octopus-deploy/package.json @@ -37,6 +37,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-scaffolder-react": "workspace:^", "@material-ui/core": "^4.9.13", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", "react-use": "^17.2.4" diff --git a/plugins/octopus-deploy/src/api/index.ts b/plugins/octopus-deploy/src/api/index.ts index cb5d428308..843f304015 100644 --- a/plugins/octopus-deploy/src/api/index.ts +++ b/plugins/octopus-deploy/src/api/index.ts @@ -62,6 +62,13 @@ export type OctopusProject = { Links: OctopusLinks; }; +/** @public */ +export type OctopusProjectGroup = { + Id: string; + Name: string; + Description: string; +}; + /** @public */ export type OctopusPluginConfig = { WebUiBaseUrl: string; @@ -82,6 +89,7 @@ export interface OctopusDeployApi { releaseHistoryCount: number; }): Promise; getProjectInfo(projectReference: ProjectReference): Promise; + getProjectGroups(): Promise; getConfig(): Promise; } @@ -109,51 +117,19 @@ export class OctopusDeployClient implements OctopusDeployApi { releaseHistoryCount: number; }): Promise { const url = await this.getProgressionApiUrl(opts); - - const response = await this.fetchApi.fetch(url); - - let responseJson; - - try { - responseJson = await response.json(); - } catch (e) { - responseJson = { releases: [] }; - } - - if (response.status !== 200) { - throw new Error( - `Error communicating with Octopus Deploy: ${ - responseJson?.error?.title || response.statusText - }`, - ); - } - - return responseJson; + return this.fetchAndHandleErrors(url); } async getProjectInfo( projectReference: ProjectReference, ): Promise { const url = await this.getProjectApiUrl(projectReference); - const response = await this.fetchApi.fetch(url); + return this.fetchAndHandleErrors(url); + } - let responseJson; - - try { - responseJson = await response.json(); - } catch (e) { - responseJson = { releases: [] }; - } - - if (response.status !== 200) { - throw new Error( - `Error communicating with Octopus Deploy: ${ - responseJson?.error?.title || response.statusText - }`, - ); - } - - return responseJson; + async getProjectGroups(): Promise { + const url = await this.getProjectGroupApiUrl(); + return this.fetchAndHandleErrors(url); } async getConfig(): Promise { @@ -162,6 +138,26 @@ export class OctopusDeployClient implements OctopusDeployApi { }; } + private async fetchAndHandleErrors(url: string): Promise { + const response = await this.fetchApi.fetch(url); + + let responseJson: T; + + try { + responseJson = await response.json(); + } catch (e) { + throw new Error(`Failed to parse JSON response: ${e}`); + } + + if (!response.ok) { + throw new Error( + `Error communicating with Octopus Deploy: ${response.status}`, + ); + } + + return responseJson; + } + private async getProgressionApiUrl(opts: { projectReference: ProjectReference; releaseHistoryCount: number; @@ -185,4 +181,9 @@ export class OctopusDeployClient implements OctopusDeployApi { projectReference.projectId, )}`; } + + private async getProjectGroupApiUrl(): Promise { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return `${proxyUrl}${this.proxyPathBase}/projectgroups/all`; + } } diff --git a/plugins/octopus-deploy/src/components/ScaffolderDropdown/ProjectGroupDropdown.tsx b/plugins/octopus-deploy/src/components/ScaffolderDropdown/ProjectGroupDropdown.tsx new file mode 100644 index 0000000000..f0ad16b231 --- /dev/null +++ b/plugins/octopus-deploy/src/components/ScaffolderDropdown/ProjectGroupDropdown.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2023 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 React from 'react'; +import { InputLabel, Input } from '@material-ui/core'; +import { Select, SelectItem } from '@backstage/core-components'; +import { useProjectGroups } from '../../hooks/useProjectGroups'; +import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha'; +import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react'; + +export const ProjectGroupDropdown = ({ + onChange, + errors, + rawErrors, + required, + help, + rawDescription, + disabled, +}: FieldExtensionComponentProps) => { + const [selectedProjectGroup, setSelectedProjectGroup] = + React.useState(''); + const { projectGroups } = useProjectGroups(); + + const projectGroupItems: SelectItem[] = projectGroups + ? projectGroups.map(pg => ({ label: pg.Name, value: pg.Name })) + : []; + + const updateProjectGroup = (pg: string) => { + setSelectedProjectGroup(pg); + onChange(pg); + }; + + return ( + + {projectGroups?.length ? ( + updateProjectGroup(e.target.value)} + value={selectedProjectGroup} + /> + + )} + + ); +}; diff --git a/plugins/octopus-deploy/src/components/ScaffolderDropdown/index.ts b/plugins/octopus-deploy/src/components/ScaffolderDropdown/index.ts new file mode 100644 index 0000000000..ce790d0674 --- /dev/null +++ b/plugins/octopus-deploy/src/components/ScaffolderDropdown/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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. + */ +export { ProjectGroupDropdown } from './ProjectGroupDropdown'; diff --git a/plugins/octopus-deploy/src/hooks/useProjectGroups.ts b/plugins/octopus-deploy/src/hooks/useProjectGroups.ts new file mode 100644 index 0000000000..05365ce780 --- /dev/null +++ b/plugins/octopus-deploy/src/hooks/useProjectGroups.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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 { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { octopusDeployApiRef, OctopusProjectGroup } from '../api'; + +export function useProjectGroups(): { + projectGroups: OctopusProjectGroup[]; + loading: boolean; + error?: Error; +} { + const api = useApi(octopusDeployApiRef); + + const { value, loading, error } = useAsync(() => { + return api.getProjectGroups(); + }, [api]); + + return { + projectGroups: value ? value : [], + loading, + error, + }; +} diff --git a/plugins/octopus-deploy/src/index.ts b/plugins/octopus-deploy/src/index.ts index 2b39431606..b5134c9ca2 100644 --- a/plugins/octopus-deploy/src/index.ts +++ b/plugins/octopus-deploy/src/index.ts @@ -16,6 +16,7 @@ export { octopusDeployPlugin, EntityOctopusDeployContent, + OctopusDeployDropdownFieldExtension, isOctopusDeployAvailable, } from './plugin'; diff --git a/plugins/octopus-deploy/src/plugin.ts b/plugins/octopus-deploy/src/plugin.ts index 39b2b49fb7..3f1a4d9588 100644 --- a/plugins/octopus-deploy/src/plugin.ts +++ b/plugins/octopus-deploy/src/plugin.ts @@ -27,7 +27,10 @@ import { configApiRef, } from '@backstage/core-plugin-api'; +import { createScaffolderFieldExtension } from '@backstage/plugin-scaffolder-react'; + import { Entity } from '@backstage/catalog-model'; +import { ProjectGroupDropdown } from './components/ScaffolderDropdown'; /** @public */ export const isOctopusDeployAvailable = (entity: Entity) => @@ -61,3 +64,11 @@ export const EntityOctopusDeployContent = octopusDeployPlugin.provide( mountPoint: octopusDeployEntityContentRouteRef, }), ); + +/** @public */ +export const OctopusDeployDropdownFieldExtension = octopusDeployPlugin.provide( + createScaffolderFieldExtension({ + name: 'OctopusDeployProjectGroupDropdown', + component: ProjectGroupDropdown, + }), +); diff --git a/yarn.lock b/yarn.lock index 08b7f87405..993fb93c80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8000,6 +8000,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-scaffolder-react": "workspace:^" "@material-ui/core": ^4.9.13 "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0