Skip to content

Commit

Permalink
Add Azure DevOps integration (#20202)
Browse files Browse the repository at this point in the history
* Add node package

* first nit changes

* nit proto udpate

* fixup

* [server] add azure support

* fixup

* fixup

* server fixup

* [dashboard] changes

* fixup

* fixup

* Fix server bugs

* Fixup

* Fix dashboard

* Fix user integration

* Fix permission update modal

* tmp

* Add unit tests and fix get file content issue

* Add readme

* fix tag and branch parser

* Update README.md

* Remove API tests

* Disable azure devops support for PAYG

* Revert "Remove API tests"

This reverts commit a525cbd.

* Fix tests

* Rebase fixup

* nit fixing

* revert me

* Fix integration udpate

* Fix ENT-780

* Don't support azure devops on PAYG

* dashboard: add comments and remove new Azure DevOps supports on user settings page

* Fix push warning and make project a part of owner

* Proper handle errors

* Fix token can't refresh issue

* Fix api

* Add project context supports

* Update components/server/src/azure-devops/azure-context-parser.spec.ts

Co-authored-by: Filip Troníček <[email protected]>

* Fix readablestream error

* Fix clone url

* Address feedback

- dashboard provider type update
- doc for checkWriteAccess
- Requirement.DEFAULT

* 1

* avatar

* Revert "revert me"

This reverts commit 189c431.

---------

Co-authored-by: Filip Troníček <[email protected]>
  • Loading branch information
mustard-mh and filiptronicek authored Sep 27, 2024
1 parent 9e0f6e1 commit 41f47c8
Show file tree
Hide file tree
Showing 42 changed files with 3,090 additions and 175 deletions.
14 changes: 14 additions & 0 deletions components/dashboard/src/components/RepositoryFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ export default function RepositoryFinder({
});
}

if (searchString.length >= 3 && authProviders.data?.some((p) => p.type === AuthProviderType.AZURE_DEVOPS)) {
// ENT-780
result.push({
id: "azure-devops",
element: (
<div className="text-sm text-pk-content-tertiary flex items-center">
<Exclamation2 className="w-4 h-4 mr-2" />
<span>Azure DevOps doesn't support repository searching.</span>
</div>
),
isSelectable: false,
});
}

