Browse Source

feat(codeowners): Add manual sync (#27771)

Objective:
We want to allow users to re-sync against an updated CODEOWNERS on Github/Gitlab. And prevent unauthorized roles from making changes.
NisanthanNanthakumar 3 years ago
parent
commit
dc4c76cdc2

+ 9 - 1
static/app/types/index.tsx

@@ -2160,13 +2160,15 @@ export type IssueOwnership = {
   autoAssignment: boolean;
 };
 
-export type CodeOwners = {
+export type CodeOwner = {
   id: string;
   raw: string;
   dateCreated: string;
   dateUpdated: string;
   provider: 'github' | 'gitlab';
   codeMapping?: RepositoryProjectPathConfig;
+  codeMappingId: string;
+  ownershipSyntax?: string;
   errors: {
     missing_external_teams: string[];
     missing_external_users: string[];
@@ -2207,3 +2209,9 @@ export type ExternalTeam = {
   provider: string;
   integrationId: string;
 };
+
+export type CodeownersFile = {
+  raw: string;
+  filepath: string;
+  html_url: string;
+};

+ 18 - 23
static/app/views/settings/project/projectOwnership/addCodeOwnerModal.tsx

@@ -13,7 +13,8 @@ import {IconCheckmark, IconNot} from 'app/icons';
 import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
 import {
-  CodeOwners,
+  CodeOwner,
+  CodeownersFile,
   Integration,
   Organization,
   Project,
@@ -30,26 +31,20 @@ type Props = {
   project: Project;
   codeMappings: RepositoryProjectPathConfig[];
   integrations: Integration[];
-  onSave: (data: CodeOwners) => void;
+  onSave: (data: CodeOwner) => void;
 } & ModalRenderProps;
 
 type State = {
-  codeownerFile: CodeOwnerFile | null;
+  codeownersFile: CodeownersFile | null;
   codeMappingId: string | null;
   isLoading: boolean;
   error: boolean;
   errorJSON: {raw?: string} | null;
 };
 
-type CodeOwnerFile = {
-  raw: string;
-  filepath: string;
-  html_url: string;
-};
-
 class AddCodeOwnerModal extends Component<Props, State> {
   state: State = {
-    codeownerFile: null,
+    codeownersFile: null,
     codeMappingId: null,
     isLoading: false,
     error: false,
@@ -60,19 +55,19 @@ class AddCodeOwnerModal extends Component<Props, State> {
     const {organization} = this.props;
     this.setState({
       codeMappingId,
-      codeownerFile: null,
+      codeownersFile: null,
       error: false,
       errorJSON: null,
       isLoading: true,
     });
     try {
-      const data: CodeOwnerFile = await this.props.api.requestPromise(
+      const data: CodeownersFile = await this.props.api.requestPromise(
         `/organizations/${organization.slug}/code-mappings/${codeMappingId}/codeowners/`,
         {
           method: 'GET',
         }
       );
-      this.setState({codeownerFile: data, isLoading: false});
+      this.setState({codeownersFile: data, isLoading: false});
     } catch (_err) {
       this.setState({isLoading: false});
     }
@@ -80,15 +75,15 @@ class AddCodeOwnerModal extends Component<Props, State> {
 
   addFile = async () => {
     const {organization, project, codeMappings} = this.props;
-    const {codeownerFile, codeMappingId} = this.state;
+    const {codeownersFile, codeMappingId} = this.state;
 
-    if (codeownerFile) {
+    if (codeownersFile) {
       const postData: {
         codeMappingId: string | null;
         raw: string;
       } = {
         codeMappingId,
-        raw: codeownerFile.raw,
+        raw: codeownersFile.raw,
       };
 
       try {
@@ -113,18 +108,18 @@ class AddCodeOwnerModal extends Component<Props, State> {
     }
   };
 
-  handleAddedFile(data: CodeOwners) {
+  handleAddedFile(data: CodeOwner) {
     this.props.onSave(data);
     this.props.closeModal();
   }
 
-  sourceFile(codeownerFile: CodeOwnerFile) {
+  sourceFile(codeownersFile: CodeownersFile) {
     return (
       <Panel>
         <SourceFileBody>
           <IconCheckmark size="md" isCircled color="green200" />
-          {codeownerFile.filepath}
-          <Button size="small" href={codeownerFile.html_url} target="_blank">
+          {codeownersFile.filepath}
+          <Button size="small" href={codeownersFile.html_url} target="_blank">
             {t('Preview File')}
           </Button>
         </SourceFileBody>
@@ -196,7 +191,7 @@ class AddCodeOwnerModal extends Component<Props, State> {
 
   render() {
     const {Header, Body, Footer} = this.props;
-    const {codeownerFile, error, errorJSON} = this.state;
+    const {codeownersFile, error, errorJSON} = this.state;
     const {codeMappings, integrations, organization} = this.props;
     const baseUrl = `/settings/${organization.slug}/integrations`;
 
@@ -247,7 +242,7 @@ class AddCodeOwnerModal extends Component<Props, State> {
               />
 
               <FileResult>
-                {codeownerFile ? this.sourceFile(codeownerFile) : this.noSourceFile()}
+                {codeownersFile ? this.sourceFile(codeownersFile) : this.noSourceFile()}
                 {error && errorJSON && this.errorMessage(baseUrl)}
               </FileResult>
             </Form>
@@ -255,7 +250,7 @@ class AddCodeOwnerModal extends Component<Props, State> {
         </Body>
         <Footer>
           <Button
-            disabled={codeownerFile ? false : true}
+            disabled={codeownersFile ? false : true}
             label={t('Add File')}
             priority="primary"
             onClick={this.addFile}

+ 48 - 16
static/app/views/settings/project/projectOwnership/codeowners.tsx

@@ -4,9 +4,9 @@ import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
 import {Client} from 'app/api';
 import Button from 'app/components/button';
 import Confirm from 'app/components/confirm';
-import {IconDelete} from 'app/icons';
+import {IconDelete, IconSync} from 'app/icons';
 import {t} from 'app/locale';
-import {CodeOwners, Organization, Project} from 'app/types';
+import {CodeOwner, CodeownersFile, Organization, Project} from 'app/types';
 import withApi from 'app/utils/withApi';
 import RulesPanel from 'app/views/settings/project/projectOwnership/rulesPanel';
 
@@ -14,12 +14,14 @@ type Props = {
   api: Client;
   organization: Organization;
   project: Project;
-  codeowners: any;
-  onDelete: (data: any) => void;
+  codeowners: CodeOwner[];
+  disabled: boolean;
+  onDelete: (data: CodeOwner) => void;
+  onUpdate: (data: CodeOwner) => void;
 };
 
 class CodeOwnersPanel extends Component<Props> {
-  handleDelete = async (codeowner: CodeOwners) => {
+  handleDelete = async (codeowner: CodeOwner) => {
     const {api, organization, project, onDelete} = this.props;
     const endpoint = `/api/0/projects/${organization.slug}/${project.slug}/codeowners/${codeowner.id}/`;
     try {
@@ -34,32 +36,62 @@ class CodeOwnersPanel extends Component<Props> {
     }
   };
 
+  handleSync = async (codeowner: CodeOwner) => {
+    const {api, organization, project, onUpdate} = this.props;
+    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.'));
+    }
+  };
   render() {
-    const {codeowners} = this.props;
-    return (codeowners || []).map(codeowner => {
-      const {
-        dateUpdated,
-        provider,
-        codeMapping: {repoName},
-        ownershipSyntax,
-      } = codeowner;
+    const {codeowners, disabled} = this.props;
+    return codeowners.map(codeowner => {
+      const {dateUpdated, provider, codeMapping, ownershipSyntax} = codeowner;
       return (
         <Fragment key={codeowner.id}>
           <RulesPanel
             data-test-id="codeowners-panel"
             type="codeowners"
-            raw={ownershipSyntax}
+            raw={ownershipSyntax || ''}
             dateUpdated={dateUpdated}
             provider={provider}
-            repoName={repoName}
+            repoName={codeMapping?.repoName}
             beta
             controls={[
+              <Button
+                key="sync"
+                icon={<IconSync size="xs" />}
+                size="xsmall"
+                onClick={() => this.handleSync(codeowner)}
+                disabled={disabled}
+              />,
               <Confirm
                 onConfirm={() => this.handleDelete(codeowner)}
                 message={t('Are you sure you want to remove this CODEOWNERS file?')}
                 key="confirm-delete"
               >
-                <Button key="delete" icon={<IconDelete size="xs" />} size="xsmall" />
+                <Button
+                  key="delete"
+                  icon={<IconDelete size="xs" />}
+                  size="xsmall"
+                  disabled={disabled}
+                />
               </Confirm>,
             ]}
           />

+ 21 - 9
static/app/views/settings/project/projectOwnership/index.tsx

@@ -11,7 +11,7 @@ import {IconWarning} from 'app/icons';
 import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
 import {
-  CodeOwners,
+  CodeOwner,
   Integration,
   Organization,
   Project,
@@ -35,7 +35,7 @@ type Props = {
 type State = {
   ownership: null | any;
   codeMappings: RepositoryProjectPathConfig[];
-  codeowners?: CodeOwners[];
+  codeowners?: CodeOwner[];
   integrations: Integration[];
 } & AsyncView['state'];
 
@@ -79,7 +79,7 @@ class ProjectOwnership extends AsyncView<Props, State> {
         project={this.props.project}
         codeMappings={codeMappings}
         integrations={integrations}
-        onSave={this.handleCodeownerAdded}
+        onSave={this.handleCodeOwnerAdded}
       />
     ));
   };
@@ -111,18 +111,28 @@ tags.sku_class:enterprise #enterprise`;
     }));
   };
 
-  handleCodeownerAdded = (data: CodeOwners) => {
+  handleCodeOwnerAdded = (data: CodeOwner) => {
     const {codeowners} = this.state;
-    const newCodeowners = codeowners?.concat(data);
+    const newCodeowners = (codeowners || []).concat(data);
     this.setState({codeowners: newCodeowners});
   };
 
-  handleCodeownerDeleted = (data: CodeOwners) => {
+  handleCodeOwnerDeleted = (data: CodeOwner) => {
     const {codeowners} = this.state;
-    const newCodeowners = codeowners?.filter(codeowner => codeowner.id !== data.id);
+    const newCodeowners = (codeowners || []).filter(
+      codeowner => codeowner.id !== data.id
+    );
     this.setState({codeowners: newCodeowners});
   };
 
+  handleCodeOwnerUpdated = (data: CodeOwner) => {
+    const codeowners = this.state.codeowners || [];
+    const index = codeowners.findIndex(item => item.id === data.id);
+    this.setState({
+      codeowners: [...codeowners.slice(0, index), data, ...codeowners.slice(index + 1)],
+    });
+  };
+
   renderCodeOwnerErrors = () => {
     const {project, organization} = this.props;
     const {codeowners} = this.state;
@@ -264,8 +274,10 @@ tags.sku_class:enterprise #enterprise`;
         />
         <Feature features={['integrations-codeowners']}>
           <CodeOwnersPanel
-            codeowners={codeowners}
-            onDelete={this.handleCodeownerDeleted}
+            codeowners={codeowners || []}
+            onDelete={this.handleCodeOwnerDeleted}
+            onUpdate={this.handleCodeOwnerUpdated}
+            disabled={disabled}
             {...this.props}
           />
         </Feature>

+ 2 - 2
tests/js/spec/views/addCodeOwnerModal.spec.jsx

@@ -69,7 +69,7 @@ describe('AddCodeOwnerModal', function () {
     expect(wrapper.find('IconCheckmark').exists()).toBe(true);
     expect(wrapper.find('SourceFileBody').find('Button').prop('href')).toEqual('blah');
     expect(wrapper.find('SourceFileBody').text()).toContain('CODEOWNERS');
-    expect(wrapper.state('codeownerFile').raw).toEqual('* @MeredithAnya\n');
+    expect(wrapper.state('codeownersFile').raw).toEqual('* @MeredithAnya\n');
   });
 
   it('renders no codeowner file found', async function () {
@@ -96,7 +96,7 @@ describe('AddCodeOwnerModal', function () {
 
     expect(wrapper.find('IconNot').exists()).toBe(true);
     expect(wrapper.find('NoSourceFileBody').text()).toEqual('No codeowner file found.');
-    expect(wrapper.state('codeownerFile')).toBe(null);
+    expect(wrapper.state('codeownersFile')).toBe(null);
   });
 
   it('adds codeowner file', async function () {