Compare commits

...

48 Commits

Author SHA1 Message Date
openhands a2928700c7 Add Azure DevOps MCP support with create_pr functionality
- Add create_pr method to AzureDevOpsServiceImpl class
- Add create_azure_devops_pr MCP function to routes
- Follows same pattern as GitHub create_pr and GitLab create_mr
- Resolves LocalProtocolError with Bearer token by using proper Azure DevOps Basic auth
- Tested with live Azure DevOps repository
2025-06-15 00:10:28 +00:00
Graham Neubig 0211db1765 Update 2025-06-14 19:50:38 -04:00
openhands 68ae4eb4bc Remove temporary test file 2025-06-14 23:18:29 +00:00
openhands 8ff51e18c5 Fix Azure DevOps repository cloning
- Add support for AZURE_DEVOPS_HOST environment variable in setup.py
- Fix Git URL construction for Azure DevOps in clone_or_init_repo method
- Azure DevOps uses different URL format: https://token@dev.azure.com/org/project/_git/repo
- Extract organization from host URL and construct proper Azure DevOps Git URLs
- Add comprehensive test to verify the fix works correctly

This resolves the issue where Azure DevOps repositories were not being cloned
due to incorrect URL format and missing host configuration.
2025-06-14 23:18:18 +00:00
openhands d331edf8cd Fix Azure DevOps test to provide both token and host
The test was failing because the validation logic requires both Azure DevOps token and host to be provided together. Updated the test to provide both values as required by the validation.
2025-06-14 22:44:45 +00:00
openhands 9aac92929b Fix linting issues: trailing whitespace, mypy errors, and frontend formatting
- Fixed trailing whitespace in git-settings.tsx
- Fixed missing return statement in Azure DevOps service get_user method
- Updated Azure DevOps service to use base_domain instead of organization_url for GitService protocol compliance
- Updated provider.py to use consistent base_domain parameter for all services
- Updated utils.py to use base_domain parameter for Azure DevOps service instantiation
- All pre-commit hooks now pass successfully
2025-06-14 20:57:16 +00:00
openhands 1c1588786b Add comprehensive tests for microagent domain detection
- Tests all three providers: GitHub, GitLab, and Azure DevOps
- Covers repository names with and without domains
- Tests single provider token scenarios
- Tests multiple provider token fallback behavior
- Tests custom domains and enterprise installations
- Tests edge cases like invalid repository names
- Validates real-world Azure DevOps scenario
- 13 test cases covering all major scenarios
- Ensures backward compatibility with existing functionality
2025-06-14 20:27:06 +00:00
openhands 76c59bd365 Fix microagent domain detection for Azure DevOps repositories
- Repository names without domains (e.g., 'org/repo') were defaulting to GitHub
- Added logic to detect provider from available tokens when domain not in repo name
- For single provider token, use that provider's domain
- Maintains backward compatibility with full repository paths (domain/org/repo)
- Resolves issue where Azure DevOps repos were incorrectly checking github.com for microagents
- Tested with Azure DevOps, GitHub, and fallback scenarios
2025-06-14 20:23:02 +00:00
openhands 53336d6cb0 Fix ProviderHandler Azure DevOps service instantiation
- Azure DevOps service constructor expects 'organization_url' parameter
- ProviderHandler was incorrectly passing 'base_domain' for all providers
- Added provider-specific logic to pass correct parameter names
- Resolves authentication error: 'Need valid provider token'
- Tested with live Azure DevOps and confirmed user authentication works
2025-06-14 19:45:02 +00:00
openhands bde01d8f5b Remove Azure DevOps integration test report
- Deleted AZURE_DEVOPS_INTEGRATION_TEST_REPORT.md from repository
- Test report was for verification purposes only, not needed in final PR
2025-06-14 19:37:06 +00:00
openhands 1bb3a4a9ad Fix User ID validation error for Azure DevOps
- Updated User model to accept both int and str for id field
- Azure DevOps returns UUID strings while GitHub/GitLab use integers
- Resolves Pydantic validation error: 'Input should be a valid integer, unable to parse string as an integer'
- Matches existing Repository model pattern that already supports both types
- Tested with live Azure DevOps and confirmed fix works correctly
2025-06-14 19:35:48 +00:00
openhands b785639ec9 Add comprehensive Azure DevOps integration test report
- Documented thorough testing with live all-hands-ai organization
- Verified all API endpoints and functionality work correctly
- Confirmed token validation, authentication, and repository operations
- Tested work item creation, updates, and WIQL queries
- Validated pull request and task suggestion functionality
- All tests passed successfully with live Azure DevOps data
2025-06-14 19:33:15 +00:00
openhands 45c133083a Improve Azure DevOps frontend UX
- Update organization URL placeholder to 'https://dev.azure.com/{your-org-name}' for clarity
- Remove confusing instruction text 'Enter just the token value. Both token and organization URL are required.'
- Updated all language translations to remove the redundant instruction text

