Browse Source

feat(stack-trace-linking): modal for creating configs (#21595)

Stephen Cefali 4 years ago
parent
commit
5b966748de

+ 74 - 1
src/sentry/api/endpoints/organization_integration_repository_project_path_configs.py

@@ -1,13 +1,70 @@
 from __future__ import absolute_import
 
+from rest_framework import status, serializers
 
 from sentry.api.bases.organization import OrganizationIntegrationsPermission
 from sentry.api.bases.organization_integrations import OrganizationIntegrationBaseEndpoint
 from sentry.api.serializers import serialize
-from sentry.models import RepositoryProjectPathConfig
+from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer
+from sentry.models import RepositoryProjectPathConfig, Project, Repository
 from sentry.utils.compat import map
 
 
+class RepositoryProjectPathConfigSerializer(CamelSnakeModelSerializer):
+    repository_id = serializers.IntegerField(required=True)
+    project_id = serializers.IntegerField(required=True)
+    stack_root = serializers.CharField(required=True, allow_blank=True)
+    source_root = serializers.CharField(required=True, allow_blank=True)
+    default_branch = serializers.CharField(required=True)
+
+    class Meta:
+        model = RepositoryProjectPathConfig
+        fields = ["repository_id", "project_id", "stack_root", "source_root", "default_branch"]
+        extra_kwargs = {}
+
+    @property
+    def org_integration(self):
+        return self.context["organization_integration"]
+
+    @property
+    def organization_id(self):
+        return self.org_integration.organization_id
+
+    def validate(self, attrs):
+        # TODO: Needs to be updated for update since the row already exists
+        query = RepositoryProjectPathConfig.objects.filter(
+            project_id=attrs.get("project_id"), stack_root=attrs.get("stack_root")
+        )
+        if query.exists():
+            raise serializers.ValidationError(
+                "Code path config already exists with this project and stack root"
+            )
+        return attrs
+
+    def validate_repository_id(self, repository_id):
+        # validate repo exists on this org and integration
+        repo_query = Repository.objects.filter(
+            id=repository_id,
+            organization_id=self.organization_id,
+            integration_id=self.org_integration.integration_id,
+        )
+        if not repo_query.exists():
+            raise serializers.ValidationError("Repository does not exist")
+        return repository_id
+
+    def validate_project_id(self, project_id):
+        # validate project exists on this org
+        project_query = Project.objects.filter(id=project_id, organization_id=self.organization_id)
+        if not project_query.exists():
+            raise serializers.ValidationError("Project does not exist")
+        return project_id
+
+    def create(self, validated_data):
+        return RepositoryProjectPathConfig.objects.create(
+            organization_integration=self.org_integration, **validated_data
+        )
+
+
 class OrganizationIntegrationRepositoryProjectPathConfigEndpoint(
     OrganizationIntegrationBaseEndpoint
 ):
@@ -19,6 +76,7 @@ class OrganizationIntegrationRepositoryProjectPathConfigEndpoint(
         """
         org_integration = self.get_organization_integration(organization, integration_id)
 
+        # front end handles ordering
         repository_project_path_configs = RepositoryProjectPathConfig.objects.filter(
             organization_integration=org_integration
         )
@@ -26,3 +84,18 @@ class OrganizationIntegrationRepositoryProjectPathConfigEndpoint(
         # TODO: Add pagination
         data = map(lambda x: serialize(x, request.user), repository_project_path_configs)
         return self.respond(data)
+
+    def post(self, request, organization, integration_id):
+        org_integration = self.get_organization_integration(organization, integration_id)
+
+        serializer = RepositoryProjectPathConfigSerializer(
+            context={"organization_integration": org_integration}, data=request.data,
+        )
+        if serializer.is_valid():
+            repository_project_path_config = serializer.save()
+            return self.respond(
+                serialize(repository_project_path_config, request.user),
+                status=status.HTTP_201_CREATED,
+            )
+
+        return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 114 - 0
src/sentry/static/sentry/app/components/repositoryProjectPathConfigForm.tsx

@@ -0,0 +1,114 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {t} from 'app/locale';
+import Form from 'app/views/settings/components/forms/form';
+import JsonForm from 'app/views/settings/components/forms/jsonForm';
+import {Project, Organization, Integration, Repository} from 'app/types';
+import {JsonFormObject} from 'app/views/settings/components/forms/type';
+
+type Props = {
+  organization: Organization;
+  integration: Integration;
+  projects: Project[];
+  repos: Repository[];
+  onSubmitSuccess: Form['onSubmitSuccess'];
+};
+
+export default class RepositoryProjectPathConfigForm extends React.Component<Props> {
+  get initialData() {
+    return {
+      defaultBranch: 'master',
+      stackRoot: '',
+      sourceRoot: '',
+    };
+  }
+
+  get formFields(): JsonFormObject {
+    const {projects, repos} = this.props;
+    const repoChoices = repos.map(({name, id}) => ({value: id, label: name}));
+    return {
+      title: t('Create Code Path'),
+      fields: [
+        {
+          name: 'projectId',
+          type: 'sentry_project_selector',
+          required: true,
+          label: t('Project'),
+          inline: false,
+          projects,
+        },
+        {
+          name: 'repositoryId',
+          type: 'select',
+          required: true,
+          label: t('Repo'),
+          inline: false,
+          placeholder: t('Choose repo'),
+          options: repoChoices,
+        },
+        {
+          name: 'defaultBranch',
+          type: 'string',
+          required: true,
+          label: t('Branch'),
+          placeholder: t('Type your branch'),
+          inline: false,
+        },
+        {
+          name: 'stackRoot',
+          type: 'string',
+          required: false,
+          label: t('Input Path'),
+          placeholder: t('Type root path of your stack traces'),
+          inline: false,
+          showHelpInTooltip: true,
+          help: t(
+            'Any stack trace starting with this path will be mapped with this rule. An empty string will match all paths.'
+          ),
+        },
+        {
+          name: 'sourceRoot',
+          type: 'string',
+          required: false,
+          label: t('Output Path'),
+          placeholder: t('Type root path of your source code'),
+          inline: false,
+          showHelpInTooltip: true,
+          help: t(
+            'When a rule matches, the input path is replaced with this path to get the path in your repository. Leaving this empty means replacing the input path with an empty string.'
+          ),
+        },
+      ],
+    };
+  }
+
+  render() {
+    const {organization, integration, onSubmitSuccess} = this.props;
+
+    //TODO: make endpoint and method dynamic
+    const endpoint = `/organizations/${organization.slug}/integrations/${integration.id}/repo-project-path-configs/`;
+    const apiMethod = 'POST';
+
+    return (
+      <StyledForm
+        onSubmitSuccess={onSubmitSuccess}
+        initialData={this.initialData}
+        apiEndpoint={endpoint}
+        apiMethod={apiMethod}
+      >
+        <JsonForm forms={[this.formFields]} />
+      </StyledForm>
+    );
+  }
+}
+
+const StyledForm = styled(Form)`
+  label {
+    color: ${p => p.theme.gray600};
+    font-family: Rubik;
+    font-size: 12px;
+    font-weight: 500;
+    text-transform: uppercase;
+  }
+`;

+ 25 - 35
src/sentry/static/sentry/app/components/repositoryProjectPathConfigRow.tsx

@@ -2,20 +2,18 @@ import React from 'react';
 import styled from '@emotion/styled';
 
 import {Client} from 'app/api';
-import {Repository, RepositoryProjectPathConfig, Project} from 'app/types';
+import {RepositoryProjectPathConfig, Project} from 'app/types';
 import {t} from 'app/locale';
 import Access from 'app/components/acl/access';
 import Button from 'app/components/button';
 import Confirm from 'app/components/confirm';
 import {IconDelete, IconEdit} from 'app/icons';
-import QuestionTooltip from 'app/components/questionTooltip';
 import space from 'app/styles/space';
 import Tooltip from 'app/components/tooltip';
 import IdBadge from 'app/components/idBadge';
 
 type Props = {
-  repoProjectPathConfig: RepositoryProjectPathConfig;
-  repo: Repository; //TODO: remove
+  pathConfig: RepositoryProjectPathConfig;
   project: Project;
 };
 
@@ -28,7 +26,7 @@ export default class RepositoryProjectPathConfigRow extends React.Component<Prop
 
   render() {
     // TODO: Improve UI
-    const {repoProjectPathConfig, project} = this.props;
+    const {pathConfig, project} = this.props;
 
     return (
       <Access access={['org:integrations']}>
@@ -36,27 +34,20 @@ export default class RepositoryProjectPathConfigRow extends React.Component<Prop
           <React.Fragment>
             <NameRepoColumn>
               <ProjectRepoHolder>
-                <RepoName>
-                  {repoProjectPathConfig.repoName}
-                  <StyledQuestionTooltip
-                    size="xs"
-                    position="top"
-                    title={t('TO BE FILLED IN LATER')}
+                <RepoName>{pathConfig.repoName}</RepoName>
+                <ProjectAndBranch>
+                  <IdBadge
+                    project={project}
+                    avatarSize={14}
+                    displayName={project.slug}
+                    avatarProps={{consistentWidth: true}}
                   />
-                </RepoName>
-                <StyledIdBadge
-                  project={project}
-                  avatarSize={14}
-                  displayName={project.slug}
-                  avatarProps={{consistentWidth: true}}
-                />
+                  <BranchWrapper>&nbsp;|&nbsp;{pathConfig.defaultBranch}</BranchWrapper>
+                </ProjectAndBranch>
               </ProjectRepoHolder>
             </NameRepoColumn>
-            <OutputPathColumn>{repoProjectPathConfig.sourceRoot}</OutputPathColumn>
-            <InputPathColumn>{repoProjectPathConfig.stackRoot}</InputPathColumn>
-            <DefaultBranchColumn>
-              {repoProjectPathConfig.defaultBranch}
-            </DefaultBranchColumn>
+            <OutputPathColumn>{pathConfig.sourceRoot}</OutputPathColumn>
+            <InputPathColumn>{pathConfig.stackRoot}</InputPathColumn>
             <ButtonColumn>
               <Tooltip
                 title={t(
@@ -91,14 +82,6 @@ export default class RepositoryProjectPathConfigRow extends React.Component<Prop
   }
 }
 
-const StyledIdBadge = styled(IdBadge)`
-  color: ${p => p.theme.gray500};
-`;
-
-const StyledQuestionTooltip = styled(QuestionTooltip)`
-  padding: ${space(0.5)};
-`;
-
 const ProjectRepoHolder = styled('div')`
   display: flex;
   flex-direction: column;
@@ -112,6 +95,17 @@ const StyledButton = styled(Button)`
   margin: ${space(0.5)};
 `;
 
+const ProjectAndBranch = styled('div')`
+  display: flex;
+  flex-direction: row;
+  color: ${p => p.theme.gray500};
+`;
+
+//match the line eight of the badge
+const BranchWrapper = styled('div')`
+  line-height: 1.2;
+`;
+
 //Columns below
 const Column = styled('span')`
   overflow: hidden;
@@ -130,10 +124,6 @@ export const InputPathColumn = styled(Column)`
   grid-area: input-path;
 `;
 
-export const DefaultBranchColumn = styled(Column)`
-  grid-area: default-branch;
-`;
-
 export const ButtonColumn = styled(Column)`
   grid-area: button;
   text-align: right;

+ 91 - 28
src/sentry/static/sentry/app/views/organizationIntegrations/integrationCodeMappings.tsx

@@ -1,5 +1,7 @@
 import React from 'react';
 import styled from '@emotion/styled';
+import Modal from 'react-bootstrap/lib/Modal';
+import sortBy from 'lodash/sortBy';
 
 import AsyncComponent from 'app/components/asyncComponent';
 import Button from 'app/components/button';
@@ -9,9 +11,9 @@ import RepositoryProjectPathConfigRow, {
   NameRepoColumn,
   OutputPathColumn,
   InputPathColumn,
-  DefaultBranchColumn,
   ButtonColumn,
 } from 'app/components/repositoryProjectPathConfigRow';
+import RepositoryProjectPathConfigForm from 'app/components/repositoryProjectPathConfigForm';
 import {Panel, PanelBody, PanelHeader, PanelItem} from 'app/components/panels';
 import space from 'app/styles/space';
 import {t} from 'app/locale';
@@ -29,42 +31,83 @@ type Props = AsyncComponent['props'] & {
 };
 
 type State = AsyncComponent['state'] & {
-  repoProjectPathConfigs: RepositoryProjectPathConfig[];
+  pathConfigs: RepositoryProjectPathConfig[];
   repos: Repository[];
+  showModal: boolean;
 };
 
 class IntegrationCodeMappings extends AsyncComponent<Props, State> {
   getDefaultState(): State {
     return {
       ...super.getDefaultState(),
-      repoProjectPathConfigs: [],
+      pathConfigs: [],
       repos: [],
+      showModal: false,
     };
   }
 
+  get integrationId() {
+    return this.props.integration.id;
+  }
+
+  get projects() {
+    return this.props.organization.projects;
+  }
+
+  get pathConfigs() {
+    // we want to sort by the project slug and the
+    // id of the config
+    return sortBy(this.state.pathConfigs, [
+      ({projectSlug}) => projectSlug,
+      ({id}) => parseInt(id, 10),
+    ]);
+  }
+
+  get repos() {
+    //endpoint doesn't support loading only the repos for this integration
+    //but most people only have one source code repo so this should be fine
+    return this.state.repos.filter(repo => repo.integrationId === this.integrationId);
+  }
+
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
     const orgSlug = this.props.organization.slug;
     return [
       [
-        'repoProjectPathConfigs',
-        `/organizations/${orgSlug}/integrations/${this.props.integration.id}/repo-project-path-configs/`,
+        'pathConfigs',
+        `/organizations/${orgSlug}/integrations/${this.integrationId}/repo-project-path-configs/`,
       ],
       ['repos', `/organizations/${orgSlug}/repos/`, {query: {status: 'active'}}],
     ];
   }
 
-  getMatchingRepo(repoProjectPathConfig: RepositoryProjectPathConfig) {
-    return this.state.repos.find(repo => repo.id === repoProjectPathConfig.repoId);
+  getMatchingProject(pathConfig: RepositoryProjectPathConfig) {
+    return this.projects.find(project => project.id === pathConfig.projectId);
   }
 
-  getMatchingProject(repoProjectPathConfig: RepositoryProjectPathConfig) {
-    return this.props.organization.projects.find(
-      project => project.id === repoProjectPathConfig.projectId
-    );
-  }
+  openModal = () => {
+    this.setState({
+      showModal: true,
+    });
+  };
+
+  closeModal = () => {
+    this.setState({
+      showModal: false,
+    });
+  };
+
+  handleSubmitSuccess = (pathConfig: RepositoryProjectPathConfig) => {
+    let {pathConfigs} = this.state;
+    // our getter handles the order of the configs
+    pathConfigs = pathConfigs.concat([pathConfig]);
+    this.setState({pathConfigs});
+    this.closeModal();
+  };
 
   renderBody() {
-    const {repoProjectPathConfigs} = this.state;
+    const {organization, integration} = this.props;
+    const {showModal} = this.state;
+    const pathConfigs = this.pathConfigs;
     return (
       <React.Fragment>
         <Panel>
@@ -73,33 +116,34 @@ class IntegrationCodeMappings extends AsyncComponent<Props, State> {
               <NameRepoColumn>{t('Code Path Mappings')}</NameRepoColumn>
               <OutputPathColumn>{t('Output Path')}</OutputPathColumn>
               <InputPathColumn>{t('Input Path')}</InputPathColumn>
-              <DefaultBranchColumn>{t('Branch')}</DefaultBranchColumn>
               <ButtonColumn>
-                <AddButton size="xsmall" icon={<IconAdd size="xs" isCircled />}>
+                <AddButton
+                  onClick={this.openModal}
+                  size="xsmall"
+                  icon={<IconAdd size="xs" isCircled />}
+                >
                   {t('Add Mapping')}
                 </AddButton>
               </ButtonColumn>
             </HeaderLayout>
           </PanelHeader>
           <PanelBody>
-            {repoProjectPathConfigs.length === 0 && (
+            {pathConfigs.length === 0 && (
               <EmptyMessage description={t('No code path mappings')} />
             )}
-            {repoProjectPathConfigs
-              .map(repoProjectPathConfig => {
-                const repo = this.getMatchingRepo(repoProjectPathConfig);
-                const project = this.getMatchingProject(repoProjectPathConfig);
-                // this should never happen since our repoProjectPathConfig would be deleted
-                // if the repo or project were deleted
-                if (!repo || !project) {
+            {pathConfigs
+              .map(pathConfig => {
+                const project = this.getMatchingProject(pathConfig);
+                // this should never happen since our pathConfig would be deleted
+                // if project was deleted
+                if (!project) {
                   return null;
                 }
                 return (
-                  <ConfigPanelItem key={repoProjectPathConfig.id}>
+                  <ConfigPanelItem key={pathConfig.id}>
                     <Layout>
                       <RepositoryProjectPathConfigRow
-                        repoProjectPathConfig={repoProjectPathConfig}
-                        repo={repo}
+                        pathConfig={pathConfig}
                         project={project}
                       />
                     </Layout>
@@ -109,6 +153,25 @@ class IntegrationCodeMappings extends AsyncComponent<Props, State> {
               .filter(item => !!item)}
           </PanelBody>
         </Panel>
+
+        <Modal
+          show={showModal}
+          onHide={this.closeModal}
+          enforceFocus={false}
+          backdrop="static"
+          animation={false}
+        >
+          <Modal.Header closeButton />
+          <Modal.Body>
+            <RepositoryProjectPathConfigForm
+              organization={organization}
+              integration={integration}
+              projects={this.projects}
+              repos={this.repos}
+              onSubmitSuccess={this.handleSubmitSuccess}
+            />
+          </Modal.Body>
+        </Modal>
       </React.Fragment>
     );
   }
@@ -123,8 +186,8 @@ const Layout = styled('div')`
   grid-column-gap: ${space(1)};
   width: 100%;
   align-items: center;
-  grid-template-columns: 4fr 2fr 2fr 1.2fr 1.5fr;
-  grid-template-areas: 'name-repo output-path input-path default-branch button';
+  grid-template-columns: 4.5fr 2.5fr 2.5fr 1.6fr;
+  grid-template-areas: 'name-repo output-path input-path button';
 `;
 
 const HeaderLayout = styled(Layout)`

+ 1 - 0
src/sentry/static/sentry/app/views/settings/components/forms/type.tsx

@@ -37,6 +37,7 @@ type BaseField = {
   label?: React.ReactNode | (() => React.ReactNode);
   name: string;
   help?: React.ReactNode | ((props: any) => React.ReactNode);
+  showHelpInTooltip?: boolean;
   required?: boolean;
   placeholder?: string | ((props: any) => React.ReactNode);
   multiline?: boolean;

+ 61 - 1
tests/sentry/api/endpoints/test_organization_integration_repository_project_path_configs.py

@@ -17,7 +17,9 @@ class OrganizationIntegrationRepositoryProjectPathConfigTest(APITestCase):
         self.team = self.create_team(organization=self.org, name="Mariachi Band")
         self.project1 = self.create_project(organization=self.org, teams=[self.team], name="Bengal")
         self.project2 = self.create_project(organization=self.org, teams=[self.team], name="Tiger")
-        self.integration = Integration.objects.create(provider="github", name="Example")
+        self.integration = Integration.objects.create(
+            provider="github", name="Example", external_id="abcd"
+        )
         self.org_integration = self.integration.add_organization(self.org, self.user)
         self.repo1 = Repository.objects.create(
             name="example", organization_id=self.org.id, integration_id=self.integration.id
@@ -27,6 +29,19 @@ class OrganizationIntegrationRepositoryProjectPathConfigTest(APITestCase):
             args=[self.org.slug, self.integration.id],
         )
 
+    def make_post(self, data=None):
+        config_data = {
+            "repository_id": self.repo1.id,
+            "project_id": self.project1.id,
+            "stack_root": "/stack/root",
+            "source_root": "/source/root",
+            "default_branch": "master",
+        }
+        if data:
+            config_data.update(data)
+
+        return self.client.post(self.url, data=config_data, format="json")
+
     def test_basic_get(self):
         path_config1 = RepositoryProjectPathConfig.objects.create(
             organization_integration=self.org_integration,
@@ -70,3 +85,48 @@ class OrganizationIntegrationRepositoryProjectPathConfigTest(APITestCase):
             "sourceRoot": "hey/there",
             "defaultBranch": None,
         }
+
+    def test_basic_post(self):
+        response = self.make_post()
+        assert response.status_code == 201, response.content
+        assert response.data == {
+            "id": six.text_type(response.data["id"]),
+            "projectId": six.text_type(self.project1.id),
+            "projectSlug": self.project1.slug,
+            "repoId": six.text_type(self.repo1.id),
+            "repoName": self.repo1.name,
+            "organizationIntegrationId": six.text_type(self.org_integration.id),
+            "stackRoot": "/stack/root",
+            "sourceRoot": "/source/root",
+            "defaultBranch": "master",
+        }
+
+    def test_empty_roots_post(self):
+        response = self.make_post({"stackRoot": "", "sourceRoot": ""})
+        assert response.status_code == 201, response.content
+
+    def test_project_does_not_exist(self):
+        bad_org = self.create_organization()
+        bad_project = self.create_project(organization=bad_org)
+        response = self.make_post({"project_id": bad_project.id})
+        assert response.status_code == 400
+        assert response.data == {"projectId": ["Project does not exist"]}
+
+    def test_repo_does_not_exist(self):
+        bad_integration = Integration.objects.create(provider="github", external_id="radsfas")
+        bad_integration.add_organization(self.org, self.user)
+        bad_repo = Repository.objects.create(
+            name="another", organization_id=self.org.id, integration_id=bad_integration.id
+        )
+        response = self.make_post({"repository_id": bad_repo.id})
+
+        assert response.status_code == 400
+        assert response.data == {"repositoryId": ["Repository does not exist"]}
+
+    def test_validate_path_conflict(self):
+        self.make_post()
+        response = self.make_post()
+        assert response.status_code == 400
+        assert response.data == {
+            "nonFieldErrors": ["Code path config already exists with this project and stack root"]
+        }