Просмотр исходного кода

feat(codecov): Add Codecov link to stacktrace link container (#43115)

![image](https://user-images.githubusercontent.com/16563948/211886708-5246dd03-ee21-4f99-90ea-f91b4b6edbd1.png)


![image](https://user-images.githubusercontent.com/16563948/211931990-ac929d0d-637f-4e5e-8c68-49d1ec756cb6.png)


[designs](https://www.figma.com/file/TUVJrwgDvH7q7tkXjskoIv/Codecov-Integration?node-id=3%3A35482&t=CYHOioT7ciNXedqN-0)

WOR-2591

Co-authored-by: Snigdha Sharma <snigdhasharma@sentry.io>
Co-authored-by: Scott Cooper <scttcper@gmail.com>
Snigdha Sharma 2 лет назад
Родитель
Сommit
23c9b58e45

+ 48 - 1
static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx

@@ -1,7 +1,7 @@
 import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
 import ProjectsStore from 'sentry/stores/projectsStore';
-import type {Frame} from 'sentry/types';
+import {CodecovStatusCode, Frame} from 'sentry/types';
 import * as analytics from 'sentry/utils/integrationUtil';
 
 import {StacktraceLink} from './stacktraceLink';
@@ -197,4 +197,51 @@ describe('StacktraceLink', function () {
       expect(container).toBeEmptyDOMElement();
     });
   });
+
+  it('renders the codecov link', async function () {
+    const organization = {
+      ...org,
+      features: ['codecov-stacktrace-integration'],
+      codecovAccess: true,
+    };
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config,
+        sourceUrl: 'https://github.com/username/path/to/file.py',
+        integrations: [integration],
+        codecovStatusCode: CodecovStatusCode.COVERAGE_EXISTS,
+      },
+    });
+    render(<StacktraceLink frame={frame} event={event} line="foo()" />, {
+      context: TestStubs.routerContext(),
+      organization,
+    });
+    expect(await screen.findByText('View Coverage Tests on Codecov')).toHaveAttribute(
+      'href',
+      'https://app.codecov.io/gh/path/to/file.py'
+    );
+  });
+
+  it('renders the missing coverage warning', async function () {
+    const organization = {
+      ...org,
+      features: ['codecov-stacktrace-integration'],
+      codecovAccess: true,
+    };
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config,
+        sourceUrl: 'https://github.com/username/path/to/file.py',
+        integrations: [integration],
+        codecovStatusCode: CodecovStatusCode.NO_COVERAGE_DATA,
+      },
+    });
+    render(<StacktraceLink frame={frame} event={event} line="foo()" />, {
+      context: TestStubs.routerContext(),
+      organization,
+    });
+    expect(await screen.findByText('Code Coverage not found')).toBeInTheDocument();
+  });
 });

+ 72 - 2
static/app/components/events/interfaces/frame/stacktraceLink.tsx

@@ -14,10 +14,17 @@ import ExternalLink from 'sentry/components/links/externalLink';
 import Link from 'sentry/components/links/link';
 import Placeholder from 'sentry/components/placeholder';
 import type {PlatformKey} from 'sentry/data/platformCategories';
-import {IconClose} from 'sentry/icons/iconClose';
+import {IconClose, IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import type {Event, Frame, Organization, Project} from 'sentry/types';
+import {
+  CodecovStatusCode,
+  Event,
+  Frame,
+  Organization,
+  Project,
+  StacktraceLinkResult,
+} from 'sentry/types';
 import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
 import {getAnalyicsDataForEvent} from 'sentry/utils/events';
 import {
@@ -100,6 +107,56 @@ function StacktraceLinkSetup({organization, project, event}: StacktraceLinkSetup
   );
 }
 
+function shouldshowCodecovFeatures(
+  organization: Organization,
+  match: StacktraceLinkResult
+) {
+  const enabled =
+    organization.features.includes('codecov-stacktrace-integration') &&
+    organization.codecovAccess;
+
+  const validStatus = [
+    CodecovStatusCode.COVERAGE_EXISTS,
+    CodecovStatusCode.NO_COVERAGE_DATA,
+  ].includes(match.codecovStatusCode!);
+
+  return enabled && validStatus && match.config?.provider.key === 'github';
+}
+
+interface CodecovLinkProps {
+  sourceUrl: string | undefined;
+  codecovStatusCode?: CodecovStatusCode;
+}
+
+function CodecovLink({sourceUrl, codecovStatusCode}: CodecovLinkProps) {
+  if (codecovStatusCode === CodecovStatusCode.NO_COVERAGE_DATA) {
+    return (
+      <CodecovWarning>
+        {t('Code Coverage not found')}
+        <IconWarning size="xs" color="errorText" />
+      </CodecovWarning>
+    );
+  }
+
+  if (codecovStatusCode === CodecovStatusCode.COVERAGE_EXISTS) {
+    if (!sourceUrl) {
+      return null;
+    }
+    const codecovUrl = sourceUrl.replaceAll(
+      new RegExp('github.com/.[^/]*', 'g'),
+      'app.codecov.io/gh'
+    );
+
+    return (
+      <OpenInLink href={codecovUrl} openInNewTab>
+        {t('View Coverage Tests on Codecov')}
+        <StyledIconWrapper>{getIntegrationIcon('codecov', 'sm')}</StyledIconWrapper>
+      </OpenInLink>
+    );
+  }
+  return null;
+}
+
 interface StacktraceLinkProps {
   event: Event;
   frame: Frame;
@@ -223,6 +280,12 @@ export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
           </StyledIconWrapper>
           {t('Open this line in %s', match.config.provider.name)}
         </OpenInLink>
+        {shouldshowCodecovFeatures(organization, match) && (
+          <CodecovLink
+            sourceUrl={match.sourceUrl}
+            codecovStatusCode={match.codecovStatusCode}
+          />
+        )}
       </CodeMappingButtonContainer>
     );
   }