This makes it clearer what format the organization URL should be in and removes redundant text since the form validation already enforces the requirement.
2025-06-14 19:28:45 +00:00
openhands 13049e291c Fix Azure DevOps token validation and improve user authentication
Critical fixes:
- Pass organization_url to AzureDevOpsServiceImpl in validate_provider_token()
- This was the root cause of 'invalid token' errors when users provided valid tokens
- Improve get_user() method to use projects API for organization-scoped tokens
- Add connection data API call for better user information retrieval

Changes:
- openhands/integrations/utils.py: Pass base_domain as organization_url parameter
- openhands/integrations/azure_devops/azure_devops_service.py: Improve user authentication flow

Testing results with live Azure DevOps (all-hands-ai org):
 Token validation now correctly identifies Azure DevOps tokens
 User authentication works with organization-scoped tokens
 Repository listing works (found 3 repos including test-project)
 API calls succeed with proper organization URL

This resolves the issue where users got 'invalid token' errors despite having valid Azure DevOps PATs.
2025-06-14 19:18:16 +00:00
openhands 823a9f1398 Implement frontend validation for Azure DevOps token and organization URL
- Add validation to ensure both Azure DevOps token and organization URL are provided together
- Prevent submission when only one field is filled (either token without URL or URL without token)
- Add comprehensive error messages in all supported languages
- Update i18n declaration with new error message keys
- Validation logic:
  * Both empty: allowed (user not configuring Azure DevOps)
  * Token only: blocked with 'Organization URL is required' error
  * URL only: blocked with 'Azure DevOps token is required' error
  * Both provided: allowed (proper configuration)
- Handles whitespace-only inputs correctly
2025-06-14 19:04:07 +00:00
openhands dcf5fc510e Improve Azure DevOps frontend help text and labels
- Update host label to clarify it's required: 'Azure DevOps Organization URL (Required)'
- Add comprehensive help text explaining why both token and organization URL are needed
- Update token help text to mention both fields are required
- Add host help text explaining organization-scoped nature of Azure DevOps tokens
- Updated all language translations for better user guidance
2025-06-14 18:55:22 +00:00
openhands f2524a3f9d Fix Azure DevOps token help text
- Remove confusing 'username:token' format instruction
- Update help text to say 'Enter just the token value'
- Updated all language translations
- Azure DevOps PATs should be entered as plain tokens, not username:token format
2025-06-14 18:49:53 +00:00
openhands 1be4f6271f Fix Azure DevOps integration issues identified during live testing
- Fix Repository model to accept string UUIDs (Azure DevOps) in addition to integer IDs (GitHub/GitLab)
- Fix WIQL API calls to properly separate query parameters from JSON body
- Update work item queries to support multiple types (Bug, Issue, Task) and states
- Ensure proper API versioning and parameter handling for POST requests

These fixes resolve validation errors and API compatibility issues when working
with live Azure DevOps organizations and enable full functionality of the
Azure DevOps integration.
2025-06-14 18:34:43 +00:00
Graham Neubig 8d1414ee01 Merge branch 'main' into feat/azure-devops-updated 2025-06-14 14:12:56 -04:00
openhands 81a3ced7dc Fix Azure DevOps microagent implementation to match GitHub/GitLab pattern
The Azure DevOps microagent was missing the YAML frontmatter that defines
it as a knowledge microagent with specific triggers. This caused it to be
loaded as a repo microagent for every session, which broke the
test_agent_controller_processes_null_observation_with_cause test.

