diff --git a/plugins/octopus-deploy/.eslintrc.js b/plugins/octopus-deploy/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/octopus-deploy/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/octopus-deploy/README.md b/plugins/octopus-deploy/README.md
new file mode 100644
index 0000000000..26f8021283
--- /dev/null
+++ b/plugins/octopus-deploy/README.md
@@ -0,0 +1,11 @@
+# Octopus Deploy Plugin
+
+Welcome to the octopus-deploy plugin!
+
+## Features
+
+- Display the deployment status of the most recent releases for a project in Octopus Deploy straight from the Backstage catalog
+
+## Getting started
+
+
diff --git a/plugins/octopus-deploy/dev/index.tsx b/plugins/octopus-deploy/dev/index.tsx
new file mode 100644
index 0000000000..503cbe6d12
--- /dev/null
+++ b/plugins/octopus-deploy/dev/index.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { createDevApp } from '@backstage/dev-utils';
+import { octopusDeployPlugin, OctopusDeployPage } from '../src/plugin';
+
+createDevApp()
+ .registerPlugin(octopusDeployPlugin)
+ .addPage({
+ element: ,
+ title: 'Root Page',
+ path: '/octopus-deploy'
+ })
+ .render();
diff --git a/plugins/octopus-deploy/package.json b/plugins/octopus-deploy/package.json
new file mode 100644
index 0000000000..8486ed1a32
--- /dev/null
+++ b/plugins/octopus-deploy/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@backstage/plugin-octopus-deploy",
+ "version": "0.1.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": true,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/core-components": "^0.12.3",
+ "@backstage/core-plugin-api": "^1.3.0",
+ "@backstage/theme": "^0.2.16",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "react-use": "^17.2.4"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.22.1",
+ "@backstage/core-app-api": "^1.4.0",
+ "@backstage/dev-utils": "^1.0.11",
+ "@backstage/test-utils": "^1.2.4",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^14.0.0",
+ "@types/node": "*",
+ "msw": "^0.49.0",
+ "cross-fetch": "^3.1.5"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/octopus-deploy/src/api/index.ts b/plugins/octopus-deploy/src/api/index.ts
new file mode 100644
index 0000000000..b066f700db
--- /dev/null
+++ b/plugins/octopus-deploy/src/api/index.ts
@@ -0,0 +1,88 @@
+import { createApiRef, DiscoveryApi, IdentityApi } from "@backstage/core-plugin-api";
+
+export type OctopusProgression = {
+ Environments: OctopusEnvironment[];
+ Releases: OctopusReleaseProgression[];
+}
+
+export type OctopusEnvironment = {
+ Id: string,
+ Name: string
+}
+
+export type OctopusReleaseProgression = {
+ Release: OctopusRelease
+ Deployments: { [key: string]: OctopusDeployment[] }
+}
+
+export type OctopusRelease = {
+ Id: string
+ Version: string
+}
+
+export type OctopusDeployment = {
+ State: string
+}
+
+export const octopusDeployApiRef = createApiRef({
+ id: 'plugin.octopusdeploy.service'
+});
+
+const DEFAULT_PROXY_PATH_BASE = '/octopus-deploy';
+
+type Options = {
+ discoveryApi: DiscoveryApi;
+ identityApi: IdentityApi
+ /**
+ * Path to use for requests via the proxy, defaults to /octopus-deploy
+ */
+ proxyPathBase?: string
+}
+
+export interface OctopusDeployApi {
+ getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise;
+}
+
+export class OctopusDeployClient implements OctopusDeployApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly identityApi: IdentityApi;
+ private readonly proxyPathBase: string;
+
+ constructor(options: Options) {
+ this.discoveryApi = options.discoveryApi;
+ this.identityApi = options.identityApi;
+ this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE;
+ }
+
+ async getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise {
+ const url = await this.getApiUrl(projectId, releaseHistoryCount);
+
+ const { token: idToken } = await this.identityApi.getCredentials();
+ const response = await fetch(url, {
+ headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
+ });
+
+ 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;
+ }
+
+ private async getApiUrl(projectId: string, releaseHistoryCount: number) {
+ const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
+ return `${proxyUrl}${this.proxyPathBase}/projects/${projectId}/progression?releaseHistoryCount=${releaseHistoryCount}`;
+ }
+}
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx
new file mode 100644
index 0000000000..d9b6a001c2
--- /dev/null
+++ b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx
@@ -0,0 +1,15 @@
+import { useEntity } from '@backstage/plugin-catalog-react';
+import { useReleases } from '../../hooks/useReleases';
+import { getAnnotationFromEntity } from '../../utils/getAnnotationFromEntity';
+import React from 'react';
+import { ReleaseTable } from '../ReleaseTable';
+
+export const EntityPageOctopusDeploy = (props: { defaultLimit?: number }) => {
+ const { entity } = useEntity();
+
+ const projectId = getAnnotationFromEntity(entity);
+
+ const { environments, releases, loading, error } = useReleases(projectId, props.defaultLimit ?? 3);
+
+ return ;
+}
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts
new file mode 100644
index 0000000000..ceed04684b
--- /dev/null
+++ b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts
@@ -0,0 +1 @@
+export { EntityPageOctopusDeploy } from './EntityPageOctopusDeploy';
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx b/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx
new file mode 100644
index 0000000000..21322057d3
--- /dev/null
+++ b/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx
@@ -0,0 +1,123 @@
+import { Box, Typography } from "@material-ui/core";
+import {
+ OctopusEnvironment,
+ OctopusReleaseProgression
+} from '../../api';
+
+import React from 'react';
+import { ResponseErrorPanel, StatusAborted, StatusError, StatusOK, StatusPending, StatusRunning, StatusWarning, Table, TableColumn } from "@backstage/core-components";
+
+type ReleaseTableProps = {
+ environments?: OctopusEnvironment[];
+ releases?: OctopusReleaseProgression[];
+ loading: boolean;
+ error?: Error
+};
+
+export const getDeploymentStatusComponent = (state: string | undefined) => {
+ switch (state) {
+ case "Success":
+ return (
+
+ Success
+
+ );
+ case "Queued":
+ return (
+
+ Queued
+
+ );
+ case "Executing":
+ return (
+
+ Executing
+
+ );
+ case "Failed":
+ return (
+
+ Failed
+
+ );
+ case "Cancelling":
+ return (
+
+ Cancelling
+
+ );
+ case "Canceled":
+ return (
+
+ Canceled
+
+ );
+ case "TimedOut":
+ return (
+
+ Timed Out
+
+ );
+ default:
+ return (
+
+ Unknown
+
+ );
+ }
+}
+
+export const ReleaseTable = ({environments, releases, loading, error}: ReleaseTableProps) => {
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ const columns: TableColumn[] = [
+ {
+ title: 'Version',
+ field: 'Release.Version',
+ highlight: false,
+ width: 'auto'
+ },
+ ...environments?.map(env => ({
+ title: env.Name,
+ width: 'auto',
+ render: (row: Partial) => {
+ const deploymentsForEnvironment = row.Deployments[env.Id];
+ if (deploymentsForEnvironment) {
+ return (
+
+
+ {getDeploymentStatusComponent(deploymentsForEnvironment[0].State)}
+
+
+ )
+ }
+ }
+ //field: `Deployments.${env.Id}[0].State` // TODO: We'll probably need to deal with multi-tenant deployments here
+ })) ?? []
+ ]
+
+ return (
+
+ Octopus Deploy - Releases ({releases ? releases.length : 0})
+
+ }
+ data={releases ?? []}
+ />
+ )
+}
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/components/ReleaseTable/index.ts b/plugins/octopus-deploy/src/components/ReleaseTable/index.ts
new file mode 100644
index 0000000000..bd0b5bb6b1
--- /dev/null
+++ b/plugins/octopus-deploy/src/components/ReleaseTable/index.ts
@@ -0,0 +1 @@
+export { ReleaseTable } from './ReleaseTable';
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/constants.ts b/plugins/octopus-deploy/src/constants.ts
new file mode 100644
index 0000000000..cec3804d9d
--- /dev/null
+++ b/plugins/octopus-deploy/src/constants.ts
@@ -0,0 +1 @@
+export const OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION = 'octopus.com/project-id';
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/hooks/useReleases.ts b/plugins/octopus-deploy/src/hooks/useReleases.ts
new file mode 100644
index 0000000000..ce38bfb286
--- /dev/null
+++ b/plugins/octopus-deploy/src/hooks/useReleases.ts
@@ -0,0 +1,26 @@
+import { useApi } from "@backstage/core-plugin-api";
+import useAsync from "react-use/lib/useAsync";
+import { octopusDeployApiRef, OctopusEnvironment, OctopusReleaseProgression } from "../api";
+
+export function useReleases(
+ projectId: string,
+ releaseHistoryCount: number
+): {
+ environments?: OctopusEnvironment[],
+ releases?: OctopusReleaseProgression[]
+ loading: boolean;
+ error?: Error;
+} {
+ const api = useApi(octopusDeployApiRef);
+
+ const { value, loading, error } = useAsync(() => {
+ return api.getReleaseProgression(projectId, releaseHistoryCount);
+ }, [ api, projectId, releaseHistoryCount ]);
+
+ return {
+ environments: value?.Environments,
+ releases: value?.Releases,
+ loading,
+ error
+ };
+}
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/index.ts b/plugins/octopus-deploy/src/index.ts
new file mode 100644
index 0000000000..9573438e87
--- /dev/null
+++ b/plugins/octopus-deploy/src/index.ts
@@ -0,0 +1,7 @@
+export {
+ octopusDeployPlugin,
+ EntityOctopusDeployContent,
+ isOctopusDeployAvailable
+ } from './plugin';
+
+ export * from './api';
diff --git a/plugins/octopus-deploy/src/plugin.test.ts b/plugins/octopus-deploy/src/plugin.test.ts
new file mode 100644
index 0000000000..ec8e3f260d
--- /dev/null
+++ b/plugins/octopus-deploy/src/plugin.test.ts
@@ -0,0 +1,7 @@
+import { octopusDeployPlugin } from './plugin';
+
+describe('octopus-deploy', () => {
+ it('should export plugin', () => {
+ expect(octopusDeployPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/octopus-deploy/src/plugin.ts b/plugins/octopus-deploy/src/plugin.ts
new file mode 100644
index 0000000000..c73f4b3aed
--- /dev/null
+++ b/plugins/octopus-deploy/src/plugin.ts
@@ -0,0 +1,49 @@
+import {
+ OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION
+} from './constants';
+import {
+ octopusDeployEntityContentRouteRef
+} from './routes';
+
+import { OctopusDeployClient, octopusDeployApiRef } from './api';
+
+import { createApiFactory, createPlugin, createRoutableExtension, discoveryApiRef, identityApiRef } from '@backstage/core-plugin-api';
+
+import { Entity } from '@backstage/catalog-model';
+
+/** @public */
+export const isOctopusDeployAvailable = (entity: Entity) =>
+ Boolean(entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION]);
+
+export const octopusDeployPlugin = createPlugin({
+ id: 'octopus-deploy',
+ apis: [
+ createApiFactory({
+ api: octopusDeployApiRef,
+ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
+ factory: ({ discoveryApi, identityApi }) => new OctopusDeployClient({ discoveryApi, identityApi })
+ })
+ ]
+});
+
+/*
+export const OctopusDeployPage = octopusDeployPlugin.provide(
+ createRoutableExtension({
+ name: 'OctopusDeployPage',
+ component: () =>
+ import('./components/ExampleComponent').then(m => m.ExampleComponent),
+ mountPoint: rootRouteRef,
+ }),
+);
+*/
+
+export const EntityOctopusDeployContent = octopusDeployPlugin.provide(
+ createRoutableExtension({
+ name: 'EntityOctopusDeployContent',
+ component: () =>
+ import('./components/EntityPageOctopusDeploy').then(
+ m => m.EntityPageOctopusDeploy
+ ),
+ mountPoint: octopusDeployEntityContentRouteRef
+ })
+)
\ No newline at end of file
diff --git a/plugins/octopus-deploy/src/routes.ts b/plugins/octopus-deploy/src/routes.ts
new file mode 100644
index 0000000000..772db37cb6
--- /dev/null
+++ b/plugins/octopus-deploy/src/routes.ts
@@ -0,0 +1,5 @@
+import { createRouteRef } from '@backstage/core-plugin-api';
+
+export const octopusDeployEntityContentRouteRef = createRouteRef({
+ id: 'octopus-deploy-entity-content'
+})
diff --git a/plugins/octopus-deploy/src/setupTests.ts b/plugins/octopus-deploy/src/setupTests.ts
new file mode 100644
index 0000000000..48c09b5346
--- /dev/null
+++ b/plugins/octopus-deploy/src/setupTests.ts
@@ -0,0 +1,2 @@
+import '@testing-library/jest-dom';
+import 'cross-fetch/polyfill';
diff --git a/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts b/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts
new file mode 100644
index 0000000000..b1a8984163
--- /dev/null
+++ b/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts
@@ -0,0 +1,14 @@
+import {
+ OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION
+} from '../constants';
+
+import { Entity } from '@backstage/catalog-model';
+
+export function getAnnotationFromEntity(entity: Entity): string {
+ const annotation = entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION];
+ if (!annotation) {
+ throw new Error('Value for annotation octopus.com/project-id was not found');
+ }
+
+ return annotation;
+}
\ No newline at end of file