Browse Source

feat(ecosystem): Add codeowners file modal, link to file (#45972)

Scott Cooper 2 years ago
parent
commit
02e279220c

+ 1 - 0
fixtures/js-stubs/codeOwner.js

@@ -17,6 +17,7 @@ export function CodeOwner({
     codeMappingId: '11',
     provider: 'github',
     codeMapping: RepositoryProjectPathConfig({project, repo, integration}),
+    codeOwnersUrl: 'https://github.com/getsentry/sentry/blob/master/.github/CODEOWNERS',
     ownershipSyntax: '',
     errors: {
       missing_user_emails: [],

+ 5 - 0
static/app/types/integrations.tsx

@@ -519,6 +519,11 @@ export type ServiceHook = {
  */
 export type CodeOwner = {
   codeMappingId: string;
+  /**
+   * Link to the CODEOWNERS file in source control
+   * 'unknown' if the api fails to fetch the file
+   */
+  codeOwnersUrl: string | 'unknown';
   dateCreated: string;
   dateUpdated: string;
   errors: {

+ 17 - 1
static/app/utils/integrationUtil.tsx

@@ -10,12 +10,14 @@ import {
   IconGithub,
   IconGitlab,
   IconJira,
+  IconSentry,
   IconVsts,
 } from 'sentry/icons';
 import {t} from 'sentry/locale';
 import HookStore from 'sentry/stores/hookStore';
-import {
+import type {
   AppOrProviderOrPlugin,
+  CodeOwner,
   DocIntegration,
   ExternalActorMapping,
   ExternalActorMappingOrSuggestion,
@@ -225,6 +227,20 @@ export const getIntegrationIcon = (
   }
 };
 
+export function getCodeOwnerIcon(
+  provider: CodeOwner['provider'],
+  iconSize: IconSize = 'md'
+) {
+  switch (provider ?? '') {
+    case 'github':
+      return <IconGithub size={iconSize} />;
+    case 'gitlab':
+      return <IconGitlab size={iconSize} />;
+    default:
+      return <IconSentry size={iconSize} />;
+  }
+}
+
 // used for project creation and onboarding
 // determines what integration maps to what project platform
 export const platformToIntegrationMap = {

+ 53 - 20
static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx

@@ -2,16 +2,21 @@ import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {openModal} from 'sentry/actionCreators/modal';
 import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import ExternalLink from 'sentry/components/links/externalLink';
 import {PanelTable} from 'sentry/components/panels';
 import TimeSince from 'sentry/components/timeSince';
-import {IconEllipsis, IconGithub, IconGitlab, IconSentry} from 'sentry/icons';
+import {IconEllipsis, IconOpen} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import type {CodeOwner, CodeownersFile, Project} from 'sentry/types';
+import {getCodeOwnerIcon} from 'sentry/utils/integrationUtil';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 
+import ViewCodeOwnerModal, {modalCss} from './viewCodeOwnerModal';
+
 interface CodeOwnerFileTableProps {
   codeowners: CodeOwner[];
   disabled: boolean;
@@ -20,17 +25,6 @@ interface CodeOwnerFileTableProps {
   project: Project;
 }
 
-function CodeownerIcon({provider}: {provider: CodeOwner['provider']}) {
-  switch (provider ?? '') {
-    case 'github':
-      return <IconGithub size="md" />;
-    case 'gitlab':
-      return <IconGitlab size="md" />;
-    default:
-      return <IconSentry size="md" />;
-  }
-}
-
 /**
  * A list of codeowner files being used for this project
  * If you're looking for ownership rules table see `OwnershipRulesTable`
@@ -50,6 +44,11 @@ export function CodeOwnerFileTable({
     return null;
   }
 
+  const handleView = (codeowner: CodeOwner) => () => {
+    // Open modal with codeowner file
+    openModal(deps => <ViewCodeOwnerModal {...deps} codeowner={codeowner} />, {modalCss});
+  };
+
   const handleSync = (codeowner: CodeOwner) => async () => {
     try {
       const codeownerFile: CodeownersFile = await api.requestPromise(
@@ -90,19 +89,50 @@ export function CodeOwnerFileTable({
   };
 
   return (
-    <StyledPanelTable headers={[t('codeowners'), t('Last Synced'), '']}>
+    <StyledPanelTable
+      headers={[
+        t('codeowners'),
+        t('Stack Trace Root'),
+        t('Source Code Root'),
+        t('Last Synced'),
+        t('File'),
+        '',
+      ]}
+    >
       {codeowners.map(codeowner => (
         <Fragment key={codeowner.id}>
           <FlexCenter>
-            <CodeownerIcon provider={codeowner.provider} />
+            {getCodeOwnerIcon(codeowner.provider)}
             {codeowner.codeMapping?.repoName}
           </FlexCenter>
+          <FlexCenter>
+            <code>{codeowner.codeMapping?.stackRoot}</code>
+          </FlexCenter>
+          <FlexCenter>
+            <code>{codeowner.codeMapping?.sourceRoot}</code>
+          </FlexCenter>
           <FlexCenter>
             <TimeSince date={codeowner.dateUpdated} />
           </FlexCenter>
+          <FlexCenter>
+            {codeowner.codeOwnersUrl === 'unknown' ? null : (
+              <StyledExternalLink href={codeowner.codeOwnersUrl}>
+                <IconOpen size="xs" />
+                {t(
+                  'View in %s',
+                  codeowner.codeMapping?.provider?.name ?? codeowner.provider
+                )}
+              </StyledExternalLink>
+            )}
+          </FlexCenter>
           <FlexCenter>
             <DropdownMenu
               items={[
+                {
+                  key: 'view',
+                  label: t('View'),
+                  onAction: handleView(codeowner),
+                },
                 {
                   key: 'sync',
                   label: t('Sync'),
@@ -123,7 +153,7 @@ export function CodeOwnerFileTable({
                 showChevron: false,
                 disabled,
               }}
-              isDisabled={disabled}
+              disabledKeys={disabled ? ['sync', 'delete'] : []}
             />
           </FlexCenter>
         </Fragment>
@@ -133,13 +163,10 @@ export function CodeOwnerFileTable({
 }
 
 const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: 1fr auto min-content;
+  grid-template-columns: 1fr 1fr 1fr auto min-content min-content;
   position: static;
   overflow: auto;
-
-  @media (min-width: ${p => p.theme.breakpoints.small}) {
-    overflow: initial;
-  }
+  white-space: nowrap;
 `;
 
 const FlexCenter = styled('div')`
@@ -147,3 +174,9 @@ const FlexCenter = styled('div')`
   align-items: center;
   gap: ${space(1)};
 `;
+
+const StyledExternalLink = styled(ExternalLink)`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+`;

+ 1 - 1
static/app/views/settings/project/projectOwnership/rulesPanel.tsx

@@ -8,11 +8,11 @@ import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 
 type Props = {
-  'data-test-id': string;
   dateUpdated: string | null;
   raw: string;
   type: 'codeowners' | 'issueowners';
   controls?: React.ReactNode[];
+  'data-test-id'?: string;
   placeholder?: string;
   provider?: string;
   repoName?: string;

+ 23 - 0
static/app/views/settings/project/projectOwnership/viewCodeOwnerModal.spec.tsx

@@ -0,0 +1,23 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import ViewCodeOwnerModal from './viewCodeOwnerModal';
+
+describe('ViewCodeOwnerModal', () => {
+  const mockComponent: any = ({children}) => <div>{children}</div>;
+
+  it('should display parsed codeowners file', () => {
+    const ownershipSyntax = `codeowners:/src/sentry/migrations/ #developer-infrastructure\n`;
+    render(
+      <ViewCodeOwnerModal
+        codeowner={TestStubs.CodeOwner({ownershipSyntax})}
+        closeModal={jest.fn()}
+        Header={mockComponent}
+        Footer={mockComponent}
+        Body={mockComponent}
+        CloseButton={mockComponent}
+      />
+    );
+
+    expect(screen.getByRole('textbox')).toHaveValue(ownershipSyntax);
+  });
+});

+ 72 - 0
static/app/views/settings/project/projectOwnership/viewCodeOwnerModal.tsx

@@ -0,0 +1,72 @@
+import {Fragment} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {SectionHeading} from 'sentry/components/charts/styles';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {CodeOwner} from 'sentry/types';
+import {getCodeOwnerIcon} from 'sentry/utils/integrationUtil';
+import theme from 'sentry/utils/theme';
+import RulesPanel from 'sentry/views/settings/project/projectOwnership/rulesPanel';
+
+interface ViewCodeOwnerModalProps extends ModalRenderProps {
+  codeowner: CodeOwner;
+}
+
+function ViewCodeOwnerModal({Body, Header, codeowner}: ViewCodeOwnerModalProps) {
+  return (
+    <Fragment>
+      <Header closeButton>
+        <HeaderContainer>
+          {getCodeOwnerIcon(codeowner.provider)}
+          <h4>{codeowner.codeMapping?.repoName}</h4>
+        </HeaderContainer>
+      </Header>
+      <Body>
+        <BodyContainer>
+          <div>
+            <div>
+              <SectionHeading>{t('Code Mapping:')}</SectionHeading>
+            </div>
+            {t('Stack Trace Root -')} <code>{codeowner.codeMapping?.stackRoot}</code>
+            <br />
+            {t('Source Code Root -')} <code>{codeowner.codeMapping?.sourceRoot}</code>
+          </div>
+
+          <RulesPanel
+            data-test-id="issueowners-panel"
+            type="codeowners"
+            provider={codeowner.provider}
+            raw={codeowner.ownershipSyntax || ''}
+            dateUpdated={codeowner.dateUpdated}
+          />
+        </BodyContainer>
+      </Body>
+    </Fragment>
+  );
+}
+
+const HeaderContainer = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const BodyContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+`;
+
+export const modalCss = css`
+  @media (min-width: ${theme.breakpoints.small}) {
+    width: 80%;
+  }
+  [role='document'] {
+    overflow: initial;
+  }
+`;
+
+export default ViewCodeOwnerModal;