Added proper frontmatter with:
- type: knowledge (instead of being loaded as repo knowledge)
- triggers: ['azure devops', 'azure', 'devops'] (only activated when mentioned)

This matches the implementation pattern used by GitHub and GitLab microagents.
Reverted the test workaround since the root cause is now fixed.
2025-06-14 13:16:23 +00:00
openhands 55d2069988 Fix test_agent_controller_processes_null_observation_with_cause
The test was failing because the addition of the Azure DevOps microagent
changed the behavior of the Memory system. The azure_devops.md microagent
is loaded as a repo microagent, which means there are always repo instructions
available, causing the Memory system to return a RecallObservation instead
of a NullObservation.

This fix mocks the global microagent loading to return empty microagents,
preserving the original test intent of testing NullObservation handling
when no workspace context is available.
2025-06-14 13:07:27 +00:00
Graham Neubig 5bc2831307 Merge branch 'main' into feat/azure-devops-updated 2025-06-14 08:43:18 -04:00
openhands 41f1ba540f Remove azure-devops package dependency and use standard HTTP API calls
- Completely replaced azure.devops SDK with httpx HTTP client
- Updated Azure DevOps service to use REST API endpoints directly
- Replaced BasicAuthentication with base64-encoded Basic auth headers
- Updated resolver interface to use HTTP API calls instead of SDK
- Fixed method signatures to match BaseGitService interface
- Removed conditional imports and error handling for azure-devops package
- All tests passing with new HTTP-based implementation
- No remaining dependencies on azure-devops package
2025-06-14 12:16:22 +00:00
openhands accef7a908 Fix Azure DevOps import issues in CI environments
Make Azure DevOps package imports conditional to handle environments
where the azure-devops package is not installed:

- Add conditional imports with fallback mock classes in azure_devops_service.py
- Add conditional imports with placeholder class in provider.py
- Raise informative ImportError when trying to use Azure DevOps without package
- Fixes test collection errors in resolver tests

This allows the codebase to import successfully even when azure-devops
package is not available, while providing clear error messages when
users try to actually use Azure DevOps functionality.
2025-06-14 05:12:41 +00:00
openhands 9db84c1d46 Apply ruff formatting fixes
Auto-format code to comply with project style guidelines:
- Fix line length in Azure DevOps token initialization
- Fix line length in CI condition checks
2025-06-14 05:04:37 +00:00
openhands aae949fbd3 Fix mypy error in Azure DevOps token validation
Remove invalid base_domain parameter from AzureDevOpsServiceImpl constructor call.
The Azure DevOps service doesn't accept base_domain parameter unlike other providers.
2025-06-14 04:57:35 +00:00
openhands fb2967c8c9 Remove development checklist file
Clean up temporary development artifacts to keep PR focused on Azure DevOps implementation only.
2025-06-14 04:54:02 +00:00
openhands 50fa1a0d23 Add Azure DevOps support to remaining files analogous to Bitbucket PR #9021
- Add Azure DevOps auth support to runtime/base.py for git operations
- Add Azure DevOps token validation to server/routes/secrets.py
- Add Azure DevOps environment token support to core/setup.py
- Add Azure DevOps CI configuration to resolver/issue_resolver.py
- Update help text and docstrings to include Azure DevOps
- Update frontend generate-auth-url.ts comment for Azure DevOps

