mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2928700c7 | |||
| 0211db1765 | |||
| 68ae4eb4bc | |||
| 8ff51e18c5 | |||
| d331edf8cd | |||
| 9aac92929b | |||
| 1c1588786b | |||
| 76c59bd365 | |||
| 53336d6cb0 | |||
| bde01d8f5b | |||
| 1bb3a4a9ad | |||
| b785639ec9 | |||
| 45c133083a | |||
| 13049e291c | |||
| 823a9f1398 | |||
| dcf5fc510e | |||
| f2524a3f9d | |||
| 1be4f6271f | |||
| 8d1414ee01 | |||
| 81a3ced7dc | |||
| 55d2069988 | |||
| 5bc2831307 | |||
| 41f1ba540f | |||
| accef7a908 | |||
| 9db84c1d46 | |||
| aae949fbd3 | |||
| fb2967c8c9 | |||
| 50fa1a0d23 | |||
| 81ebbcf05d | |||
| c4111a7b19 | |||
| d47447ba45 | |||
| 8a3daa888e | |||
| bda316a7e5 | |||
| d1020bbd90 | |||
| 89b783baef | |||
| 2edf026f3f | |||
| 902e006731 | |||
| 8fec9962ab | |||
| 528e4970f6 | |||
| da77c7ee1c | |||
| 60280ff97e | |||
| 6329344c2a | |||
| bcde287a11 | |||
| e689b41e2e | |||
| d2e03758f9 | |||
| 196feac00e | |||
| d25b0ca567 | |||
| 33ce4b3b8d |
@@ -89,6 +89,9 @@ describe("Content", () => {
|
||||
await screen.findByTestId("gitlab-token-input");
|
||||
await screen.findByTestId("gitlab-token-help-anchor");
|
||||
|
||||
await screen.findByTestId("azure-devops-token-input");
|
||||
await screen.findByTestId("azure-devops-token-help-anchor");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -107,6 +110,13 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("azure-devops-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("azure-devops-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +143,12 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const azureDevOpsInput = screen.getByTestId("azure-devops-token-input");
|
||||
expect(azureDevOpsInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("ado-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
@@ -140,6 +156,7 @@ describe("Content", () => {
|
||||
provider_tokens_set: {
|
||||
github: null,
|
||||
gitlab: null,
|
||||
azure_devops: null,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
@@ -158,12 +175,19 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const azureDevOpsInput = screen.getByTestId("azure-devops-token-input");
|
||||
expect(azureDevOpsInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("ado-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: null,
|
||||
azure_devops: null,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
@@ -182,6 +206,12 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const azureDevOpsInput = screen.getByTestId("azure-devops-token-input");
|
||||
expect(azureDevOpsInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("ado-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,15 +273,49 @@ describe("Form submission", () => {
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
azure_devops: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the GitLab token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token", host: "" },
|
||||
github: { token: "", host: "" },
|
||||
gitlab: { token: "test-token", host: "" },
|
||||
azure_devops: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the Azure DevOps token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input");
|
||||
const azureDevOpsHostInput = await screen.findByTestId("azure-devops-host-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(azureDevOpsInput, "test-token");
|
||||
await userEvent.type(azureDevOpsHostInput, "https://dev.azure.com/test-org");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
azure_devops: { token: "test-token", host: "https://dev.azure.com/test-org" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,6 +343,14 @@ describe("Form submission", () => {
|
||||
|
||||
await userEvent.clear(gitlabInput);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input");
|
||||
await userEvent.type(azureDevOpsInput, "test-token");
|
||||
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(azureDevOpsInput);
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable a disconnect tokens button if there is at least one token set", async () => {
|
||||
@@ -291,6 +363,7 @@ describe("Form submission", () => {
|
||||
provider_tokens_set: {
|
||||
github: null,
|
||||
gitlab: null,
|
||||
azure_devops: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -322,6 +395,7 @@ describe("Form submission", () => {
|
||||
provider_tokens_set: {
|
||||
github: null,
|
||||
gitlab: null,
|
||||
azure_devops: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m22 18-5 4-8-3v3l-4.19-5.75 12.91 1.05v-10.96l4.28-.69zm-17.19-1.75v-7.29l12.91-2.62-7.12-4.34v2.84l-6.63 1.92-1.97 2.62v5.69z"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -20,19 +20,31 @@ export function ActionSuggestions({
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isAzureDevOps = providers.includes("azure_devops");
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
// Determine the correct terminology based on the provider
|
||||
let pr;
|
||||
let prShort;
|
||||
let providerName;
|
||||
if (isGitLab) {
|
||||
pr = "merge request";
|
||||
prShort = "MR";
|
||||
providerName = "GitLab";
|
||||
} else if (isAzureDevOps) {
|
||||
pr = "pull request";
|
||||
prShort = "PR";
|
||||
providerName = "Azure DevOps";
|
||||
} else {
|
||||
pr = "pull request";
|
||||
prShort = "PR";
|
||||
providerName = "GitHub";
|
||||
}
|
||||
|
||||
const terms = {
|
||||
pr,
|
||||
prShort,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${providerName}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${providerName} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToPR: `Please push the latest changes to the existing ${pr}.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -54,7 +54,17 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else if (task.git_provider === "azure_devops") {
|
||||
// Azure DevOps URLs format: https://dev.azure.com/{organization}/{project}/_workitems/edit/{id}
|
||||
// For pull requests: https://dev.azure.com/{organization}/{project}/_git/{repository}/pullrequest/{id}
|
||||
const [project, repository] = task.repo.split("/");
|
||||
if (task.task_type === "OPEN_ISSUE") {
|
||||
href = `https://dev.azure.com/${project}/_workitems/edit/${task.issue_number}`;
|
||||
} else {
|
||||
href = `https://dev.azure.com/${project}/_git/${repository}/pullrequest/${task.issue_number}`;
|
||||
}
|
||||
} else {
|
||||
// Default to GitHub
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function AzureDevOpsTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="azure-devops-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.AZURE_DEVOPS$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="azure-devops-token-help-anchor-link"
|
||||
aria-label="Azure DevOps token help link"
|
||||
href="https://dev.azure.com/_usersSettings/tokens"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="azure-devops-token-help-anchor-link-2"
|
||||
aria-label="Azure DevOps token see more link"
|
||||
href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { AzureDevOpsTokenHelpAnchor } from "./azure-devops-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface AzureDevOpsTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
onAzureDevOpsHostChange: (value: string) => void;
|
||||
isAzureDevOpsTokenSet: boolean;
|
||||
name: string;
|
||||
azureDevOpsHostSet: string | null | undefined;
|
||||
}
|
||||
|
||||
export function AzureDevOpsTokenInput({
|
||||
onChange,
|
||||
onAzureDevOpsHostChange,
|
||||
isAzureDevOpsTokenSet,
|
||||
name,
|
||||
azureDevOpsHostSet,
|
||||
}: AzureDevOpsTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.AZURE_DEVOPS$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isAzureDevOpsTokenSet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isAzureDevOpsTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="ado-set-token-indicator"
|
||||
isSet={isAzureDevOpsTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
onChange={onAzureDevOpsHostChange || (() => {})}
|
||||
name="azure-devops-host-input"
|
||||
testId="azure-devops-host-input"
|
||||
label={t(I18nKey.AZURE_DEVOPS$HOST_LABEL)}
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
placeholder="https://dev.azure.com/{your-org-name}"
|
||||
defaultValue={azureDevOpsHostSet || undefined}
|
||||
startContent={
|
||||
azureDevOpsHostSet &&
|
||||
azureDevOpsHostSet.trim() !== "" && (
|
||||
<KeyStatusIcon testId="ado-set-host-indicator" isSet />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<AzureDevOpsTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
@@ -23,6 +24,11 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const azureDevOpsAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "azure_devops",
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@@ -37,6 +43,13 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAzureDevOpsAuth = () => {
|
||||
if (azureDevOpsAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = azureDevOpsAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
@@ -67,6 +80,17 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleAzureDevOpsAuth}
|
||||
className="w-full"
|
||||
startContent={<AzureDevOpsLogo width={20} height={20} />}
|
||||
>
|
||||
{t(I18nKey.AZURE_DEVOPS$CONNECT_TO_AZURE_DEVOPS) ||
|
||||
"Connect to Azure DevOps"}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useAutoLogin = () => {
|
||||
// Get the stored login method
|
||||
const loginMethod = getLoginMethod();
|
||||
|
||||
// Get the auth URLs for both providers
|
||||
// Get the auth URLs for all providers
|
||||
const githubAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "github",
|
||||
@@ -26,6 +26,11 @@ export const useAutoLogin = () => {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const azureDevOpsAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "azure_devops",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-login in SAAS mode
|
||||
if (config?.APP_MODE !== "saas") {
|
||||
@@ -48,8 +53,14 @@ export const useAutoLogin = () => {
|
||||
}
|
||||
|
||||
// Get the appropriate auth URL based on the stored login method
|
||||
const authUrl =
|
||||
loginMethod === LoginMethod.GITHUB ? githubAuthUrl : gitlabAuthUrl;
|
||||
let authUrl: string | null = null;
|
||||
if (loginMethod === LoginMethod.GITHUB) {
|
||||
authUrl = githubAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.GITLAB) {
|
||||
authUrl = gitlabAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.AZURE_DEVOPS) {
|
||||
authUrl = azureDevOpsAuthUrl;
|
||||
}
|
||||
|
||||
// If we have an auth URL, redirect to it
|
||||
if (authUrl) {
|
||||
@@ -68,5 +79,6 @@ export const useAutoLogin = () => {
|
||||
loginMethod,
|
||||
githubAuthUrl,
|
||||
gitlabAuthUrl,
|
||||
azureDevOpsAuthUrl,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -508,6 +508,16 @@ export enum I18nKey {
|
||||
SETTINGS_FORM$BASE_URL = "SETTINGS_FORM$BASE_URL",
|
||||
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
||||
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
|
||||
AZURE_DEVOPS$CONNECT_TO_AZURE_DEVOPS = "AZURE_DEVOPS$CONNECT_TO_AZURE_DEVOPS",
|
||||
AZURE_DEVOPS$TOKEN_LABEL = "AZURE_DEVOPS$TOKEN_LABEL",
|
||||
AZURE_DEVOPS$HOST_LABEL = "AZURE_DEVOPS$HOST_LABEL",
|
||||
AZURE_DEVOPS$HOST_HELP_TEXT = "AZURE_DEVOPS$HOST_HELP_TEXT",
|
||||
AZURE_DEVOPS$HOST_REQUIRED_ERROR = "AZURE_DEVOPS$HOST_REQUIRED_ERROR",
|
||||
AZURE_DEVOPS$TOKEN_REQUIRED_ERROR = "AZURE_DEVOPS$TOKEN_REQUIRED_ERROR",
|
||||
AZURE_DEVOPS$GET_TOKEN = "AZURE_DEVOPS$GET_TOKEN",
|
||||
AZURE_DEVOPS$TOKEN_HELP_TEXT = "AZURE_DEVOPS$TOKEN_HELP_TEXT",
|
||||
AZURE_DEVOPS$TOKEN_LINK_TEXT = "AZURE_DEVOPS$TOKEN_LINK_TEXT",
|
||||
AZURE_DEVOPS$INSTRUCTIONS_LINK_TEXT = "AZURE_DEVOPS$INSTRUCTIONS_LINK_TEXT",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
|
||||
@@ -8127,6 +8127,166 @@
|
||||
"tr": "GitLab'a bağlan",
|
||||
"uk": "Увійти за допомогою GitLab"
|
||||
},
|
||||
"AZURE_DEVOPS$CONNECT_TO_AZURE_DEVOPS": {
|
||||
"en": "Log in with Azure DevOps",
|
||||
"ja": "Azure DevOpsに接続",
|
||||
"zh-CN": "连接到Azure DevOps",
|
||||
"zh-TW": "連接到Azure DevOps",
|
||||
"ko-KR": "Azure DevOps에 연결",
|
||||
"de": "Mit Azure DevOps verbinden",
|
||||
"no": "Koble til Azure DevOps",
|
||||
"it": "Connetti a Azure DevOps",
|
||||
"pt": "Conectar ao Azure DevOps",
|
||||
"es": "Conectar a Azure DevOps",
|
||||
"ar": "الاتصال بـ Azure DevOps",
|
||||
"fr": "Se connecter à Azure DevOps",
|
||||
"tr": "Azure DevOps'a bağlan",
|
||||
"uk": "Увійти за допомогою Azure DevOps"
|
||||
},
|
||||
"AZURE_DEVOPS$TOKEN_LABEL": {
|
||||
"en": "Azure DevOps Token",
|
||||
"ja": "Azure DevOpsトークン",
|
||||
"zh-CN": "Azure DevOps令牌",
|
||||
"zh-TW": "Azure DevOps權杖",
|
||||
"ko-KR": "Azure DevOps 토큰",
|
||||
"no": "Azure DevOps-token",
|
||||
"it": "Token Azure DevOps",
|
||||
"pt": "Token do Azure DevOps",
|
||||
"es": "Token de Azure DevOps",
|
||||
"ar": "رمز Azure DevOps",
|
||||
"fr": "Jeton Azure DevOps",
|
||||
"tr": "Azure DevOps Token",
|
||||
"de": "Azure DevOps-Token",
|
||||
"uk": "Токен Azure DevOps"
|
||||
},
|
||||
"AZURE_DEVOPS$HOST_LABEL": {
|
||||
"en": "Azure DevOps Organization URL (Required)",
|
||||
"ja": "Azure DevOps組織URL(必須)",
|
||||
"zh-CN": "Azure DevOps组织URL(必需)",
|
||||
"zh-TW": "Azure DevOps組織URL(必需)",
|
||||
"ko-KR": "Azure DevOps 조직 URL (필수)",
|
||||
"no": "Azure DevOps organisasjons-URL (påkrevd)",
|
||||
"it": "URL organizzazione Azure DevOps (obbligatorio)",
|
||||
"pt": "URL da organização Azure DevOps (obrigatório)",
|
||||
"es": "URL de organización de Azure DevOps (requerido)",
|
||||
"ar": "رابط منظمة Azure DevOps (مطلوب)",
|
||||
"fr": "URL d'organisation Azure DevOps (requis)",
|
||||
"tr": "Azure DevOps Organizasyon URL'si (gerekli)",
|
||||
"de": "Azure DevOps-Organisations-URL (erforderlich)",
|
||||
"uk": "URL організації Azure DevOps (обов'язково)"
|
||||
},
|
||||
"AZURE_DEVOPS$HOST_HELP_TEXT": {
|
||||
"en": "Enter your organization URL (e.g., dev.azure.com/your-org). This is required because Azure DevOps tokens are organization-scoped.",
|
||||
"ja": "組織URL(例:dev.azure.com/your-org)を入力してください。Azure DevOpsトークンは組織スコープのため、これは必須です。",
|
||||
"zh-CN": "输入您的组织URL(例如:dev.azure.com/your-org)。这是必需的,因为Azure DevOps令牌是组织范围的。",
|
||||
"zh-TW": "輸入您的組織URL(例如:dev.azure.com/your-org)。這是必需的,因為Azure DevOps權杖是組織範圍的。",
|
||||
"ko-KR": "조직 URL을 입력하세요 (예: dev.azure.com/your-org). Azure DevOps 토큰이 조직 범위이므로 필수입니다.",
|
||||
"no": "Skriv inn organisasjons-URL (f.eks. dev.azure.com/your-org). Dette er påkrevd fordi Azure DevOps-tokens er organisasjonsbegrenset.",
|
||||
"it": "Inserisci l'URL della tua organizzazione (es. dev.azure.com/your-org). Questo è obbligatorio perché i token Azure DevOps sono limitati all'organizzazione.",
|
||||
"pt": "Digite a URL da sua organização (ex: dev.azure.com/your-org). Isso é obrigatório porque os tokens do Azure DevOps são limitados à organização.",
|
||||
"es": "Ingrese la URL de su organización (ej: dev.azure.com/your-org). Esto es requerido porque los tokens de Azure DevOps están limitados a la organización.",
|
||||
"ar": "أدخل رابط منظمتك (مثل: dev.azure.com/your-org). هذا مطلوب لأن رموز Azure DevOps محدودة النطاق للمنظمة.",
|
||||
"fr": "Entrez l'URL de votre organisation (ex: dev.azure.com/your-org). Ceci est requis car les jetons Azure DevOps sont limités à l'organisation.",
|
||||
"tr": "Organizasyon URL'nizi girin (örn: dev.azure.com/your-org). Azure DevOps tokenları organizasyon kapsamlı olduğu için bu gereklidir.",
|
||||
"de": "Geben Sie Ihre Organisations-URL ein (z.B. dev.azure.com/your-org). Dies ist erforderlich, da Azure DevOps-Token organisationsbezogen sind.",
|
||||
"uk": "Введіть URL вашої організації (наприклад: dev.azure.com/your-org). Це обов'язково, оскільки токени Azure DevOps обмежені організацією."
|
||||
},
|
||||
"AZURE_DEVOPS$HOST_REQUIRED_ERROR": {
|
||||
"en": "Organization URL is required when Azure DevOps token is provided.",
|
||||
"ja": "Azure DevOpsトークンが提供されている場合、組織URLが必要です。",
|
||||
"zh-CN": "提供Azure DevOps令牌时需要组织URL。",
|
||||
"zh-TW": "提供Azure DevOps權杖時需要組織URL。",
|
||||
"ko-KR": "Azure DevOps 토큰이 제공될 때 조직 URL이 필요합니다.",
|
||||
"no": "Organisasjons-URL kreves når Azure DevOps-token er oppgitt.",
|
||||
"it": "L'URL dell'organizzazione è richiesto quando viene fornito il token Azure DevOps.",
|
||||
"pt": "A URL da organização é necessária quando o token do Azure DevOps é fornecido.",
|
||||
"es": "Se requiere la URL de la organización cuando se proporciona el token de Azure DevOps.",
|
||||
"ar": "رابط المنظمة مطلوب عند توفير رمز Azure DevOps.",
|
||||
"fr": "L'URL d'organisation est requise lorsque le jeton Azure DevOps est fourni.",
|
||||
"tr": "Azure DevOps jetonu sağlandığında organizasyon URL'si gereklidir.",
|
||||
"de": "Organisations-URL ist erforderlich, wenn Azure DevOps-Token bereitgestellt wird.",
|
||||
"uk": "URL організації потрібен, коли надається токен Azure DevOps."
|
||||
},
|
||||
"AZURE_DEVOPS$TOKEN_REQUIRED_ERROR": {
|
||||
"en": "Azure DevOps token is required when organization URL is provided.",
|
||||
"ja": "組織URLが提供されている場合、Azure DevOpsトークンが必要です。",
|
||||
"zh-CN": "提供组织URL时需要Azure DevOps令牌。",
|
||||
"zh-TW": "提供組織URL時需要Azure DevOps權杖。",
|
||||
"ko-KR": "조직 URL이 제공될 때 Azure DevOps 토큰이 필요합니다.",
|
||||
"no": "Azure DevOps-token kreves når organisasjons-URL er oppgitt.",
|
||||
"it": "Il token Azure DevOps è richiesto quando viene fornito l'URL dell'organizzazione.",
|
||||
"pt": "O token do Azure DevOps é necessário quando a URL da organização é fornecida.",
|
||||
"es": "Se requiere el token de Azure DevOps cuando se proporciona la URL de la organización.",
|
||||
"ar": "رمز Azure DevOps مطلوب عند توفير رابط المنظمة.",
|
||||
"fr": "Le jeton Azure DevOps est requis lorsque l'URL d'organisation est fournie.",
|
||||
"tr": "Organizasyon URL'si sağlandığında Azure DevOps jetonu gereklidir.",
|
||||
"de": "Azure DevOps-Token ist erforderlich, wenn Organisations-URL bereitgestellt wird.",
|
||||
"uk": "Токен Azure DevOps потрібен, коли надається URL організації."
|
||||
},
|
||||
"AZURE_DEVOPS$GET_TOKEN": {
|
||||
"en": "Get an Azure DevOps token",
|
||||
"ja": "Azure DevOpsトークンを取得",
|
||||
"zh-CN": "获取Azure DevOps令牌",
|
||||
"zh-TW": "獲取Azure DevOps權杖",
|
||||
"ko-KR": "Azure DevOps 토큰 받기",
|
||||
"no": "Få et Azure DevOps-token",
|
||||
"it": "Ottieni un token Azure DevOps",
|
||||
"pt": "Obter um token do Azure DevOps",
|
||||
"es": "Obtener un token de Azure DevOps",
|
||||
"ar": "الحصول على رمز Azure DevOps",
|
||||
"fr": "Obtenir un jeton Azure DevOps",
|
||||
"tr": "Azure DevOps token al",
|
||||
"de": "Azure DevOps-Token erhalten",
|
||||
"uk": "Отримати токен Azure DevOps"
|
||||
},
|
||||
"AZURE_DEVOPS$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>Azure DevOps personal access token</0> or <1>click here for instructions</1>.",
|
||||
"ja": "<0>Azure DevOps個人アクセストークン</0>を取得するか、<1>手順についてはここをクリック</1>。",
|
||||
"zh-CN": "获取您的<0>Azure DevOps个人访问令牌</0>或<1>点击此处获取说明</1>。",
|
||||
"zh-TW": "取得您的<0>Azure DevOps個人存取權杖</0>或<1>點擊此處獲取說明</1>。",
|
||||
"ko-KR": "<0>Azure DevOps 개인 액세스 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>하세요.",
|
||||
"no": "Få ditt <0>Azure DevOps personlige tilgangstoken</0> eller <1>klikk her for instruksjoner</1>.",
|
||||
"it": "Ottieni il tuo <0>token di accesso personale Azure DevOps</0> o <1>clicca qui per istruzioni</1>.",
|
||||
"pt": "Obtenha seu <0>token de acesso pessoal do Azure DevOps</0> ou <1>clique aqui para instruções</1>.",
|
||||
"es": "Obtenga su <0>token de acceso personal de Azure DevOps</0> o <1>haga clic aquí para obtener instrucciones</1>.",
|
||||
"ar": "احصل على <0>رمز الوصول الشخصي Azure DevOps</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>.",
|
||||
"fr": "Obtenez votre <0>jeton d'accès personnel Azure DevOps</0> ou <1>cliquez ici pour les instructions</1>.",
|
||||
"tr": "<0>Azure DevOps kişisel erişim jetonunuzu</0> alın veya <1>talimatlar için buraya tıklayın</1>.",
|
||||
"de": "Holen Sie sich Ihr <0>Azure DevOps Personal Access Token</0> oder <1>klicken Sie hier für Anweisungen</1>.",
|
||||
"uk": "Отримайте свій <0>особистий токен доступу Azure DevOps</0> або <1>натисніть тут, щоб отримати інструкції</1>."
|
||||
},
|
||||
"AZURE_DEVOPS$TOKEN_LINK_TEXT": {
|
||||
"en": "Azure DevOps personal access token",
|
||||
"ja": "Azure DevOps個人アクセストークン",
|
||||
"zh-CN": "Azure DevOps个人访问令牌",
|
||||
"zh-TW": "Azure DevOps個人存取權杖",
|
||||
"ko-KR": "Azure DevOps 개인 액세스 토큰",
|
||||
"no": "Azure DevOps personlige tilgangstoken",
|
||||
"it": "token di accesso personale Azure DevOps",
|
||||
"pt": "token de acesso pessoal do Azure DevOps",
|
||||
"es": "token de acceso personal de Azure DevOps",
|
||||
"ar": "رمز الوصول الشخصي Azure DevOps",
|
||||
"fr": "jeton d'accès personnel Azure DevOps",
|
||||
"tr": "Azure DevOps kişisel erişim jetonu",
|
||||
"de": "Azure DevOps Personal Access Token",
|
||||
"uk": "особистий токен доступу Azure DevOps"
|
||||
},
|
||||
"AZURE_DEVOPS$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
|
||||
"en": "Log in to OpenHands",
|
||||
"ja": "IDプロバイダーでサインイン",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
@@ -32,18 +33,24 @@ function GitSettingsScreen() {
|
||||
React.useState(false);
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
const [azureDevOpsTokenInputHasValue, setAzureDevOpsTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const [githubHostInputHasValue, setGithubHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
|
||||
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
const existingAzureDevOpsHost = settings?.PROVIDER_TOKENS_SET.azure_devops;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = providers.includes("github");
|
||||
const isGitLabTokenSet = providers.includes("gitlab");
|
||||
const isAzureDevOpsTokenSet = providers.includes("azure_devops");
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const disconnectButtonClicked =
|
||||
@@ -56,14 +63,33 @@ function GitSettingsScreen() {
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
const azureDevOpsToken =
|
||||
formData.get("azure-devops-token-input")?.toString() || "";
|
||||
const githubHost = formData.get("github-host-input")?.toString() || "";
|
||||
const gitlabHost = formData.get("gitlab-host-input")?.toString() || "";
|
||||
const azureDevOpsHost =
|
||||
formData.get("azure-devops-host-input")?.toString() || "";
|
||||
|
||||
// Validate Azure DevOps token and host dependency
|
||||
const hasAzureDevOpsToken = azureDevOpsToken.trim() !== "";
|
||||
const hasAzureDevOpsHost = azureDevOpsHost.trim() !== "";
|
||||
|
||||
if (hasAzureDevOpsToken && !hasAzureDevOpsHost) {
|
||||
displayErrorToast(t(I18nKey.AZURE_DEVOPS$HOST_REQUIRED_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAzureDevOpsHost && !hasAzureDevOpsToken) {
|
||||
displayErrorToast(t(I18nKey.AZURE_DEVOPS$TOKEN_REQUIRED_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
saveGitProviders(
|
||||
{
|
||||
providers: {
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
azure_devops: { token: azureDevOpsToken, host: azureDevOpsHost },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -77,8 +103,10 @@ function GitSettingsScreen() {
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
setAzureDevOpsTokenInputHasValue(false);
|
||||
setGithubHostInputHasValue(false);
|
||||
setGitlabHostInputHasValue(false);
|
||||
setAzureDevOpsHostInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -87,8 +115,10 @@ function GitSettingsScreen() {
|
||||
const formIsClean =
|
||||
!githubTokenInputHasValue &&
|
||||
!gitlabTokenInputHasValue &&
|
||||
!azureDevOpsTokenInputHasValue &&
|
||||
!githubHostInputHasValue &&
|
||||
!gitlabHostInputHasValue;
|
||||
!gitlabHostInputHasValue &&
|
||||
!azureDevOpsHostInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
@@ -111,7 +141,7 @@ function GitSettingsScreen() {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
onGitHubHostChange={(value) => {
|
||||
setGitlabHostInputHasValue(!!value);
|
||||
setGithubHostInputHasValue(!!value);
|
||||
}}
|
||||
githubHostSet={existingGithubHost}
|
||||
/>
|
||||
@@ -130,6 +160,20 @@ function GitSettingsScreen() {
|
||||
gitlabHostSet={existingGitlabHost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<AzureDevOpsTokenInput
|
||||
name="azure-devops-token-input"
|
||||
isAzureDevOpsTokenSet={isAzureDevOpsTokenSet}
|
||||
onChange={(value) => {
|
||||
setAzureDevOpsTokenInputHasValue(!!value);
|
||||
}}
|
||||
onAzureDevOpsHostChange={(value) => {
|
||||
setAzureDevOpsHostInputHasValue(!!value);
|
||||
}}
|
||||
azureDevOpsHostSet={existingAzureDevOpsHost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,7 +187,9 @@ function GitSettingsScreen() {
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
isDisabled={
|
||||
!isGitHubTokenSet && !isGitLabTokenSet && !isAzureDevOpsTokenSet
|
||||
}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ProviderOptions = {
|
||||
github: "github",
|
||||
gitlab: "gitlab",
|
||||
azure_devops: "azure_devops",
|
||||
} as const;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generates a URL to redirect to for OAuth authentication
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab")
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "azure_devops")
|
||||
* @param requestUrl The URL of the request
|
||||
* @returns The URL to redirect to for OAuth
|
||||
*/
|
||||
|
||||
@@ -7,11 +7,12 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
export enum LoginMethod {
|
||||
GITHUB = "github",
|
||||
GITLAB = "gitlab",
|
||||
AZURE_DEVOPS = "azure_devops",
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the login method in local storage
|
||||
* @param method The login method (github or gitlab)
|
||||
* @param method The login method (github, gitlab, or azure_devops)
|
||||
*/
|
||||
export const setLoginMethod = (method: LoginMethod): void => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: azure_devops
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- azure devops
|
||||
- azure
|
||||
- devops
|
||||
---
|
||||
|
||||
<ROLE>
|
||||
You are an Azure DevOps expert who can help users interact with Azure DevOps repositories, work items, and pull requests.
|
||||
</ROLE>
|
||||
|
||||
<AZURE_DEVOPS_INTEGRATION>
|
||||
OpenHands supports Azure DevOps integration similar to GitHub and GitLab. You can use the `AZURE_DEVOPS_TOKEN` environment variable to authenticate with Azure DevOps.
|
||||
|
||||
## Authentication
|
||||
To use Azure DevOps with OpenHands, you need a Personal Access Token (PAT) with appropriate permissions:
|
||||
1. Go to your Azure DevOps organization settings
|
||||
2. Select "Personal access tokens"
|
||||
3. Create a new token with the following scopes:
|
||||
- Code (Read & Write)
|
||||
- Work Items (Read & Write)
|
||||
- Pull Request Threads (Read & Write)
|
||||
|
||||
## Repository Format
|
||||
When working with Azure DevOps repositories in OpenHands, use the following format:
|
||||
- Repository name: `project/repo`
|
||||
- Organization: Your Azure DevOps organization name
|
||||
|
||||
## Environment Variables
|
||||
- `AZURE_DEVOPS_TOKEN`: Your Azure DevOps Personal Access Token
|
||||
|
||||
## Common Operations
|
||||
- Clone a repository: `git clone https://dev.azure.com/organization/project/_git/repo`
|
||||
- Create a pull request: Use the Azure DevOps API or web interface
|
||||
- Work with issues: Azure DevOps uses work items instead of issues
|
||||
|
||||
## Azure DevOps API
|
||||
OpenHands uses the official Azure DevOps Python API to interact with Azure DevOps. The API is available at https://github.com/microsoft/azure-devops-python-api.
|
||||
|
||||
```python
|
||||
from azure.devops.connection import Connection
|
||||
from msrest.authentication import BasicAuthentication
|
||||
import os
|
||||
|
||||
# Authentication
|
||||
personal_access_token = os.environ.get('AZURE_DEVOPS_TOKEN')
|
||||
organization_url = 'https://dev.azure.com/your-organization'
|
||||
|
||||
# Create a connection
|
||||
credentials = BasicAuthentication('', personal_access_token)
|
||||
connection = Connection(base_url=organization_url, creds=credentials)
|
||||
|
||||
# Get clients
|
||||
git_client = connection.clients.get_git_client()
|
||||
work_item_client = connection.clients.get_work_item_tracking_client()
|
||||
|
||||
# Example: Get repositories
|
||||
repositories = git_client.get_repositories()
|
||||
for repo in repositories:
|
||||
print(f"{repo.name} - {repo.url}")
|
||||
|
||||
# Example: Get work items
|
||||
work_items = work_item_client.get_work_items(ids=[1, 2, 3])
|
||||
for work_item in work_items:
|
||||
print(f"{work_item.id} - {work_item.fields['System.Title']}")
|
||||
```
|
||||
</AZURE_DEVOPS_INTEGRATION>
|
||||
|
||||
<TROUBLESHOOTING>
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Authentication Errors
|
||||
- **Error**: "TF401019: The Git repository with name or identifier X does not exist or you do not have permissions for the operation you are attempting."
|
||||
- **Solution**: Check that your PAT has the correct permissions and that you're using the correct organization, project, and repository names.
|
||||
|
||||
### Repository Format
|
||||
- **Error**: "Invalid repository name format: X. Expected format: project/repo"
|
||||
- **Solution**: Make sure you're using the correct format for repository names: `project/repo`.
|
||||
|
||||
### API Limitations
|
||||
- Azure DevOps API has rate limits. If you encounter rate limit errors, add delays between API calls.
|
||||
- Some operations may require additional permissions beyond what's listed above.
|
||||
|
||||
### Work Item Types
|
||||
- Azure DevOps uses different work item types (Bug, Task, User Story, etc.) instead of the Issue concept in GitHub/GitLab.
|
||||
- When working with work items, make sure to specify the correct work item type.
|
||||
</TROUBLESHOOTING>
|
||||
|
||||
<BEST_PRACTICES>
|
||||
## Best Practices for Azure DevOps
|
||||
|
||||
### Repository Structure
|
||||
- Use a clear branching strategy (e.g., GitFlow, trunk-based development)
|
||||
- Protect your main branch with branch policies
|
||||
|
||||
### Pull Requests
|
||||
- Use descriptive titles and descriptions
|
||||
- Link work items to pull requests
|
||||
- Use the "Squash merge" option to keep history clean
|
||||
|
||||
### Work Items
|
||||
- Use the appropriate work item type for each task
|
||||
- Maintain a clear hierarchy of work items
|
||||
- Use tags for better organization
|
||||
|
||||
### CI/CD Pipelines
|
||||
- Store pipeline definitions as YAML in your repository
|
||||
- Use templates for common tasks
|
||||
- Leverage variable groups for secrets management
|
||||
</BEST_PRACTICES>
|
||||
|
||||
<EXAMPLES>
|
||||
## Example Commands
|
||||
|
||||
### Clone a Repository
|
||||
```bash
|
||||
git clone https://dev.azure.com/organization/project/_git/repo
|
||||
```
|
||||
|
||||
### Create a Branch
|
||||
```bash
|
||||
git checkout -b feature/new-feature
|
||||
```
|
||||
|
||||
### Push Changes
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add new feature"
|
||||
git push -u origin feature/new-feature
|
||||
```
|
||||
|
||||
### Create a Pull Request (using API)
|
||||
```python
|
||||
from azure.devops.connection import Connection
|
||||
from msrest.authentication import BasicAuthentication
|
||||
import os
|
||||
|
||||
# Authentication
|
||||
personal_access_token = os.environ.get('AZURE_DEVOPS_TOKEN')
|
||||
organization_url = 'https://dev.azure.com/your-organization'
|
||||
|
||||
# Create a connection
|
||||
credentials = BasicAuthentication('', personal_access_token)
|
||||
connection = Connection(base_url=organization_url, creds=credentials)
|
||||
|
||||
# Get Git client
|
||||
git_client = connection.clients.get_git_client()
|
||||
|
||||
# Create pull request
|
||||
pr = git_client.create_pull_request(
|
||||
git_pull_request={
|
||||
'source_ref_name': 'refs/heads/feature/new-feature',
|
||||
'target_ref_name': 'refs/heads/main',
|
||||
'title': 'Add new feature',
|
||||
'description': 'This PR adds a new feature'
|
||||
},
|
||||
repository_id='repository-id',
|
||||
project='project-name'
|
||||
)
|
||||
```
|
||||
|
||||
### Get Work Items
|
||||
```python
|
||||
from azure.devops.connection import Connection
|
||||
from msrest.authentication import BasicAuthentication
|
||||
import os
|
||||
|
||||
# Authentication
|
||||
personal_access_token = os.environ.get('AZURE_DEVOPS_TOKEN')
|
||||
organization_url = 'https://dev.azure.com/your-organization'
|
||||
|
||||
# Create a connection
|
||||
credentials = BasicAuthentication('', personal_access_token)
|
||||
connection = Connection(base_url=organization_url, creds=credentials)
|
||||
|
||||
# Get Work Item Tracking client
|
||||
wit_client = connection.clients.get_work_item_tracking_client()
|
||||
|
||||
# Get work items
|
||||
work_items = wit_client.get_work_items(ids=[1, 2, 3])
|
||||
for work_item in work_items:
|
||||
print(f"{work_item.id} - {work_item.fields['System.Title']}")
|
||||
```
|
||||
</EXAMPLES>
|
||||
@@ -107,6 +107,13 @@ def initialize_repository_for_runtime(
|
||||
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
|
||||
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
|
||||
|
||||
if 'AZURE_DEVOPS_TOKEN' in os.environ:
|
||||
azure_devops_token = SecretStr(os.environ['AZURE_DEVOPS_TOKEN'])
|
||||
azure_devops_host = os.environ.get('AZURE_DEVOPS_HOST')
|
||||
provider_tokens[ProviderType.AZURE_DEVOPS] = ProviderToken(
|
||||
token=azure_devops_token, host=azure_devops_host
|
||||
)
|
||||
|
||||
secret_store = (
|
||||
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Azure DevOps integration package.
|
||||
"""
|
||||
@@ -0,0 +1,801 @@
|
||||
"""Azure DevOps service implementation using standard HTTP API calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
BaseGitService,
|
||||
Branch,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class AzureDevOpsServiceImpl(BaseGitService):
|
||||
"""Azure DevOps service implementation using standard HTTP API calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
token: SecretStr | None = None,
|
||||
external_auth_id: str | None = None,
|
||||
external_auth_token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
"""Initialize the Azure DevOps service.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
token: The Azure DevOps personal access token
|
||||
external_auth_id: External auth ID (not used for Azure DevOps)
|
||||
external_auth_token: External auth token (not used for Azure DevOps)
|
||||
external_token_manager: Whether to use external token manager (not used for Azure DevOps)
|
||||
base_domain: The Azure DevOps organization URL (e.g., https://dev.azure.com/organization)
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.token = token
|
||||
self.external_auth_id = external_auth_id
|
||||
self.external_auth_token = external_auth_token
|
||||
self.external_token_manager = external_token_manager
|
||||
self.organization_url = base_domain or 'https://dev.azure.com'
|
||||
|
||||
# Extract organization name from URL for API calls
|
||||
if self.organization_url.startswith('https://dev.azure.com/'):
|
||||
self.organization = self.organization_url.replace(
|
||||
'https://dev.azure.com/', ''
|
||||
).rstrip('/')
|
||||
else:
|
||||
# Handle custom Azure DevOps Server URLs
|
||||
self.organization = (
|
||||
self.organization_url.split('/')[-1]
|
||||
if '/' in self.organization_url
|
||||
else self.organization_url
|
||||
)
|
||||
|
||||
self.base_url = f'https://dev.azure.com/{self.organization}/_apis'
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return ProviderType.AZURE_DEVOPS.value
|
||||
|
||||
async def _get_azure_devops_headers(self) -> dict[str, str]:
|
||||
"""Get headers for Azure DevOps API requests."""
|
||||
if not self.token:
|
||||
self.token = await self.get_latest_token()
|
||||
|
||||
if not self.token:
|
||||
raise AuthenticationError('No Azure DevOps token provided')
|
||||
|
||||
# Azure DevOps uses Basic authentication with PAT
|
||||
# Username can be empty, password is the PAT
|
||||
credentials = base64.b64encode(
|
||||
f':{self.token.get_secret_value()}'.encode()
|
||||
).decode()
|
||||
|
||||
return {
|
||||
'Authorization': f'Basic {credentials}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
"""Check if the token has expired."""
|
||||
return status_code == 401
|
||||
|
||||
async def execute_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
headers: dict,
|
||||
params: dict | None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> httpx.Response:
|
||||
"""Execute an HTTP request."""
|
||||
if method == RequestMethod.GET:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
elif method == RequestMethod.POST:
|
||||
# For Azure DevOps, we need to handle the case where params contains both
|
||||
# query parameters and JSON data. We'll use a special key to separate them.
|
||||
json_data = params.pop('_json_data', None) if params else None
|
||||
response = await client.post(
|
||||
url, headers=headers, params=params, json=json_data
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported HTTP method: {method}')
|
||||
|
||||
return response
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
json_data: dict | None = None,
|
||||
) -> tuple[Any, dict]:
|
||||
"""Make a request to the Azure DevOps API."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
azure_devops_headers = await self._get_azure_devops_headers()
|
||||
|
||||
# Make initial request
|
||||
# For POST requests, embed json_data in params using special key
|
||||
if method == RequestMethod.POST and json_data is not None:
|
||||
if params is None:
|
||||
params = {}
|
||||
params['_json_data'] = json_data
|
||||
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=azure_devops_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
# Handle token refresh if needed
|
||||
if self._has_token_expired(response.status_code):
|
||||
logger.warning('Azure DevOps token expired, attempting refresh')
|
||||
# For Azure DevOps, we don't have automatic token refresh
|
||||
# The user needs to provide a new PAT
|
||||
raise AuthenticationError(
|
||||
'Azure DevOps token expired. Please provide a new Personal Access Token.'
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
f'Azure DevOps API error: {response.status_code} - {response.text}'
|
||||
)
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError(
|
||||
'Authentication failed with Azure DevOps'
|
||||
)
|
||||
elif response.status_code == 403:
|
||||
raise AuthenticationError(
|
||||
'Access forbidden. Check your Azure DevOps permissions.'
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
raise ValueError('Resource not found')
|
||||
else:
|
||||
raise UnknownException(
|
||||
f'Azure DevOps API error: {response.status_code}'
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
except Exception:
|
||||
response_data = response.text
|
||||
|
||||
return response_data, {}
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f'Request error: {e}')
|
||||
raise UnknownException(f'Request failed: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error: {e}')
|
||||
raise UnknownException(f'Unexpected error: {e}')
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get the latest token.
|
||||
|
||||
Returns:
|
||||
The latest token
|
||||
"""
|
||||
return self.token
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user.
|
||||
|
||||
Returns:
|
||||
The authenticated user
|
||||
"""
|
||||
try:
|
||||
# Try to get user profile from Azure DevOps
|
||||
# For organization-scoped tokens, we'll use the projects API to verify authentication
|
||||
# since the global profile API requires "All accessible organizations" scope
|
||||
|
||||
# Fallback: Try to get projects to verify authentication
|
||||
projects_url = f'{self.base_url}/projects'
|
||||
projects_params = {'api-version': '7.1-preview.4'}
|
||||
|
||||
projects_data, _ = await self._make_request(
|
||||
projects_url, params=projects_params
|
||||
)
|
||||
|
||||
# If we can get projects, authentication is working
|
||||
if projects_data:
|
||||
# Try to get connection data for more user info
|
||||
try:
|
||||
connection_url = f'{self.base_url}/connectionData'
|
||||
connection_params = {'api-version': '7.1-preview.1'}
|
||||
connection_data, _ = await self._make_request(
|
||||
connection_url, params=connection_params
|
||||
)
|
||||
|
||||
if connection_data and isinstance(connection_data, dict):
|
||||
auth_user = connection_data.get('authenticatedUser', {})
|
||||
return User(
|
||||
id=auth_user.get('id', 0),
|
||||
login=auth_user.get(
|
||||
'uniqueName', self.user_id or 'azure_devops_user'
|
||||
),
|
||||
avatar_url=auth_user.get('imageUrl', ''),
|
||||
name=auth_user.get(
|
||||
'displayName', self.user_id or 'Azure DevOps User'
|
||||
),
|
||||
email=auth_user.get('uniqueName'),
|
||||
company=None,
|
||||
)
|
||||
except Exception as connection_error:
|
||||
logger.debug(f'Could not get connection data: {connection_error}')
|
||||
|
||||
# Basic fallback if connection data fails
|
||||
return User(
|
||||
id=0, # Placeholder ID
|
||||
login=self.user_id or 'azure_devops_user',
|
||||
avatar_url='',
|
||||
name=self.user_id or 'Azure DevOps User',
|
||||
email=None,
|
||||
company=None,
|
||||
)
|
||||
|
||||
# If projects API also fails, try the old profile approach as last resort
|
||||
profile_url = f'{self.base_url}/profile/profiles/me'
|
||||
profile_params = {'api-version': '7.1-preview.3'}
|
||||
|
||||
try:
|
||||
profile_data, _ = await self._make_request(
|
||||
profile_url, params=profile_params
|
||||
)
|
||||
|
||||
if profile_data and isinstance(profile_data, dict):
|
||||
return User(
|
||||
id=profile_data.get('id', 0),
|
||||
login=profile_data.get(
|
||||
'emailAddress', self.user_id or 'azure_devops_user'
|
||||
),
|
||||
avatar_url=profile_data.get('avatar', {}).get('value', ''),
|
||||
name=profile_data.get(
|
||||
'displayName', self.user_id or 'Azure DevOps User'
|
||||
),
|
||||
email=profile_data.get('emailAddress'),
|
||||
company=None,
|
||||
)
|
||||
except Exception as profile_error:
|
||||
logger.warning(f'Could not get user profile: {profile_error}')
|
||||
raise AuthenticationError('Failed to authenticate with Azure DevOps')
|
||||
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting Azure DevOps user: {e}')
|
||||
raise AuthenticationError(f'Failed to authenticate with Azure DevOps: {e}')
|
||||
|
||||
# This should never be reached, but added for mypy
|
||||
raise AuthenticationError('Failed to authenticate with Azure DevOps')
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user.
|
||||
|
||||
Args:
|
||||
sort: The sort order
|
||||
app_mode: The app mode
|
||||
|
||||
Returns:
|
||||
A list of repositories
|
||||
"""
|
||||
try:
|
||||
# Get all repositories across all projects
|
||||
repos_url = f'{self.base_url}/git/repositories'
|
||||
repos_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
repos_data, _ = await self._make_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
return []
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
|
||||
# Convert to Repository objects
|
||||
result = []
|
||||
for repo in repositories:
|
||||
project_name = repo.get('project', {}).get('name', 'Unknown')
|
||||
repo_name = repo.get('name', 'Unknown')
|
||||
|
||||
result.append(
|
||||
Repository(
|
||||
id=repo.get('id', ''),
|
||||
full_name=f'{project_name}/{repo_name}',
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
is_public=False, # Azure DevOps repos are private by default
|
||||
stargazers_count=None,
|
||||
link_header=None,
|
||||
pushed_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting Azure DevOps repositories: {e}')
|
||||
return []
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
per_page: The number of results per page
|
||||
sort: The sort order
|
||||
order: The sort direction
|
||||
|
||||
Returns:
|
||||
A list of repositories
|
||||
"""
|
||||
try:
|
||||
# Get all repositories (Azure DevOps doesn't have a search API for repos)
|
||||
repos_url = f'{self.base_url}/git/repositories'
|
||||
repos_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
repos_data, _ = await self._make_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
return []
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
|
||||
# Filter repositories by name (simple client-side filtering)
|
||||
filtered_repos = [
|
||||
repo
|
||||
for repo in repositories
|
||||
if query.lower() in repo.get('name', '').lower()
|
||||
or query.lower() in repo.get('project', {}).get('name', '').lower()
|
||||
]
|
||||
|
||||
# Convert to Repository objects
|
||||
result = []
|
||||
for repo in filtered_repos[:per_page]:
|
||||
project_name = repo.get('project', {}).get('name', 'Unknown')
|
||||
repo_name = repo.get('name', 'Unknown')
|
||||
|
||||
result.append(
|
||||
Repository(
|
||||
id=repo.get('id', ''),
|
||||
full_name=f'{project_name}/{repo_name}',
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
is_public=False, # Azure DevOps repos are private by default
|
||||
stargazers_count=None,
|
||||
link_header=None,
|
||||
pushed_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Error searching Azure DevOps repositories: {e}')
|
||||
return []
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user.
|
||||
|
||||
Returns:
|
||||
A list of suggested tasks including:
|
||||
- Open issues assigned to the user
|
||||
- Pull requests authored by the user with:
|
||||
- Merge conflicts
|
||||
- Failing checks
|
||||
- Unresolved comments
|
||||
"""
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
try:
|
||||
# Get open work items (bugs/issues)
|
||||
await self._get_work_item_tasks(tasks)
|
||||
|
||||
# Get pull request tasks
|
||||
await self._get_pull_request_tasks(tasks)
|
||||
|
||||
return tasks
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting Azure DevOps suggested tasks: {e}')
|
||||
return []
|
||||
|
||||
async def _get_work_item_tasks(self, tasks: list[SuggestedTask]) -> None:
|
||||
"""Get work item tasks using WIQL query."""
|
||||
try:
|
||||
# Use WIQL to query for open bugs
|
||||
wiql_url = f'{self.base_url}/wit/wiql'
|
||||
wiql_params = {'api-version': '7.1-preview.2'}
|
||||
|
||||
wiql_query = {
|
||||
'query': """
|
||||
select [System.Id],
|
||||
[System.WorkItemType],
|
||||
[System.Title],
|
||||
[System.State],
|
||||
[System.TeamProject]
|
||||
from WorkItems
|
||||
where [System.WorkItemType] in ('Bug', 'Issue', 'Task')
|
||||
and [System.State] <> 'Closed'
|
||||
and [System.State] <> 'Resolved'
|
||||
and [System.State] <> 'Done'
|
||||
order by [System.ChangedDate] desc
|
||||
"""
|
||||
}
|
||||
|
||||
wiql_data, _ = await self._make_request(
|
||||
wiql_url,
|
||||
params=wiql_params,
|
||||
method=RequestMethod.POST,
|
||||
json_data=wiql_query,
|
||||
)
|
||||
|
||||
if not wiql_data or not isinstance(wiql_data, dict):
|
||||
return
|
||||
|
||||
work_items = wiql_data.get('workItems', [])[:10] # Limit to 10
|
||||
|
||||
# Get full work item details
|
||||
for work_item in work_items:
|
||||
work_item_id = work_item.get('id')
|
||||
if not work_item_id:
|
||||
continue
|
||||
|
||||
# Get work item details
|
||||
work_item_url = f'{self.base_url}/wit/workitems/{work_item_id}'
|
||||
work_item_params = {'api-version': '7.1-preview.3'}
|
||||
|
||||
work_item_data, _ = await self._make_request(
|
||||
work_item_url, params=work_item_params
|
||||
)
|
||||
|
||||
if work_item_data and isinstance(work_item_data, dict):
|
||||
fields = work_item_data.get('fields', {})
|
||||
project_name = fields.get('System.TeamProject', '')
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=project_name,
|
||||
issue_number=work_item_id,
|
||||
title=fields.get('System.Title', ''),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting work item tasks: {e}')
|
||||
|
||||
async def _get_pull_request_tasks(self, tasks: list[SuggestedTask]) -> None:
|
||||
"""Get pull request tasks."""
|
||||
try:
|
||||
# Get all repositories
|
||||
repos_url = f'{self.base_url}/git/repositories'
|
||||
repos_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
repos_data, _ = await self._make_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
return
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
|
||||
# For each repository, get pull requests
|
||||
for repo in repositories:
|
||||
project_name = repo.get('project', {}).get('name', '')
|
||||
repo_name = repo.get('name', '')
|
||||
repo_id = repo.get('id', '')
|
||||
full_repo_name = f'{project_name}/{repo_name}'
|
||||
|
||||
if not project_name or not repo_id:
|
||||
continue
|
||||
|
||||
# Get active pull requests
|
||||
prs_url = f'{self.base_url}/git/repositories/{repo_id}/pullrequests'
|
||||
prs_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'searchCriteria.status': 'active',
|
||||
}
|
||||
|
||||
prs_data, _ = await self._make_request(prs_url, params=prs_params)
|
||||
|
||||
if not prs_data or not isinstance(prs_data, dict):
|
||||
continue
|
||||
|
||||
pull_requests = prs_data.get('value', [])
|
||||
|
||||
for pr in pull_requests:
|
||||
pr_id = pr.get('pullRequestId')
|
||||
if not pr_id:
|
||||
continue
|
||||
|
||||
task_type = None
|
||||
|
||||
# Check for merge conflicts
|
||||
if pr.get('mergeStatus') == 'conflicts':
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
else:
|
||||
# Check for failing policy evaluations
|
||||
try:
|
||||
policy_url = f'{self.base_url}/policy/evaluations'
|
||||
policy_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'artifactId': f'vstfs:///CodeReview/CodeReviewId/{project_name}/{pr_id}',
|
||||
}
|
||||
|
||||
policy_data, _ = await self._make_request(
|
||||
policy_url, params=policy_params
|
||||
)
|
||||
|
||||
if policy_data and isinstance(policy_data, dict):
|
||||
evaluations = policy_data.get('value', [])
|
||||
has_failing_checks = any(
|
||||
eval.get('status') == 'rejected'
|
||||
for eval in evaluations
|
||||
)
|
||||
|
||||
if has_failing_checks:
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
except Exception:
|
||||
# Policy evaluations might not be accessible, continue
|
||||
pass
|
||||
|
||||
# Check for unresolved comments if no other issues found
|
||||
if not task_type:
|
||||
try:
|
||||
threads_url = f'{self.base_url}/git/repositories/{repo_id}/pullRequests/{pr_id}/threads'
|
||||
threads_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
threads_data, _ = await self._make_request(
|
||||
threads_url, params=threads_params
|
||||
)
|
||||
|
||||
if threads_data and isinstance(threads_data, dict):
|
||||
threads = threads_data.get('value', [])
|
||||
has_unresolved_comments = any(
|
||||
thread.get('status') == 'active'
|
||||
and not thread.get('isDeleted', False)
|
||||
for thread in threads
|
||||
)
|
||||
|
||||
if has_unresolved_comments:
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
except Exception:
|
||||
# Threads might not be accessible, continue
|
||||
pass
|
||||
|
||||
# Add the task if we identified a specific issue
|
||||
if task_type:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
task_type=task_type,
|
||||
repo=full_repo_name,
|
||||
issue_number=pr_id,
|
||||
title=pr.get('title', ''),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting pull request tasks: {e}')
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Get repository details from repository name.
|
||||
|
||||
Args:
|
||||
repository: The repository name (format: project/repo)
|
||||
|
||||
Returns:
|
||||
The repository details
|
||||
"""
|
||||
try:
|
||||
# Parse the repository name (expected format: project/repo)
|
||||
parts = repository.split('/')
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
f'Invalid repository name format: {repository}. Expected format: project/repo'
|
||||
)
|
||||
|
||||
project_name, repo_name = parts
|
||||
|
||||
# Get repositories for the specific project
|
||||
repos_url = f'{self.base_url}/git/repositories'
|
||||
repos_params = {'api-version': '7.1-preview.1', 'project': project_name}
|
||||
|
||||
repos_data, _ = await self._make_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
raise ValueError(f'Repository not found: {repository}')
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
raise ValueError(f'Repository not found: {repository}')
|
||||
|
||||
return Repository(
|
||||
id=repo.get('id', ''),
|
||||
full_name=f'{project_name}/{repo_name}',
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
is_public=False, # Azure DevOps repos are private by default
|
||||
stargazers_count=None,
|
||||
link_header=None,
|
||||
pushed_at=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting Azure DevOps repository details: {e}')
|
||||
raise AuthenticationError(f'Failed to get repository details: {e}')
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository.
|
||||
|
||||
Args:
|
||||
repository: The repository name (format: project/repo)
|
||||
|
||||
Returns:
|
||||
A list of branches
|
||||
"""
|
||||
try:
|
||||
# Parse the repository name (expected format: project/repo)
|
||||
parts = repository.split('/')
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
f'Invalid repository name format: {repository}. Expected format: project/repo'
|
||||
)
|
||||
|
||||
project_name, repo_name = parts
|
||||
|
||||
# First, get the repository ID
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
repo_id = repo_details.id
|
||||
|
||||
# Get the branches (refs) for the repository
|
||||
refs_url = f'{self.base_url}/git/repositories/{repo_id}/refs'
|
||||
refs_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'filter': 'heads/', # Only get branch refs, not tags
|
||||
}
|
||||
|
||||
refs_data, _ = await self._make_request(refs_url, params=refs_params)
|
||||
|
||||
if not refs_data or not isinstance(refs_data, dict):
|
||||
return []
|
||||
|
||||
refs = refs_data.get('value', [])
|
||||
|
||||
# Convert to Branch objects
|
||||
result = []
|
||||
for ref in refs:
|
||||
# Extract branch name from ref name (remove 'refs/heads/' prefix)
|
||||
ref_name = ref.get('name', '')
|
||||
if ref_name.startswith('refs/heads/'):
|
||||
branch_name = ref_name[len('refs/heads/') :]
|
||||
|
||||
result.append(
|
||||
Branch(
|
||||
name=branch_name,
|
||||
commit_sha=ref.get('objectId', ''),
|
||||
protected=False, # Azure DevOps doesn't expose this information directly
|
||||
last_push_date=None, # Azure DevOps doesn't expose this information directly
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting Azure DevOps branches: {e}')
|
||||
return []
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> str:
|
||||
"""Create a pull request in Azure DevOps.
|
||||
|
||||
Args:
|
||||
repo_name: The repository name (format: project/repo)
|
||||
source_branch: The source branch name
|
||||
target_branch: The target branch name
|
||||
title: The pull request title
|
||||
body: The pull request description (optional)
|
||||
draft: Whether the pull request should be a draft (optional)
|
||||
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
|
||||
Raises:
|
||||
ValueError: If the repository name format is invalid
|
||||
AuthenticationError: If authentication fails
|
||||
UnknownException: If the API request fails
|
||||
"""
|
||||
try:
|
||||
# Parse the repository name (expected format: project/repo)
|
||||
parts = repo_name.split('/')
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
f'Invalid repository name format: {repo_name}. Expected format: project/repo'
|
||||
)
|
||||
|
||||
project_name, repo_name_only = parts
|
||||
|
||||
# Get the repository details to get the repository ID
|
||||
repo_details = await self.get_repository_details_from_repo_name(repo_name)
|
||||
repo_id = repo_details.id
|
||||
|
||||
# Prepare the pull request data
|
||||
pr_data = {
|
||||
'sourceRefName': f'refs/heads/{source_branch}',
|
||||
'targetRefName': f'refs/heads/{target_branch}',
|
||||
'title': title,
|
||||
'description': body
|
||||
or f'Pull request from {source_branch} to {target_branch}',
|
||||
'isDraft': draft,
|
||||
}
|
||||
|
||||
# Create the pull request
|
||||
pr_url = f'{self.base_url}/git/repositories/{repo_id}/pullrequests'
|
||||
pr_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
response_data, _ = await self._make_request(
|
||||
url=pr_url,
|
||||
params=pr_params,
|
||||
method=RequestMethod.POST,
|
||||
json_data=pr_data,
|
||||
)
|
||||
|
||||
if not response_data or not isinstance(response_data, dict):
|
||||
raise UnknownException(
|
||||
'Failed to create pull request: Invalid response'
|
||||
)
|
||||
|
||||
# Extract the pull request URL
|
||||
pr_id = response_data.get('pullRequestId')
|
||||
if not pr_id:
|
||||
raise UnknownException(
|
||||
'Failed to create pull request: No PR ID returned'
|
||||
)
|
||||
|
||||
# Construct the web URL for the pull request
|
||||
web_url = f'{self.organization_url}/{project_name}/_git/{repo_name_only}/pullrequest/{pr_id}'
|
||||
|
||||
logger.info(f'Successfully created Azure DevOps pull request: {web_url}')
|
||||
return web_url
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating Azure DevOps pull request: {e}')
|
||||
raise UnknownException(f'Failed to create pull request: {e}')
|
||||
@@ -14,6 +14,9 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.azure_devops.azure_devops_service import (
|
||||
AzureDevOpsServiceImpl,
|
||||
)
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.service_types import (
|
||||
@@ -27,6 +30,8 @@ from openhands.integrations.service_types import (
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
AZURE_DEVOPS_AVAILABLE = True
|
||||
|
||||
|
||||
class ProviderToken(BaseModel):
|
||||
token: SecretStr | None = Field(default=None)
|
||||
@@ -108,6 +113,7 @@ class ProviderHandler:
|
||||
self.service_class_map: dict[ProviderType, type[GitService]] = {
|
||||
ProviderType.GITHUB: GithubServiceImpl,
|
||||
ProviderType.GITLAB: GitLabServiceImpl,
|
||||
ProviderType.AZURE_DEVOPS: AzureDevOpsServiceImpl,
|
||||
}
|
||||
|
||||
self.external_auth_id = external_auth_id
|
||||
@@ -124,6 +130,8 @@ class ProviderHandler:
|
||||
"""Helper method to instantiate a service for a given provider"""
|
||||
token = self.provider_tokens[provider]
|
||||
service_class = self.service_class_map[provider]
|
||||
|
||||
# All services now use base_domain consistently
|
||||
return service_class(
|
||||
user_id=token.user_id,
|
||||
external_auth_id=self.external_auth_id,
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.server.types import AppMode
|
||||
class ProviderType(Enum):
|
||||
GITHUB = 'github'
|
||||
GITLAB = 'gitlab'
|
||||
AZURE_DEVOPS = 'azure_devops'
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
@@ -51,6 +52,19 @@ class SuggestedTask(BaseModel):
|
||||
'ciProvider': 'GitHub',
|
||||
'requestVerb': 'pull request',
|
||||
}
|
||||
elif self.git_provider == ProviderType.AZURE_DEVOPS:
|
||||
return {
|
||||
'requestType': 'Pull Request',
|
||||
'requestTypeShort': 'PR',
|
||||
'apiName': 'Azure DevOps API',
|
||||
'tokenEnvVar': 'AZURE_DEVOPS_TOKEN',
|
||||
'ciSystem': 'Azure Pipelines',
|
||||
'ciProvider': 'Azure DevOps',
|
||||
'requestVerb': 'pull request',
|
||||
'work item': 'work item',
|
||||
'repository': 'repository',
|
||||
'pull request': 'pull request',
|
||||
}
|
||||
|
||||
raise ValueError(f'Provider {self.git_provider} for suggested task prompts')
|
||||
|
||||
@@ -83,7 +97,9 @@ class SuggestedTask(BaseModel):
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
id: (
|
||||
int | str
|
||||
) # Support both integer IDs (GitHub/GitLab) and string UUIDs (Azure DevOps)
|
||||
login: str
|
||||
avatar_url: str
|
||||
company: str | None = None
|
||||
@@ -99,7 +115,9 @@ class Branch(BaseModel):
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
id: int
|
||||
id: (
|
||||
int | str
|
||||
) # Support both integer IDs (GitHub/GitLab) and string UUIDs (Azure DevOps)
|
||||
full_name: str
|
||||
git_provider: ProviderType
|
||||
is_public: bool
|
||||
@@ -175,7 +193,7 @@ class BaseGitService(ABC):
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
"""Protocol defining the interface for Git service providers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -186,15 +204,15 @@ class GitService(Protocol):
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the service with authentication details"""
|
||||
"""Initialize the service with authentication details."""
|
||||
...
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get latest working token of the user"""
|
||||
"""Get latest working token of the user."""
|
||||
...
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user's information"""
|
||||
"""Get the authenticated user's information."""
|
||||
...
|
||||
|
||||
async def search_repositories(
|
||||
@@ -204,21 +222,21 @@ class GitService(Protocol):
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories"""
|
||||
"""Search for repositories."""
|
||||
...
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
"""Get repositories for the authenticated user."""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
"""Get suggested tasks for the authenticated user across all repositories."""
|
||||
...
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Gets all repository details from repository name"""
|
||||
"""Gets all repository details from repository name."""
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
"""Get branches for a repository."""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import traceback
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.azure_devops.azure_devops_service import (
|
||||
AzureDevOpsServiceImpl,
|
||||
)
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.integrations.provider import ProviderType
|
||||
@@ -12,35 +13,53 @@ async def validate_provider_token(
|
||||
token: SecretStr, base_domain: str | None = None
|
||||
) -> ProviderType | None:
|
||||
"""
|
||||
Determine whether a token is for GitHub or GitLab by attempting to get user info
|
||||
from both services.
|
||||
Determine whether a token is for GitHub, GitLab, or Azure DevOps by attempting to get user info
|
||||
from the services.
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
base_domain: Optional base domain for the service
|
||||
|
||||
Returns:
|
||||
'github' if it's a GitHub token
|
||||
'gitlab' if it's a GitLab token
|
||||
None if the token is invalid for both services
|
||||
'azure_devops' if it's an Azure DevOps token
|
||||
None if the token is invalid for all services
|
||||
"""
|
||||
# Skip validation for empty tokens
|
||||
if token is None or not token.get_secret_value().strip():
|
||||
return None
|
||||
# Try GitHub first
|
||||
github_error = None
|
||||
try:
|
||||
github_service = GitHubService(token=token, base_domain=base_domain)
|
||||
await github_service.verify_access()
|
||||
return ProviderType.GITHUB
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'Failed to validate Github token: {e} \n {traceback.format_exc()}'
|
||||
)
|
||||
github_error = e
|
||||
|
||||
# Try GitLab next
|
||||
gitlab_error = None
|
||||
try:
|
||||
gitlab_service = GitLabService(token=token, base_domain=base_domain)
|
||||
await gitlab_service.get_user()
|
||||
return ProviderType.GITLAB
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'Failed to validate GitLab token: {e} \n {traceback.format_exc()}'
|
||||
gitlab_error = e
|
||||
|
||||
# Try Azure DevOps last
|
||||
azure_devops_error = None
|
||||
try:
|
||||
azure_devops_service = AzureDevOpsServiceImpl(
|
||||
token=token, base_domain=base_domain
|
||||
)
|
||||
await azure_devops_service.get_user()
|
||||
return ProviderType.AZURE_DEVOPS
|
||||
except Exception as e:
|
||||
azure_devops_error = e
|
||||
|
||||
logger.debug(
|
||||
f'Failed to validate token: {github_error} \n {gitlab_error} \n {azure_devops_error}'
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OpenHands Github & Gitlab Issue Resolver 🙌
|
||||
# OpenHands Github, Gitlab & Azure DevOps Issue Resolver 🙌
|
||||
|
||||
Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out!
|
||||
Need help resolving issues in GitHub, GitLab, or Azure DevOps but don't have the time to do it yourself? Let an AI agent help you out!
|
||||
|
||||
This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands)
|
||||
to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed
|
||||
to attempt to resolve issues automatically. While it can handle multiple issues, it's primarily designed
|
||||
to help you resolve one issue at a time with high quality.
|
||||
|
||||
Getting started is simple - just follow the instructions below.
|
||||
@@ -74,8 +74,8 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio
|
||||
pip install openhands-ai
|
||||
```
|
||||
|
||||
2. Create a GitHub or GitLab access token:
|
||||
- Create a GitHub acces token
|
||||
2. Create an access token for your platform:
|
||||
- Create a GitHub access token
|
||||
- Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new)
|
||||
- Create a fine-grained token with these scopes:
|
||||
- "Content"
|
||||
@@ -84,7 +84,7 @@ pip install openhands-ai
|
||||
- "Workflows"
|
||||
- If you don't have push access to the target repo, you can fork it first
|
||||
|
||||
- Create a GitLab acces token
|
||||
- Create a GitLab access token
|
||||
- Visit [GitLab's token settings](https://gitlab.com/-/user_settings/personal_access_tokens)
|
||||
- Create a fine-grained token with these scopes:
|
||||
- 'api'
|
||||
@@ -93,20 +93,30 @@ pip install openhands-ai
|
||||
- 'read_repository'
|
||||
- 'write_repository'
|
||||
|
||||
- Create an Azure DevOps access token
|
||||
- Visit [Azure DevOps Personal Access Tokens](https://dev.azure.com/your-organization/_usersSettings/tokens)
|
||||
- Create a token with these scopes:
|
||||
- "Code (Read & Write)"
|
||||
- "Work Items (Read & Write)"
|
||||
- "Pull Request Threads (Read & Write)"
|
||||
- "Pull Request Contribute"
|
||||
|
||||
3. Set up environment variables:
|
||||
|
||||
```bash
|
||||
|
||||
# GitHub credentials
|
||||
|
||||
export GITHUB_TOKEN="your-github-token"
|
||||
export GIT_USERNAME="your-github-username" # Optional, defaults to token owner
|
||||
|
||||
# GitLab credentials if you're using GitLab repo
|
||||
|
||||
export GITLAB_TOKEN="your-gitlab-token"
|
||||
export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner
|
||||
|
||||
# Azure DevOps credentials if you're using Azure DevOps repo
|
||||
export AZURE_DEVOPS_TOKEN="your-azure-devops-token"
|
||||
export GIT_USERNAME="your-azure-devops-username" # Optional, defaults to token owner
|
||||
|
||||
# LLM configuration
|
||||
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # Recommended
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import RequestMethod
|
||||
from openhands.resolver.interfaces.issue import (
|
||||
Issue,
|
||||
IssueHandlerInterface,
|
||||
ReviewThread,
|
||||
)
|
||||
|
||||
|
||||
class AzureDevOpsIssueHandler(IssueHandlerInterface):
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
username: str | None = None,
|
||||
base_domain: str = 'dev.azure.com',
|
||||
):
|
||||
"""Initialize an Azure DevOps issue handler.
|
||||
|
||||
Args:
|
||||
owner: The owner (organization) of the repository
|
||||
repo: The name of the repository (format: project/repo)
|
||||
token: The Azure DevOps personal access token
|
||||
username: Optional Azure DevOps username
|
||||
base_domain: The domain for Azure DevOps (default: "dev.azure.com")
|
||||
"""
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.token = token
|
||||
self.username = username
|
||||
self.base_domain = base_domain
|
||||
|
||||
# Parse the repository name (expected format: project/repo)
|
||||
parts = repo.split('/')
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
f'Invalid repository name format: {repo}. Expected format: project/repo'
|
||||
)
|
||||
|
||||
self.project_name, self.repo_name = parts
|
||||
|
||||
self.base_url = self.get_base_url()
|
||||
self.download_url = self.get_download_url()
|
||||
self.clone_url = self.get_clone_url()
|
||||
self.headers = self.get_headers()
|
||||
|
||||
# Set up API base URL
|
||||
self.api_base_url = f'https://{self.base_domain}/{self.owner}/_apis'
|
||||
|
||||
def set_owner(self, owner: str) -> None:
|
||||
self.owner = owner
|
||||
|
||||
def get_headers(self) -> dict[str, str]:
|
||||
# Azure DevOps uses Basic authentication with PAT
|
||||
# Username can be empty, password is the PAT
|
||||
credentials = base64.b64encode(f':{self.token}'.encode()).decode()
|
||||
return {
|
||||
'Authorization': f'Basic {credentials}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async def _make_api_request(
|
||||
self,
|
||||
url: str,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
) -> dict | list | None:
|
||||
"""Make an HTTP request to the Azure DevOps API."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
if method == RequestMethod.GET:
|
||||
response = await client.get(
|
||||
url, headers=self.headers, params=params
|
||||
)
|
||||
elif method == RequestMethod.POST:
|
||||
response = await client.post(
|
||||
url, headers=self.headers, params=params, json=json_data
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported HTTP method: {method}')
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
f'Azure DevOps API error: {response.status_code} - {response.text}'
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return response.text
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f'Request error: {e}')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error: {e}')
|
||||
return None
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.project_name}/_apis/git/repositories/{self.repo_name}'
|
||||
|
||||
def get_authorize_url(self) -> str:
|
||||
return f'https://{self.username}:{self.token}@{self.base_domain}/'
|
||||
|
||||
def get_branch_url(self, branch_name: str) -> str:
|
||||
return self.get_base_url() + f'/refs?filter=heads/{branch_name}'
|
||||
|
||||
def get_download_url(self) -> str:
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.project_name}/_apis/wit/workitems'
|
||||
|
||||
def get_clone_url(self) -> str:
|
||||
return f'https://{self.username}:{self.token}@{self.base_domain}/{self.owner}/{self.project_name}/_git/{self.repo_name}'
|
||||
|
||||
def get_graphql_url(self) -> str:
|
||||
return f'https://{self.base_domain}/{self.owner}/_apis/graphql'
|
||||
|
||||
def get_compare_url(self, branch_name: str) -> str:
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.project_name}/_git/{self.repo_name}/branchCompare?baseVersion=GC{self.get_default_branch_name()}&targetVersion=GC{branch_name}'
|
||||
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[Issue]:
|
||||
"""Download issues from Azure DevOps.
|
||||
|
||||
Args:
|
||||
issue_numbers: The numbers of the issues to download
|
||||
comment_id: The ID of a single comment, if provided, otherwise all comments
|
||||
|
||||
Returns:
|
||||
List of Azure DevOps issues.
|
||||
"""
|
||||
if not issue_numbers:
|
||||
raise ValueError('Unspecified issue number')
|
||||
|
||||
all_issues = self.download_issues()
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
all_issues = [issue for issue in all_issues if issue['id'] in issue_numbers]
|
||||
|
||||
if len(issue_numbers) == 1 and not all_issues:
|
||||
raise ValueError(f'Issue {issue_numbers[0]} not found')
|
||||
|
||||
converted_issues = []
|
||||
for issue in all_issues:
|
||||
# Check for required fields (id and title)
|
||||
if any(
|
||||
[
|
||||
issue.get('fields', {}).get(key) is None
|
||||
for key in ['System.Id', 'System.Title']
|
||||
]
|
||||
):
|
||||
logger.warning(f'Skipping issue {issue} as it is missing id or title.')
|
||||
continue
|
||||
|
||||
# Handle empty body by using empty string
|
||||
description = issue.get('fields', {}).get('System.Description', '')
|
||||
if description is None:
|
||||
description = ''
|
||||
|
||||
# Get issue thread comments
|
||||
thread_comments = self.get_issue_comments(
|
||||
issue['id'], comment_id=comment_id
|
||||
)
|
||||
|
||||
# Convert empty lists to None for optional fields
|
||||
issue_details = Issue(
|
||||
owner=self.owner,
|
||||
repo=self.repo,
|
||||
number=issue['id'],
|
||||
title=issue['fields']['System.Title'],
|
||||
body=description,
|
||||
thread_comments=thread_comments,
|
||||
review_comments=None, # Initialize review comments as None for regular issues
|
||||
)
|
||||
|
||||
converted_issues.append(issue_details)
|
||||
|
||||
return converted_issues
|
||||
|
||||
def download_issues(self) -> list[Any]:
|
||||
"""Download issues from Azure DevOps using HTTP API calls."""
|
||||
return asyncio.run(self._download_issues_async())
|
||||
|
||||
async def _download_issues_async(self) -> list[Any]:
|
||||
"""Download issues from Azure DevOps asynchronously."""
|
||||
# Use WIQL to query for open bugs
|
||||
wiql_url = f'{self.api_base_url}/wit/wiql'
|
||||
wiql_params = {'api-version': '7.1-preview.2'}
|
||||
|
||||
wiql_query = {
|
||||
'query': f"""
|
||||
select [System.Id],
|
||||
[System.WorkItemType],
|
||||
[System.Title],
|
||||
[System.State],
|
||||
[System.Description]
|
||||
from WorkItems
|
||||
where [System.TeamProject] = '{self.project_name}'
|
||||
and [System.WorkItemType] in ('Bug', 'Issue', 'Task')
|
||||
and [System.State] <> 'Closed'
|
||||
and [System.State] <> 'Resolved'
|
||||
and [System.State] <> 'Done'
|
||||
order by [System.ChangedDate] desc
|
||||
"""
|
||||
}
|
||||
|
||||
wiql_data = await self._make_api_request(
|
||||
wiql_url,
|
||||
method=RequestMethod.POST,
|
||||
params=wiql_params,
|
||||
json_data=wiql_query,
|
||||
)
|
||||
|
||||
if not wiql_data or not isinstance(wiql_data, dict):
|
||||
return []
|
||||
|
||||
work_items = wiql_data.get('workItems', [])
|
||||
|
||||
# Get full work item details
|
||||
all_issues = []
|
||||
for work_item in work_items:
|
||||
work_item_id = work_item.get('id')
|
||||
if not work_item_id:
|
||||
continue
|
||||
|
||||
# Get work item details
|
||||
work_item_url = f'{self.api_base_url}/wit/workitems/{work_item_id}'
|
||||
work_item_params = {'api-version': '7.1-preview.3'}
|
||||
|
||||
work_item_data = await self._make_api_request(
|
||||
work_item_url, params=work_item_params
|
||||
)
|
||||
|
||||
if work_item_data and isinstance(work_item_data, dict):
|
||||
# Convert the work item to a dictionary format similar to GitHub/GitLab
|
||||
issue = {
|
||||
'id': work_item_data.get('id'),
|
||||
'fields': work_item_data.get('fields', {}),
|
||||
}
|
||||
all_issues.append(issue)
|
||||
|
||||
return all_issues
|
||||
|
||||
def get_issue_comments(
|
||||
self, issue_number: int, comment_id: int | None = None
|
||||
) -> list[str] | None:
|
||||
"""Download comments for a specific issue from Azure DevOps."""
|
||||
return asyncio.run(self._get_issue_comments_async(issue_number, comment_id))
|
||||
|
||||
async def _get_issue_comments_async(
|
||||
self, issue_number: int, comment_id: int | None = None
|
||||
) -> list[str] | None:
|
||||
"""Download comments for a specific issue from Azure DevOps asynchronously."""
|
||||
# Get the comments for the work item
|
||||
comments_url = f'{self.api_base_url}/wit/workItems/{issue_number}/comments'
|
||||
comments_params = {'api-version': '7.1-preview.3'}
|
||||
|
||||
comments_data = await self._make_api_request(
|
||||
comments_url, params=comments_params
|
||||
)
|
||||
|
||||
if not comments_data or not isinstance(comments_data, dict):
|
||||
return None
|
||||
|
||||
comments = comments_data.get('comments', [])
|
||||
|
||||
all_comments = []
|
||||
if comments:
|
||||
if comment_id:
|
||||
matching_comment = next(
|
||||
(
|
||||
comment.get('text', '')
|
||||
for comment in comments
|
||||
if comment.get('id') == comment_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_comment:
|
||||
return [matching_comment]
|
||||
else:
|
||||
all_comments = [
|
||||
comment.get('text', '')
|
||||
for comment in comments
|
||||
if comment.get('text')
|
||||
]
|
||||
|
||||
return all_comments if all_comments else None
|
||||
|
||||
def branch_exists(self, branch_name: str) -> bool:
|
||||
"""Check if a branch exists."""
|
||||
return asyncio.run(self._branch_exists_async(branch_name))
|
||||
|
||||
async def _branch_exists_async(self, branch_name: str) -> bool:
|
||||
"""Check if a branch exists asynchronously."""
|
||||
logger.info(f'Checking if branch {branch_name} exists...')
|
||||
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return False
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return False
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Get the branches (refs) for the repository
|
||||
refs_url = f'{self.api_base_url}/git/repositories/{repo_id}/refs'
|
||||
refs_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'filter': f'heads/{branch_name}',
|
||||
}
|
||||
|
||||
refs_data = await self._make_api_request(refs_url, params=refs_params)
|
||||
|
||||
if not refs_data or not isinstance(refs_data, dict):
|
||||
return False
|
||||
|
||||
refs = refs_data.get('value', [])
|
||||
exists = len(refs) > 0
|
||||
|
||||
logger.info(f'Branch {branch_name} exists: {exists}')
|
||||
return exists
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking if branch exists: {e}')
|
||||
return False
|
||||
|
||||
def get_branch_name(self, base_branch_name: str) -> str:
|
||||
branch_name = base_branch_name
|
||||
attempt = 1
|
||||
while self.branch_exists(branch_name):
|
||||
attempt += 1
|
||||
branch_name = f'{base_branch_name}-try{attempt}'
|
||||
return branch_name
|
||||
|
||||
def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None:
|
||||
"""Reply to a comment on a pull request."""
|
||||
asyncio.run(self._reply_to_comment_async(pr_number, comment_id, reply))
|
||||
|
||||
async def _reply_to_comment_async(
|
||||
self, pr_number: int, comment_id: str, reply: str
|
||||
) -> None:
|
||||
"""Reply to a comment on a pull request asynchronously."""
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Create a comment reply
|
||||
comment_reply = f'Openhands fix success summary\n\n\n{reply}'
|
||||
|
||||
# Add the comment to the thread
|
||||
comment_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullRequests/{pr_number}/threads/{comment_id}/comments'
|
||||
comment_params = {'api-version': '7.1-preview.1'}
|
||||
comment_data = {'content': comment_reply}
|
||||
|
||||
await self._make_api_request(
|
||||
comment_url,
|
||||
method=RequestMethod.POST,
|
||||
params=comment_params,
|
||||
json_data=comment_data,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error replying to comment: {e}')
|
||||
|
||||
def get_pull_url(self, pr_number: int) -> str:
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.project_name}/_git/{self.repo_name}/pullrequest/{pr_number}'
|
||||
|
||||
def get_default_branch_name(self) -> str:
|
||||
"""Get the default branch name."""
|
||||
return asyncio.run(self._get_default_branch_name_async())
|
||||
|
||||
async def _get_default_branch_name_async(self) -> str:
|
||||
"""Get the default branch name asynchronously."""
|
||||
try:
|
||||
# First, get the repository
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return 'main' # Default to 'main' if repository not found
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return 'main' # Default to 'main' if repository not found
|
||||
|
||||
# Get the default branch
|
||||
default_branch = repo.get('defaultBranch', 'refs/heads/main')
|
||||
return default_branch.replace('refs/heads/', '')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting default branch: {e}')
|
||||
return 'main' # Default to 'main' if an error occurs
|
||||
|
||||
def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Create a pull request."""
|
||||
return asyncio.run(self._create_pull_request_async(data))
|
||||
|
||||
async def _create_pull_request_async(
|
||||
self, data: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pull request asynchronously."""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
raise RuntimeError(f'Repository not found: {self.repo_name}')
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
raise RuntimeError(f'Repository not found: {self.repo_name}')
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Create the pull request
|
||||
pr_data = {
|
||||
'sourceRefName': f'refs/heads/{data.get("head", "")}',
|
||||
'targetRefName': f'refs/heads/{data.get("base", "")}',
|
||||
'title': data.get('title', ''),
|
||||
'description': data.get('body', ''),
|
||||
}
|
||||
|
||||
pr_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullrequests'
|
||||
pr_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
created_pr = await self._make_api_request(
|
||||
pr_url, method=RequestMethod.POST, params=pr_params, json_data=pr_data
|
||||
)
|
||||
|
||||
if not created_pr or not isinstance(created_pr, dict):
|
||||
raise RuntimeError('Failed to create pull request')
|
||||
|
||||
# Convert to a format similar to GitHub/GitLab
|
||||
pr_id = created_pr.get('pullRequestId')
|
||||
if pr_id is None:
|
||||
raise RuntimeError('Pull request ID not found in response')
|
||||
|
||||
pr_result = {
|
||||
'id': pr_id,
|
||||
'number': pr_id,
|
||||
'html_url': self.get_pull_url(pr_id),
|
||||
}
|
||||
|
||||
return pr_result
|
||||
except Exception as e:
|
||||
if '403' in str(e):
|
||||
raise RuntimeError(
|
||||
'Failed to create pull request due to missing permissions. '
|
||||
'Make sure that the provided token has push permissions for the repository.'
|
||||
)
|
||||
raise RuntimeError(f'Failed to create pull request: {e}')
|
||||
|
||||
def request_reviewers(self, reviewer: str, pr_number: int) -> None:
|
||||
"""Request reviewers for a pull request."""
|
||||
asyncio.run(self._request_reviewers_async(reviewer, pr_number))
|
||||
|
||||
async def _request_reviewers_async(self, reviewer: str, pr_number: int) -> None:
|
||||
"""Request reviewers for a pull request asynchronously."""
|
||||
# Azure DevOps doesn't have a direct API for requesting reviewers
|
||||
# Instead, we'll add a comment mentioning the reviewer
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Create a comment mentioning the reviewer
|
||||
comment = f'@{reviewer} Please review this pull request.'
|
||||
|
||||
# Add the comment to the pull request
|
||||
thread_data = {
|
||||
'comments': [{'content': comment}],
|
||||
'status': 'active',
|
||||
}
|
||||
|
||||
thread_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullRequests/{pr_number}/threads'
|
||||
thread_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
await self._make_api_request(
|
||||
thread_url,
|
||||
method=RequestMethod.POST,
|
||||
params=thread_params,
|
||||
json_data=thread_data,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to request review from {reviewer}: {e}')
|
||||
|
||||
def send_comment_msg(self, issue_number: int, msg: str) -> None:
|
||||
"""Send a comment message to an Azure DevOps issue or pull request."""
|
||||
asyncio.run(self._send_comment_msg_async(issue_number, msg))
|
||||
|
||||
async def _send_comment_msg_async(self, issue_number: int, msg: str) -> None:
|
||||
"""Send a comment message to an Azure DevOps issue or pull request asynchronously."""
|
||||
try:
|
||||
# Add the comment to the work item
|
||||
comment_url = f'{self.api_base_url}/wit/workItems/{issue_number}/comments'
|
||||
comment_params = {'api-version': '7.1-preview.3'}
|
||||
comment_data = {'text': msg}
|
||||
|
||||
await self._make_api_request(
|
||||
comment_url,
|
||||
method=RequestMethod.POST,
|
||||
params=comment_params,
|
||||
json_data=comment_data,
|
||||
)
|
||||
logger.info(f'Comment added to the issue: {msg}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to post comment: {e}')
|
||||
|
||||
def get_context_from_external_issues_references(
|
||||
self,
|
||||
closing_issues: list[str],
|
||||
closing_issue_numbers: list[int],
|
||||
issue_body: str,
|
||||
review_comments: list[str] | None,
|
||||
review_threads: list[ReviewThread],
|
||||
thread_comments: list[str] | None,
|
||||
) -> list[str]:
|
||||
"""Get context from external issue references."""
|
||||
# This method can remain largely the same as it doesn't use Azure DevOps SDK
|
||||
context_items = []
|
||||
if closing_issues:
|
||||
context_items.append(f'Closing issues: {", ".join(closing_issues)}')
|
||||
if closing_issue_numbers:
|
||||
context_items.append(
|
||||
f'Closing issue numbers: {", ".join(map(str, closing_issue_numbers))}'
|
||||
)
|
||||
if issue_body:
|
||||
context_items.append(f'Issue body: {issue_body}')
|
||||
if review_comments:
|
||||
context_items.extend(review_comments)
|
||||
if review_threads:
|
||||
for thread in review_threads:
|
||||
context_items.append(f'Review thread: {thread.comment}')
|
||||
if thread_comments:
|
||||
context_items.extend(thread_comments)
|
||||
return context_items
|
||||
|
||||
|
||||
class AzureDevOpsPRHandler(AzureDevOpsIssueHandler):
|
||||
"""Azure DevOps Pull Request handler that extends the issue handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
username: str | None = None,
|
||||
base_domain: str = 'dev.azure.com',
|
||||
):
|
||||
"""Initialize an Azure DevOps PR handler.
|
||||
|
||||
Args:
|
||||
owner: The owner (organization) of the repository
|
||||
repo: The name of the repository (format: project/repo)
|
||||
token: The Azure DevOps personal access token
|
||||
username: Optional Azure DevOps username
|
||||
base_domain: The domain for Azure DevOps (default: "dev.azure.com")
|
||||
"""
|
||||
super().__init__(owner, repo, token, username, base_domain)
|
||||
|
||||
def download_issues(self) -> list[Any]:
|
||||
"""Download pull requests from Azure DevOps."""
|
||||
return asyncio.run(self._download_pull_requests_async())
|
||||
|
||||
async def _download_pull_requests_async(self) -> list[Any]:
|
||||
"""Download pull requests from Azure DevOps asynchronously."""
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return []
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return []
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Get all active pull requests for the repository
|
||||
prs_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullrequests'
|
||||
prs_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'searchCriteria.status': 'active',
|
||||
}
|
||||
|
||||
prs_data = await self._make_api_request(prs_url, params=prs_params)
|
||||
|
||||
if not prs_data or not isinstance(prs_data, dict):
|
||||
return []
|
||||
|
||||
pull_requests = prs_data.get('value', [])
|
||||
|
||||
# Convert pull requests to the issue format
|
||||
all_issues = []
|
||||
for pr in pull_requests:
|
||||
# Convert the PR to a dictionary format similar to issues
|
||||
issue = {
|
||||
'id': pr.get('pullRequestId'),
|
||||
'fields': {
|
||||
'System.Id': pr.get('pullRequestId'),
|
||||
'System.Title': pr.get('title', ''),
|
||||
'System.Description': pr.get('description', ''),
|
||||
},
|
||||
'source_branch': pr.get('sourceRefName', ''),
|
||||
'repository': repo,
|
||||
}
|
||||
all_issues.append(issue)
|
||||
|
||||
return all_issues
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error downloading pull requests: {e}')
|
||||
return []
|
||||
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[Issue]:
|
||||
"""Download pull requests from Azure DevOps.
|
||||
|
||||
Args:
|
||||
issue_numbers: The numbers of the pull requests to download
|
||||
comment_id: The ID of a single comment, if provided, otherwise all comments
|
||||
|
||||
Returns:
|
||||
List of Azure DevOps pull requests as Issue objects.
|
||||
"""
|
||||
if not issue_numbers:
|
||||
raise ValueError('Unspecified issue number')
|
||||
|
||||
all_issues = self.download_issues()
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
all_issues = [issue for issue in all_issues if issue['id'] in issue_numbers]
|
||||
|
||||
if len(issue_numbers) == 1 and not all_issues:
|
||||
raise ValueError(f'Issue {issue_numbers[0]} not found')
|
||||
|
||||
converted_issues = []
|
||||
for issue in all_issues:
|
||||
# Get PR metadata
|
||||
(
|
||||
closing_issues,
|
||||
closing_issue_numbers,
|
||||
review_bodies,
|
||||
review_threads,
|
||||
thread_ids,
|
||||
) = self.download_pr_metadata(issue['id'], comment_id)
|
||||
|
||||
# Create the Issue object
|
||||
converted_issue = Issue(
|
||||
number=issue['id'],
|
||||
title=issue['fields']['System.Title'],
|
||||
body=issue['fields']['System.Description'],
|
||||
owner=self.owner,
|
||||
repo=f'{self.project_name}/{self.repo_name}',
|
||||
head_branch=issue['source_branch'].replace('refs/heads/', ''),
|
||||
closing_issues=closing_issues,
|
||||
closing_issue_numbers=closing_issue_numbers,
|
||||
review_bodies=review_bodies,
|
||||
review_threads=review_threads,
|
||||
thread_ids=thread_ids,
|
||||
)
|
||||
converted_issues.append(converted_issue)
|
||||
|
||||
return converted_issues
|
||||
|
||||
def download_pr_metadata(
|
||||
self, pull_number: int, comment_id: int | None = None
|
||||
) -> tuple[list[str], list[int], list[str] | None, list[ReviewThread], list[str]]:
|
||||
"""Get metadata for a pull request."""
|
||||
return asyncio.run(self._download_pr_metadata_async(pull_number, comment_id))
|
||||
|
||||
async def _download_pr_metadata_async(
|
||||
self, pull_number: int, comment_id: int | None = None
|
||||
) -> tuple[list[str], list[int], list[str] | None, list[ReviewThread], list[str]]:
|
||||
"""Get metadata for a pull request asynchronously.
|
||||
|
||||
Args:
|
||||
pull_number: The number of the pull request to query.
|
||||
comment_id: Optional ID of a specific comment to focus on.
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
1. List of closing issue bodies
|
||||
2. List of closing issue numbers
|
||||
3. List of review bodies
|
||||
4. List of review threads
|
||||
5. List of thread IDs
|
||||
"""
|
||||
try:
|
||||
# First, get the repository ID
|
||||
repos_url = f'{self.api_base_url}/git/repositories'
|
||||
repos_params = {
|
||||
'api-version': '7.1-preview.1',
|
||||
'project': self.project_name,
|
||||
}
|
||||
|
||||
repos_data = await self._make_api_request(repos_url, params=repos_params)
|
||||
|
||||
if not repos_data or not isinstance(repos_data, dict):
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return [], [], None, [], []
|
||||
|
||||
repositories = repos_data.get('value', [])
|
||||
repo = next(
|
||||
(
|
||||
r
|
||||
for r in repositories
|
||||
if r.get('name', '').lower() == self.repo_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not repo:
|
||||
logger.warning(f'Repository not found: {self.repo_name}')
|
||||
return [], [], None, [], []
|
||||
|
||||
repo_id = repo.get('id')
|
||||
|
||||
# Get the pull request details
|
||||
pr_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullRequests/{pull_number}'
|
||||
pr_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
pr_data = await self._make_api_request(pr_url, params=pr_params)
|
||||
|
||||
if not pr_data:
|
||||
logger.warning(f'Pull request {pull_number} not found')
|
||||
return [], [], None, [], []
|
||||
|
||||
# Get threads (comments) for the pull request
|
||||
threads_url = f'{self.api_base_url}/git/repositories/{repo_id}/pullRequests/{pull_number}/threads'
|
||||
threads_params = {'api-version': '7.1-preview.1'}
|
||||
|
||||
threads_data = await self._make_api_request(
|
||||
threads_url, params=threads_params
|
||||
)
|
||||
|
||||
review_threads = []
|
||||
thread_ids = []
|
||||
review_bodies = []
|
||||
|
||||
if threads_data and isinstance(threads_data, dict):
|
||||
threads = threads_data.get('value', [])
|
||||
|
||||
for thread in threads:
|
||||
thread_id = str(thread.get('id', ''))
|
||||
thread_ids.append(thread_id)
|
||||
|
||||
comments = thread.get('comments', [])
|
||||
if comments:
|
||||
# Get the first comment as the main review body
|
||||
first_comment = comments[0]
|
||||
content = first_comment.get('content', '')
|
||||
if content:
|
||||
review_bodies.append(content)
|
||||
|
||||
# Create review thread
|
||||
review_thread = ReviewThread(
|
||||
id=thread_id,
|
||||
body=content,
|
||||
line=None, # Azure DevOps doesn't provide line numbers in the same way
|
||||
start_line=None,
|
||||
original_line=None,
|
||||
original_start_line=None,
|
||||
diff_hunk='', # Would need additional API call to get diff
|
||||
path='', # Would need additional API call to get file path
|
||||
)
|
||||
review_threads.append(review_thread)
|
||||
|
||||
# For now, we don't extract closing issues from PR description
|
||||
# This would require parsing the description text
|
||||
closing_issues: list[str] = []
|
||||
closing_issue_numbers: list[int] = []
|
||||
|
||||
return (
|
||||
closing_issues,
|
||||
closing_issue_numbers,
|
||||
review_bodies if review_bodies else None,
|
||||
review_threads,
|
||||
thread_ids,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error downloading PR metadata: {e}')
|
||||
return [], [], None, [], []
|
||||
@@ -121,5 +121,5 @@ class IssueHandlerInterface(ABC):
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[Issue]:
|
||||
"""Download issues from Gitlab."""
|
||||
"""Download issues from the git provider (GitHub, GitLab, or Azure DevOps)."""
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.resolver.interfaces.azure_devops import (
|
||||
AzureDevOpsIssueHandler,
|
||||
AzureDevOpsPRHandler,
|
||||
)
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
|
||||
from openhands.resolver.interfaces.issue_definitions import (
|
||||
@@ -42,7 +46,7 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else: # platform == Platform.GITLAB
|
||||
elif self.platform == ProviderType.GITLAB:
|
||||
return ServiceContextIssue(
|
||||
GitlabIssueHandler(
|
||||
self.owner,
|
||||
@@ -53,6 +57,19 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.AZURE_DEVOPS:
|
||||
return ServiceContextIssue(
|
||||
AzureDevOpsIssueHandler(
|
||||
self.owner,
|
||||
self.repo,
|
||||
self.token,
|
||||
self.username,
|
||||
self.base_domain,
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {self.platform}')
|
||||
elif self.issue_type == 'pr':
|
||||
if self.platform == ProviderType.GITHUB:
|
||||
return ServiceContextPR(
|
||||
@@ -65,7 +82,7 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else: # platform == Platform.GITLAB
|
||||
elif self.platform == ProviderType.GITLAB:
|
||||
return ServiceContextPR(
|
||||
GitlabPRHandler(
|
||||
self.owner,
|
||||
@@ -76,5 +93,18 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.AZURE_DEVOPS:
|
||||
return ServiceContextPR(
|
||||
AzureDevOpsPRHandler(
|
||||
self.owner,
|
||||
self.repo,
|
||||
self.token,
|
||||
self.username,
|
||||
self.base_domain,
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {self.platform}')
|
||||
else:
|
||||
raise ValueError(f'Invalid issue type: {self.issue_type}')
|
||||
|
||||
@@ -50,6 +50,7 @@ AGENT_CLASS = 'CodeActAgent'
|
||||
|
||||
class IssueResolver:
|
||||
GITLAB_CI = os.getenv('GITLAB_CI') == 'true'
|
||||
AZURE_DEVOPS_CI = os.getenv('TF_BUILD') == 'True'
|
||||
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
"""Initialize the IssueResolver with the given parameters.
|
||||
@@ -76,7 +77,12 @@ class IssueResolver:
|
||||
raise ValueError('Invalid repository format. Expected owner/repo')
|
||||
owner, repo = parts
|
||||
|
||||
token = args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
|
||||
token = (
|
||||
args.token
|
||||
or os.getenv('GITHUB_TOKEN')
|
||||
or os.getenv('GITLAB_TOKEN')
|
||||
or os.getenv('AZURE_DEVOPS_TOKEN')
|
||||
)
|
||||
username = args.username if args.username else os.getenv('GIT_USERNAME')
|
||||
if not username:
|
||||
raise ValueError('Username is required.')
|
||||
@@ -120,7 +126,11 @@ class IssueResolver:
|
||||
base_domain = args.base_domain
|
||||
if base_domain is None:
|
||||
base_domain = (
|
||||
'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
|
||||
'github.com'
|
||||
if platform == ProviderType.GITHUB
|
||||
else 'gitlab.com'
|
||||
if platform == ProviderType.GITLAB
|
||||
else 'dev.azure.com'
|
||||
)
|
||||
|
||||
self.output_dir = args.output_dir
|
||||
@@ -240,6 +250,14 @@ class IssueResolver:
|
||||
if user_id == 0:
|
||||
sandbox_config.user_id = get_unique_uid()
|
||||
|
||||
# Configure sandbox for Azure DevOps CI environment
|
||||
if cls.AZURE_DEVOPS_CI:
|
||||
sandbox_config.use_host_network = False
|
||||
sandbox_config.enable_auto_lint = True
|
||||
sandbox_config.runtime_startup_env_vars = {
|
||||
'TF_BUILD': 'True',
|
||||
}
|
||||
|
||||
openhands_config.sandbox.base_container_image = (
|
||||
sandbox_config.base_container_image
|
||||
)
|
||||
@@ -273,7 +291,9 @@ class IssueResolver:
|
||||
if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
|
||||
raise RuntimeError(f'Failed to change directory to /workspace.\n{obs}')
|
||||
|
||||
if self.platform == ProviderType.GITLAB and self.GITLAB_CI:
|
||||
if (self.platform == ProviderType.GITLAB and self.GITLAB_CI) or (
|
||||
self.platform == ProviderType.AZURE_DEVOPS and self.AZURE_DEVOPS_CI
|
||||
):
|
||||
action = CmdRunAction(command='sudo chown -R 1001:0 /workspace/*')
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
@@ -335,7 +355,9 @@ class IssueResolver:
|
||||
if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
|
||||
raise RuntimeError(f'Failed to set git config. Observation: {obs}')
|
||||
|
||||
if self.platform == ProviderType.GITLAB and self.GITLAB_CI:
|
||||
if (self.platform == ProviderType.GITLAB and self.GITLAB_CI) or (
|
||||
self.platform == ProviderType.AZURE_DEVOPS and self.AZURE_DEVOPS_CI
|
||||
):
|
||||
action = CmdRunAction(command='sudo git add -A')
|
||||
else:
|
||||
action = CmdRunAction(command='git add -A')
|
||||
|
||||
@@ -116,7 +116,7 @@ def main() -> None:
|
||||
'--base-domain',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)',
|
||||
help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "dev.azure.com" for Azure DevOps)',
|
||||
)
|
||||
|
||||
my_args = parser.parse_args()
|
||||
|
||||
@@ -11,6 +11,7 @@ from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.resolver.interfaces.azure_devops import AzureDevOpsIssueHandler
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
@@ -235,40 +236,55 @@ def send_pull_request(
|
||||
pr_title: str | None = None,
|
||||
base_domain: str | None = None,
|
||||
) -> str:
|
||||
"""Send a pull request to a GitHub or Gitlab repository.
|
||||
"""Send a pull request to a GitHub, GitLab, or Azure DevOps repository.
|
||||
|
||||
Args:
|
||||
issue: The issue to send the pull request for
|
||||
token: The GitHub or Gitlab token to use for authentication
|
||||
username: The GitHub or Gitlab username, if provided
|
||||
token: The token to use for authentication
|
||||
username: The username, if provided
|
||||
platform: The platform of the repository.
|
||||
patch_dir: The directory containing the patches to apply
|
||||
pr_type: The type: branch (no PR created), draft or ready (regular PR created)
|
||||
fork_owner: The owner of the fork to push changes to (if different from the original repo owner)
|
||||
additional_message: The additional messages to post as a comment on the PR in json list format
|
||||
target_branch: The target branch to create the pull request against (defaults to repository default branch)
|
||||
reviewer: The GitHub or Gitlab username of the reviewer to assign
|
||||
reviewer: The username of the reviewer to assign
|
||||
pr_title: Custom title for the pull request (optional)
|
||||
base_domain: The base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)
|
||||
base_domain: The base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "dev.azure.com" for Azure DevOps)
|
||||
"""
|
||||
if pr_type not in ['branch', 'draft', 'ready']:
|
||||
raise ValueError(f'Invalid pr_type: {pr_type}')
|
||||
|
||||
# Determine default base_domain based on platform
|
||||
if base_domain is None:
|
||||
base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
|
||||
if platform == ProviderType.GITHUB:
|
||||
base_domain = 'github.com'
|
||||
elif platform == ProviderType.GITLAB:
|
||||
base_domain = 'gitlab.com'
|
||||
else: # platform == ProviderType.AZURE_DEVOPS
|
||||
base_domain = 'dev.azure.com'
|
||||
|
||||
# Create the appropriate handler based on platform
|
||||
handler = None
|
||||
if platform == ProviderType.GITHUB:
|
||||
handler = ServiceContextIssue(
|
||||
GithubIssueHandler(issue.owner, issue.repo, token, username, base_domain),
|
||||
None,
|
||||
)
|
||||
else: # platform == Platform.GITLAB
|
||||
elif platform == ProviderType.GITLAB:
|
||||
handler = ServiceContextIssue(
|
||||
GitlabIssueHandler(issue.owner, issue.repo, token, username, base_domain),
|
||||
None,
|
||||
)
|
||||
elif platform == ProviderType.AZURE_DEVOPS:
|
||||
handler = ServiceContextIssue(
|
||||
AzureDevOpsIssueHandler(
|
||||
issue.owner, issue.repo, token, username, base_domain
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {platform}')
|
||||
|
||||
# Create a new branch with a unique name
|
||||
base_branch_name = f'openhands-fix-issue-{issue.number}'
|
||||
|
||||
@@ -17,7 +17,7 @@ from openhands.integrations.utils import validate_provider_token
|
||||
|
||||
async def identify_token(token: str, base_domain: str | None) -> ProviderType:
|
||||
"""
|
||||
Identifies whether a token belongs to GitHub or GitLab.
|
||||
Identifies whether a token belongs to GitHub, GitLab, or Azure DevOps.
|
||||
Parameters:
|
||||
token (str): The personal access token to check.
|
||||
base_domain (str): Custom base domain for provider (e.g GitHub Enterprise)
|
||||
|
||||
@@ -411,6 +411,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
provider_domains = {
|
||||
ProviderType.GITHUB: 'github.com',
|
||||
ProviderType.GITLAB: 'gitlab.com',
|
||||
ProviderType.AZURE_DEVOPS: 'dev.azure.com',
|
||||
}
|
||||
|
||||
domain = provider_domains[provider]
|
||||
@@ -425,10 +426,45 @@ class Runtime(FileEditRuntimeMixin):
|
||||
if git_token:
|
||||
if provider == ProviderType.GITLAB:
|
||||
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
|
||||
elif provider == ProviderType.AZURE_DEVOPS:
|
||||
# Azure DevOps URL format: https://token@dev.azure.com/organization/project/_git/repository
|
||||
# Extract organization from domain if it's a full URL
|
||||
if domain.startswith('https://dev.azure.com/'):
|
||||
org_name = domain.replace('https://dev.azure.com/', '').rstrip(
|
||||
'/'
|
||||
)
|
||||
base_domain = 'dev.azure.com'
|
||||
else:
|
||||
# If domain is just the host, we need to get organization from the token host
|
||||
token_host = git_provider_tokens[provider].host
|
||||
if token_host and token_host.startswith(
|
||||
'https://dev.azure.com/'
|
||||
):
|
||||
org_name = token_host.replace(
|
||||
'https://dev.azure.com/', ''
|
||||
).rstrip('/')
|
||||
base_domain = 'dev.azure.com'
|
||||
else:
|
||||
# Fallback: assume domain contains the organization
|
||||
org_name = domain.replace('dev.azure.com', '').strip('/')
|
||||
base_domain = 'dev.azure.com'
|
||||
|
||||
# Parse project/repo from selected_repository
|
||||
repo_parts = selected_repository.split('/')
|
||||
if len(repo_parts) == 2:
|
||||
project_name, repo_name = repo_parts
|
||||
remote_repo_url = f'https://{git_token.get_secret_value()}@{base_domain}/{org_name}/{project_name}/_git/{repo_name}'
|
||||
else:
|
||||
# Fallback to original format if parsing fails
|
||||
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{domain}/{selected_repository}.git'
|
||||
if provider == ProviderType.AZURE_DEVOPS:
|
||||
# Public Azure DevOps repos (rare, but handle gracefully)
|
||||
remote_repo_url = f'https://{domain}/{selected_repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{domain}/{selected_repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{domain}/{selected_repository}.git'
|
||||
|
||||
@@ -647,6 +683,8 @@ fi
|
||||
provider = ProviderType.GITHUB
|
||||
elif 'gitlab.com' in repo_path:
|
||||
provider = ProviderType.GITLAB
|
||||
elif 'dev.azure.com' in repo_path:
|
||||
provider = ProviderType.AZURE_DEVOPS
|
||||
|
||||
# Add authentication if available
|
||||
if (
|
||||
@@ -658,6 +696,8 @@ fi
|
||||
if git_token:
|
||||
if provider == ProviderType.GITLAB:
|
||||
remote_url = f'https://oauth2:{git_token.get_secret_value()}@{repo_path.replace("gitlab.com/", "")}.git'
|
||||
elif provider == ProviderType.AZURE_DEVOPS:
|
||||
remote_url = f'https://{git_token.get_secret_value()}@{repo_path.replace("dev.azure.com/", "")}.git'
|
||||
else:
|
||||
remote_url = f'https://{git_token.get_secret_value()}@{repo_path.replace("github.com/", "")}.git'
|
||||
|
||||
@@ -673,7 +713,7 @@ fi
|
||||
the microagents from the ./microagents/ folder.
|
||||
|
||||
Args:
|
||||
selected_repository: The repository path (e.g., "github.com/acme-co/api")
|
||||
selected_repository: The repository path (e.g., "github.com/acme-co/api" or "acme-co/api")
|
||||
|
||||
Returns:
|
||||
A list of loaded microagents from the org/user level repository
|
||||
@@ -684,14 +724,35 @@ fi
|
||||
if len(repo_parts) < 2:
|
||||
return loaded_microagents
|
||||
|
||||
# Extract the domain and org/user name
|
||||
domain = repo_parts[0] if len(repo_parts) > 2 else 'github.com'
|
||||
# Determine the provider and domain
|
||||
provider_domains = {
|
||||
ProviderType.GITHUB: 'github.com',
|
||||
ProviderType.GITLAB: 'gitlab.com',
|
||||
ProviderType.AZURE_DEVOPS: 'dev.azure.com',
|
||||
}
|
||||
|
||||
# First, try to extract domain from repository name if it includes one
|
||||
if len(repo_parts) > 2:
|
||||
domain = repo_parts[0]
|
||||
else:
|
||||
# Repository name doesn't include domain (e.g., "org/repo")
|
||||
# Try to determine provider from available tokens
|
||||
domain = 'github.com' # Default fallback
|
||||
|
||||
if self.git_provider_tokens:
|
||||
# If we only have one provider token, use that
|
||||
if len(self.git_provider_tokens) == 1:
|
||||
provider = next(iter(self.git_provider_tokens))
|
||||
domain = provider_domains.get(provider, 'github.com')
|
||||
else:
|
||||
# Multiple providers - would need additional logic to determine which one
|
||||
# For now, default to GitHub
|
||||
pass
|
||||
|
||||
org_name = repo_parts[-2]
|
||||
|
||||
# Construct the org-level .openhands repo path
|
||||
org_openhands_repo = f'{domain}/{org_name}/.openhands'
|
||||
if domain not in org_openhands_repo:
|
||||
org_openhands_repo = f'github.com/{org_openhands_repo}'
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
|
||||
@@ -8,6 +8,9 @@ from fastmcp.server.dependencies import get_http_request
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.azure_devops.azure_devops_service import (
|
||||
AzureDevOpsServiceImpl,
|
||||
)
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
@@ -206,3 +209,65 @@ async def create_mr(
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@mcp_server.tool()
|
||||
async def create_azure_devops_pr(
|
||||
repo_name: Annotated[
|
||||
str, Field(description='Azure DevOps repository ({{project}}/{{repo}})')
|
||||
],
|
||||
source_branch: Annotated[str, Field(description='Source branch on repo')],
|
||||
target_branch: Annotated[str, Field(description='Target branch on repo')],
|
||||
title: Annotated[str, Field(description='PR Title')],
|
||||
body: Annotated[str | None, Field(description='PR body')],
|
||||
draft: Annotated[bool, Field(description='Whether PR opened is a draft')] = True,
|
||||
) -> str:
|
||||
"""Open a PR in Azure DevOps"""
|
||||
|
||||
logger.info('Calling OpenHands MCP create_azure_devops_pr')
|
||||
|
||||
request = get_http_request()
|
||||
headers = request.headers
|
||||
conversation_id = headers.get('X-OpenHands-ServerConversation-ID', None)
|
||||
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
access_token = await get_access_token(request)
|
||||
user_id = await get_user_id(request)
|
||||
|
||||
azure_devops_token = (
|
||||
provider_tokens.get(ProviderType.AZURE_DEVOPS, ProviderToken())
|
||||
if provider_tokens
|
||||
else ProviderToken()
|
||||
)
|
||||
|
||||
azure_devops_service = AzureDevOpsServiceImpl(
|
||||
user_id=azure_devops_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
token=azure_devops_token.token,
|
||||
base_domain=azure_devops_token.host,
|
||||
)
|
||||
|
||||
try:
|
||||
body = await get_convo_link(azure_devops_service, conversation_id, body or '')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to append convo link: {e}')
|
||||
|
||||
try:
|
||||
response = await azure_devops_service.create_pr(
|
||||
repo_name=repo_name,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
body=body,
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
if conversation_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
error = f'Error creating Azure DevOps pull request: {e}'
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
@@ -75,7 +75,8 @@ async def check_provider_tokens(
|
||||
if incoming_provider_tokens.provider_tokens:
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
|
||||
if token_value.token:
|
||||
# Only validate if token is not empty
|
||||
if token_value.token and token_value.token.get_secret_value():
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
token_value.token, token_value.host
|
||||
) # FE always sends latest host
|
||||
@@ -90,6 +91,7 @@ async def check_provider_tokens(
|
||||
existing_token
|
||||
and (existing_token.host != token_value.host)
|
||||
and existing_token.token
|
||||
and existing_token.token.get_secret_value()
|
||||
):
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
existing_token.token, token_value.host
|
||||
@@ -129,10 +131,23 @@ async def store_provider_tokens(
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in list(provider_info.provider_tokens.items()):
|
||||
if provider in existing_providers and not token_value.token:
|
||||
# If token is empty, keep the existing token if available
|
||||
if provider in existing_providers and (
|
||||
not token_value.token or not token_value.token.get_secret_value()
|
||||
):
|
||||
existing_token = user_secrets.provider_tokens.get(provider)
|
||||
if existing_token and existing_token.token:
|
||||
if (
|
||||
existing_token
|
||||
and existing_token.token
|
||||
and existing_token.token.get_secret_value()
|
||||
):
|
||||
provider_info.provider_tokens[provider] = existing_token
|
||||
# If both new and existing tokens are empty, skip this provider
|
||||
elif (
|
||||
not token_value.token
|
||||
or not token_value.token.get_secret_value()
|
||||
):
|
||||
continue
|
||||
|
||||
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
|
||||
provider
|
||||
|
||||
@@ -58,7 +58,7 @@ def get_impl(cls: type[T], impl_name: str | None) -> type[T]:
|
||||
Common Use Cases:
|
||||
- Server components (ConversationManager, UserAuth, etc.)
|
||||
- Storage implementations (ConversationStore, SettingsStore, etc.)
|
||||
- Service integrations (GitHub, GitLab services)
|
||||
- Service integrations (GitHub, GitLab, Azure DevOps services)
|
||||
|
||||
The implementation is cached to avoid repeated imports of the same class.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.azure_devops.azure_devops_service import (
|
||||
AzureDevOpsServiceImpl,
|
||||
)
|
||||
from openhands.integrations.service_types import ProviderType, SuggestedTask, TaskType
|
||||
from openhands.resolver.interfaces.azure_devops import (
|
||||
AzureDevOpsIssueHandler,
|
||||
AzureDevOpsPRHandler,
|
||||
)
|
||||
|
||||
|
||||
class TestAzureDevOpsIntegration(unittest.TestCase):
|
||||
def test_provider_type_enum(self):
|
||||
"""Test that AZURE_DEVOPS is in the ProviderType enum."""
|
||||
self.assertIn(ProviderType.AZURE_DEVOPS, ProviderType)
|
||||
|
||||
def test_suggested_task_provider_terms(self):
|
||||
"""Test that Azure DevOps terms are included in SuggestedTask.get_provider_terms()."""
|
||||
# Create a SuggestedTask with AZURE_DEVOPS provider
|
||||
task = SuggestedTask(
|
||||
git_provider=ProviderType.AZURE_DEVOPS,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo='test-repo',
|
||||
issue_number=1,
|
||||
title='Test Issue',
|
||||
)
|
||||
terms = task.get_provider_terms()
|
||||
self.assertIn('work item', terms)
|
||||
self.assertIn('pull request', terms)
|
||||
self.assertIn('repository', terms)
|
||||
|
||||
def test_azure_devops_service_impl_init(self):
|
||||
"""Test AzureDevOpsServiceImpl initialization."""
|
||||
# Arrange
|
||||
user_id = 'test-user'
|
||||
token = SecretStr('test-token')
|
||||
|
||||
# Act
|
||||
service = AzureDevOpsServiceImpl(user_id=user_id, token=token)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(service.user_id, user_id)
|
||||
self.assertEqual(service.token, token)
|
||||
self.assertEqual(service.provider, ProviderType.AZURE_DEVOPS.value)
|
||||
|
||||
def test_azure_devops_issue_handler_init(self):
|
||||
"""Test AzureDevOpsIssueHandler initialization."""
|
||||
# Arrange
|
||||
owner = 'test-org'
|
||||
repo = 'test-project/test-repo'
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
|
||||
# Act
|
||||
handler = AzureDevOpsIssueHandler(owner, repo, token, username)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(handler.owner, owner)
|
||||
self.assertEqual(handler.repo, repo)
|
||||
self.assertEqual(handler.token, token)
|
||||
self.assertEqual(handler.username, username)
|
||||
self.assertEqual(handler.project_name, 'test-project')
|
||||
self.assertEqual(handler.repo_name, 'test-repo')
|
||||
|
||||
def test_azure_devops_pr_handler_init(self):
|
||||
"""Test AzureDevOpsPRHandler initialization."""
|
||||
# Arrange
|
||||
owner = 'test-org'
|
||||
repo = 'test-project/test-repo'
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
|
||||
# Act
|
||||
handler = AzureDevOpsPRHandler(owner, repo, token, username)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(handler.owner, owner)
|
||||
self.assertEqual(handler.repo, repo)
|
||||
self.assertEqual(handler.token, token)
|
||||
self.assertEqual(handler.username, username)
|
||||
self.assertEqual(handler.project_name, 'test-project')
|
||||
self.assertEqual(handler.repo_name, 'test-repo')
|
||||
|
||||
def test_azure_devops_issue_handler_get_base_url(self):
|
||||
"""Test AzureDevOpsIssueHandler.get_base_url()."""
|
||||
# Arrange
|
||||
owner = 'test-org'
|
||||
repo = 'test-project/test-repo'
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
handler = AzureDevOpsIssueHandler(owner, repo, token, username)
|
||||
|
||||
# Act
|
||||
base_url = handler.get_base_url()
|
||||
|
||||
# Assert
|
||||
expected_url = 'https://dev.azure.com/test-org/test-project/_apis/git/repositories/test-repo'
|
||||
self.assertEqual(base_url, expected_url)
|
||||
|
||||
def test_azure_devops_issue_handler_get_clone_url(self):
|
||||
"""Test AzureDevOpsIssueHandler.get_clone_url()."""
|
||||
# Arrange
|
||||
owner = 'test-org'
|
||||
repo = 'test-project/test-repo'
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
handler = AzureDevOpsIssueHandler(owner, repo, token, username)
|
||||
|
||||
# Act
|
||||
clone_url = handler.get_clone_url()
|
||||
|
||||
# Assert
|
||||
expected_url = 'https://test-user:test-token@dev.azure.com/test-org/test-project/_git/test-repo'
|
||||
self.assertEqual(clone_url, expected_url)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Test microagent domain detection for different Git providers."""
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class MockRuntime:
|
||||
"""Mock runtime class to test microagent domain detection logic."""
|
||||
|
||||
def __init__(self, git_provider_tokens=None):
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
|
||||
def get_microagents_from_org_or_user(self, selected_repository: str):
|
||||
"""Simplified version of the microagent domain detection logic."""
|
||||
repo_parts = selected_repository.split('/')
|
||||
if len(repo_parts) < 2:
|
||||
return {}
|
||||
|
||||
# Determine the provider and domain
|
||||
provider_domains = {
|
||||
ProviderType.GITHUB: 'github.com',
|
||||
ProviderType.GITLAB: 'gitlab.com',
|
||||
ProviderType.AZURE_DEVOPS: 'dev.azure.com',
|
||||
}
|
||||
|
||||
# First, try to extract domain from repository name if it includes one
|
||||
if len(repo_parts) > 2:
|
||||
domain = repo_parts[0]
|
||||
provider = None
|
||||
else:
|
||||
# Repository name doesn't include domain (e.g., "org/repo")
|
||||
# Try to determine provider from available tokens
|
||||
domain = 'github.com' # Default fallback
|
||||
provider = None
|
||||
|
||||
if self.git_provider_tokens:
|
||||
# If we only have one provider token, use that
|
||||
if len(self.git_provider_tokens) == 1:
|
||||
provider = next(iter(self.git_provider_tokens))
|
||||
domain = provider_domains.get(provider, 'github.com')
|
||||
else:
|
||||
# Multiple providers - would need additional logic to determine which one
|
||||
# For now, default to GitHub
|
||||
pass
|
||||
|
||||
org_name = repo_parts[-2]
|
||||
|
||||
# Construct the org-level .openhands repo path
|
||||
org_openhands_repo = f'{domain}/{org_name}/.openhands'
|
||||
|
||||
return {
|
||||
'domain': domain,
|
||||
'provider': provider,
|
||||
'org_name': org_name,
|
||||
'org_openhands_repo': org_openhands_repo,
|
||||
}
|
||||
|
||||
|
||||
class TestMicroagentDomainDetection:
|
||||
"""Test cases for microagent domain detection across different Git providers."""
|
||||
|
||||
def test_github_with_full_domain(self):
|
||||
"""Test GitHub repository with full domain in name."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'github.com/octocat/Hello-World'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'github.com'
|
||||
assert result['org_name'] == 'octocat'
|
||||
assert result['org_openhands_repo'] == 'github.com/octocat/.openhands'
|
||||
|
||||
def test_gitlab_with_full_domain(self):
|
||||
"""Test GitLab repository with full domain in name."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'gitlab.com/gitlab-org/gitlab'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'gitlab.com'
|
||||
assert result['org_name'] == 'gitlab-org'
|
||||
assert result['org_openhands_repo'] == 'gitlab.com/gitlab-org/.openhands'
|
||||
|
||||
def test_azure_devops_with_full_domain(self):
|
||||
"""Test Azure DevOps repository with full domain in name."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'dev.azure.com/myorg/myproject'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'dev.azure.com'
|
||||
assert result['org_name'] == 'myorg'
|
||||
assert result['org_openhands_repo'] == 'dev.azure.com/myorg/.openhands'
|
||||
|
||||
def test_github_single_token_short_name(self):
|
||||
"""Test GitHub repository with short name and single GitHub token."""
|
||||
github_token = ProviderToken(
|
||||
token=SecretStr('github_token_123'), user_id=None, host='github.com'
|
||||
)
|
||||
|
||||
git_provider_tokens = MappingProxyType({ProviderType.GITHUB: github_token})
|
||||
|
||||
runtime = MockRuntime(git_provider_tokens)
|
||||
result = runtime.get_microagents_from_org_or_user('octocat/Hello-World')
|
||||
|
||||
assert result['domain'] == 'github.com'
|
||||
assert result['provider'] == ProviderType.GITHUB
|
||||
assert result['org_name'] == 'octocat'
|
||||
assert result['org_openhands_repo'] == 'github.com/octocat/.openhands'
|
||||
|
||||
def test_gitlab_single_token_short_name(self):
|
||||
"""Test GitLab repository with short name and single GitLab token."""
|
||||
gitlab_token = ProviderToken(
|
||||
token=SecretStr('gitlab_token_123'), user_id=None, host='gitlab.com'
|
||||
)
|
||||
|
||||
git_provider_tokens = MappingProxyType({ProviderType.GITLAB: gitlab_token})
|
||||
|
||||
runtime = MockRuntime(git_provider_tokens)
|
||||
result = runtime.get_microagents_from_org_or_user('gitlab-org/gitlab')
|
||||
|
||||
assert result['domain'] == 'gitlab.com'
|
||||
assert result['provider'] == ProviderType.GITLAB
|
||||
assert result['org_name'] == 'gitlab-org'
|
||||
assert result['org_openhands_repo'] == 'gitlab.com/gitlab-org/.openhands'
|
||||
|
||||
def test_azure_devops_single_token_short_name(self):
|
||||
"""Test Azure DevOps repository with short name and single Azure DevOps token."""
|
||||
azure_token = ProviderToken(
|
||||
token=SecretStr('azure_token_123'),
|
||||
user_id=None,
|
||||
host='https://dev.azure.com/myorg',
|
||||
)
|
||||
|
||||
git_provider_tokens = MappingProxyType({ProviderType.AZURE_DEVOPS: azure_token})
|
||||
|
||||
runtime = MockRuntime(git_provider_tokens)
|
||||
result = runtime.get_microagents_from_org_or_user('myorg/myproject')
|
||||
|
||||
assert result['domain'] == 'dev.azure.com'
|
||||
assert result['provider'] == ProviderType.AZURE_DEVOPS
|
||||
assert result['org_name'] == 'myorg'
|
||||
assert result['org_openhands_repo'] == 'dev.azure.com/myorg/.openhands'
|
||||
|
||||
def test_multiple_tokens_defaults_to_github(self):
|
||||
"""Test that with multiple tokens, it defaults to GitHub for short names."""
|
||||
github_token = ProviderToken(
|
||||
token=SecretStr('github_token_123'), user_id=None, host='github.com'
|
||||
)
|
||||
|
||||
azure_token = ProviderToken(
|
||||
token=SecretStr('azure_token_123'),
|
||||
user_id=None,
|
||||
host='https://dev.azure.com/myorg',
|
||||
)
|
||||
|
||||
git_provider_tokens = MappingProxyType(
|
||||
{ProviderType.GITHUB: github_token, ProviderType.AZURE_DEVOPS: azure_token}
|
||||
)
|
||||
|
||||
runtime = MockRuntime(git_provider_tokens)
|
||||
result = runtime.get_microagents_from_org_or_user('someorg/somerepo')
|
||||
|
||||
# With multiple tokens, should default to GitHub
|
||||
assert result['domain'] == 'github.com'
|
||||
assert result['provider'] is None # No specific provider determined
|
||||
assert result['org_name'] == 'someorg'
|
||||
assert result['org_openhands_repo'] == 'github.com/someorg/.openhands'
|
||||
|
||||
def test_no_tokens_defaults_to_github(self):
|
||||
"""Test that without tokens, it defaults to GitHub for short names."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user('someorg/somerepo')
|
||||
|
||||
assert result['domain'] == 'github.com'
|
||||
assert result['provider'] is None
|
||||
assert result['org_name'] == 'someorg'
|
||||
assert result['org_openhands_repo'] == 'github.com/someorg/.openhands'
|
||||
|
||||
def test_custom_gitlab_domain(self):
|
||||
"""Test custom GitLab domain with full path."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'gitlab.example.com/myorg/myproject'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'gitlab.example.com'
|
||||
assert result['org_name'] == 'myorg'
|
||||
assert result['org_openhands_repo'] == 'gitlab.example.com/myorg/.openhands'
|
||||
|
||||
def test_custom_github_enterprise_domain(self):
|
||||
"""Test custom GitHub Enterprise domain with full path."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'github.enterprise.com/myorg/myproject'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'github.enterprise.com'
|
||||
assert result['org_name'] == 'myorg'
|
||||
assert result['org_openhands_repo'] == 'github.enterprise.com/myorg/.openhands'
|
||||
|
||||
def test_invalid_repository_name(self):
|
||||
"""Test invalid repository name with only one part."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user('invalid')
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_deeply_nested_repository_path(self):
|
||||
"""Test repository path with more than 3 parts."""
|
||||
runtime = MockRuntime()
|
||||
result = runtime.get_microagents_from_org_or_user(
|
||||
'github.com/org/subgroup/project'
|
||||
)
|
||||
|
||||
assert result['domain'] == 'github.com'
|
||||
assert result['org_name'] == 'subgroup' # Second to last part
|
||||
assert result['org_openhands_repo'] == 'github.com/subgroup/.openhands'
|
||||
|
||||
def test_azure_devops_real_world_scenario(self):
|
||||
"""Test real-world Azure DevOps scenario with actual token structure."""
|
||||
azure_token = ProviderToken(
|
||||
token=SecretStr('pat_token_value'),
|
||||
user_id=None,
|
||||
host='https://dev.azure.com/all-hands-ai',
|
||||
)
|
||||
|
||||
git_provider_tokens = MappingProxyType({ProviderType.AZURE_DEVOPS: azure_token})
|
||||
|
||||
runtime = MockRuntime(git_provider_tokens)
|
||||
result = runtime.get_microagents_from_org_or_user('test-project/test-project')
|
||||
|
||||
assert result['domain'] == 'dev.azure.com'
|
||||
assert result['provider'] == ProviderType.AZURE_DEVOPS
|
||||
assert result['org_name'] == 'test-project'
|
||||
assert result['org_openhands_repo'] == 'dev.azure.com/test-project/.openhands'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run tests if executed directly
|
||||
import sys
|
||||
|
||||
test_class = TestMicroagentDomainDetection()
|
||||
test_methods = [method for method in dir(test_class) if method.startswith('test_')]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test_method in test_methods:
|
||||
try:
|
||||
print(f'Running {test_method}...')
|
||||
getattr(test_class, test_method)()
|
||||
print(f'✅ {test_method} passed')
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f'❌ {test_method} failed: {e}')
|
||||
failed += 1
|
||||
|
||||
print(f'\nResults: {passed} passed, {failed} failed')
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print('🎉 All tests passed!')
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user