if (searchString.length < 3) {
// add an element that tells the user to type more
result.push({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
import { isGitpodIo } from "../../utils";
import { useMemo } from "react";

const optionsForPAYG = [
{ type: AuthProviderType.GITHUB, label: "GitHub" },
{ type: AuthProviderType.GITLAB, label: "GitLab" },
{ type: AuthProviderType.BITBUCKET_SERVER, label: "Bitbucket Server" },
{ type: AuthProviderType.BITBUCKET, label: "Bitbucket Cloud" },
];

const optionsForEnterprise = [...optionsForPAYG, { type: AuthProviderType.AZURE_DEVOPS, label: "Azure DevOps" }];

export const isSupportAzureDevOpsIntegration = () => {
return isGitpodIo();
};

export const useAuthProviderOptionsQuery = (isOrgLevel: boolean) => {
return useMemo(() => {
const isPAYG = isGitpodIo();
// Azure DevOps is not supported for PAYG users and is only available for org-level integrations
// because auth flow is identified by auth provider's host, which will always be `dev.azure.com`
//
// Don't remove this until we can setup an generial application for Azure DevOps (investigate needed)
if (isPAYG || !isOrgLevel) {
return optionsForPAYG;
}
return optionsForEnterprise;
}, [isOrgLevel]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type CreateAuthProviderArgs = {
clientId: string;
clientSecret: string;
orgId: string;
authorizationUrl?: string;
tokenUrl?: string;
};
};
export const useCreateOrgAuthProviderMutation = () => {
Expand All @@ -28,6 +30,8 @@ export const useCreateOrgAuthProviderMutation = () => {
oauth2Config: {
clientId: provider.clientId,
clientSecret: provider.clientSecret,
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
},
type: provider.type,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type CreateAuthProviderArgs = {
clientId: string;
clientSecret: string;
userId: string;
authorizationUrl?: string;
tokenUrl?: string;
};
};
export const useCreateUserAuthProviderMutation = () => {
Expand All @@ -28,6 +30,8 @@ export const useCreateUserAuthProviderMutation = () => {
oauth2Config: {
clientId: provider.clientId,
clientSecret: provider.clientSecret,
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
},
type: provider.type,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type UpdateAuthProviderArgs = {
id: string;
clientId: string;
clientSecret: string;
authorizationUrl?: string;
tokenUrl?: string;
};
};
export const useUpdateOrgAuthProviderMutation = () => {
Expand All @@ -26,6 +28,8 @@ export const useUpdateOrgAuthProviderMutation = () => {
authProviderId: provider.id,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
}),
);
return response.authProvider!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type UpdateAuthProviderArgs = {
id: string;
clientId: string;
clientSecret: string;
authorizationUrl?: string;
tokenUrl?: string;
};
};
export const useUpdateUserAuthProviderMutation = () => {
Expand All @@ -26,6 +28,8 @@ export const useUpdateUserAuthProviderMutation = () => {
authProviderId: provider.id,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
}),
);
return response.authProvider!;
Expand Down
1 change: 1 addition & 0 deletions components/dashboard/src/images/azuredevops.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions components/dashboard/src/provider-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_
import bitbucket from "./images/bitbucket.svg";
import github from "./images/github.svg";
import gitlab from "./images/gitlab.svg";
import azuredevops from "./images/azuredevops.svg";
import { gitpodHostUrl } from "./service/service";

function iconForAuthProvider(type: string | AuthProviderType) {
Expand All @@ -24,6 +25,9 @@ function iconForAuthProvider(type: string | AuthProviderType) {
case "BitbucketServer":
case AuthProviderType.BITBUCKET_SERVER:
return <img className="fill-current filter-grayscale w-5 h-5 ml-3 mr-3 my-auto" src={bitbucket} alt="" />;
case "AzureDevOps":
case AuthProviderType.AZURE_DEVOPS:
return <img className="fill-current filter-grayscale w-5 h-5 ml-3 mr-3 my-auto" src={azuredevops} alt="" />;
default:
return <></>;
}
Expand All @@ -39,6 +43,8 @@ export function toAuthProviderLabel(type: AuthProviderType) {
return "Bitbucket Cloud";
case AuthProviderType.BITBUCKET_SERVER:
return "Bitbucket Server";
case AuthProviderType.AZURE_DEVOPS:
return "Azure DevOps";
default:
return "-";
}
Expand All @@ -52,6 +58,8 @@ function simplifyProviderName(host: string) {
return "GitLab";
case "bitbucket.org":
return "Bitbucket";
case "dev.azure.com":
return "Azure DevOps";
default:
return host;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { useCreateOrgAuthProviderMutation } from "../../data/auth-providers/crea
import { useUpdateOrgAuthProviderMutation } from "../../data/auth-providers/update-org-auth-provider-mutation";
import { authProviderClient, userClient } from "../../service/public-api";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import {
isSupportAzureDevOpsIntegration,
useAuthProviderOptionsQuery,
} from "../../data/auth-providers/auth-provider-options-query";

type Props = {
provider?: AuthProvider;
Expand All @@ -37,6 +41,10 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
const [host, setHost] = useState<string>(props.provider?.host ?? "");
const [clientId, setClientId] = useState<string>(props.provider?.oauth2Config?.clientId ?? "");
const [clientSecret, setClientSecret] = useState<string>(props.provider?.oauth2Config?.clientSecret ?? "");
const [authorizationUrl, setAuthorizationUrl] = useState(props.provider?.oauth2Config?.authorizationUrl ?? "");
const [tokenUrl, setTokenUrl] = useState(props.provider?.oauth2Config?.tokenUrl ?? "");
const availableProviderOptions = useAuthProviderOptionsQuery(true);
const supportAzureDevOps = isSupportAzureDevOpsIntegration();

const [savedProvider, setSavedProvider] = useState(props.provider);
const isNew = !savedProvider;
Expand Down Expand Up @@ -82,6 +90,21 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
clientSecret.trim().length > 0,
);

const {
message: authorizationUrlError,
onBlur: authorizationUrlOnBlur,
isValid: authorizationUrlValid,
} = useOnBlurError(
`Authorization URL is missing.`,
type !== AuthProviderType.AZURE_DEVOPS || authorizationUrl.trim().length > 0,
);

const {
message: tokenUrlError,
onBlur: tokenUrlOnBlur,
isValid: tokenUrlValid,
} = useOnBlurError(`Token URL is missing.`, type !== AuthProviderType.AZURE_DEVOPS || tokenUrl.trim().length > 0);

// Call our error onBlur handler, and remove prefixed "https://"
const hostOnBlur = useCallback(() => {
hostOnBlurErrorTracking();
Expand Down Expand Up @@ -112,6 +135,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {

const trimmedId = clientId.trim();
const trimmedSecret = clientSecret.trim();
const trimmedAuthorizationUrl = authorizationUrl.trim();
const trimmedTokenUrl = tokenUrl.trim();

try {
let newProvider: AuthProvider;
Expand All @@ -123,6 +148,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
orgId: team.id,
clientId: trimmedId,
clientSecret: trimmedSecret,
authorizationUrl: trimmedAuthorizationUrl,
tokenUrl: trimmedTokenUrl,
},
});
} else {
Expand All @@ -131,6 +158,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
id: savedProvider.id,
clientId: trimmedId,
clientSecret: clientSecret === "redacted" ? "" : trimmedSecret,
authorizationUrl: trimmedAuthorizationUrl,
tokenUrl: trimmedTokenUrl,
},
});
}
Expand Down Expand Up @@ -181,6 +210,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
}, [
clientId,
clientSecret,
authorizationUrl,
tokenUrl,
host,
invalidateOrgAuthProviders,
isNew,
Expand All @@ -196,8 +227,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
]);

