Skip to content

Commit

Permalink
feat: PR link in promotion list
Browse files Browse the repository at this point in the history
Signed-off-by: Mayursinh Sarvaiya <[email protected]>
  • Loading branch information
Marvin9 committed Dec 12, 2024
1 parent 6dad3a1 commit 3d763da
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 31 deletions.
23 changes: 13 additions & 10 deletions ui/src/features/stage/promotion-details-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ const Step = ({
};

const UiPlugins = uiPlugins
.filter((plugin) => plugin.DeepLinkPlugin.PromotionStep.shouldRender({ step, result }))
.map((plugin) => plugin.DeepLinkPlugin.PromotionStep.render);
.filter((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.shouldRender({ step, result }))
.map((plugin) => plugin.DeepLinkPlugin?.PromotionStep?.render);

return {
className: classNames('', {
Expand All @@ -130,14 +130,17 @@ const Step = ({
<span className='font-semibold text-base '>{meta.spec.identifier}</span>
{UiPlugins.length > 0 && (
<UiPluginHoles.DeepLinks.PromotionStep className='ml-2'>
{UiPlugins.map((ApplyPlugin, idx) => (
<ApplyPlugin
result={result}
step={step}
output={output as Record<string, unknown>}
key={idx}
/>
))}
{UiPlugins.map(
(ApplyPlugin, idx) =>
ApplyPlugin && (
<ApplyPlugin
result={result}
step={step}
output={output as Record<string, unknown>}
key={idx}
/>
)
)}
</UiPluginHoles.DeepLinks.PromotionStep>
)}
{!!step?.as && (
Expand Down
22 changes: 22 additions & 0 deletions ui/src/features/stage/promotions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/ui-plugin-hole/ui-plugin-holes';

import { PromotionDetailsModal } from './promotion-details-modal';
import { hasAbortRequest, promotionCompareFn } from './utils/promotion';
Expand Down Expand Up @@ -171,6 +173,26 @@ export const Promotions = () => {
</Link>
</Tooltip>
)
},
{
title: 'Extra',
render: (_, promotion) => {
const UiPlugins = uiPlugins
.filter((plugin) => plugin.DeepLinkPlugin?.Promotion?.shouldRender({ promotion }))
.map((plugin) => plugin.DeepLinkPlugin?.Promotion?.render);

if (UiPlugins?.length > 0) {
return (
<UiPluginHoles.DeepLinks.Promotion className='w-fit'>
{UiPlugins.map(
(ApplyPlugin, idx) => ApplyPlugin && <ApplyPlugin key={idx} promotion={promotion} />
)}
</UiPluginHoles.DeepLinks.Promotion>
);
}

return '-';
}
}
];

Expand Down
15 changes: 14 additions & 1 deletion ui/src/plugins/plugin-helper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PromotionStep } from '@ui/gen/v1alpha1/generated_pb';
import { Promotion, PromotionStep } from '@ui/gen/v1alpha1/generated_pb';
import { decodeRawData } from '@ui/utils/decode-raw-data';

export const getPromotionStepConfig = (step: PromotionStep): Record<string, unknown> =>
Expand All @@ -10,3 +10,16 @@ export const getPromotionStepConfig = (step: PromotionStep): Record<string, unkn
}
})
);

export const getPromotionState = (promotion: Promotion): Record<string, Record<string, unknown>> =>
JSON.parse(
decodeRawData({
result: {
case: 'raw',
value: promotion?.status?.state?.raw || new Uint8Array()
}
})
);

export const getPromotionStepAlias = (promotionStep: PromotionStep, stepIndex: string) =>
promotionStep?.as || `step-${stepIndex}`;
12 changes: 9 additions & 3 deletions ui/src/plugins/plugin-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
import { ReactNode } from 'react';

import { PromotionDirectiveStepStatus } from '@ui/features/common/promotion-directive-step-status/utils';
import { PromotionStep } from '@ui/gen/v1alpha1/generated_pb';
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: {
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: {
Expand All @@ -30,11 +30,17 @@ export interface DeepLinkPluginsInstallation {
output?: Record<string, unknown>;
}) => 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;
DeepLinkPlugin?: DeepLinkPluginsInstallation;
}
116 changes: 101 additions & 15 deletions ui/src/plugins/pr-plugin.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
import { faGithub, faGitlab } from '@fortawesome/free-brands-svg-icons';
import { faCodePullRequest } from '@fortawesome/free-solid-svg-icons';
import { faChevronDown, faCodePullRequest } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Flex } from 'antd';
import { Flex, Space } from 'antd';
import Dropdown from 'antd/es/dropdown/dropdown';
import { ReactNode, useMemo } from 'react';

import { PromotionDirectiveStepStatus } from '@ui/features/common/promotion-directive-step-status/utils';
import { PromotionStep } from '@ui/gen/v1alpha1/generated_pb';

import { getPromotionStepConfig } from './plugin-helper';
import { getPromotionState, getPromotionStepAlias, getPromotionStepConfig } from './plugin-helper';
import { PluginsInstallation } from './plugin-interfaces';

const getPullRequestLink = (
promotionStep: PromotionStep,
promotionStepOutput: Record<string, unknown>
) => {
const stepConfig = getPromotionStepConfig(promotionStep);

// plugins responsibility to keep up to date with actual promotion step config
const provider = stepConfig?.provider as 'github' | 'gitlab';

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`
);
}

// url without slash at the end so it is easy to append path
const url = repoURL?.endsWith?.('/') ? repoURL.slice(0, -1) : repoURL;

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`);
};

