]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14057 Add PAT form for azure onboarding
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 9 Nov 2020 16:16:09 +0000 (17:16 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 25 Nov 2020 20:06:26 +0000 (20:06 +0000)
19 files changed:
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectModeSelection-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7226731ae5ce35737528644c2aa0efa23babf23b..1c72c859fce7b1bf9e098f5ec20ffafafb8c9289 100644 (file)
@@ -48,10 +48,10 @@ interface State {
 /*
  * ALMs for which the import feature has been implemented
  */
-const IMPORT_COMPATIBLE_ALMS = [AlmKeys.Bitbucket, AlmKeys.GitHub, AlmKeys.GitLab];
+const IMPORT_COMPATIBLE_ALMS = [AlmKeys.Azure, AlmKeys.Bitbucket, AlmKeys.GitHub, AlmKeys.GitLab];
 
 const almSettingsValidators = {
-  [AlmKeys.Azure]: (_: AlmSettingsInstance) => true,
+  [AlmKeys.Azure]: (settings: AlmSettingsInstance) => !!settings.url,
   [AlmKeys.Bitbucket]: (_: AlmSettingsInstance) => true,
   [AlmKeys.GitHub]: (_: AlmSettingsInstance) => true,
   [AlmKeys.GitLab]: (settings: AlmSettingsInstance) => !!settings.url
@@ -73,7 +73,9 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> {
             this.setState({ governanceReady: true });
           }
         },
-        () => {}
+        () => {
+          /* error handled globally */
+        }
       );
     }
   }
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx
new file mode 100644 (file)
index 0000000..203d152
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput';
+import DetachIcon from 'sonar-ui-common/components/icons/DetachIcon';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { AlmSettingsInstance } from '../../../types/alm-settings';
+
+export interface AzurePersonalAccessTokenFormProps {
+  almSetting: AlmSettingsInstance;
+  onPersonalAccessTokenCreate: (token: string) => void;
+  submitting?: boolean;
+  validationFailed: boolean;
+}
+
+function getAzurePatUrl(url: string) {
+  return `${url.replace(/\/$/, '')}/_usersSettings/tokens`;
+}
+
+export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) {
+  const {
+    almSetting: { alm, url },
+    submitting = false,
+    validationFailed
+  } = props;
+
+  const [touched, setTouched] = React.useState(false);
+  React.useEffect(() => {
+    setTouched(false);
+  }, [submitting]);
+
+  const [token, setToken] = React.useState('');
+
+  const isInvalid = (validationFailed && !touched) || (touched && !token);
+
+  let errorMessage;
+  if (!token) {
+    errorMessage = translate('onboarding.create_project.pat_form.pat_required');
+  } else if (isInvalid) {
+    errorMessage = translate('onboarding.create_project.pat_incorrect', alm);
+  }
+
+  return (
+    <div className="boxed-group abs-width-600">
+      <div className="boxed-group-inner">
+        <h2>{translate('onboarding.create_project.pat_form.title', alm)}</h2>
+
+        <div className="big-spacer-top big-spacer-bottom">
+          <FormattedMessage
+            id="onboarding.create_project.pat_help.instructions"
+            defaultMessage={translate('onboarding.create_project.pat_help.instructions', alm)}
+            values={{
+              link: url ? (
+                <a
+                  className="link-with-icon"
+                  href={getAzurePatUrl(url)}
+                  rel="noopener noreferrer"
+                  target="_blank">
+                  <DetachIcon className="little-spacer-right" />
+                  <span>
+                    {translate('onboarding.create_project.pat_help.instructions.link', alm)}
+                  </span>
+                </a>
+              ) : (
+                translate('onboarding.create_project.pat_help.instructions.link', alm)
+              ),
+              scope: (
+                <strong>
+                  <em>Code (Read & Write)</em>
+                </strong>
+              )
+            }}
+          />
+        </div>
+
+        <form
+          onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
+            e.preventDefault();
+            props.onPersonalAccessTokenCreate(token);
+          }}>
+          <ValidationInput
+            error={errorMessage}
+            id="personal_access_token"
+            isInvalid={isInvalid}
+            isValid={false}
+            label={translate('onboarding.create_project.enter_pat')}
+            required={true}>
+            <input
+              autoFocus={true}
+              className={classNames('width-100 little-spacer-bottom', {
+                'is-invalid': isInvalid
+              })}
+              id="personal_access_token"
+              minLength={1}
+              name="personal_access_token"
+              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
+                setToken(e.target.value);
+                setTouched(true);
+              }}
+              type="text"
+              value={token}
+            />
+          </ValidationInput>
+
+          <SubmitButton disabled={isInvalid || submitting || !touched}>
+            {translate('onboarding.create_project.pat_form.list_repositories')}
+          </SubmitButton>
+          <DeferredSpinner className="spacer-left" loading={submitting} />
+        </form>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx
new file mode 100644 (file)
index 0000000..292abc2
--- /dev/null
@@ -0,0 +1,143 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { WithRouterProps } from 'react-router';
+import {
+  checkPersonalAccessTokenIsValid,
+  setAlmPersonalAccessToken
+} from '../../../api/alm-integrations';
+import { AlmSettingsInstance } from '../../../types/alm-settings';
+import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
+
+interface Props extends Pick<WithRouterProps, 'location'> {
+  canAdmin: boolean;
+  loadingBindings: boolean;
+  onProjectCreate: (projectKeys: string[]) => void;
+  settings: AlmSettingsInstance[];
+}
+
+interface State {
+  loading: boolean;
+  patIsValid?: boolean;
+  settings?: AlmSettingsInstance;
+  submittingToken?: boolean;
+  tokenValidationFailed: boolean;
+}
+
+export default class AzureProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      // For now, we only handle a single instance. So we always use the first
+      // one from the list.
+      settings: props.settings[0],
+      loading: false,
+      tokenValidationFailed: false
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchInitialData();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.settings.length === 0 && this.props.settings.length > 0) {
+      this.setState(
+        { settings: this.props.settings.length === 1 ? this.props.settings[0] : undefined },
+        () => this.fetchInitialData()
+      );
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchInitialData = async () => {
+    this.setState({ loading: true });
+
+    const patIsValid = await this.checkPersonalAccessToken().catch(() => false);
+
+    if (this.mounted) {
+      this.setState({
+        patIsValid,
+        loading: false
+      });
+    }
+  };
+
+  checkPersonalAccessToken = () => {
+    const { settings } = this.state;
+
+    if (!settings) {
+      return Promise.resolve(false);
+    }
+
+    return checkPersonalAccessTokenIsValid(settings.key);
+  };
+
+  handlePersonalAccessTokenCreate = async (token: string) => {
+    const { settings } = this.state;
+
+    if (!settings || token.length < 1) {
+      return;
+    }
+
+    this.setState({ submittingToken: true, tokenValidationFailed: false });
+
+    try {
+      await setAlmPersonalAccessToken(settings.key, token);
+      const patIsValid = await this.checkPersonalAccessToken();
+
+      if (this.mounted) {
+        this.setState({ submittingToken: false, patIsValid, tokenValidationFailed: !patIsValid });
+
+        if (patIsValid) {
+          this.cleanUrl();
+          await this.fetchInitialData();
+        }
+      }
+    } catch (e) {
+      if (this.mounted) {
+        this.setState({ submittingToken: false });
+      }
+    }
+  };
+
+  render() {
+    const { canAdmin, loadingBindings, location } = this.props;
+    const { loading, patIsValid, settings, submittingToken, tokenValidationFailed } = this.state;
+
+    return (
+      <AzureCreateProjectRenderer
+        canAdmin={canAdmin}
+        loading={loading || loadingBindings}
+        onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
+        settings={settings}
+        showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
+        submittingToken={submittingToken}
+        tokenValidationFailed={tokenValidationFailed}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx
new file mode 100644 (file)
index 0000000..2d43a8b
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { getBaseUrl } from 'sonar-ui-common/helpers/urls';
+import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
+import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
+import AzureProjectsList from './AzureProjectsList';
+import CreateProjectPageHeader from './CreateProjectPageHeader';
+import WrongBindingCountAlert from './WrongBindingCountAlert';
+
+export interface AzureProjectCreateRendererProps {
+  canAdmin?: boolean;
+  loading: boolean;
+  onPersonalAccessTokenCreate: (token: string) => void;
+  settings?: AlmSettingsInstance;
+  showPersonalAccessTokenForm?: boolean;
+  submittingToken?: boolean;
+  tokenValidationFailed: boolean;
+}
+
+export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
+  const {
+    canAdmin,
+    loading,
+    showPersonalAccessTokenForm,
+    settings,
+    submittingToken,
+    tokenValidationFailed
+  } = props;
+
+  return (
+    <>
+      <CreateProjectPageHeader
+        title={
+          <span className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="24"
+              src={`${getBaseUrl()}/images/alm/azure.svg`}
+            />
+            {translate('onboarding.create_project.azure.title')}
+          </span>
+        }
+      />
+
+      {loading && <i className="spinner" />}
+
+      {!loading && !settings && (
+        <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />
+      )}
+
+      {!loading &&
+        settings &&
+        (showPersonalAccessTokenForm ? (
+          <div className="display-flex-justify-center">
+            <AzurePersonalAccessTokenForm
+              almSetting={settings}
+              onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate}
+              submitting={submittingToken}
+              validationFailed={tokenValidationFailed}
+            />
+          </div>
+        ) : (
+          <AzureProjectsList />
+        ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx
new file mode 100644 (file)
index 0000000..c6f34ed
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+
+export interface AzureProjectsListProps {}
+
+export default function AzureProjectsList(_props: AzureProjectsListProps) {
+  return (
+    <div>
+      <Alert variant="warning">Coming soon!</Alert>
+    </div>
+  );
+}
index 4508e2762c20852688ab0cfb4126fced5dc2a2db..6524aad672a4301a3a8fc5fc6d70f618fe69ce1a 100644 (file)
@@ -112,6 +112,7 @@ export default function CreateProjectModeSelection(props: CreateProjectModeSelec
           </div>
         </button>
 
+        {renderAlmOption(props, AlmKeys.Azure, CreateProjectModes.AzureDevOps)}
         {renderAlmOption(props, AlmKeys.Bitbucket, CreateProjectModes.BitbucketServer)}
         {renderAlmOption(props, AlmKeys.GitHub, CreateProjectModes.GitHub)}
         {renderAlmOption(props, AlmKeys.GitLab, CreateProjectModes.GitLab)}
index 3976e1acb93e51d578e99facc36bb262ef60a7c0..a2cbadb2912bea7ef7cb5c0fc262f8d392a3a76d 100644 (file)
@@ -27,6 +27,7 @@ import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn';
 import { withAppState } from '../../../components/hoc/withAppState';
 import { getProjectUrl } from '../../../helpers/urls';
 import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
+import AzureProjectCreate from './AzureProjectCreate';
 import BitbucketProjectCreate from './BitbucketProjectCreate';
 import CreateProjectModeSelection from './CreateProjectModeSelection';
 import GitHubProjectCreate from './GitHubProjectCreate';
@@ -41,6 +42,7 @@ interface Props extends Pick<WithRouterProps, 'router' | 'location'> {
 }
 
 interface State {
+  azureSettings: AlmSettingsInstance[];
   bitbucketSettings: AlmSettingsInstance[];
   githubSettings: AlmSettingsInstance[];
   gitlabSettings: AlmSettingsInstance[];
@@ -49,7 +51,13 @@ interface State {
 
 export class CreateProjectPage extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { bitbucketSettings: [], githubSettings: [], gitlabSettings: [], loading: true };
+  state: State = {
+    azureSettings: [],
+    bitbucketSettings: [],
+    githubSettings: [],
+    gitlabSettings: [],
+    loading: true
+  };
 
   componentDidMount() {
     const {
@@ -71,6 +79,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
       .then(almSettings => {
         if (this.mounted) {
           this.setState({
+            azureSettings: almSettings.filter(s => s.alm === AlmKeys.Azure),
             bitbucketSettings: almSettings.filter(s => s.alm === AlmKeys.Bitbucket),
             githubSettings: almSettings.filter(s => s.alm === AlmKeys.GitHub),
             gitlabSettings: almSettings.filter(s => s.alm === AlmKeys.GitLab),
@@ -105,9 +114,26 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
       location,
       router
     } = this.props;
-    const { bitbucketSettings, githubSettings, gitlabSettings, loading } = this.state;
+    const {
+      azureSettings,
+      bitbucketSettings,
+      githubSettings,
+      gitlabSettings,
+      loading
+    } = this.state;
 
     switch (mode) {
+      case CreateProjectModes.AzureDevOps: {
+        return (
+          <AzureProjectCreate
+            canAdmin={!!canAdmin}
+            loadingBindings={loading}
+            location={location}
+            onProjectCreate={this.handleProjectCreate}
+            settings={azureSettings}
+          />
+        );
+      }
       case CreateProjectModes.BitbucketServer: {
         return (
           <BitbucketProjectCreate
@@ -148,7 +174,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
       }
       default: {
         const almCounts = {
-          [AlmKeys.Azure]: 0,
+          [AlmKeys.Azure]: azureSettings.length,
           [AlmKeys.Bitbucket]: bitbucketSettings.length,
           [AlmKeys.GitHub]: githubSettings.length,
           [AlmKeys.GitLab]: gitlabSettings.length
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx
new file mode 100644 (file)
index 0000000..d83088f
--- /dev/null
@@ -0,0 +1,72 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import { change, submit } from 'sonar-ui-common/helpers/testUtils';
+import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
+import { AlmKeys } from '../../../../types/alm-settings';
+import AzurePersonalAccessTokenForm, {
+  AzurePersonalAccessTokenFormProps
+} from '../AzurePersonalAccessTokenForm';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ submitting: true })).toMatchSnapshot('submitting');
+  expect(shallowRender({ validationFailed: true })).toMatchSnapshot('validation failed');
+});
+
+it('should correctly handle form interactions', () => {
+  const onPersonalAccessTokenCreate = jest.fn();
+  const wrapper = shallowRender({ onPersonalAccessTokenCreate });
+
+  // Submit button disabled by default.
+  expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true);
+
+  // Submit button enabled if there's a value.
+  change(wrapper.find('input'), 'token');
+  expect(wrapper.find(SubmitButton).prop('disabled')).toBe(false);
+
+  // Expect correct calls to be made when submitting.
+  submit(wrapper.find('form'));
+  expect(onPersonalAccessTokenCreate).toBeCalled();
+
+  // If validation fails, we toggle the submitting flag and call useEffect()
+  // to set the `touched` flag to false again. Trigger a re-render, and mock
+  // useEffect(). This should de-activate the submit button again.
+  jest.spyOn(React, 'useEffect').mockImplementationOnce(f => f());
+  wrapper.setProps({ submitting: false });
+  expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true);
+});
+
+function shallowRender(props: Partial<AzurePersonalAccessTokenFormProps> = {}) {
+  return shallow<AzurePersonalAccessTokenFormProps>(
+    <AzurePersonalAccessTokenForm
+      almSetting={mockAlmSettingsInstance({
+        alm: AlmKeys.Azure,
+        url: 'http://www.example.com'
+      })}
+      onPersonalAccessTokenCreate={jest.fn()}
+      validationFailed={false}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx
new file mode 100644 (file)
index 0000000..7ce8932
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+/* eslint-disable sonarjs/no-duplicate-string */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import {
+  checkPersonalAccessTokenIsValid,
+  setAlmPersonalAccessToken
+} from '../../../../api/alm-integrations';
+import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
+import { mockLocation } from '../../../../helpers/testMocks';
+import { AlmKeys } from '../../../../types/alm-settings';
+import AzureProjectCreate from '../AzureProjectCreate';
+
+jest.mock('../../../../api/alm-integrations', () => {
+  return {
+    checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue(true),
+    setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null)
+  };
+});
+
+beforeEach(jest.clearAllMocks);
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should correctly fetch binding info on mount', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(checkPersonalAccessTokenIsValid).toBeCalledWith('foo');
+});
+
+it('should correctly handle a valid PAT', async () => {
+  (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce(true);
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(checkPersonalAccessTokenIsValid).toBeCalled();
+  expect(wrapper.state().patIsValid).toBe(true);
+});
+
+it('should correctly handle an invalid PAT', async () => {
+  (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce(false);
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(checkPersonalAccessTokenIsValid).toBeCalled();
+  expect(wrapper.state().patIsValid).toBe(false);
+});
+
+it('should correctly handle setting a new PAT', async () => {
+  const wrapper = shallowRender();
+  wrapper.instance().handlePersonalAccessTokenCreate('token');
+  expect(setAlmPersonalAccessToken).toBeCalledWith('foo', 'token');
+  expect(wrapper.state().submittingToken).toBe(true);
+
+  (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce(false);
+  await waitAndUpdate(wrapper);
+  expect(checkPersonalAccessTokenIsValid).toBeCalled();
+  expect(wrapper.state().submittingToken).toBe(false);
+  expect(wrapper.state().tokenValidationFailed).toBe(true);
+});
+
+function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) {
+  return shallow<AzureProjectCreate>(
+    <AzureProjectCreate
+      canAdmin={true}
+      loadingBindings={false}
+      location={mockLocation()}
+      onProjectCreate={jest.fn()}
+      settings={[mockAlmSettingsInstance({ alm: AlmKeys.Azure, key: 'foo' })]}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx
new file mode 100644 (file)
index 0000000..d38e64d
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+/* eslint-disable sonarjs/no-duplicate-string */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
+import { AlmKeys } from '../../../../types/alm-settings';
+import AzureProjectCreateRenderer, {
+  AzureProjectCreateRendererProps
+} from '../AzureProjectCreateRenderer';
+
+it('should render correctly', () => {
+  expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
+  expect(shallowRender({ settings: undefined })).toMatchSnapshot('no settings');
+  expect(shallowRender({ showPersonalAccessTokenForm: true })).toMatchSnapshot('token form');
+  expect(shallowRender({})).toMatchSnapshot('project list');
+});
+
+function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) {
+  return shallow(
+    <AzureProjectCreateRenderer
+      canAdmin={true}
+      loading={false}
+      onPersonalAccessTokenCreate={jest.fn()}
+      tokenValidationFailed={false}
+      settings={mockAlmSettingsInstance({ alm: AlmKeys.Azure })}
+      showPersonalAccessTokenForm={false}
+      submittingToken={false}
+      {...overrides}
+    />
+  );
+}
index 6d0717eecafa1853fc7110c414ddf5a2d656e5a9..2cfeddd296eaf9816e7b2f1871c702225317eda9 100644 (file)
@@ -39,14 +39,27 @@ it('should correctly pass the selected mode up', () => {
   const onSelectMode = jest.fn();
   const wrapper = shallowRender({ onSelectMode });
 
+  const almButton = 'button.create-project-mode-type-alm';
+
   click(wrapper.find('button.create-project-mode-type-manual'));
   expect(onSelectMode).toBeCalledWith(CreateProjectModes.Manual);
+  onSelectMode.mockClear();
+
+  click(wrapper.find(almButton).at(0));
+  expect(onSelectMode).toBeCalledWith(CreateProjectModes.AzureDevOps);
+  onSelectMode.mockClear();
 
-  click(wrapper.find('button.create-project-mode-type-alm').at(0));
+  click(wrapper.find(almButton).at(1));
   expect(onSelectMode).toBeCalledWith(CreateProjectModes.BitbucketServer);
+  onSelectMode.mockClear();
 
-  click(wrapper.find('button.create-project-mode-type-alm').at(1));
+  click(wrapper.find(almButton).at(2));
   expect(onSelectMode).toBeCalledWith(CreateProjectModes.GitHub);
+  onSelectMode.mockClear();
+
+  click(wrapper.find(almButton).at(3));
+  expect(onSelectMode).toBeCalledWith(CreateProjectModes.GitLab);
+  onSelectMode.mockClear();
 });
 
 function shallowRender(
index e2d16dd4ed7eb8b0dad932f60a2d63322d50970f..cb0e4d074b377d28d6977ffb2397e23fa8fe47e5 100644 (file)
@@ -50,6 +50,14 @@ it('should render correctly if the manual method is selected', () => {
   ).toMatchSnapshot();
 });
 
+it('should render correctly if the Azure method is selected', () => {
+  expect(
+    shallowRender({
+      location: mockLocation({ query: { mode: CreateProjectModes.AzureDevOps } })
+    })
+  ).toMatchSnapshot();
+});
+
 it('should render correctly if the BBS method is selected', () => {
   expect(
     shallowRender({
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..c8399e9
--- /dev/null
@@ -0,0 +1,229 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<div
+  className="boxed-group abs-width-600"
+>
+  <div
+    className="boxed-group-inner"
+  >
+    <h2>
+      onboarding.create_project.pat_form.title.azure
+    </h2>
+    <div
+      className="big-spacer-top big-spacer-bottom"
+    >
+      <FormattedMessage
+        defaultMessage="onboarding.create_project.pat_help.instructions.azure"
+        id="onboarding.create_project.pat_help.instructions"
+        values={
+          Object {
+            "link": <a
+              className="link-with-icon"
+              href="http://www.example.com/_usersSettings/tokens"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <DetachIcon
+                className="little-spacer-right"
+              />
+              <span>
+                onboarding.create_project.pat_help.instructions.link.azure
+              </span>
+            </a>,
+            "scope": <strong>
+              <em>
+                Code (Read & Write)
+              </em>
+            </strong>,
+          }
+        }
+      />
+    </div>
+    <form
+      onSubmit={[Function]}
+    >
+      <ValidationInput
+        error="onboarding.create_project.pat_form.pat_required"
+        id="personal_access_token"
+        isInvalid={false}
+        isValid={false}
+        label="onboarding.create_project.enter_pat"
+        required={true}
+      >
+        <input
+          autoFocus={true}
+          className="width-100 little-spacer-bottom"
+          id="personal_access_token"
+          minLength={1}
+          name="personal_access_token"
+          onChange={[Function]}
+          type="text"
+          value=""
+        />
+      </ValidationInput>
+      <SubmitButton
+        disabled={true}
+      >
+        onboarding.create_project.pat_form.list_repositories
+      </SubmitButton>
+      <DeferredSpinner
+        className="spacer-left"
+        loading={false}
+      />
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: submitting 1`] = `
+<div
+  className="boxed-group abs-width-600"
+>
+  <div
+    className="boxed-group-inner"
+  >
+    <h2>
+      onboarding.create_project.pat_form.title.azure
+    </h2>
+    <div
+      className="big-spacer-top big-spacer-bottom"
+    >
+      <FormattedMessage
+        defaultMessage="onboarding.create_project.pat_help.instructions.azure"
+        id="onboarding.create_project.pat_help.instructions"
+        values={
+          Object {
+            "link": <a
+              className="link-with-icon"
+              href="http://www.example.com/_usersSettings/tokens"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <DetachIcon
+                className="little-spacer-right"
+              />
+              <span>
+                onboarding.create_project.pat_help.instructions.link.azure
+              </span>
+            </a>,
+            "scope": <strong>
+              <em>
+                Code (Read & Write)
+              </em>
+            </strong>,
+          }
+        }
+      />
+    </div>
+    <form
+      onSubmit={[Function]}
+    >
+      <ValidationInput
+        error="onboarding.create_project.pat_form.pat_required"
+        id="personal_access_token"
+        isInvalid={false}
+        isValid={false}
+        label="onboarding.create_project.enter_pat"
+        required={true}
+      >
+        <input
+          autoFocus={true}
+          className="width-100 little-spacer-bottom"
+          id="personal_access_token"
+          minLength={1}
+          name="personal_access_token"
+          onChange={[Function]}
+          type="text"
+          value=""
+        />
+      </ValidationInput>
+      <SubmitButton
+        disabled={true}
+      >
+        onboarding.create_project.pat_form.list_repositories
+      </SubmitButton>
+      <DeferredSpinner
+        className="spacer-left"
+        loading={true}
+      />
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: validation failed 1`] = `
+<div
+  className="boxed-group abs-width-600"
+>
+  <div
+    className="boxed-group-inner"
+  >
+    <h2>
+      onboarding.create_project.pat_form.title.azure
+    </h2>
+    <div
+      className="big-spacer-top big-spacer-bottom"
+    >
+      <FormattedMessage
+        defaultMessage="onboarding.create_project.pat_help.instructions.azure"
+        id="onboarding.create_project.pat_help.instructions"
+        values={
+          Object {
+            "link": <a
+              className="link-with-icon"
+              href="http://www.example.com/_usersSettings/tokens"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <DetachIcon
+                className="little-spacer-right"
+              />
+              <span>
+                onboarding.create_project.pat_help.instructions.link.azure
+              </span>
+            </a>,
+            "scope": <strong>
+              <em>
+                Code (Read & Write)
+              </em>
+            </strong>,
+          }
+        }
+      />
+    </div>
+    <form
+      onSubmit={[Function]}
+    >
+      <ValidationInput
+        error="onboarding.create_project.pat_form.pat_required"
+        id="personal_access_token"
+        isInvalid={true}
+        isValid={false}
+        label="onboarding.create_project.enter_pat"
+        required={true}
+      >
+        <input
+          autoFocus={true}
+          className="width-100 little-spacer-bottom is-invalid"
+          id="personal_access_token"
+          minLength={1}
+          name="personal_access_token"
+          onChange={[Function]}
+          type="text"
+          value=""
+        />
+      </ValidationInput>
+      <SubmitButton
+        disabled={true}
+      >
+        onboarding.create_project.pat_form.list_repositories
+      </SubmitButton>
+      <DeferredSpinner
+        className="spacer-left"
+        loading={false}
+      />
+    </form>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap
new file mode 100644 (file)
index 0000000..47f7055
--- /dev/null
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<AzureProjectCreateRenderer
+  canAdmin={true}
+  loading={true}
+  onPersonalAccessTokenCreate={[Function]}
+  settings={
+    Object {
+      "alm": "azure",
+      "key": "foo",
+    }
+  }
+  showPersonalAccessTokenForm={true}
+  tokenValidationFailed={false}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap
new file mode 100644 (file)
index 0000000..dc15c4a
--- /dev/null
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: loading 1`] = `
+<Fragment>
+  <CreateProjectPageHeader
+    title={
+      <span
+        className="text-middle"
+      >
+        <img
+          alt=""
+          className="spacer-right"
+          height="24"
+          src="/images/alm/azure.svg"
+        />
+        onboarding.create_project.azure.title
+      </span>
+    }
+  />
+  <i
+    className="spinner"
+  />
+</Fragment>
+`;
+
+exports[`should render correctly: no settings 1`] = `
+<Fragment>
+  <CreateProjectPageHeader
+    title={
+      <span
+        className="text-middle"
+      >
+        <img
+          alt=""
+          className="spacer-right"
+          height="24"
+          src="/images/alm/azure.svg"
+        />
+        onboarding.create_project.azure.title
+      </span>
+    }
+  />
+  <WrongBindingCountAlert
+    alm="azure"
+    canAdmin={true}
+  />
+</Fragment>
+`;
+
+exports[`should render correctly: project list 1`] = `
+<Fragment>
+  <CreateProjectPageHeader
+    title={
+      <span
+        className="text-middle"
+      >
+        <img
+          alt=""
+          className="spacer-right"
+          height="24"
+          src="/images/alm/azure.svg"
+        />
+        onboarding.create_project.azure.title
+      </span>
+    }
+  />
+  <AzureProjectsList />
+</Fragment>
+`;
+
+exports[`should render correctly: token form 1`] = `
+<Fragment>
+  <CreateProjectPageHeader
+    title={
+      <span
+        className="text-middle"
+      >
+        <img
+          alt=""
+          className="spacer-right"
+          height="24"
+          src="/images/alm/azure.svg"
+        />
+        onboarding.create_project.azure.title
+      </span>
+    }
+  />
+  <div
+    className="display-flex-justify-center"
+  >
+    <AzurePersonalAccessTokenForm
+      almSetting={
+        Object {
+          "alm": "azure",
+          "key": "key",
+        }
+      }
+      onPersonalAccessTokenCreate={[MockFunction]}
+      submitting={false}
+      validationFailed={false}
+    />
+  </div>
+</Fragment>
+`;
index cd06179f636ddd0c9a1117c43e2823774805a11f..cb6db4d27f532ba2a9ea06f2b7acb5d85ecff441 100644 (file)
@@ -35,6 +35,37 @@ exports[`should render correctly: default 1`] = `
         onboarding.create_project.select_method.manual
       </div>
     </button>
+    <button
+      className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled"
+      disabled={true}
+      onClick={[Function]}
+      type="button"
+    >
+      <img
+        alt=""
+        height={80}
+        src="/images/alm/azure.svg"
+      />
+      <div
+        className="medium big-spacer-top"
+      >
+        onboarding.create_project.select_method.azure
+      </div>
+      <div
+        className="text-muted small spacer-top"
+        style={
+          Object {
+            "lineHeight": 1.5,
+          }
+        }
+      >
+        onboarding.create_project.alm_not_configured
+        <HelpTooltip
+          className="little-spacer-left"
+          overlay="onboarding.create_project.zero_alm_instances.azure"
+        />
+      </div>
+    </button>
     <button
       className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm"
       disabled={false}
@@ -153,6 +184,37 @@ exports[`should render correctly: invalid configs 1`] = `
         onboarding.create_project.select_method.manual
       </div>
     </button>
+    <button
+      className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled"
+      disabled={true}
+      onClick={[Function]}
+      type="button"
+    >
+      <img
+        alt=""
+        height={80}
+        src="/images/alm/azure.svg"
+      />
+      <div
+        className="medium big-spacer-top"
+      >
+        onboarding.create_project.select_method.azure
+      </div>
+      <div
+        className="text-muted small spacer-top"
+        style={
+          Object {
+            "lineHeight": 1.5,
+          }
+        }
+      >
+        onboarding.create_project.alm_not_configured
+        <HelpTooltip
+          className="little-spacer-left"
+          overlay="onboarding.create_project.zero_alm_instances.azure"
+        />
+      </div>
+    </button>
     <button
       className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled"
       disabled={true}
@@ -286,6 +348,29 @@ exports[`should render correctly: loading instances 1`] = `
         onboarding.create_project.select_method.manual
       </div>
     </button>
+    <button
+      className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled"
+      disabled={true}
+      onClick={[Function]}
+      type="button"
+    >
+      <img
+        alt=""
+        height={80}
+        src="/images/alm/azure.svg"
+      />
+      <div
+        className="medium big-spacer-top"
+      >
+        onboarding.create_project.select_method.azure
+      </div>
+      <span>
+        onboarding.create_project.check_alm_supported
+        <i
+          className="little-spacer-left spinner"
+        />
+      </span>
+    </button>
     <button
       className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled"
       disabled={true}
index 8dd4ffc6bc8d732be7c3ec1d4fe2ecb00d393883..7f37890764ce5c81db54b7133e40c51e04413c19 100644 (file)
@@ -53,6 +53,44 @@ exports[`should render correctly if no branch support 1`] = `
 </Fragment>
 `;
 
+exports[`should render correctly if the Azure method is selected 1`] = `
+<Fragment>
+  <Helmet
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="my_account.create_new.TRK"
+    titleTemplate="%s"
+  />
+  <A11ySkipTarget
+    anchor="create_project_main"
+  />
+  <div
+    className="page page-limited huge-spacer-bottom position-relative"
+    id="create-project"
+  >
+    <AzureProjectCreate
+      canAdmin={false}
+      loadingBindings={true}
+      location={
+        Object {
+          "action": "PUSH",
+          "hash": "",
+          "key": "key",
+          "pathname": "/path",
+          "query": Object {
+            "mode": "azure",
+          },
+          "search": "",
+          "state": Object {},
+        }
+      }
+      onProjectCreate={[Function]}
+      settings={Array []}
+    />
+  </div>
+</Fragment>
+`;
+
 exports[`should render correctly if the BBS method is selected 1`] = `
 <Fragment>
   <Helmet
index c84dd2c1d4cc3647c3d68642cb9a8f15eacb3c61..c000fa481d549041cb3a7b1382f134c55b825eca 100644 (file)
@@ -19,6 +19,7 @@
  */
 export enum CreateProjectModes {
   Manual = 'manual',
+  AzureDevOps = 'azure',
   BitbucketServer = 'bitbucket',
   GitHub = 'github',
   GitLab = 'gitlab'
index f8cd9e2d12e4bf76ca9375a63fa2de57807d5f31..02ee1bf0c49cf455945e02ffdfa23a0b72976242 100644 (file)
@@ -1854,6 +1854,7 @@ my_account.create_new.VW=Create Portfolio
 my_account.create_new.APP=Create Application
 my_account.add_project=Add Project
 my_account.add_project.manual=Manually
+my_account.add_project.azure=Azure DevOps
 my_account.add_project.bitbucket=Bitbucket
 my_account.add_project.github=GitHub
 my_account.add_project.gitlab=GitLab
@@ -3202,6 +3203,7 @@ footer.web_api=Web API
 # ONBOARDING
 #
 #------------------------------------------------------------------------------
+onboarding.alm.azure=Azure DevOps Server
 onboarding.alm.bitbucket=Bitbucket Server
 onboarding.alm.gitlab=GitLab
 
@@ -3211,6 +3213,7 @@ onboarding.project_analysis.guide_to_integrate_pipelines=follow the guide to int
 
 onboarding.create_project.setup_manually=Create a project
 onboarding.create_project.select_method.manual=Manually
+onboarding.create_project.select_method.azure=From Azure DevOps Server
 onboarding.create_project.select_method.bitbucket=From Bitbucket Server
 onboarding.create_project.select_method.github=From GitHub
 onboarding.create_project.select_method.gitlab=From GitLab
@@ -3239,10 +3242,14 @@ onboarding.create_project.from_bbs=Create a project from Bitbucket Server
 
 onboarding.create_application.key.description=If specified, this value is used as the key instead of generating it from the name of the Application. Only letters, digits, dashes and underscores can be used.
 
+onboarding.create_project.pat_form.title.azure=Allow SonarQube to access and list your Azure DevOps Server repositories
 onboarding.create_project.pat_form.title.bitbucket=Grant access to your repositories
 onboarding.create_project.pat_form.title.gitlab=Grant access to your projects
+onboarding.create_project.pat_form.help.azure=SonarQube needs a personal access token to access and list your repositories from Azure DevOps Server.
 onboarding.create_project.pat_form.help.bitbucket=SonarQube needs a personal access token to access and list your repositories from Bitbucket Server.
 onboarding.create_project.pat_form.help.gitlab=SonarQube needs a personal access token to access and list your projects from GitLab.
+onboarding.create_project.pat_form.pat_required=Please enter a personal access token
+onboarding.create_project.pat_form.list_repositories=List repositories
 onboarding.create_project.select_method=How do you want to create your project?
 onboarding.create_project.too_many_alm_instances.bitbucket=You must have exactly 1 Bitbucket Server instance configured in order to use this method.
 onboarding.create_project.too_many_alm_instances.github=You must have exactly 1 Bitbucket Server instance configured in order to use this method.
@@ -3253,10 +3260,14 @@ onboarding.create_project.zero_alm_instances.gitlab=You must first configure a G
 onboarding.create_project.wrong_binding_count=You must have exactly 1 {alm} instance configured in order to use this method, but none were found. Either create the project manually, or contact your system administrator.
 onboarding.create_project.wrong_binding_count.admin=You must have exactly 1 {alm} instance configured in order to use this method. You can configure instances under {url}.
 onboarding.create_project.enter_pat=Enter personal access token
+onboarding.create_project.pat_incorrect.azure=Your personal access couldn't be validated.
 onboarding.create_project.pat_incorrect.bitbucket=Your personal access couldn't be validated.
 onboarding.create_project.pat_incorrect.gitlab=Your personal access couldn't be validated. Please make sure it has the right scope and that it is not expired.
 onboarding.create_project.pat_help.title=How to create a personal access token?
 
+onboarding.create_project.pat_help.instructions.azure=Create and provide an Azure DevOps Server {link}. You need to select the {scope} scope so we can display a list of your repositories which are available for analysis.
+onboarding.create_project.pat_help.instructions.link.azure=personal access token
+
 onboarding.create_project.pat_help.instructions=Click the following link to generate a token in {alm}, and copy-paste it into the personal access token field.
 onboarding.create_project.pat_help.instructions2.bitbucket=Set a name, for example "SonarQube", and select the following permissions:
 onboarding.create_project.pat_help.link=Create personal access token
@@ -3274,6 +3285,8 @@ onboarding.create_project.no_bbs_repos.filter=No repositories match your filter.
 onboarding.create_project.only_showing_X_first_repos=We're only displaying the first {0} repositories. If you're looking for a repository that's not in this list, use the search above.
 onboarding.create_project.import_selected_repo=Set up selected repository
 onboarding.create_project.go_to_project=Go to project
+
+onboarding.create_project.azure.title=Which Azure DevOps Server repository do you want to set up?
 onboarding.create_project.github.title=Which GitHub repository do you want to set up?
 onboarding.create_project.github.choose_organization=Choose organization
 onboarding.create_project.github.warning.title=Could not connect to GitHub