const isValid = useMemo(
() => clientIdValid && clientSecretValid && hostValid,
[clientIdValid, clientSecretValid, hostValid],
() => clientIdValid && clientSecretValid && hostValid && authorizationUrlValid && tokenUrlValid,
[clientIdValid, clientSecretValid, hostValid, authorizationUrlValid, tokenUrlValid],
);

const getNumber = (paramValue: string | null) => {
Expand All @@ -223,7 +254,8 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
<ModalBody>
{isNew && (
<Subheading>
Configure a Git Integration with a self-managed instance of GitLab, GitHub, or Bitbucket Server.
Configure a Git Integration with a self-managed instance of GitLab, GitHub{" "}
{supportAzureDevOps ? ", Bitbucket Server or Azure DevOps" : "or Bitbucket"}.
</Subheading>
)}

Expand All @@ -235,10 +267,11 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
topMargin={false}
onChange={(val) => setType(getNumber(val))}
>
<option value={AuthProviderType.GITHUB}>GitHub</option>
<option value={AuthProviderType.GITLAB}>GitLab</option>
<option value={AuthProviderType.BITBUCKET}>Bitbucket Cloud</option>
<option value={AuthProviderType.BITBUCKET_SERVER}>Bitbucket Server</option>
{availableProviderOptions.map((option) => (
<option key={option.type} value={option.type}>
{option.label}
</option>
))}
</SelectInputField>
<TextInputField
label="Provider Host Name"
Expand All @@ -254,6 +287,25 @@ export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
<InputWithCopy value={redirectURL} tip="Copy the redirect URI to clipboard" />
</InputField>

{type === AuthProviderType.AZURE_DEVOPS && (
<>
<TextInputField
label="Authorization URL"
value={authorizationUrl}
error={authorizationUrlError}
onBlur={authorizationUrlOnBlur}
onChange={setAuthorizationUrl}
/>
<TextInputField
label="Token URL"
value={tokenUrl}
error={tokenUrlError}
onBlur={tokenUrlOnBlur}
onChange={setTokenUrl}
/>
</>
)}

<TextInputField
label={type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"}
value={clientId}
Expand Down Expand Up @@ -314,6 +366,8 @@ const getPlaceholderForIntegrationType = (type: AuthProviderType) => {
return "bitbucket.org";
case AuthProviderType.BITBUCKET_SERVER:
return "bitbucket.example.com";
case AuthProviderType.AZURE_DEVOPS:
return "dev.azure.com";
default:
return "";
}
Expand All @@ -337,6 +391,9 @@ const RedirectUrlDescription: FunctionComponent<RedirectUrlDescriptionProps> = (
case AuthProviderType.BITBUCKET_SERVER:
docsUrl = "https://www.gitpod.io/docs/configure/authentication/bitbucket-server";
break;
case AuthProviderType.AZURE_DEVOPS:
docsUrl = "https://www.gitpod.io/docs/configure/authentication/azure-devops";
break;
default:
return null;
}
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/src/user-settings/AuthEntryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ContextMenuEntry } from "../components/ContextMenu";
import { Item, ItemFieldIcon, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
import { AuthProviderDescription } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
import { toAuthProviderLabel } from "../provider-utils";
import { getScopeNameForScope } from "@gitpod/public-api-common/lib/auth-providers";

interface AuthEntryItemParams {
ap: AuthProviderDescription;
Expand Down Expand Up @@ -53,7 +54,7 @@ export const AuthEntryItem = (props: AuthEntryItemParams) => {
</ItemField>
<ItemField className="hidden xl:w-1/3 xl:flex xl:flex-col my-auto">
<span className="my-auto truncate text-gray-500 overflow-ellipsis dark:text-gray-400">
{props.getPermissions(props.ap.id)?.join(", ") || "–"}
{props.getPermissions(props.ap.id)?.map(getScopeNameForScope)?.join(", ") || "–"}
</span>
<span className="text-sm my-auto text-gray-400 dark:text-gray-500">Permissions</span>
</ItemField>
Expand Down
Loading

0 comments on commit 41f47c8

Please sign in to comment.