@@ -327,3 +390,10 @@ const StyledLink = styled(Link)`
   ${LinkStyles}
   color: ${p => p.theme.gray300};
 `;
+
+const CodecovWarning = styled('div')`
+  display: flex;
+  color: ${p => p.theme.errorText};
+  gap: ${space(0.75)};
+  align-items: center;
+`;

+ 15 - 0
static/app/icons/iconCodecov.tsx

@@ -0,0 +1,15 @@
+import {forwardRef} from 'react';
+
+import {SvgIcon, SVGIconProps} from './svgIcon';
+
+const IconCodecov = forwardRef<SVGSVGElement, SVGIconProps>((props, ref) => {
+  return (
+    <SvgIcon {...props} ref={ref} viewBox="0 0 24 24">
+      <path d="M12.006.481C5.391.486.005 5.831 0 12.399v.03l2.042 1.19.028-.018a5.82 5.82 0 0 1 3.308-1.02c.37 0 .733.034 1.085.1l-.036-.006a5.69 5.69 0 0 1 2.874 1.43l-.004-.002.35.326.198-.434c.192-.42.414-.814.66-1.173.1-.144.208-.29.332-.446l.205-.257-.252-.211a8.33 8.33 0 0 0-3.836-1.807l-.052-.008a8.565 8.565 0 0 0-4.08.251l.06-.016c.972-4.256 4.714-7.223 9.133-7.226a9.31 9.31 0 0 1 6.6 2.713 9.196 9.196 0 0 1 2.508 4.498 8.385 8.385 0 0 0-2.498-.379h-.154c-.356.006-.7.033-1.036.078l.045-.005-.042.006a8.103 8.103 0 0 0-.39.06c-.057.01-.114.022-.17.033a8.102 8.102 0 0 0-.392.09l-.138.034a9.21 9.21 0 0 0-.483.144l-.03.01c-.354.12-.708.268-1.05.44l-.027.013a8.41 8.41 0 0 0-.47.256l-.035.022a8.216 8.216 0 0 0-2.108 1.8l-.011.014-.075.092a8.345 8.345 0 0 0-.378.503c-.088.13-.177.269-.288.452l-.06.104a8.985 8.985 0 0 0-.234.432l-.016.029c-.17.34-.317.698-.44 1.063l-.017.053a8.052 8.052 0 0 0-.41 2.716v-.007.112a12 12 0 0 0 .023.431l-.002-.037a11.676 11.676 0 0 0 .042.412l.005.042.013.103c.018.127.038.252.062.378.241 1.266.845 2.532 1.745 3.66l.041.051.042-.05c.359-.424 1.249-1.77 1.325-2.577v-.015l-.006-.013a5.56 5.56 0 0 1-.64-2.595c0-3.016 2.37-5.521 5.396-5.702l.2-.007a5.93 5.93 0 0 1 3.47 1.025l.027.019L24 12.416v-.03a11.77 11.77 0 0 0-3.51-8.423A11.962 11.962 0 0 0 12.007.48z" />
+    </SvgIcon>
+  );
+});
+
+IconCodecov.displayName = 'IconCodecov';
+
+export {IconCodecov};

+ 1 - 0
static/app/icons/index.tsx

@@ -14,6 +14,7 @@ export {IconCircle} from './iconCircle';
 export {IconClock} from './iconClock';
 export {IconClose} from './iconClose';
 export {IconCode} from './iconCode';
+export {IconCodecov} from './iconCodecov';
 export {IconCommit} from './iconCommit';
 export {IconContract} from './iconContract';
 export {IconCopy} from './iconCopy';

+ 3 - 0
static/app/utils/integrationUtil.tsx

@@ -5,6 +5,7 @@ import {Result} from 'sentry/components/forms/controls/selectAsyncControl';
 import {
   IconAsana,
   IconBitbucket,
+  IconCodecov,
   IconGeneric,
   IconGithub,
   IconGitlab,
@@ -217,6 +218,8 @@ export const getIntegrationIcon = (
       return <IconJira size={iconSize} />;
     case 'vsts':
       return <IconVsts size={iconSize} />;
+    case 'codecov':
+      return <IconCodecov size={iconSize} />;
     default:
       return <IconGeneric size={iconSize} />;
   }