// TODO: refactor as it is getting bigger
const plugin: PluginsInstallation = {
DeepLinkPlugin: {
PromotionStep: {
Expand All @@ -23,8 +60,6 @@ const plugin: PluginsInstallation = {

const repoURL = stepConfig?.repoURL as string;

const prNumber = props.output?.prNumber;

const nodes: ReactNode[] = [];

if (provider === 'github') {
Expand All @@ -41,24 +76,75 @@ const plugin: PluginsInstallation = {
);
}

if (typeof prNumber === 'number') {
// url without slash at the end so it is easy to append path
let url = repoURL.endsWith('/') ? repoURL.slice(0, -1) : repoURL;
const url = getPullRequestLink(props.step, props.output || {});

nodes.push(
<a href={url} target='_blank'>
<FontAwesomeIcon icon={faCodePullRequest} />
</a>
);

if (provider === 'github') {
url = `${url}/pull/${prNumber}`;
} else if (provider === 'gitlab') {
url = `${url}/merge_requests/${prNumber}`;
return <Flex gap={8}>{nodes}</Flex>;
}
},
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
}
}
}

nodes.push(
<a href={url} target='_blank'>
if (deepLinks?.length === 0) {
return null;
}

if (deepLinks.length === 1) {
// no need to have step context just show the deep link
return (
<a href={deepLinks[0][1]} target='_blank'>
<FontAwesomeIcon icon={faCodePullRequest} />
</a>
);
}

return <Flex gap={8}>{nodes}</Flex>;
return (
<Dropdown
menu={{
items: deepLinks.map((deepLink, idx) => ({
key: idx,
label: (
<a key={idx} href={deepLink[1]} target='_blank'>
{deepLink[0]} - <FontAwesomeIcon icon={faCodePullRequest} />
</a>
)
}))
}}
>
<a onClick={(e) => e.preventDefault()}>
<Space>
PRs
<FontAwesomeIcon icon={faChevronDown} className='text-xs' />
</Space>
</a>
</Dropdown>
);
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion ui/src/plugins/ui-plugin-hole/deep-link-promotion-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ export const DeepLinkPromotionStep = ({
className
}: PropsWithChildren<{ className?: string }>) => {
return (
<div className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm')}>
<div
className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm w-fit')}
onClick={(e) => {
// prevent opening the collapsible menu
e.stopPropagation();
}}
>
{children}
</div>
);
Expand Down
11 changes: 11 additions & 0 deletions ui/src/plugins/ui-plugin-hole/deep-link-promotion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import classNames from 'classnames';
import { PropsWithChildren } from 'react';

export const DeepLinkPromotion = ({
children,
className
}: PropsWithChildren<{ className?: string }>) => (
<div className={classNames(className, 'bg-gray-100 px-2 py-1 rounded-md text-sm')}>
{children}
</div>
);
4 changes: 3 additions & 1 deletion ui/src/plugins/ui-plugin-hole/ui-plugin-holes.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +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
PromotionStep: DeepLinkPromotionStep,
Promotion: DeepLinkPromotion
}
};

0 comments on commit 3d763da

Please sign in to comment.