From b24c1d2a2ccf0db20d18ffa33199f2121757ec0c Mon Sep 17 00:00:00 2001 From: Mayursinh Sarvaiya Date: Tue, 17 Dec 2024 16:51:56 -0400 Subject: [PATCH] feat(ui): PR deep links in Promotion steps and in Promotion lists (#3129) Signed-off-by: Mayursinh Sarvaiya --- ui/package.json | 2 + ui/pnpm-lock.yaml | 59 +++++++++++++++ ui/src/features/common/manifest-preview.tsx | 4 ++ .../promotion-directives/registry/types.ts | 2 +- .../registry/use-discover-registries.ts | 44 +----------- ui/src/features/stage/create-stage.tsx | 4 +- .../stage/promotion-details-modal.tsx | 47 +++++++----- ui/src/features/stage/promotions.tsx | 22 ++++++ ui/src/plugins/README.md | 3 + ui/src/plugins/atoms/plugin-helper.ts | 25 +++++++ ui/src/plugins/atoms/plugin-interfaces.ts | 46 ++++++++++++ .../deep-link-promotion-step.tsx | 23 ++++++ .../ui-plugin-hole/deep-link-promotion.tsx | 15 ++++ .../atoms/ui-plugin-hole/error-boundary.tsx | 41 +++++++++++ .../atoms/ui-plugin-hole/ui-plugin-holes.tsx | 11 +++ ui/src/plugins/index.tsx | 6 ++ .../pr-plugin/deep-link/promotion-step.tsx | 53 ++++++++++++++ .../plugins/pr-plugin/deep-link/promotion.tsx | 71 +++++++++++++++++++ ui/src/plugins/pr-plugin/get-pr-link.ts | 40 +++++++++++ ui/src/plugins/pr-plugin/pr-plugin.tsx | 13 ++++ 20 files changed, 468 insertions(+), 63 deletions(-) create mode 100644 ui/src/plugins/README.md create mode 100644 ui/src/plugins/atoms/plugin-helper.ts create mode 100644 ui/src/plugins/atoms/plugin-interfaces.ts create mode 100644 ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion-step.tsx create mode 100644 ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion.tsx create mode 100644 ui/src/plugins/atoms/ui-plugin-hole/error-boundary.tsx create mode 100644 ui/src/plugins/atoms/ui-plugin-hole/ui-plugin-holes.tsx create mode 100644 ui/src/plugins/index.tsx create mode 100644 ui/src/plugins/pr-plugin/deep-link/promotion-step.tsx create mode 100644 ui/src/plugins/pr-plugin/deep-link/promotion.tsx create mode 100644 ui/src/plugins/pr-plugin/get-pr-link.ts create mode 100644 ui/src/plugins/pr-plugin/pr-plugin.tsx diff --git a/ui/package.json b/ui/package.json index 156eecc4d..411605a7a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "@eslint/compat": "^1.2.4", "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", "@tanstack/react-query-devtools": "^5.62.7", + "@types/git-url-parse": "^9.0.3", "@types/json-schema": "^7.0.15", "@types/node": "^22.10.2", "@types/react": "^18.3.11", @@ -72,6 +73,7 @@ "classnames": "^2.5.1", "dagre": "^0.8.5", "date-fns": "^4.1.0", + "git-url-parse": "^16.0.0", "moment": "^2.30.1", "monaco-editor": "^0.52.2", "monaco-yaml": "^5.2.3", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 4b30d2147..308527981 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + git-url-parse: + specifier: ^16.0.0 + version: 16.0.0 moment: specifier: ^2.30.1 version: 2.30.1 @@ -120,6 +123,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.62.7 version: 5.62.7(@tanstack/react-query@5.62.7(react@18.3.1))(react@18.3.1) + '@types/git-url-parse': + specifier: ^9.0.3 + version: 9.0.3 '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 @@ -1071,6 +1077,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/git-url-parse@9.0.3': + resolution: {integrity: sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1089,6 +1098,9 @@ packages: '@types/node@22.10.2': resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} + '@types/parse-path@7.0.3': + resolution: {integrity: sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==} + '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -1935,6 +1947,12 @@ packages: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} + git-up@8.0.0: + resolution: {integrity: sha512-uBI8Zdt1OZlrYfGcSVroLJKgyNNXlgusYFzHk614lTasz35yg2PVpL1RMy0LOO2dcvF9msYW3pRfUSmafZNrjg==} + + git-url-parse@16.0.0: + resolution: {integrity: sha512-Y8iAF0AmCaqXc6a5GYgPQW9ESbncNLOL+CeQAJRhmWUOmnPkKpBYeWYp4mFd3LA5j53CdGDdslzX12yEBVHQQg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2121,6 +2139,9 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} + is-ssh@1.4.0: + resolution: {integrity: sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==} + is-string@1.1.0: resolution: {integrity: sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==} engines: {node: '>= 0.4'} @@ -2470,6 +2491,13 @@ packages: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} + parse-path@7.0.0: + resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==} + + parse-url@9.2.0: + resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} + engines: {node: '>=14.13.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2749,6 +2777,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protocols@2.0.1: + resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -4408,6 +4439,8 @@ snapshots: '@types/node': 22.10.2 optional: true + '@types/git-url-parse@9.0.3': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -4427,6 +4460,8 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/parse-path@7.0.3': {} + '@types/prop-types@15.7.13': {} '@types/react-dom@18.3.0': @@ -5576,6 +5611,15 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.2.6 + git-up@8.0.0: + dependencies: + is-ssh: 1.4.0 + parse-url: 9.2.0 + + git-url-parse@16.0.0: + dependencies: + git-up: 8.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5744,6 +5788,10 @@ snapshots: dependencies: call-bind: 1.0.8 + is-ssh@1.4.0: + dependencies: + protocols: 2.0.1 + is-string@1.1.0: dependencies: call-bind: 1.0.8 @@ -6097,6 +6145,15 @@ snapshots: parse-node-version@1.0.1: {} + parse-path@7.0.0: + dependencies: + protocols: 2.0.1 + + parse-url@9.2.0: + dependencies: + '@types/parse-path': 7.0.3 + parse-path: 7.0.0 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -6333,6 +6390,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protocols@2.0.1: {} + prr@1.0.1: optional: true diff --git a/ui/src/features/common/manifest-preview.tsx b/ui/src/features/common/manifest-preview.tsx index 5946db9fb..757d3796c 100644 --- a/ui/src/features/common/manifest-preview.tsx +++ b/ui/src/features/common/manifest-preview.tsx @@ -12,6 +12,10 @@ export const ManifestPreview = ({ height?: string; }) => { const encodedObject = yaml.stringify(object.toJson(), (_, v) => { + if (!v) { + return; + } + if (typeof v === 'string' && v === '') { return; } diff --git a/ui/src/features/promotion-directives/registry/types.ts b/ui/src/features/promotion-directives/registry/types.ts index 3ec49fef1..5f3c068c1 100644 --- a/ui/src/features/promotion-directives/registry/types.ts +++ b/ui/src/features/promotion-directives/registry/types.ts @@ -15,6 +15,6 @@ export type Runner = { identifier: string; // UI helper // this accepts font-awesome icon - unstable_icons: IconDefinition[]; + unstable_icons?: IconDefinition[]; config: JSONSchema7; }; diff --git a/ui/src/features/promotion-directives/registry/use-discover-registries.ts b/ui/src/features/promotion-directives/registry/use-discover-registries.ts index 36e3351be..c94c4c4af 100644 --- a/ui/src/features/promotion-directives/registry/use-discover-registries.ts +++ b/ui/src/features/promotion-directives/registry/use-discover-registries.ts @@ -1,29 +1,3 @@ -import { - faArrowUp, - faChartLine, - faCheck, - faClock, - faClone, - faCloudUploadAlt, - faCodeBranch, - faCodePullRequest, - faCopy, - faDraftingCompass, - faEdit, - faExchangeAlt, - faFileCode, - faFileEdit, - faFileImage, - faGlobe, - faHammer, - faHeart, - faNetworkWired, - faRedoAlt, - faSyncAlt, - faUpDown, - faUpload, - faWrench -} from '@fortawesome/free-solid-svg-icons'; import { JSONSchema7 } from 'json-schema'; // IMPORTANT(Marvin9): this must be replaced with proper discovery mechanism @@ -53,82 +27,66 @@ export const useDiscoverPromotionDirectivesRegistries = (): PromotionDirectivesR runners: [ { identifier: 'argocd-update', - unstable_icons: [faUpDown, faHeart], config: argocdUpdateConfig as JSONSchema7 }, { identifier: 'copy', - unstable_icons: [faCopy], config: copyConfig as JSONSchema7 }, { identifier: 'git-clone', - unstable_icons: [faCodeBranch, faClone], config: gitCloneConfig as JSONSchema7 }, { identifier: 'git-push', - unstable_icons: [faArrowUp, faCloudUploadAlt, faUpload], config: gitPushConfig as JSONSchema7 }, { identifier: 'git-commit', - unstable_icons: [faCheck, faCodeBranch], config: gitCommitConfig as unknown as JSONSchema7 }, { identifier: 'git-open-pr', - unstable_icons: [faCodePullRequest, faFileCode], config: gitOpenPR as unknown as JSONSchema7 }, { identifier: 'git-wait-for-pr', - unstable_icons: [faClock, faCodePullRequest], config: gitWaitForPR as unknown as JSONSchema7 }, { identifier: 'yaml-update', - config: yamlUpdateConfig as unknown as JSONSchema7, - unstable_icons: [faFileCode, faSyncAlt, faEdit] + config: yamlUpdateConfig as unknown as JSONSchema7 }, { identifier: 'git-push', - unstable_icons: [faArrowUp, faCloudUploadAlt], config: gitPushConfig as unknown as JSONSchema7 }, { identifier: 'git-clear', - unstable_icons: [faRedoAlt, faCodeBranch], config: gitOverwriteConfig as JSONSchema7 }, { identifier: 'helm-update-chart', - unstable_icons: [faSyncAlt, faChartLine], config: helmUpdateChartConfig as JSONSchema7 }, { identifier: 'helm-update-image', - unstable_icons: [faSyncAlt, faFileImage], config: helmUpdateImageConfig as JSONSchema7 }, { identifier: 'helm-template', - unstable_icons: [faFileCode, faDraftingCompass], config: helmTemplateConfig as JSONSchema7 }, { identifier: 'kustomize-build', - unstable_icons: [faWrench, faHammer], config: kustomizeBuildConfig as JSONSchema7 }, { identifier: 'kustomize-set-image', - unstable_icons: [faFileImage, faFileEdit], config: kustomizeSetImageConfig as JSONSchema7 }, { identifier: 'http', - unstable_icons: [faGlobe, faNetworkWired, faExchangeAlt], config: httpConfig as JSONSchema7 } ] diff --git a/ui/src/features/stage/create-stage.tsx b/ui/src/features/stage/create-stage.tsx index 1bebcc210..fd74fa712 100644 --- a/ui/src/features/stage/create-stage.tsx +++ b/ui/src/features/stage/create-stage.tsx @@ -23,6 +23,7 @@ import { FieldContainer } from '@ui/features/common/form/field-container'; import schema from '@ui/gen/schema/stages.kargo.akuity.io_v1alpha1.json'; import { createResource } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; import { PromotionStep, Stage } from '@ui/gen/v1alpha1/generated_pb'; +import { cleanEmptyObjectValues } from '@ui/utils/helpers'; import { zodValidators } from '@ui/utils/validators'; import { getStageYAMLExample } from '../project/pipelines/utils/stage-yaml-example'; @@ -67,7 +68,8 @@ const stageFormToYAML = ( spec: { requestedFreight: data.requestedFreight, ...(promotionTemplateSteps?.length > 0 && { - promotionTemplate: { spec: { steps: promotionTemplateSteps } } + // IMPORTANT TO CLEANUP EMPTY VALUES OR UNEXPECTED CONFIG FOR PROMOTION STEP WOULD HAPPEN + promotionTemplate: { spec: cleanEmptyObjectValues({ steps: promotionTemplateSteps }) } }) } }); diff --git a/ui/src/features/stage/promotion-details-modal.tsx b/ui/src/features/stage/promotion-details-modal.tsx index 5bd0ff284..ee52579ba 100644 --- a/ui/src/features/stage/promotion-details-modal.tsx +++ b/ui/src/features/stage/promotion-details-modal.tsx @@ -28,16 +28,18 @@ import { Runner } from '@ui/features/promotion-directives/registry/types'; import { canAbortPromotion } from '@ui/features/stage/utils/promotion'; import { abortPromotion } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb'; +import uiPlugins from '@ui/plugins'; +import { UiPluginHoles } from '@ui/plugins/atoms/ui-plugin-hole/ui-plugin-holes'; import { decodeRawData } from '@ui/utils/decode-raw-data'; const Step = ({ step, result, - logs + output }: { step: PromotionStep; result: PromotionDirectiveStepStatus; - logs?: object; + output?: object; }) => { const [showDetails, setShowDetails] = useState(false); @@ -75,7 +77,7 @@ const Step = ({ const opts: SegmentedOptions = []; - if (logs) { + if (output) { opts.push({ label: 'Output', value: 'output', @@ -100,9 +102,13 @@ const Step = ({ const yamlView = { config: meta?.config, - output: logs ? JSON.stringify(logs || {}, null, ' ') : '' + output: output ? JSON.stringify(output || {}, null, ' ') : '' }; + const filteredUiPlugins = uiPlugins + .filter((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.shouldRender({ step, result })) + .map((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.render); + return { className: classNames('', { 'border-green-500': progressing, @@ -120,23 +126,28 @@ const Step = ({ {success && } {failed && } - - {meta.spec.identifier} + + {meta.spec.identifier} + {filteredUiPlugins.length > 0 && ( + + {filteredUiPlugins.map( + (ApplyPlugin, idx) => + ApplyPlugin && ( + } + key={idx} + /> + ) + )} + + )} {!!step?.as && ( {step.as} )} - - - {meta.spec.unstable_icons.map((icon, i) => ( - - ))} - - ), @@ -178,7 +189,7 @@ export const PromotionDetailsModal = ({ }) }); - const logsByStepAlias: Record = useMemo(() => { + const outputsByStepAlias: Record = useMemo(() => { if (promotion?.status?.state?.raw) { try { const raw = decodeRawData({ result: { case: 'raw', value: promotion.status.state.raw } }); @@ -258,7 +269,7 @@ export const PromotionDetailsModal = ({ return Step({ step, result: getPromotionDirectiveStepStatus(i, promotion.status), - logs: logsByStepAlias?.[step?.as || ''] + output: outputsByStepAlias?.[step?.as || ''] }); })} /> diff --git a/ui/src/features/stage/promotions.tsx b/ui/src/features/stage/promotions.tsx index ceda7deb0..94c633099 100644 --- a/ui/src/features/stage/promotions.tsx +++ b/ui/src/features/stage/promotions.tsx @@ -21,6 +21,8 @@ import { import { KargoService } from '@ui/gen/service/v1alpha1/service_connect'; import { ListPromotionsResponse } from '@ui/gen/service/v1alpha1/service_pb'; import { Freight, Promotion } from '@ui/gen/v1alpha1/generated_pb'; +import uiPlugins from '@ui/plugins'; +import { UiPluginHoles } from '@ui/plugins/atoms/ui-plugin-hole/ui-plugin-holes'; import { PromotionDetailsModal } from './promotion-details-modal'; import { hasAbortRequest, promotionCompareFn } from './utils/promotion'; @@ -171,6 +173,26 @@ export const Promotions = () => { ) + }, + { + title: '', + render: (_, promotion) => { + const filteredUiPlugins = uiPlugins + .filter((plugin) => plugin.DeepLinkPlugin?.Promotion?.shouldRender({ promotion })) + .map((plugin) => plugin.DeepLinkPlugin?.Promotion?.render); + + if (filteredUiPlugins?.length > 0) { + return ( + + {filteredUiPlugins.map( + (ApplyPlugin, idx) => ApplyPlugin && + )} + + ); + } + + return '-'; + } } ]; diff --git a/ui/src/plugins/README.md b/ui/src/plugins/README.md new file mode 100644 index 000000000..080711672 --- /dev/null +++ b/ui/src/plugins/README.md @@ -0,0 +1,3 @@ +> NOTE: This isn't actual on-the-fly plugin architecture but type of separation that will make future work easier to split when time arrive and UI learns as much as it can till then. + + diff --git a/ui/src/plugins/atoms/plugin-helper.ts b/ui/src/plugins/atoms/plugin-helper.ts new file mode 100644 index 000000000..4d9524258 --- /dev/null +++ b/ui/src/plugins/atoms/plugin-helper.ts @@ -0,0 +1,25 @@ +import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb'; +import { decodeRawData } from '@ui/utils/decode-raw-data'; + +export const getPromotionStepConfig = (step: PromotionStep): Record => + JSON.parse( + decodeRawData({ + result: { + case: 'raw', + value: step?.config?.raw || new Uint8Array() + } + }) + ); + +export const getPromotionState = (promotion: Promotion): Record> => + JSON.parse( + decodeRawData({ + result: { + case: 'raw', + value: promotion?.status?.state?.raw || new Uint8Array() + } + }) + ); + +export const getPromotionStepAlias = (promotionStep: PromotionStep, stepIndex: string) => + promotionStep?.as || `step-${stepIndex}`; diff --git a/ui/src/plugins/atoms/plugin-interfaces.ts b/ui/src/plugins/atoms/plugin-interfaces.ts new file mode 100644 index 000000000..57357456f --- /dev/null +++ b/ui/src/plugins/atoms/plugin-interfaces.ts @@ -0,0 +1,46 @@ +// 1. Use case: Deep links +// Scopes: +// - PromotionStep: +// Plugin generates information from given plugin step and renders in the step view in Promotion steps modal +// - Promotion: +// Plugin summarises promotion and provide useful link(s) and renders in the promotion list view +// - Stage: +// Plugin summarises stage and provide useful link(s) and renders at the top in stage details page +// - Pipeline Stage: +// Similar to stage but since this is in Pipeline and directly accessible so needs carefully show + +import { ReactNode } from 'react'; + +import { PromotionDirectiveStepStatus } from '@ui/features/common/promotion-directive-step-status/utils'; +import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb'; + +export interface DeepLinkPluginsInstallation { + // Scopes + + // Plugin generates information from given plugin step and renders in the step view in Promotion steps modal + PromotionStep?: { + // thoughts.. instead of coupling this to name of step, this would open doors for plugin development based on combination of promotion steps + shouldRender: (opts: { step: PromotionStep; result: PromotionDirectiveStepStatus }) => boolean; + render: (props: { + // step metadata + step: PromotionStep; + // flexible to render based on status + result: PromotionDirectiveStepStatus; + // output from steps + output?: Record; + }) => ReactNode; + }; + + // Plugin summarises promotion and provide useful link(s) and renders in the promotion list view + Promotion?: { + shouldRender: (opts: { promotion: Promotion }) => boolean; + render: (props: { promotion: Promotion }) => ReactNode; + }; +} + +export interface PluginsInstallation { + identity?: string; + description?: string; + + DeepLinkPlugin?: DeepLinkPluginsInstallation; +} diff --git a/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion-step.tsx b/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion-step.tsx new file mode 100644 index 000000000..8bccc4e54 --- /dev/null +++ b/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion-step.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import { PropsWithChildren } from 'react'; + +import { PluginErrorBoundary } from './error-boundary'; + +export const DeepLinkPromotionStep = ({ + children, + className +}: PropsWithChildren<{ className?: string }>) => { + return ( + +
{ + // prevent opening the collapsible menu + e.stopPropagation(); + }} + > + {children} +
+
+ ); +}; diff --git a/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion.tsx b/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion.tsx new file mode 100644 index 000000000..f8681d68c --- /dev/null +++ b/ui/src/plugins/atoms/ui-plugin-hole/deep-link-promotion.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames'; +import { PropsWithChildren } from 'react'; + +import { PluginErrorBoundary } from './error-boundary'; + +export const DeepLinkPromotion = ({ + children, + className +}: PropsWithChildren<{ className?: string }>) => ( + +
+ {children} +
+
+); diff --git a/ui/src/plugins/atoms/ui-plugin-hole/error-boundary.tsx b/ui/src/plugins/atoms/ui-plugin-hole/error-boundary.tsx new file mode 100644 index 000000000..3162a337d --- /dev/null +++ b/ui/src/plugins/atoms/ui-plugin-hole/error-boundary.tsx @@ -0,0 +1,41 @@ +import { Badge, Tooltip } from 'antd'; +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children?: ReactNode; +} + +interface State { + hasError: boolean; +} + +export class PluginErrorBoundary extends Component { + public state: State = { + hasError: false + }; + + public static getDerivedStateFromError(): State { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // eslint-disable-next-line no-console + console.error('Plugin error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( + + + + ); + } + + return this.props.children; + } +} diff --git a/ui/src/plugins/atoms/ui-plugin-hole/ui-plugin-holes.tsx b/ui/src/plugins/atoms/ui-plugin-hole/ui-plugin-holes.tsx new file mode 100644 index 000000000..54f2fb508 --- /dev/null +++ b/ui/src/plugins/atoms/ui-plugin-hole/ui-plugin-holes.tsx @@ -0,0 +1,11 @@ +// the UI space for plugins but with limit in terms of layout + +import { DeepLinkPromotion } from './deep-link-promotion'; +import { DeepLinkPromotionStep } from './deep-link-promotion-step'; + +export const UiPluginHoles = { + DeepLinks: { + PromotionStep: DeepLinkPromotionStep, + Promotion: DeepLinkPromotion + } +}; diff --git a/ui/src/plugins/index.tsx b/ui/src/plugins/index.tsx new file mode 100644 index 000000000..2f67feb88 --- /dev/null +++ b/ui/src/plugins/index.tsx @@ -0,0 +1,6 @@ +import { PluginsInstallation } from './atoms/plugin-interfaces'; +import prPlugin from './pr-plugin/pr-plugin'; + +const plugins: PluginsInstallation[] = [prPlugin]; + +export default plugins; diff --git a/ui/src/plugins/pr-plugin/deep-link/promotion-step.tsx b/ui/src/plugins/pr-plugin/deep-link/promotion-step.tsx new file mode 100644 index 000000000..500581d1e --- /dev/null +++ b/ui/src/plugins/pr-plugin/deep-link/promotion-step.tsx @@ -0,0 +1,53 @@ +import { faGithub, faGitlab } from '@fortawesome/free-brands-svg-icons'; +import { faCodePullRequest } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Flex } from 'antd'; +import { ReactNode, useMemo } from 'react'; + +import { PromotionDirectiveStepStatus } from '@ui/features/common/promotion-directive-step-status/utils'; +import { getPromotionStepConfig } from '@ui/plugins/atoms/plugin-helper'; +import { DeepLinkPluginsInstallation } from '@ui/plugins/atoms/plugin-interfaces'; + +import { getPullRequestLink } from '../get-pr-link'; + +const plugin: DeepLinkPluginsInstallation['PromotionStep'] = { + shouldRender({ step, result }) { + return result === PromotionDirectiveStepStatus.SUCCESS && step?.uses === 'git-open-pr'; + }, + render(props) { + const stepConfig = useMemo(() => getPromotionStepConfig(props.step), [props.step]); + + // plugins responsibility to keep up to date with actual promotion step config + const provider = (stepConfig?.provider as 'github' | 'gitlab') || 'github'; + + const repoURL = stepConfig?.repoURL as string; + + const nodes: ReactNode[] = []; + + if (provider === 'github') { + nodes.push( + + + + ); + } else if (provider === 'gitlab') { + nodes.push( + + + + ); + } + + const url = getPullRequestLink(props.step, props.output || {}); + + nodes.push( + + + + ); + + return {nodes}; + } +}; + +export default plugin; diff --git a/ui/src/plugins/pr-plugin/deep-link/promotion.tsx b/ui/src/plugins/pr-plugin/deep-link/promotion.tsx new file mode 100644 index 000000000..9ccb30fdb --- /dev/null +++ b/ui/src/plugins/pr-plugin/deep-link/promotion.tsx @@ -0,0 +1,71 @@ +import { faChevronDown, faCodePullRequest } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Dropdown, Space } from 'antd'; + +import { getPromotionState, getPromotionStepAlias } from '@ui/plugins/atoms/plugin-helper'; +import { DeepLinkPluginsInstallation } from '@ui/plugins/atoms/plugin-interfaces'; + +import { getPullRequestLink } from '../get-pr-link'; + +const plugin: DeepLinkPluginsInstallation['Promotion'] = { + shouldRender(opts) { + return Boolean(opts.promotion?.spec?.steps?.find((step) => step?.uses === 'git-open-pr')); + }, + render(props) { + // array of [step-alias, deep-link] + const deepLinks: string[][] = []; + + const promotionState = getPromotionState(props.promotion); + + for (const [idx, step] of Object.entries(props.promotion?.spec?.steps || [])) { + if (step?.uses === 'git-open-pr') { + const alias = getPromotionStepAlias(step, idx); + + try { + const deepLink = getPullRequestLink(step, promotionState[alias]); + + deepLinks.push([alias, deepLink]); + } catch { + // TODO: failed to get deep link.. most probably due to invalid config/output + } + } + } + + if (deepLinks?.length === 0) { + return null; + } + + if (deepLinks.length === 1) { + // no need to have step context just show the deep link + return ( + + + + ); + } + + return ( + ({ + key: idx, + label: ( + + {deepLink[0]} - + + ) + })) + }} + > + e.preventDefault()}> + + PRs + + + + + ); + } +}; + +export default plugin; diff --git a/ui/src/plugins/pr-plugin/get-pr-link.ts b/ui/src/plugins/pr-plugin/get-pr-link.ts new file mode 100644 index 000000000..647140f3f --- /dev/null +++ b/ui/src/plugins/pr-plugin/get-pr-link.ts @@ -0,0 +1,40 @@ +import gitUrlParse from 'git-url-parse'; + +import { PromotionStep } from '@ui/gen/v1alpha1/generated_pb'; +import { getPromotionStepConfig } from '@ui/plugins/atoms/plugin-helper'; + +export const getPullRequestLink = ( + promotionStep: PromotionStep, + promotionStepOutput: Record +) => { + const stepConfig = getPromotionStepConfig(promotionStep); + + // plugins responsibility to keep up to date with actual promotion step config + const provider = + (stepConfig?.provider as 'github' | 'gitlab') || 'github'; /* default provider is github */ + + const repoURL = stepConfig?.repoURL as string; + + const prNumber = promotionStepOutput?.prNumber; + + if (typeof prNumber !== 'number' || !repoURL) { + // TODO: Throw plugin identified error to handle it at plugin level + throw new Error( + `cannot generate pull request link, either promotion step didn't return proper output or promotion step config is corrupted` + ); + } + + const parsedUrl = gitUrlParse(repoURL); + + const url = `${parsedUrl.protocol}://${parsedUrl.resource}/${parsedUrl.full_name}`; + + if (provider === 'github') { + return `${url}/pull/${prNumber}`; + } + + if (provider === 'gitlab') { + return `${url}/merge_requests/${prNumber}`; + } + + throw new Error(`provider ${provider} not supported by this plugin`); +}; diff --git a/ui/src/plugins/pr-plugin/pr-plugin.tsx b/ui/src/plugins/pr-plugin/pr-plugin.tsx new file mode 100644 index 000000000..676691f11 --- /dev/null +++ b/ui/src/plugins/pr-plugin/pr-plugin.tsx @@ -0,0 +1,13 @@ +import { PluginsInstallation } from '../atoms/plugin-interfaces'; + +import promotionDeepLinkPlugin from './deep-link/promotion'; +import promotionStepDeepLinkPlugin from './deep-link/promotion-step'; + +const plugin: PluginsInstallation = { + DeepLinkPlugin: { + PromotionStep: promotionStepDeepLinkPlugin, + Promotion: promotionDeepLinkPlugin + } +}; + +export default plugin;