All changes mirror the Bitbucket PR #9021 structure but for Azure DevOps.
2025-06-14 04:45:41 +00:00
openhands 81ebbcf05d Revert files that don't have analogous files in Bitbucket PR #9021
This ensures the Azure DevOps implementation only modifies files that have
corresponding changes in the Bitbucket PR, maintaining consistency between
the two provider implementations.
2025-06-14 04:37:58 +00:00
openhands c4111a7b19 Add Azure DevOps support to utils and resolver files 2025-06-14 04:37:22 +00:00
openhands d47447ba45 Update poetry.lock after test dependency installation 2025-06-14 04:29:26 +00:00
openhands 8a3daa888e Fix Azure DevOps test failures
- Fixed test_issue_handler_factory.py to use correct Azure DevOps repo format (project/repo)
- Removed invalid Azure DevOps pipeline tests that referenced non-existent AZURE_PIPELINE attribute
- All Azure DevOps specific tests now pass (25/25)
- All resolver tests now pass (192/192)
2025-06-14 04:28:51 +00:00
openhands bda316a7e5 Complete Azure DevOps implementation
- Add comprehensive Azure DevOps translations to frontend
- Add Azure DevOps support to auto-login hook and local storage
- Fix GitHub host input bug in git settings
- Add Azure DevOps to LoginMethod enum
- Update auto-login hook to support Azure DevOps authentication
- All Azure DevOps backend integration already exists
- Frontend components and auth modal already support Azure DevOps
- Pre-commit hooks and frontend build pass successfully
2025-06-14 04:16:13 +00:00
openhands d1020bbd90 Merge main into Azure DevOps branch and resolve conflicts 2025-06-14 03:38:44 +00:00
Graham Neubig 89b783baef Update openhands/resolver/resolve_issue.py 2025-06-03 17:58:43 -04:00
openhands 2edf026f3f Merge origin/main into feat/azure-devops 2025-06-03 20:25:27 +00:00
Kent Johnson 902e006731 Use black SVG from https://iconduck.com/icons/90187/microsoft-azure-devops 2025-05-22 10:08:34 -05:00
openhands 8fec9962ab Asked OH to do 3 more passes looking for missing ADO edits 2025-05-21 18:08:29 +00:00
Kent Johnson 528e4970f6 Merge branch 'main' into feat/azure-devops 2025-05-20 11:01:03 -05:00
openhands da77c7ee1c Hopefully fix lint errors 2025-05-20 02:34:16 +00:00
openhands 60280ff97e Fix azure-devops version 2025-05-19 23:21:42 +00:00
Kent Johnson 6329344c2a Merge branch 'main' into feat/azure-devops 2025-05-19 18:00:35 -05:00
openhands bcde287a11 Use newer Azure DevOps APIs 2025-05-19 04:11:31 +00:00
openhands e689b41e2e Fix Azure DevOps work item type to use 'Bug' instead of 'Issue' in resolver interface 2025-05-19 04:02:36 +00:00
openhands d2e03758f9 Fix Azure DevOps work item type to use 'Bug' instead of 'Issue' 2025-05-19 04:01:42 +00:00
openhands 196feac00e Enhance Azure DevOps integration for parity with GitHub and GitLab 2025-05-19 03:55:17 +00:00
openhands d25b0ca567 Add Azure DevOps integration 2025-05-19 00:16:13 +00:00
openhands 33ce4b3b8d Add Azure DevOps integration 2025-05-18 23:40:48 +00:00
35 changed files with 3085 additions and 72 deletions
@@ -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}`;
}
@@ -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 -3
View File
@@ -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,
]);
};
+10
View File
@@ -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",
+160
View File
@@ -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プロバイダーでサインイン",
+49 -3
View File
@@ -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
View File
@@ -1,6 +1,7 @@
export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
azure_devops: "azure_devops",
} as const;
export type Provider = keyof typeof ProviderOptions;
+1 -1
View File
@@ -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
*/
+2 -1
View File
@@ -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);
+188
View File
@@ -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>
+7
View File
@@ -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}')
+8
View File
@@ -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,
+29 -11
View File
@@ -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."""
+29 -10
View File
@@ -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
+18 -8
View File
@@ -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, [], []
+1 -1
View File
@@ -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
+32 -2
View File
@@ -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}')
+26 -4
View File
@@ -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')
+1 -1
View File
@@ -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()
+23 -7
View File
@@ -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}'
+1 -1
View File
@@ -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)
+67 -6
View File
@@ -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',
+65
View File
@@ -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
+18 -3
View File
@@ -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
+1 -1
View File
@@ -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.
"""
+121
View File
@@ -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)