-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: Add Azure DevOps repository support #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9c70c93
6bfdc3c
ac3c83f
e955138
9aca6ef
036bf50
ad81931
83dfbdd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -118,6 +118,11 @@ def download_repo(repo_url: str, local_path: str, repo_type: str = None, access_ | |
| elif repo_type == "bitbucket": | ||
| # Format: https://x-token-auth:{token}@bitbucket.org/owner/repo.git | ||
| clone_url = urlunparse((parsed.scheme, f"x-token-auth:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) | ||
| elif repo_type == "azure_devops": | ||
| # Format: https://{token}@dev.azure.com/org/project/_git/repo | ||
| # Strip any existing username from netloc (ADO URLs often include user@) | ||
| hostname = parsed.hostname or parsed.netloc.split('@')[-1] | ||
| clone_url = urlunparse((parsed.scheme, f"{encoded_token}@{hostname}", parsed.path, '', '', '')) | ||
|
|
||
| logger.info("Using access token for authentication") | ||
|
|
||
|
|
@@ -684,6 +689,59 @@ def get_bitbucket_file_content(repo_url: str, file_path: str, access_token: str | |
| raise ValueError(f"Failed to get file content: {str(e)}") | ||
|
|
||
|
|
||
| def get_azure_devops_file_content(repo_url: str, file_path: str, access_token: str = None) -> str: | ||
| """ | ||
| Retrieves the content of a file from an Azure DevOps repository using the ADO REST API. | ||
|
|
||
| Args: | ||
| repo_url (str): The URL (e.g., "https://dev.azure.com/org/project/_git/repo") | ||
| file_path (str): Path to file (e.g., "src/main.py") | ||
| access_token (str, optional): Azure DevOps PAT | ||
|
|
||
| Returns: | ||
| str: The content of the file | ||
| """ | ||
| try: | ||
| parsed_url = urlparse(repo_url) | ||
| if not parsed_url.scheme or not parsed_url.netloc: | ||
| raise ValueError("Not a valid Azure DevOps repository URL") | ||
|
|
||
| path_parts = parsed_url.path.strip('/').split('/') | ||
|
|
||
| if '_git' not in path_parts: | ||
| raise ValueError("Not a valid Azure DevOps repository URL - missing _git in path") | ||
|
|
||
| git_index = path_parts.index('_git') | ||
| repo_name = path_parts[git_index + 1].replace(".git", "") if git_index + 1 < len(path_parts) else None | ||
|
|
||
| if not repo_name: | ||
| raise ValueError("Could not extract repository name from Azure DevOps URL") | ||
|
|
||
| # Build API URL: https://dev.azure.com/{org}/{project}/_apis/git/repositories/{repo}/items | ||
| # Strip any existing username from netloc (ADO URLs often include user@) | ||
| hostname = parsed_url.hostname or parsed_url.netloc.split('@')[-1] | ||
| project_path = '/'.join(path_parts[:git_index]) | ||
| api_base = f"{parsed_url.scheme}://{hostname}/{project_path}" | ||
|
Comment on lines
+722
to
+724
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| api_url = f"{api_base}/_apis/git/repositories/{repo_name}/items?path={file_path}&api-version=7.0" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The api_url = f"{api_base}/_apis/git/repositories/{quote(repo_name)}/items?path={quote(file_path)}&api-version={ADO_API_VERSION}" |
||
|
|
||
| headers = {} | ||
| if access_token: | ||
| encoded = base64.b64encode(f":{access_token}".encode()).decode() | ||
| headers["Authorization"] = f"Basic {encoded}" | ||
|
|
||
| logger.info(f"Fetching file content from Azure DevOps API: {api_url}") | ||
| try: | ||
| response = requests.get(api_url, headers=headers) | ||
| response.raise_for_status() | ||
| except RequestException as e: | ||
| raise ValueError(f"Error fetching file content: {e}") | ||
|
|
||
| return response.text | ||
|
|
||
| except Exception as e: | ||
| raise ValueError(f"Failed to get file content: {str(e)}") | ||
|
|
||
|
|
||
| def get_file_content(repo_url: str, file_path: str, repo_type: str = None, access_token: str = None) -> str: | ||
| """ | ||
| Retrieves the content of a file from a Git repository (GitHub or GitLab). | ||
|
|
@@ -706,8 +764,10 @@ def get_file_content(repo_url: str, file_path: str, repo_type: str = None, acces | |
| return get_gitlab_file_content(repo_url, file_path, access_token) | ||
| elif repo_type == "bitbucket": | ||
| return get_bitbucket_file_content(repo_url, file_path, access_token) | ||
| elif repo_type == "azure_devops": | ||
| return get_azure_devops_file_content(repo_url, file_path, access_token) | ||
| else: | ||
| raise ValueError("Unsupported repository type. Only GitHub, GitLab, and Bitbucket are supported.") | ||
| raise ValueError("Unsupported repository type. Only GitHub, GitLab, Bitbucket, and Azure DevOps are supported.") | ||
|
|
||
| class DatabaseManager: | ||
| """ | ||
|
|
@@ -763,7 +823,17 @@ def _extract_repo_name_from_url(self, repo_url_or_path: str, repo_type: str) -> | |
| # Extract owner and repo name to create unique identifier | ||
| url_parts = repo_url_or_path.rstrip('/').split('/') | ||
|
|
||
| if repo_type in ["github", "gitlab", "bitbucket"] and len(url_parts) >= 5: | ||
| if repo_type == "azure_devops": | ||
| # Azure DevOps URL: https://dev.azure.com/{org}/{project}/_git/{repo} | ||
| try: | ||
| git_index = url_parts.index('_git') | ||
| repo = url_parts[git_index + 1].replace(".git", "") | ||
| project = url_parts[git_index - 1] | ||
| repo_name = f"{project}_{repo}" | ||
| except (ValueError, IndexError): | ||
| repo_name = url_parts[-1].replace(".git", "") | ||
| return repo_name | ||
| elif repo_type in ["github", "gitlab", "bitbucket"] and len(url_parts) >= 5: | ||
| # GitHub URL format: https://github.com/owner/repo | ||
| # GitLab URL format: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo | ||
| # Bitbucket URL format: https://bitbucket.org/owner/repo | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,7 +13,7 @@ import { extractUrlDomain, extractUrlPath } from '@/utils/urlDecoder'; | |||||
| import Link from 'next/link'; | ||||||
| import { useParams, useSearchParams } from 'next/navigation'; | ||||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
| import { FaBitbucket, FaBookOpen, FaComments, FaDownload, FaExclamationTriangle, FaFileExport, FaFolder, FaGithub, FaGitlab, FaHome, FaSync, FaTimes } from 'react-icons/fa'; | ||||||
| import { FaBitbucket, FaBookOpen, FaComments, FaDownload, FaExclamationTriangle, FaFileExport, FaFolder, FaGithub, FaGitlab, FaHome, FaMicrosoft, FaSync, FaTimes } from 'react-icons/fa'; | ||||||
| // Define the WikiSection and WikiStructure types directly in this file | ||||||
| // since the imported types don't have the sections and rootSections properties | ||||||
| interface WikiSection { | ||||||
|
|
@@ -173,6 +173,18 @@ const createBitbucketHeaders = (bitbucketToken: string): HeadersInit => { | |||||
| return headers; | ||||||
| }; | ||||||
|
|
||||||
| const createAzureDevOpsHeaders = (adoToken: string): HeadersInit => { | ||||||
| const headers: HeadersInit = { | ||||||
| 'Content-Type': 'application/json', | ||||||
| }; | ||||||
|
|
||||||
| if (adoToken) { | ||||||
| headers['Authorization'] = `Basic ${btoa(':' + adoToken)}`; | ||||||
| } | ||||||
|
|
||||||
| return headers; | ||||||
| }; | ||||||
|
|
||||||
|
|
||||||
| export default function RepoWikiPage() { | ||||||
| // Get route parameters and search params | ||||||
|
|
@@ -205,9 +217,11 @@ export default function RepoWikiPage() { | |||||
| ? 'bitbucket' | ||||||
| : repoHost?.includes('gitlab') | ||||||
| ? 'gitlab' | ||||||
| : repoHost?.includes('github') | ||||||
| ? 'github' | ||||||
| : searchParams.get('type') || 'github'; | ||||||
| : repoHost?.includes('dev.azure.com') | ||||||
| ? 'azure_devops' | ||||||
| : repoHost?.includes('github') | ||||||
| ? 'github' | ||||||
| : searchParams.get('type') || 'github'; | ||||||
|
|
||||||
| // Import language context for translations | ||||||
| const { messages } = useLanguage(); | ||||||
|
|
@@ -1479,6 +1493,68 @@ IMPORTANT: | |||||
| console.warn('Could not fetch Bitbucket README.md, continuing with empty README', err); | ||||||
| } | ||||||
| } | ||||||
| else if (effectiveRepoInfo.type === 'azure_devops') { | ||||||
| // Azure DevOps API approach | ||||||
| const adoUrl = effectiveRepoInfo.repoUrl ?? ''; | ||||||
| const adoParsed = new URL(adoUrl); | ||||||
| const adoParts = adoParsed.pathname.replace(/^\/|\/$/g, '').split('/'); | ||||||
| const gitIndex = adoParts.indexOf('_git'); | ||||||
|
|
||||||
| if (gitIndex < 1 || gitIndex + 1 >= adoParts.length) { | ||||||
| throw new Error('Invalid Azure DevOps repository URL'); | ||||||
| } | ||||||
|
|
||||||
| const adoRepo = adoParts[gitIndex + 1]; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The repository name extracted from the Azure DevOps URL doesn't account for a potential
Suggested change
|
||||||
| const adoBase = `${adoParsed.protocol}//${adoParsed.hostname}/${adoParts.slice(0, gitIndex).join('/')}`; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| const headers = createAzureDevOpsHeaders(currentToken); | ||||||
|
|
||||||
| // Get default branch | ||||||
| let defaultBranchLocal = 'main'; | ||||||
| try { | ||||||
| const repoInfoUrl = `${adoBase}/_apis/git/repositories/${adoRepo}?api-version=7.0`; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The API version // At top of file
const ADO_API_VERSION = '7.0';
// In function
const repoInfoUrl = `${adoBase}/_apis/git/repositories/${adoRepo}?api-version=${ADO_API_VERSION}`; |
||||||
| const repoInfoRes = await fetch(repoInfoUrl, { headers }); | ||||||
| if (repoInfoRes.ok) { | ||||||
| const repoData = await repoInfoRes.json(); | ||||||
| // ADO returns defaultBranch as "refs/heads/main" | ||||||
| defaultBranchLocal = (repoData.defaultBranch || 'refs/heads/main').replace('refs/heads/', ''); | ||||||
| console.log(`Found Azure DevOps default branch: ${defaultBranchLocal}`); | ||||||
| } | ||||||
| } catch (err) { | ||||||
| console.warn('Could not fetch ADO repo info:', err); | ||||||
| } | ||||||
| setDefaultBranch(defaultBranchLocal); | ||||||
|
|
||||||
| // Get file tree using Items API with recursion | ||||||
| const treeUrl = `${adoBase}/_apis/git/repositories/${adoRepo}/items?recursionLevel=Full&versionDescriptor.version=${defaultBranchLocal}&api-version=7.0`; | ||||||
| const treeRes = await fetch(treeUrl, { headers }); | ||||||
|
|
||||||
| if (!treeRes.ok) { | ||||||
| const errorData = await treeRes.text(); | ||||||
| throw new Error(`Azure DevOps API error: ${treeRes.status} - ${errorData}`); | ||||||
| } | ||||||
|
|
||||||
| const treeData = await treeRes.json(); | ||||||
|
|
||||||
| if (!treeData.value || treeData.value.length === 0) { | ||||||
| throw new Error('Could not fetch repository structure from Azure DevOps.'); | ||||||
| } | ||||||
|
|
||||||
| fileTreeData = treeData.value | ||||||
| .filter((item: { gitObjectType: string; path: string }) => item.gitObjectType === 'blob') | ||||||
| .map((item: { path: string }) => item.path.replace(/^\//, '')) | ||||||
| .join('\n'); | ||||||
|
|
||||||
| // Fetch README | ||||||
| try { | ||||||
| const readmeUrl = `${adoBase}/_apis/git/repositories/${adoRepo}/items?path=README.md&api-version=7.0`; | ||||||
| const readmeRes = await fetch(readmeUrl, { headers }); | ||||||
| if (readmeRes.ok) { | ||||||
| readmeContent = await readmeRes.text(); | ||||||
| } | ||||||
| } catch (err) { | ||||||
| console.warn('Could not fetch Azure DevOps README.md:', err); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Now determine the wiki structure | ||||||
| await determineWikiStructure(fileTreeData, readmeContent, owner, repo); | ||||||
|
|
@@ -2059,6 +2135,8 @@ IMPORTANT: | |||||
| <FaGithub className="mr-2" /> | ||||||
| ) : effectiveRepoInfo.type === 'gitlab' ? ( | ||||||
| <FaGitlab className="mr-2" /> | ||||||
| ) : effectiveRepoInfo.type === 'azure_devops' ? ( | ||||||
| <FaMicrosoft className="mr-2" /> | ||||||
| ) : ( | ||||||
| <FaBitbucket className="mr-2" /> | ||||||
| )} | ||||||
|
|
@@ -2269,7 +2347,7 @@ IMPORTANT: | |||||
| onApply={confirmRefresh} | ||||||
| showWikiType={true} | ||||||
| showTokenInput={effectiveRepoInfo.type !== 'local' && !currentToken} // Show token input if not local and no current token | ||||||
| repositoryType={effectiveRepoInfo.type as 'github' | 'gitlab' | 'bitbucket'} | ||||||
| repositoryType={effectiveRepoInfo.type as 'github' | 'gitlab' | 'bitbucket' | 'azure_devops'} | ||||||
| authRequired={authRequired} | ||||||
| authCode={authCode} | ||||||
| setAuthCode={setAuthCode} | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,8 +32,8 @@ interface ConfigurationModalProps { | |
| setCustomModel: (value: string) => void; | ||
|
|
||
| // Platform selection | ||
| selectedPlatform: 'github' | 'gitlab' | 'bitbucket'; | ||
| setSelectedPlatform: (value: 'github' | 'gitlab' | 'bitbucket') => void; | ||
| selectedPlatform: 'github' | 'gitlab' | 'bitbucket' | 'azure_devops'; | ||
| setSelectedPlatform: (value: 'github' | 'gitlab' | 'bitbucket' | 'azure_devops') => void; | ||
|
|
||
| // Access token | ||
| accessToken: string; | ||
|
|
@@ -98,8 +98,8 @@ export default function ConfigurationModal({ | |
| }: ConfigurationModalProps) { | ||
| const { messages: t } = useLanguage(); | ||
|
|
||
| // Show token section state | ||
| const [showTokenSection, setShowTokenSection] = useState(false); | ||
| // Show token section state - auto-expand for Azure DevOps since PAT is required | ||
| const [showTokenSection, setShowTokenSection] = useState(selectedPlatform === 'azure_devops'); | ||
|
|
||
| if (!isOpen) return null; | ||
|
|
||
|
|
@@ -231,15 +231,42 @@ export default function ConfigurationModal({ | |
| /> | ||
| </div> | ||
|
|
||
| {/* Access token section using TokenInput component */} | ||
| {/* Platform selection - always visible */} | ||
| <div className="mb-4"> | ||
| <label className="block text-sm font-medium text-[var(--foreground)] mb-2"> | ||
| {t.form?.selectPlatform || 'Select Platform'} | ||
| </label> | ||
| <div className="flex gap-2"> | ||
| {(['github', 'gitlab', 'bitbucket', 'azure_devops'] as const).map((platform) => ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The list of supported platforms For example: // in a shared constants file, e.g., src/lib/constants.ts
export const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'bitbucket', 'azure_devops'] as const;
// in ConfigurationModal.tsx
import { SUPPORTED_PLATFORMS } from '@/lib/constants';
// ...
{SUPPORTED_PLATFORMS.map((platform) => (
// ... |
||
| <button | ||
| key={platform} | ||
| type="button" | ||
| onClick={() => { | ||
| setSelectedPlatform(platform); | ||
| if (platform === 'azure_devops') setShowTokenSection(true); | ||
| }} | ||
| className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md border transition-all ${selectedPlatform === platform | ||
| ? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)] shadow-sm' | ||
| : 'border-[var(--border-color)] text-[var(--foreground)] hover:bg-[var(--background)]' | ||
| }`} | ||
| > | ||
| <span className="text-sm"> | ||
| {platform === 'azure_devops' ? 'Azure DevOps' : platform.charAt(0).toUpperCase() + platform.slice(1)} | ||
| </span> | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Access token input */} | ||
| <TokenInput | ||
| selectedPlatform={selectedPlatform} | ||
| setSelectedPlatform={setSelectedPlatform} | ||
| accessToken={accessToken} | ||
| setAccessToken={setAccessToken} | ||
| showTokenSection={showTokenSection} | ||
| onToggleTokenSection={() => setShowTokenSection(!showTokenSection)} | ||
| allowPlatformChange={true} | ||
| allowPlatformChange={false} | ||
| /> | ||
|
|
||
| {/* Authorization Code Input */} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hostname extracted from the user-provided
repo_urlis used to construct the clone URL without validation. This can lead to SSRF and credential leakage if an attacker provides a URL pointing to a malicious server, as theencoded_token(PAT) will be included in the request sent to that server.