Browse Source

feat(ecosystem): Hide raw codeowners content, list in table (#45624)

Scott Cooper 2 years ago
parent
commit
3e63de9900

+ 10 - 2
fixtures/js-stubs/codeOwner.js

@@ -1,6 +1,14 @@
 import {GitHubIntegration} from './githubIntegration';
+import {Project} from './project';
+import {Repository} from './repository';
+import {RepositoryProjectPathConfig} from './repositoryProjectPathConfig';
 
-export function CodeOwner(params = {}) {
+export function CodeOwner({
+  project = Project(),
+  repo = Repository(),
+  integration = GitHubIntegration(),
+  ...params
+} = {}) {
   return {
     id: '1225',
     raw: '',
@@ -8,7 +16,7 @@ export function CodeOwner(params = {}) {
     dateUpdated: '2023-02-24T18:43:08.729490Z',
     codeMappingId: '11',
     provider: 'github',
-    codeMapping: GitHubIntegration(),
+    codeMapping: RepositoryProjectPathConfig({project, repo, integration}),
     ownershipSyntax: '',
     errors: {
       missing_user_emails: [],

+ 76 - 0
static/app/views/settings/project/projectOwnership/codeOwnerFileTable.spec.tsx

@@ -0,0 +1,76 @@
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {CodeOwnerFileTable} from './codeOwnerFileTable';
+
+describe('CodeOwnerFileTable', () => {
+  const organization = TestStubs.Organization();
+  const project = TestStubs.Project();
+  const codeowner = TestStubs.CodeOwner();
+
+  it('renders empty', () => {
+    const {container} = render(
+      <CodeOwnerFileTable
+        project={project}
+        codeowners={[]}
+        onDelete={() => {}}
+        onUpdate={() => {}}
+        disabled={false}
+      />
+    );
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it('renders table w/ sync & delete actions', async () => {
+    const newCodeowner = {
+      ...codeowner,
+      raw: '# new codeowner rules',
+    };
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/${organization.slug}/code-mappings/${codeowner.codeMappingId}/codeowners/`,
+      body: newCodeowner,
+    });
+    MockApiClient.addMockResponse({
+      method: 'PUT',
+      url: `/projects/${organization.slug}/${project.slug}/codeowners/${codeowner.id}/`,
+      body: newCodeowner,
+    });
+
+    MockApiClient.addMockResponse({
+      method: 'DELETE',
+      url: `/projects/${organization.slug}/${project.slug}/codeowners/${codeowner.id}/`,
+      body: {},
+    });
+
+    const onDelete = jest.fn();
+    const onUpdate = jest.fn();
+    render(
+      <CodeOwnerFileTable
+        project={project}
+        codeowners={[codeowner]}
+        onDelete={onDelete}
+        onUpdate={onUpdate}
+        disabled={false}
+      />,
+      {
+        organization,
+      }
+    );
+
+    expect(screen.getByText('example/repo-name')).toBeInTheDocument();
+
+    userEvent.click(screen.getByRole('button', {name: 'Actions'}));
+    userEvent.click(screen.getByRole('menuitemradio', {name: 'Sync'}));
+
+    await waitFor(() => {
+      expect(onUpdate).toHaveBeenCalledWith(newCodeowner);
+    });
+
+    userEvent.click(screen.getByRole('button', {name: 'Actions'}));
+    userEvent.click(screen.getByRole('menuitemradio', {name: 'Delete'}));
+
+    await waitFor(() => {
+      expect(onDelete).toHaveBeenCalledWith(codeowner);
+    });
+  });
+});

+ 149 - 0
static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx

@@ -0,0 +1,149 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import {PanelTable} from 'sentry/components/panels';
+import TimeSince from 'sentry/components/timeSince';
+import {IconEllipsis, IconGithub, IconGitlab, IconSentry} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import type {CodeOwner, CodeownersFile, Project} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface CodeOwnerFileTableProps {
+  codeowners: CodeOwner[];
+  disabled: boolean;
+  onDelete: (data: CodeOwner) => void;
+  onUpdate: (data: CodeOwner) => void;
+  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`
+ */
+export function CodeOwnerFileTable({
+  codeowners,
+  project,
+  onUpdate,
+  onDelete,
+  disabled,
+}: CodeOwnerFileTableProps) {
+  const api = useApi();
+  const organization = useOrganization();
+
+  // Do we need an empty state instead?
+  if (codeowners.length === 0) {
+    return null;
+  }
+
+  const handleSync = (codeowner: CodeOwner) => async () => {
+    try {
+      const codeownerFile: CodeownersFile = await api.requestPromise(
+        `/organizations/${organization.slug}/code-mappings/${codeowner.codeMappingId}/codeowners/`,
+        {
+          method: 'GET',
+        }
+      );
+
+      const data = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/codeowners/${codeowner.id}/`,
+        {
+          method: 'PUT',
+          data: {raw: codeownerFile.raw},
+        }
+      );
+      onUpdate({...codeowner, ...data});
+      addSuccessMessage(t('CODEOWNERS file sync successful.'));
+    } catch (_err) {
+      addErrorMessage(t('An error occurred trying to sync CODEOWNERS file.'));
+    }
+  };
+
+  const handleDelete = (codeowner: CodeOwner) => async () => {
+    try {
+      await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/codeowners/${codeowner.id}/`,
+        {
+          method: 'DELETE',
+        }
+      );
+      onDelete(codeowner);
+      addSuccessMessage(t('Deletion successful'));
+    } catch {
+      // no 4xx errors should happen on delete
+      addErrorMessage(t('An error occurred'));
+    }
+  };
+
+  return (
+    <StyledPanelTable headers={[t('codeowners'), t('Last Synced'), '']}>
+      {codeowners.map(codeowner => (
+        <Fragment key={codeowner.id}>
+          <FlexCenter>
+            <CodeownerIcon provider={codeowner.provider} />
+            {codeowner.codeMapping?.repoName}
+          </FlexCenter>
+          <FlexCenter>
+            <TimeSince date={codeowner.dateUpdated} />
+          </FlexCenter>
+          <FlexCenter>
+            <DropdownMenu
+              items={[
+                {
+                  key: 'sync',
+                  label: t('Sync'),
+                  onAction: handleSync(codeowner),
+                },
+                {
+                  key: 'delete',
+                  label: t('Delete'),
+                  priority: 'danger',
+                  onAction: handleDelete(codeowner),
+                },
+              ]}
+              position="bottom-end"
+              triggerProps={{
+                'aria-label': t('Actions'),
+                size: 'xs',
+                icon: <IconEllipsis size="xs" />,
+                showChevron: false,
+                disabled,
+              }}
+              isDisabled={disabled}
+            />
+          </FlexCenter>
+        </Fragment>
+      ))}
+    </StyledPanelTable>
+  );
+}
+
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: 1fr auto min-content;
+  position: static;
+  overflow: auto;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    overflow: initial;
+  }
+`;
+
+const FlexCenter = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+`;

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

@@ -65,7 +65,13 @@ export function EditOwnershipRules({ownership, ...props}: EditOwnershipRulesModa
           </Block>
         </Fragment>
       )}
-      {ownership && <OwnerInput {...props} initialText={ownership.raw || ''} />}
+      {ownership && (
+        <OwnerInput
+          {...props}
+          dateUpdated={ownership.lastUpdated}
+          initialText={ownership.raw || ''}
+        />
+      )}
     </Fragment>
   );
 }

+ 23 - 13
static/app/views/settings/project/projectOwnership/index.tsx

@@ -4,7 +4,6 @@ import styled from '@emotion/styled';
 
 import {openEditOwnershipRules, openModal} from 'sentry/actionCreators/modal';
 import Access from 'sentry/components/acl/access';
-import Feature from 'sentry/components/acl/feature';
 import {Button} from 'sentry/components/button';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import Form from 'sentry/components/forms/form';
@@ -20,6 +19,7 @@ import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHea
 import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
 import AddCodeOwnerModal from 'sentry/views/settings/project/projectOwnership/addCodeOwnerModal';
 import {CodeOwnerErrors} from 'sentry/views/settings/project/projectOwnership/codeownerErrors';
+import {CodeOwnerFileTable} from 'sentry/views/settings/project/projectOwnership/codeOwnerFileTable';
 import CodeOwnersPanel from 'sentry/views/settings/project/projectOwnership/codeowners';
 import {OwnershipRulesTable} from 'sentry/views/settings/project/projectOwnership/ownshipRulesTable';
 import RulesPanel from 'sentry/views/settings/project/projectOwnership/rulesPanel';
@@ -137,6 +137,7 @@ tags.sku_class:enterprise #enterprise`;
     const hasStreamlineTargetingContext = organization.features?.includes(
       'streamline-targeting-context'
     );
+    const hasCodeowners = organization.features?.includes('integrations-codeowners');
 
     return (
       <Fragment>
@@ -172,7 +173,7 @@ tags.sku_class:enterprise #enterprise`;
                   {t('View Issues')}
                 </Button>
               )}
-              <Feature features={['integrations-codeowners']}>
+              {hasCodeowners && (
                 <Access access={['org:integrations']}>
                   {({hasAccess}) =>
                     hasAccess ? (
@@ -187,7 +188,7 @@ tags.sku_class:enterprise #enterprise`;
                     ) : null
                   }
                 </Access>
-              </Feature>
+              )}
             </Fragment>
           }
         />
@@ -209,7 +210,7 @@ tags.sku_class:enterprise #enterprise`;
             />
           </ErrorBoundary>
         )}
-        {ownership && (
+        {!hasStreamlineTargetingContext && ownership && (
           <RulesPanel
             data-test-id="issueowners-panel"
             type="issueowners"
@@ -236,15 +237,24 @@ tags.sku_class:enterprise #enterprise`;
           />
         )}
         <PermissionAlert />
-        <Feature features={['integrations-codeowners']}>
-          <CodeOwnersPanel
-            codeowners={codeowners || []}
-            onDelete={this.handleCodeOwnerDeleted}
-            onUpdate={this.handleCodeOwnerUpdated}
-            disabled={disabled}
-            {...this.props}
-          />
-        </Feature>
+        {hasCodeowners &&
+          (hasStreamlineTargetingContext ? (
+            <CodeOwnerFileTable
+              project={project}
+              codeowners={codeowners ?? []}
+              onDelete={this.handleCodeOwnerDeleted}
+              onUpdate={this.handleCodeOwnerUpdated}
+              disabled={disabled}
+            />
+          ) : (
+            <CodeOwnersPanel
+              codeowners={codeowners || []}
+              onDelete={this.handleCodeOwnerDeleted}
+              onUpdate={this.handleCodeOwnerUpdated}
+              disabled={disabled}
+              {...this.props}
+            />
+          ))}
         {ownership && (
           <Form
             apiEndpoint={`/projects/${organization.slug}/${project.slug}/ownership/`}

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

@@ -152,6 +152,7 @@ class ProjectOwnershipModal extends AsyncComponent<Props, State> {
           initialText={ownership?.raw || ''}
           urls={urls}
           paths={paths}
+          dateUpdated={ownership.lastUpdated}
           onCancel={onCancel}
         />
       </Fragment>

+ 18 - 2
static/app/views/settings/project/projectOwnership/ownerInput.tsx

@@ -7,6 +7,7 @@ import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import TextArea from 'sentry/components/forms/controls/textarea';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
+import TimeSince from 'sentry/components/timeSince';
 import {t} from 'sentry/locale';
 import MemberListStore from 'sentry/stores/memberListStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
@@ -22,6 +23,7 @@ const defaultProps = {
 };
 
 type Props = {
+  dateUpdated: string | null;
   initialText: string;
   onCancel: () => void;
   organization: Organization;
@@ -151,7 +153,8 @@ class OwnerInput extends Component<Props, State> {
   };
 
   render() {
-    const {project, organization, disabled, urls, paths, initialText} = this.props;
+    const {project, organization, disabled, urls, paths, initialText, dateUpdated} =
+      this.props;
     const {hasChanges, text, error} = this.state;
 
     const hasStreamlineTargetingFeature = organization.features.includes(
@@ -179,7 +182,15 @@ class OwnerInput extends Component<Props, State> {
           }}
         >
           <Panel>
-            <PanelHeader>{t('Ownership Rules')}</PanelHeader>
+            <PanelHeader>
+              {t('Ownership Rules')}
+
+              {dateUpdated && (
+                <SyncDate>
+                  {t('Last Edited')} <TimeSince date={dateUpdated} />
+                </SyncDate>
+              )}
+            </PanelHeader>
             <PanelBody>
               <StyledTextArea
                 aria-label={t('Ownership Rules')}
@@ -266,4 +277,9 @@ const InvalidOwners = styled('div')`
   margin-top: 12px;
 `;
 
+const SyncDate = styled('div')`
+  font-weight: normal;
+  text-transform: none;
+`;
+
 export default OwnerInput;