From 29595c192954b91004876405e545fb5d94823dc2 Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Fri, 9 Jan 2026 15:50:34 +0000 Subject: [PATCH] Add Azure DevOps repository support for PR operations (Vibe Kanban) (#1808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * All tasks are complete. Let me summarize what was implemented: I've successfully implemented Azure DevOps repos support alongside the existing GitHub integration. Here's what was done: - **`mod.rs`** - Defines the `GitHostService` trait and factory functions for creating services - **`types.rs`** - Unified types: `GitHostProvider`, `RepoInfo`, `GitHostError`, `CreatePrRequest`, `UnifiedPrComment` - **`detection.rs`** - URL pattern matching for auto-detecting GitHub (including Enterprise) and Azure DevOps - **`github/mod.rs`** - `GitHubHostService` implementing the trait - **`github/cli.rs`** - `GhCli` wrapper (moved from old location) - **`azure/mod.rs`** - `AzureHostService` implementing the trait - **`azure/cli.rs`** - `AzCli` wrapper for Azure DevOps CLI commands 1. **Auto-detection**: The system detects the provider from the git remote URL: - `github.com` → GitHub - `github.*.com` → GitHub Enterprise - `dev.azure.com`, `*.visualstudio.com`, or URLs with `/_git/` → Azure DevOps 2. **Unified trait interface**: Both providers implement `GitHostService` with methods for: - `get_repo_info()` - Get repository identification - `check_auth()` - Verify CLI authentication - `create_pr()` - Create pull requests - `get_pr_status()` - Get PR status by URL - `list_prs_for_branch()` - List PRs for a branch - `get_pr_comments()` - Get PR comments 3. **Azure CLI commands supported**: - `az repos pr create` - Create PRs - `az repos pr show` - View PR details - `az repos pr list` - List PRs - `az repos pr list-threads` - Get PR comments - `az account show` - Auth check - `pr.rs` - Uses auto-detection for PR creation - `pr_monitor.rs` - Uses provider detection from PR URL - `task_attempts.rs` - Uses git_host for push auth checking - `error.rs` - Updated `GitHost` error variant - `share.rs` - Updated `GitHost` error variant - `generate_types.rs` - Updated type exports - Old `crates/services/src/services/github.rs` module and directory (replaced by git_host) Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf All tests pass, including the new URL decoding tests. The fix is complete - the URL-encoded components (like `Vibe%20Kanban%20Test`) will now be properly decoded to `Vibe Kanban Test` before being passed to the Azure CLI. Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf All done! Here's a summary of the changes: Updated `get_repo_info()` to use the Azure CLI's `az repos show --detect true` command instead of manual URL parsing, similar to how GitHub uses `gh repo view --json owner,name`. 1. **Added `extract_repo_name_from_url()`** - A simple helper that extracts just the repository name from Azure DevOps URLs (looks for `/_git/{repo}` pattern). This is still needed because `az repos show` requires the `--repository` parameter. 2. **Updated `get_repo_info()`** - Now uses `az repos show --repository {name} --detect true --output json` which: - Auto-detects organization and project from git config - Returns canonical names (properly decoded) - Validates the repo exists 3. **Added `extract_org_from_response()`** - Extracts the organization from the `remoteUrl` field in the CLI response, with fallback to URL parsing. 4. **Fixed `run()` method** - Now properly passes the `dir` parameter to `Command::current_dir()` so the `--detect` flag works correctly. 5. **Kept `parse_azure_remote_url()`** - Still used as a fallback for organization extraction and for PR URL parsing. 6. **Added tests** - New tests for `extract_repo_name_from_url()` covering various URL formats. Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf Done! The cleanup is complete. Here's what was removed: Removed the brittle `parse_azure_remote_url()` function and related code: 1. **Removed `AzureRepoInfo` struct** - No longer needed 2. **Removed `parse_azure_remote_url()` function** - The ~90-line function that manually parsed various Azure DevOps URL formats 3. **Updated `get_repo_info()`** - Changed from fallback (`unwrap_or_else`) to error (`ok_or_else`) when organization can't be extracted from the CLI response 4. **Removed 4 tests** - `test_parse_azure_remote_url_https`, `test_parse_azure_remote_url_visualstudio`, `test_parse_azure_remote_url_ssh`, `test_parse_azure_remote_url_with_spaces` The code now relies entirely on: - `extract_repo_name_from_url()` - Simple helper to get repo name from URL (for passing to `az repos show`) - `az repos show --detect true` - Azure CLI command that auto-detects org/project from git config - `extract_org_from_response()` - Extracts org from the CLI's `remoteUrl` JSON field The implementation is complete. Here's a summary of the changes: Simplified the Azure DevOps integration by using the CLI's `--detect` flag instead of parsing URLs to extract org/project/repo. 1. **`types.rs`** - Changed `RepoInfo::AzureDevOps` to store just `repo_path: PathBuf` instead of `organization`, `project`, and `repo_name`. Removed the unused `repo_name()` method. 2. **`cli.rs`** - Major simplification: - Removed URL parsing helpers: `decode_url_component()`, `extract_repo_name_from_url()`, `extract_org_from_response()` - `get_repo_info()` now just verifies it's a valid Azure repo via `az repos show --detect true` and returns the path - `create_pr()` now takes `repo_path` and uses `--detect true` instead of explicit org/project/repo - `list_prs_for_branch()` now takes `repo_path` and uses `--detect true` - `get_pr_threads()` now takes `repo_path` and uses `--detect true` - `view_pr()` now takes the full PR URL and parses org + PR ID internally - `parse_pr_url()` simplified to return just `(org, pr_id)` instead of 4 values - Parsing functions simplified to extract URLs from response's `repository.webUrl` field - Updated tests for the new API 3. **`mod.rs`** - Updated `AzureHostService` to: - Extract `repo_path` from `RepoInfo::AzureDevOps` - Pass `repo_path` to CLI methods that need it - Pass the PR URL directly to `view_pr()` for `get_pr_status()` - URL parsing reduced to minimal extraction of org + PR ID from PR URLs (only needed for `get_pr_status()`) - Repo-based operations don't parse URLs at all - CLI auto-detects via git config - Much cleaner, less brittle code Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf All done. The implementation is complete: **Summary of changes:** 1. **`types.rs`**: Updated `RepoInfo::AzureDevOps` to store `organization_url`, `project`, `repo_name` instead of `repo_path` 2. **`cli.rs`**: - `get_repo_info()` now parses the JSON response from `az repos show --detect true` to extract repo name, project name, and organization URL - Added `extract_org_url()` helper to extract the base org URL from the API URL - Updated `create_pr()`, `list_prs_for_branch()`, `get_pr_threads()` to take explicit `organization_url`, `project`, `repo_name` params 3. **`mod.rs`** (`AzureHostService`): - `create_pr()`, `list_prs_for_branch()`, `get_pr_comments()` all now extract org/project/repo from `RepoInfo` and pass them to the CLI methods The key insight was that the `url` field from `az repos show` (e.g., `https://dev.azure.com/{org}/.../_apis/...`) can be truncated to `https://dev.azure.com/{org}` and used directly with the `--organization` flag — minimal URL parsing needed. Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf * Done. Refactored all 4 `serde_json::Value` parsing sites to use typed structs: **Changes to `cli.rs`:** 1. **Added typed response structs** with `#[derive(Deserialize)]`: - `AzRepoShowResponse`, `AzProject` - for `az repos show` - `AzPrResponse`, `AzRepository`, `AzCommit` - for PR responses - `AzThread`, `AzThreadContext`, `AzFilePosition`, `AzThreadComment`, `AzAuthor` - for thread comments 2. **Updated parsing functions**: - `get_repo_info()` - now deserializes into `AzRepoShowResponse` - `parse_pr_response()` - now deserializes into `AzPrResponse` - `parse_pr_list_response()` - now deserializes into `Vec` - `parse_pr_threads()` - now deserializes into `Vec` 3. **Replaced `extract_pr_info()`** with `az_pr_to_info()` that takes the typed struct directly 4. **Removed `serde_json::Value` import** - no longer needed The code is now cleaner with no `.get().and_then().as_str()` chains, and serde provides better error messages on parse failures. * Done. Added the missing `--repository` parameter to `get_pr_threads()`: 1. **`cli.rs`**: Added `repo_name: &str` parameter and `--repository` flag to the CLI command 2. **`mod.rs`**: Updated `get_pr_comments()` to extract both `organization_url` and `repo_name` from `RepoInfo` and pass them to the CLI Done. Here's a summary of the changes: **Changes made to `crates/services/src/services/git_host/azure/cli.rs`:** 1. **Removed git2 dependency** - No longer imports `git2::Repository` 2. **Added new typed structs** for `az repos list` response: - `AzRepoListItem` - with `name`, `project`, and `remote_url` fields - `AzRepoProject` - with `name` field 3. **Rewrote `get_repo_info()`** to use `az repos list --detect true`: - Runs CLI command from the repo directory to auto-detect org/project - Takes the first repo from the list (assuming single repo per directory) - Extracts org from `remoteUrl` field 4. **Replaced `parse_azure_remote_url()`** with simpler `extract_org_from_remote_url()`: - Only extracts the organization name from the URL - Handles both `dev.azure.com` and `visualstudio.com` formats - Much simpler than the previous 3-tuple URL parser 5. **Updated tests** - Replaced the old URL parsing tests with new `extract_org_from_remote_url` tests The key improvement is that `az repos list --detect true` uses the Azure CLI's built-in detection (from git config) to find the org and project, then returns all repo info including project name. This avoids git2 library usage and complex URL parsing entirely. Done. Here's a summary of all changes: **Summary of Changes** 1. **`crates/services/src/services/git_host/types.rs`**: - Added `project_id` and `repo_id` fields to `RepoInfo::AzureDevOps` enum variant 2. **`crates/services/src/services/git_host/azure/cli.rs`**: - Added `id` field to `AzRepoListItem` struct - Added `id` field to `AzRepoProject` struct - Added `AzThreadsResponse` struct to wrap the `{ "value": [...] }` REST API response - Updated `get_repo_info()` to return `project_id` and `repo_id` from `az repos list` output - Updated `get_pr_threads()` to: - Accept `project_id` and `repo_id` instead of names - Use `az rest` with REST API URL instead of non-existent `az repos pr list-threads` - Updated `parse_pr_threads()` to unwrap the `value` field from REST API response 3. **`crates/services/src/services/git_host/azure/mod.rs`**: - Updated `create_pr` and `list_prs_for_branch` to use `..` pattern for new fields - Updated `get_pr_comments()` to extract and pass `project_id` and `repo_id` to CLI **Key Improvements**: - Uses UUIDs (`project_id`, `repo_id`) instead of names for REST API calls, avoiding URL encoding issues with names containing spaces like "Vibe Kanban Test" - Fixed `get_pr_threads()` to use `az rest` since `az repos pr list-threads` doesn't exist as an Azure CLI command - All unit tests pass Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf Done. The fix adds `--resource 499b84ac-1321-427f-aa17-267ca6975798` to the `az rest` command, which tells it to request an Azure DevOps token instead of a generic Azure token. This should now properly authenticate to the Azure DevOps REST API. Done. The fix replaces `az rest` with `az devops invoke --area git --resource pullRequestThreads`. This uses the same authentication as other `az repos pr` commands, so it should work without requiring the separate "identity materialization" step. Done. The fix replaces the single combined string for `--route-parameters` with individual arguments for each `key=value` pair. This matches how the Azure CLI expects the parameters to be passed - each route parameter should be a separate shell argument after the `--route-parameters` flag. Done. Removed all the requested comments from: - `crates/server/src/routes/task_attempts.rs` (lines 451, 490) - `crates/server/src/routes/task_attempts/pr.rs` (lines 283, 516) - `crates/services/src/services/git_host/azure/cli.rs` (section header comment, doc comments on structs and methods) * Done. Removed the module-level doc comment and all the method doc comments from the `GitHostService` trait and public functions in `mod.rs`. Done. Removed the obvious/redundant comments from `types.rs`. Done. Removed the unnecessary `Arc` usage from all three functions in `mod.rs`. The strings are already cloned when extracted from `RepoInfo`, so we can just clone them directly in the retry closure. * All tests pass. Fixed the `npm run check` and `npm run lint` issues by: 1. Regenerated TypeScript types from Rust 2. Updated `frontend/src/lib/api.ts` to use `CreatePrApiRequest` instead of `CreateGitHubPrRequest` 3. Updated `CreatePRDialog.tsx` to handle the new error types (`cli_not_installed`/`cli_not_logged_in` with a `provider` field instead of `github_cli_not_installed`/`github_cli_not_logged_in`) 4. Fixed clippy warnings about collapsible if statements in `parse_pr_url()` * Done. I've made `url`, `diff_hunk`, and `author_association` optional in the `UnifiedPrComment` type: **Changes made:** 1. **`crates/services/src/services/git_host/types.rs`** - Changed the `UnifiedPrComment` enum: - `url: String` → `url: Option` - `diff_hunk: String` → `diff_hunk: Option` - `author_association: String` → `author_association: Option` 2. **`crates/services/src/services/git_host/azure/cli.rs`** - Updated Azure comment creation to use `None` instead of empty strings 3. **`crates/services/src/services/git_host/github/mod.rs`** - Wrapped GitHub values in `Some()` 4. **`frontend/src/components/ui/github-comment-card.tsx`** - Updated interface to allow `url?: string | null` and `diffHunk?: string | null` 5. **`frontend/src/components/ui/wysiwyg/nodes/github-comment-node.tsx`**: - Updated `NormalizedComment` interface - Fixed click handler to check `if (data.url)` before opening - Removed `data.url` from validation (only `id`, `comment_type`, `author`, `body` required) All checks pass (`pnpm run check`, `pnpm run lint`, `cargo test --workspace`). * Done. All three changes are complete: 1. **Removed unnecessary temp file** in `azure/cli.rs:create_pr()` - was writing body to temp file then immediately reading it back. Now just passes `body` directly to `OsString`. 2. **Added structs for JSON parsing** in `github/cli.rs:parse_pr_comments()` - replaced manual `item.get("field")...` parsing with `GhCommentsWrapper` and `GhCommentResponse` structs using serde. 3. **Added structs for JSON parsing** in `github/cli.rs:parse_pr_review_comments()` - replaced manual parsing with `GhReviewCommentResponse` struct. All checks pass (`pnpm run check`, `pnpm run lint`, `cargo test --workspace`). Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf Done. Both changes are complete: 1. **Inlined single-field structs** - Consolidated `GhCommentAuthor` and `GhReviewCommentUser` into a single `GhUserLogin` struct that's reused by both `GhCommentResponse` and `GhReviewCommentResponse`. 2. **Converted `extract_pr_info` to struct parsing** - Added `GhPrResponse` and `GhMergeCommit` structs. Replaced the manual `Value::get()` parsing with serde deserialization. The new `pr_response_to_info()` helper converts the parsed struct to `PullRequestInfo`. Also removed the now-unused `serde_json::Value` import. All checks pass (`pnpm run check`, `pnpm run lint`, `cargo test --workspace`). Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf * Done. I've updated `detection.rs` to use `GitCli` instead of `git2`: 1. **Added `get_remote_url` method to `GitCli`** (`git/cli.rs`) - Uses `git remote get-url ` to get the URL 2. **Updated `detect_provider`** (`git_host/detection.rs`) - Now uses `GitCli::get_remote_url()` instead of `git2::Repository` This keeps the codebase consistent with the pattern of using `GitCli` for git operations rather than mixing `git2` directly in various modules. All checks pass (`pnpm run check`, `pnpm run lint`, `cargo test --workspace`). Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf * Done! The refactor is complete. Here's a summary of the changes: **New Architecture:** 1. **`ProviderKind` enum** (was `GitHostProvider`) - The discriminant for provider types (GitHub, AzureDevOps, Unknown) 2. **`GitHostProvider` trait** (was `GitHostService`) - The interface that provider implementations must satisfy 3. **`GitHubProvider` struct** (was `GitHubHostService`) - GitHub implementation 4. **`AzureDevOpsProvider` struct** (was `AzureHostService`) - Azure DevOps implementation 5. **`GitHostService` struct** (new) - Main entry point that: - Holds repo context (`repo_path`, `GitCli`) - Provides `provider_for_remote(remote_name)` to get a provider for a specific remote - Provides `provider_from_url(url)` for URL-based detection (e.g., PR URLs) - Provides `provider_for_kind(kind)` for direct provider construction **Benefits:** - Provider detection is now contextual - callers specify which remote they want - The service struct encapsulates provider detection logic - Cleaner separation: `GitHostService` is the facade, `GitHostProvider` trait is the contract - Ready for multi-remote support (different providers for different remotes) All checks pass (`pnpm run check`, `pnpm run lint`, `cargo test --workspace`). Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf All the call sites have been updated to use the new cleaner `GitHostService` facade API. Here's a summary of the changes: **Changes made:** 1. **`pr.rs:414-418` (`attach_existing_pr`)**: Simplified from: ```rust let git_host = git_host::GitHostService::new(&repo.path); let git_host_provider = git_host.provider_for_remote("origin")?; let repo_info = git_host_provider.get_repo_info(&repo.path).await?; let prs = git_host_provider.list_prs_for_branch(&repo_info, &workspace.branch).await?; ``` To: ```rust let git_host = git_host::GitHostService::new(&repo.path); let prs = git_host.list_prs_for_branch(&workspace.branch).await?; ``` 2. **`pr.rs:511-525` (`get_pr_comments`)**: Simplified from provider-based pattern to: ```rust let git_host = git_host::GitHostService::new(&repo.path); let provider = git_host.provider_kind()?; match git_host.get_pr_comments(pr_info.number).await { ... } ``` 3. **`task_attempts.rs:451-453` (`push_task_attempt_branch`)**: Removed explicit `check_auth()` call since auth is now checked during the actual operation. Just verify provider is supported: ```rust let git_host = git_host::GitHostService::new(&worktree_path); git_host.provider_kind()?; ``` 4. **`task_attempts.rs:490-492` (`force_push_task_attempt_branch`)**: Same simplification. The new API is much cleaner - call sites just create a `GitHostService::new(path)` and call methods directly without dealing with providers, repo info, or explicit auth checks. Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf The refactoring is complete. Here's a summary of what was done: **Changes made:** 1. **`crates/services/Cargo.toml`** - Added `enum_dispatch` dependency 2. **`crates/services/src/services/git_host/mod.rs`** - Completely rewritten: - `GitHostProvider` is now a public trait with `#[enum_dispatch(GitHostService)]` - `GitHostService` is now an enum with `GitHub` and `AzureDevOps` variants - Constructors: `from_pr_url(url)` and `from_remote_url(url)` - no `new()` method - Methods now take `repo_path` as a parameter where needed 3. **`crates/services/src/services/git_host/github/mod.rs`** - Updated to match new trait signature (methods take `repo_path`, `check_auth` called inside `create_pr`) 4. **`crates/services/src/services/git_host/azure/mod.rs`** - Updated similarly 5. **`crates/services/src/services/git.rs`** - Added `get_remote_url()` method to `GitService` 6. **Call site updates:** - `pr_monitor.rs` - Uses `from_pr_url` - `pr.rs` - Uses `get_remote_name_from_branch_name` + `get_remote_url` + `from_remote_url` - `task_attempts.rs` - Same pattern for push functions **New API usage:** ```rust // From a PR URL (e.g., for checking PR status) let git_host = GitHostService::from_pr_url(&pr_url)?; let status = git_host.get_pr_status(&pr_url).await?; // From a remote URL (e.g., for creating PRs) let remote_name = git_service.get_remote_name_from_branch_name(&repo_path, &branch)?; let remote_url = git_service.get_remote_url(&repo_path, &remote_name)?; let git_host = GitHostService::from_remote_url(&remote_url)?; let pr_info = git_host.create_pr(&repo_path, &pr_request).await?; // Get provider kind (infallible since already constructed) let provider = git_host.provider_kind(); ``` Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf All cleaned up. Here's the final architecture: **`git_host/mod.rs`:** ```rust pub trait GitHostProvider: Send + Sync { async fn create_pr(&self, repo_path: &Path, request: &CreatePrRequest) -> Result; async fn get_pr_status(&self, pr_url: &str) -> Result; async fn list_prs_for_branch(&self, repo_path: &Path, branch_name: &str) -> Result, GitHostError>; async fn get_pr_comments(&self, repo_path: &Path, pr_number: i64) -> Result, GitHostError>; fn provider_kind(&self) -> ProviderKind; } pub enum GitHostService { GitHub(GitHubProvider), AzureDevOps(AzureDevOpsProvider), } impl GitHostService { pub fn from_url(url: &str) -> Result } ``` **Changes made:** 1. Removed `check_auth` and `get_repo_info` from the public trait - they're now private methods on each provider 2. Consolidated `from_pr_url` and `from_remote_url` into a single `from_url` constructor 3. The trait now has exactly 5 methods - the minimal public API needed by call sites **Call site usage is clean:** ```rust // From any URL (PR URL or remote URL) let git_host = GitHostService::from_url(&url)?; // Use the trait methods let pr_info = git_host.create_pr(&repo_path, &request).await?; let status = git_host.get_pr_status(&pr_url).await?; let prs = git_host.list_prs_for_branch(&repo_path, &branch).await?; let comments = git_host.get_pr_comments(&repo_path, pr_number).await?; let kind = git_host.provider_kind(); ``` Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf Done. The refactoring is complete: 1. **Created `GitHubRepoInfo`** in `git_host/github/cli.rs` - a simple struct with `owner` and `repo_name` 2. **Created `AzureRepoInfo`** in `git_host/azure/cli.rs` - a struct with `organization_url`, `project`, `project_id`, `repo_name`, `repo_id` 3. **Updated both providers** to use their respective provider-specific structs directly, removing all the match arms and error cases for "wrong variant" that could never happen 4. **Removed the shared `RepoInfo` enum** from `types.rs` and the public exports The code is cleaner now - each provider has its own internal representation of repo info, and there's no shared enum that requires impossible error handling paths. Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf The fix compiles. The issue was that `get_remote_name_from_branch_name` was calling `get_remote_from_branch_ref` which uses `repo.branch_remote_name()` - this git2 function requires the branch to have upstream tracking configured. For local branches that haven't been pushed yet (or don't have tracking set), this fails with the error you saw. The fix changes the behavior to fall back to the default remote (typically "origin") when the branch doesn't have upstream tracking configured. This makes sense because: 1. Most repos only have one remote anyway 2. When creating a PR for a local branch, we need to push to *some* remote - the default is the sensible choice 3. The original code computed `default_remote` but never actually used it as a fallback Done. Added `get_remote_url_from_branch_or_default` to `GitService` and updated all 4 call sites. The pattern is now: ```rust let remote_url = deployment .git() .get_remote_url_from_branch_or_default(&repo_path, &branch)?; let git_host = git_host::GitHostService::from_url(&remote_url)?; ``` Two lines instead of four, and the function name makes the fallback behavior explicit. Done. Removed the unnecessary provider detection checks from both `push_task_attempt_branch` and `force_push_task_attempt_branch`. The push operations now just push directly - git push works with any remote regardless of whether we support it as a PR provider. Auth errors from git push itself (SSH keys, HTTPS credentials) still come through `GitServiceError::GitCLI(...)` and propagate to the frontend the same way. * All done. Removed the low-value comments: - "Create git host from the PR URL" - "Fetch comments" - "Get the URL for a remote (e.g., "origin")." - "Azure DevOps-specific repository information" - "GitHub-specific repository information" - "Get the remote URL for the branch to detect the provider" (2 places) - "Get repository info from the GitHub CLI" - "Get repository info from the Azure CLI" - "Check authentication status" (2 places) Kept comments that provide actual value like the fallback behavior explanation on `get_remote_url_from_branch_or_default`. All done. Removed the low-value comments: - "Create git host from the PR URL" - "Fetch comments" - "Get the URL for a remote (e.g., "origin")." - "Azure DevOps-specific repository information" - "GitHub-specific repository information" - "Get the remote URL for the branch to detect the provider" (2 places) - "Get repository info from the GitHub CLI" - "Get repository info from the Azure CLI" - "Check authentication status" (2 places) - "Get PR status from URL (auto-detects provider)" - "High-level errors originating from the Azure CLI." - "High-level errors originating from the GitHub CLI." - "Azure DevOps hosting provider implementation" - "GitHub hosting provider implementation" - "Newtype wrapper for invoking the `gh` command." - "Newtype wrapper for invoking the `az` command." - "Trait for git hosting provider operations (GitHub, Azure DevOps, etc.)" - All the trait method doc comments (Create a pull request, Get PR status, etc.) - "Git hosting service - dispatches to the appropriate provider." - "Create a GitHostService by detecting the provider from a URL." - Simplified the Azure DevOps pattern comments in detection.rs * Done. Here's a summary of the fix: **Problem**: `get_repo_info` used `az repos list --detect true` which returns ALL repos in the Azure DevOps project, then just took the first one. This was wrong for multi-repo projects. **Solution**: 1. `AzureDevOpsProvider` now stores the remote URL passed to `from_url()` 2. `get_repo_info` now takes the remote URL and filters the repo list to find the matching repo using `remote_urls_match()` 3. Added `remote_urls_match()` helper that normalizes URLs (lowercase, strips trailing `/` and `.git`) for comparison 4. Added `extract_organization_url()` to get the Azure organization URL from a remote URL (supports both `dev.azure.com` and legacy `visualstudio.com` formats) 5. Removed the now-unused `extract_org_from_remote_url()` function The flow is now: - Call site gets remote URL via `get_remote_url_from_branch_or_default()` - Creates `GitHostService::from_url(&remote_url)` which stores the URL on `AzureDevOpsProvider` - When `get_repo_info` is called, it lists all repos in the project and finds the one matching the stored remote URL Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf * Done. Summary of changes: 1. **`pr.rs:get_pr_comments`** - Now gets the remote URL from `workspace_repo.target_branch` instead of using the PR URL to create the `GitHostService` 2. **`azure/cli.rs`**: - Added `ssh_url` field to `AzRepoListItem` - Updated `get_repo_info` to detect if the input URL is SSH (`starts_with("git@")`) and compare against the appropriate field (`ssh_url` vs `remote_url`) - Renamed `remote_urls_match` to `urls_match` since it now handles both HTTPS and SSH URLs - Added SSH URL test case * Build passes. The simple fix handles both SSH formats without adding a dependency. * Build passes. Now `ssh_url` is optional - if it's not in the response and the user has an SSH remote configured, they'll get a clear error message ("No repo found matching remote URL: ..."). * Done. The `urls_match` function now normalizes `ssh://` prefix to scp-style before comparing. * Done. Summary of changes: 1. **Removed `remote_url` field from `AzureDevOpsProvider`** - no longer stored on the struct 2. **Updated `GitHostProvider` trait** - added `remote_url: &str` parameter to: - `create_pr` - `list_prs_for_branch` - `get_pr_comments` 3. **Updated `AzureDevOpsProvider`** - methods now accept `remote_url` and pass it to `get_repo_info` 4. **Updated `GitHubProvider`** - methods accept `_remote_url` (unused, since gh CLI auto-detects) 5. **Updated call sites in `pr.rs`** - all three methods now pass `remote_url`: - `create_pr` passes `&remote_url` - `list_prs_for_branch` passes `&remote_url` - `get_pr_comments` passes `&remote_url` 6. **Fixed `attach_existing_pr`** - now uses `workspace_repo.target_branch` instead of `workspace.branch` when getting the remote URL (consistent with other call sites) * Cleanup script changes for workspace 28eb9564-f95a-42e0-8cf2-5228c2ffc0bf * Update frontend remote copy (vibe-kanban a7bf13b6) We added azure support but in the frontend many things have github specific wording. Please fix! * Done. I've addressed the PR comment by: 1. **Renamed `CreatePrError` to `PrError`** - A more generic name that works for both `create_pr` and `attach_existing_pr` 2. **Updated `attach_existing_pr`** to mirror the error handling from `create_pr`: - Changed return type from `ApiResponse` to `ApiResponse` - Added error handling for `GitHostService::from_url()` - handles `UnsupportedProvider` and `CliNotInstalled` - Added error handling for `list_prs_for_branch()` - handles `CliNotInstalled` and `AuthFailed` 3. **Updated `generate_types.rs`** to use the renamed `PrError` 4. **Updated `frontend/src/lib/api.ts`** to use `PrError` instead of `CreatePrError` 5. **Regenerated TypeScript types** and verified all checks pass --- Cargo.lock | 1 + crates/server/src/bin/generate_types.rs | 7 +- crates/server/src/error.rs | 8 +- crates/server/src/routes/task_attempts.rs | 13 +- crates/server/src/routes/task_attempts/pr.rs | 180 +++-- crates/services/Cargo.toml | 1 + crates/services/src/services/git.rs | 30 +- crates/services/src/services/git/cli.rs | 15 + .../src/services/git_host/azure/cli.rs | 684 ++++++++++++++++++ .../src/services/git_host/azure/mod.rs | 281 +++++++ .../src/services/git_host/detection.rs | 178 +++++ .../src/services/{ => git_host}/github/cli.rs | 259 +++---- .../src/services/git_host/github/mod.rs | 368 ++++++++++ crates/services/src/services/git_host/mod.rs | 63 ++ .../services/src/services/git_host/types.rs | 133 ++++ crates/services/src/services/github.rs | 424 ----------- crates/services/src/services/mod.rs | 2 +- crates/services/src/services/pr_monitor.rs | 16 +- crates/services/src/services/share.rs | 4 +- .../dialogs/tasks/CreatePRDialog.tsx | 26 +- ...ommentsDialog.tsx => PrCommentsDialog.tsx} | 38 +- .../components/tasks/TaskFollowUpSection.tsx | 18 +- ...b-comment-card.tsx => pr-comment-card.tsx} | 20 +- frontend/src/components/ui/wysiwyg.tsx | 16 +- ...b-comment-node.tsx => pr-comment-node.tsx} | 37 +- frontend/src/i18n/locales/en/tasks.json | 22 +- frontend/src/i18n/locales/es/tasks.json | 22 +- frontend/src/i18n/locales/ja/tasks.json | 22 +- frontend/src/i18n/locales/ko/tasks.json | 22 +- frontend/src/i18n/locales/zh-Hans/tasks.json | 22 +- frontend/src/i18n/locales/zh-Hant/tasks.json | 22 +- frontend/src/lib/api.ts | 10 +- shared/types.ts | 14 +- 33 files changed, 2196 insertions(+), 782 deletions(-) create mode 100644 crates/services/src/services/git_host/azure/cli.rs create mode 100644 crates/services/src/services/git_host/azure/mod.rs create mode 100644 crates/services/src/services/git_host/detection.rs rename crates/services/src/services/{ => git_host}/github/cli.rs (69%) create mode 100644 crates/services/src/services/git_host/github/mod.rs create mode 100644 crates/services/src/services/git_host/mod.rs create mode 100644 crates/services/src/services/git_host/types.rs delete mode 100644 crates/services/src/services/github.rs rename frontend/src/components/dialogs/tasks/{GitHubCommentsDialog.tsx => PrCommentsDialog.tsx} (86%) rename frontend/src/components/ui/{github-comment-card.tsx => pr-comment-card.tsx} (92%) rename frontend/src/components/ui/wysiwyg/nodes/{github-comment-node.tsx => pr-comment-node.tsx} (68%) diff --git a/Cargo.lock b/Cargo.lock index 413d9b93..fda87c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4788,6 +4788,7 @@ dependencies = [ "db", "dirs 5.0.1", "dunce", + "enum_dispatch", "executors", "fst", "futures", diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index c80934a6..1e977ddd 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -119,7 +119,7 @@ fn generate_types_content() -> String { server::routes::shared_tasks::AssignSharedTaskRequest::decl(), server::routes::tasks::ShareTaskResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), - server::routes::task_attempts::pr::CreateGitHubPrRequest::decl(), + server::routes::task_attempts::pr::CreatePrApiRequest::decl(), server::routes::images::ImageResponse::decl(), server::routes::images::ImageMetadata::decl(), server::routes::task_attempts::CreateTaskAttemptBody::decl(), @@ -131,7 +131,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::AbortConflictsRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), server::routes::task_attempts::PushError::decl(), - server::routes::task_attempts::pr::CreatePrError::decl(), + server::routes::task_attempts::pr::PrError::decl(), server::routes::task_attempts::BranchStatus::decl(), server::routes::task_attempts::RunScriptError::decl(), server::routes::task_attempts::DeleteWorkspaceError::decl(), @@ -140,7 +140,8 @@ fn generate_types_content() -> String { server::routes::task_attempts::pr::PrCommentsResponse::decl(), server::routes::task_attempts::pr::GetPrCommentsError::decl(), server::routes::task_attempts::pr::GetPrCommentsQuery::decl(), - services::services::github::UnifiedPrComment::decl(), + services::services::git_host::UnifiedPrComment::decl(), + services::services::git_host::ProviderKind::decl(), server::routes::task_attempts::RepoBranchStatus::decl(), server::routes::task_attempts::UpdateWorkspace::decl(), server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index d26680ea..06b5ce0a 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -16,7 +16,7 @@ use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, git::GitServiceError, - github::GitHubServiceError, + git_host::GitHostError, image::ImageError, project::ProjectServiceError, remote_client::RemoteClientError, @@ -45,7 +45,7 @@ pub enum ApiError { #[error(transparent)] GitService(#[from] GitServiceError), #[error(transparent)] - GitHubService(#[from] GitHubServiceError), + GitHost(#[from] GitHostError), #[error(transparent)] Deployment(#[from] DeploymentError), #[error(transparent)] @@ -120,7 +120,7 @@ impl IntoResponse for ApiError { } _ => (StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError"), }, - ApiError::GitHubService(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHubServiceError"), + ApiError::GitHost(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHostError"), ApiError::Deployment(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DeploymentError"), ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"), ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"), @@ -302,7 +302,7 @@ impl From for ApiError { "GitHub token is required to fetch repository metadata for sharing".to_string(), ), ShareError::Git(err) => ApiError::GitService(err), - ShareError::GitHub(err) => ApiError::GitHubService(err), + ShareError::GitHost(err) => ApiError::GitHost(err), ShareError::MissingAuth => ApiError::Unauthorized, ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()), ShareError::InvalidOrganizationId => { diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 862db213..6c68ad84 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -47,7 +47,6 @@ use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, git::{ConflictOp, GitCliError, GitServiceError}, - github::GitHubService, workspace_manager::WorkspaceManager, }; use sqlx::Error as SqlxError; @@ -526,9 +525,6 @@ pub async fn push_task_attempt_branch( ) -> Result>, ApiError> { let pool = &deployment.db().pool; - let github_service = GitHubService::new()?; - github_service.check_token().await?; - let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? @@ -547,7 +543,7 @@ pub async fn push_task_attempt_branch( match deployment .git() - .push_to_github(&worktree_path, &workspace.branch, false) + .push_to_remote(&worktree_path, &workspace.branch, false) { Ok(_) => Ok(ResponseJson(ApiResponse::success(()))), Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson( @@ -564,9 +560,6 @@ pub async fn force_push_task_attempt_branch( ) -> Result>, ApiError> { let pool = &deployment.db().pool; - let github_service = GitHubService::new()?; - github_service.check_token().await?; - let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? @@ -585,7 +578,7 @@ pub async fn force_push_task_attempt_branch( deployment .git() - .push_to_github(&worktree_path, &workspace.branch, true)?; + .push_to_remote(&worktree_path, &workspace.branch, true)?; Ok(ResponseJson(ApiResponse::success(()))) } @@ -1738,7 +1731,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/push/force", post(force_push_task_attempt_branch)) .route("/rebase", post(rebase_task_attempt)) .route("/conflicts/abort", post(abort_conflicts_task_attempt)) - .route("/pr", post(pr::create_github_pr)) + .route("/pr", post(pr::create_pr)) .route("/pr/attach", post(pr::attach_existing_pr)) .route("/pr/comments", get(pr::get_pr_comments)) .route("/open-editor", post(open_task_attempt_in_editor)) diff --git a/crates/server/src/routes/task_attempts/pr.rs b/crates/server/src/routes/task_attempts/pr.rs index 0c04da9c..d9fb1129 100644 --- a/crates/server/src/routes/task_attempts/pr.rs +++ b/crates/server/src/routes/task_attempts/pr.rs @@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, git::{GitCliError, GitServiceError}, - github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment}, + git_host::{ + self, CreatePrRequest, GitHostError, GitHostProvider, ProviderKind, UnifiedPrComment, + }, }; use ts_rs::TS; use utils::response::ApiResponse; @@ -33,7 +35,7 @@ use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] -pub struct CreateGitHubPrRequest { +pub struct CreatePrApiRequest { pub title: String, pub body: Option, pub target_branch: Option, @@ -46,12 +48,13 @@ pub struct CreateGitHubPrRequest { #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] -pub enum CreatePrError { - GithubCliNotInstalled, - GithubCliNotLoggedIn, +pub enum PrError { + CliNotInstalled { provider: ProviderKind }, + CliNotLoggedIn { provider: ProviderKind }, GitCliNotLoggedIn, GitCliNotInstalled, TargetBranchNotFound { branch: String }, + UnsupportedProvider, } #[derive(Debug, Serialize, TS)] @@ -77,8 +80,8 @@ pub struct PrCommentsResponse { #[ts(tag = "type", rename_all = "snake_case")] pub enum GetPrCommentsError { NoPrAttached, - GithubCliNotInstalled, - GithubCliNotLoggedIn, + CliNotInstalled { provider: ProviderKind }, + CliNotLoggedIn { provider: ProviderKind }, } #[derive(Debug, Deserialize, TS)] @@ -86,7 +89,7 @@ pub struct GetPrCommentsQuery { pub repo_id: Uuid, } -pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the GitHub PR that was just created with a better title and description. +pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the PR that was just created with a better title and description. The PR number is #{pr_number} and the URL is {pr_url}. Analyze the changes in this branch and write: @@ -97,7 +100,7 @@ Analyze the changes in this branch and write: - Any important implementation details - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" -Use `gh pr edit` to update the PR."#; +Use the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps)."#; async fn trigger_pr_description_follow_up( deployment: &DeploymentImpl, @@ -190,11 +193,11 @@ async fn trigger_pr_description_follow_up( Ok(()) } -pub async fn create_github_pr( +pub async fn create_pr( Extension(workspace): Extension, State(deployment): State, - Json(request): Json, -) -> Result>, ApiError> { + Json(request): Json, +) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = @@ -206,7 +209,7 @@ pub async fn create_github_pr( .await? .ok_or(RepoError::NotFound)?; - let repo_path = repo.path; + let repo_path = repo.path.clone(); let target_branch = if let Some(branch) = request.target_branch { branch } else { @@ -218,7 +221,7 @@ pub async fn create_github_pr( .ensure_container_exists(&workspace) .await?; let workspace_path = PathBuf::from(&container_ref); - let worktree_path = workspace_path.join(repo.name); + let worktree_path = workspace_path.join(&repo.name); match deployment .git() @@ -226,40 +229,40 @@ pub async fn create_github_pr( { Ok(false) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::TargetBranchNotFound { + PrError::TargetBranchNotFound { branch: target_branch.clone(), }, ))); } Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, + PrError::GitCliNotLoggedIn, ))); } Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, + PrError::GitCliNotInstalled, ))); } Err(e) => return Err(ApiError::GitService(e)), Ok(true) => {} } - // Push the branch to GitHub first + // Push the branch to remote first if let Err(e) = deployment .git() - .push_to_github(&worktree_path, &workspace.branch, false) + .push_to_remote(&worktree_path, &workspace.branch, false) { - tracing::error!("Failed to push branch to GitHub: {}", e); + tracing::error!("Failed to push branch to remote: {}", e); match e { GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, + PrError::GitCliNotLoggedIn, ))); } GitServiceError::GitCLI(GitCliError::NotAvailable) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, + PrError::GitCliNotInstalled, ))); } _ => return Err(ApiError::GitService(e)), @@ -285,7 +288,29 @@ pub async fn create_github_pr( } else { target_branch }; - // Create the PR using GitHub service + + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo_path, &workspace.branch)?; + + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::UnsupportedProvider) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::UnsupportedProvider, + ))); + } + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); + + // Create the PR let pr_request = CreatePrRequest { title: request.title.clone(), body: request.body.clone(), @@ -293,9 +318,11 @@ pub async fn create_github_pr( base_branch: norm_target_branch_name.clone(), draft: request.draft, }; - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo_path).await?; - match github_service.create_pr(&repo_info, &pr_request).await { + + match git_host + .create_pr(&repo_path, &remote_url, &pr_request) + .await + { Ok(pr_info) => { // Update the workspace with PR information if let Err(e) = Merge::create_pr( @@ -315,11 +342,13 @@ pub async fn create_github_pr( if let Err(e) = utils::browser::open_browser(&pr_info.url).await { tracing::warn!("Failed to open PR in browser: {}", e); } + deployment .track_if_analytics_allowed( - "github_pr_created", + "pr_created", serde_json::json!({ "workspace_id": workspace.id.to_string(), + "provider": format!("{:?}", provider), }), ) .await; @@ -345,18 +374,21 @@ pub async fn create_github_pr( } Err(e) => { tracing::error!( - "Failed to create GitHub PR for attempt {}: {}", + "Failed to create PR for attempt {} using {:?}: {}", workspace.id, + provider, e ); match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled), + GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( + ApiResponse::error_with_data(PrError::CliNotInstalled { + provider: *provider, + }), )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), + GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotLoggedIn { provider }, + ))), + _ => Err(ApiError::GitHost(e)), } } } @@ -366,7 +398,7 @@ pub async fn attach_existing_pr( Extension(workspace): Extension, State(deployment): State, Json(request): Json, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let pool = &deployment.db().pool; let task = workspace @@ -394,13 +426,45 @@ pub async fn attach_existing_pr( }))); } - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo.path).await?; + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo.path, &workspace_repo.target_branch)?; + + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::UnsupportedProvider) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::UnsupportedProvider, + ))); + } + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); // List all PRs for branch (open, closed, and merged) - let prs = github_service - .list_all_prs_for_branch(&repo_info, &workspace.branch) - .await?; + let prs = match git_host + .list_prs_for_branch(&repo.path, &remote_url, &workspace.branch) + .await + { + Ok(prs) => prs, + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(GitHostError::AuthFailed(_)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotLoggedIn { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; // Take the first PR (prefer open, but also accept merged/closed) if let Some(pr_info) = prs.into_iter().next() { @@ -496,12 +560,24 @@ pub async fn get_pr_comments( } }; - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo.path).await?; + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo.path, &workspace_repo.target_branch)?; - // Fetch comments from GitHub - match github_service - .get_pr_comments(&repo_info, pr_info.number) + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + GetPrCommentsError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); + + match git_host + .get_pr_comments(&repo.path, &remote_url, pr_info.number) .await { Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse { @@ -515,13 +591,15 @@ pub async fn get_pr_comments( e ); match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled), + GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( + ApiResponse::error_with_data(GetPrCommentsError::CliNotInstalled { + provider: *provider, + }), )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), + GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( + GetPrCommentsError::CliNotLoggedIn { provider }, + ))), + _ => Err(ApiError::GitHost(e)), } } } diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 2cad13d3..1817cf05 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -28,6 +28,7 @@ dirs = "5.0" git2 = { workspace = true } tempfile = "3.21" async-trait = { workspace = true } +enum_dispatch = "0.3.13" rust-embed = "8.2" ignore = "0.4" regex = "1.11.1" diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index d4e4047e..5562ec0a 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1601,9 +1601,29 @@ impl GitService { ) -> Result { let repo = Repository::open(repo_path)?; let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference(); - let default_remote = self.default_remote_name(&repo); - self.get_remote_from_branch_ref(&repo, &branch_ref) - .map(|r| r.name().unwrap_or(&default_remote).to_string()) + self.get_remote_from_branch_ref(&repo, &branch_ref)? + .name() + .map(|name| name.to_string()) + .ok_or_else(|| { + GitServiceError::InvalidRepository(format!( + "Remote for branch '{branch_name}' has no name" + )) + }) + } + + /// Get the remote URL for a branch. For remote-tracking branches, uses the branch's remote. + /// For local branches or if remote detection fails, falls back to the default remote. + pub fn get_remote_url_from_branch_or_default( + &self, + repo_path: &Path, + branch_name: &str, + ) -> Result { + let remote_name = self + .get_remote_name_from_branch_name(repo_path, branch_name) + .unwrap_or(self.default_remote_name(&Repository::open(repo_path)?)); + let cli = GitCli::new(); + cli.get_remote_url(repo_path, &remote_name) + .map_err(GitServiceError::GitCLI) } fn get_remote_from_branch_ref<'a>( @@ -1631,7 +1651,7 @@ impl GitService { }) } - pub fn push_to_github( + pub fn push_to_remote( &self, worktree_path: &Path, branch_name: &str, @@ -1649,7 +1669,7 @@ impl GitService { .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; let git_cli = GitCli::new(); if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name, force) { - tracing::error!("Push to GitHub failed: {}", e); + tracing::error!("Push to remote failed: {}", e); return Err(e.into()); } diff --git a/crates/services/src/services/git/cli.rs b/crates/services/src/services/git/cli.rs index 2909a09e..d3b4d56c 100644 --- a/crates/services/src/services/git/cli.rs +++ b/crates/services/src/services/git/cli.rs @@ -415,6 +415,21 @@ impl GitCli { } } + pub fn get_remote_url( + &self, + repo_path: &Path, + remote_name: &str, + ) -> Result { + let output = self.git(repo_path, ["remote", "get-url", remote_name])?; + Ok(output.trim().to_string()) + } + + /// Get the default remote name (first remote listed, or "origin" as fallback). + pub fn default_remote_name(&self, repo_path: &Path) -> Result { + let output = self.git(repo_path, ["remote"])?; + Ok(output.lines().next().unwrap_or("origin").to_string()) + } + // Parse `git diff --name-status` output into structured entries. // Handles rename/copy scores like `R100` by matching the first letter. fn parse_name_status(output: &str) -> Vec { diff --git a/crates/services/src/services/git_host/azure/cli.rs b/crates/services/src/services/git_host/azure/cli.rs new file mode 100644 index 00000000..cb5ad420 --- /dev/null +++ b/crates/services/src/services/git_host/azure/cli.rs @@ -0,0 +1,684 @@ +//! Minimal helpers around the Azure CLI (`az repos`). +//! +//! This module provides low-level access to the Azure CLI for Azure DevOps +//! repository and pull request operations. + +use std::{ + ffi::{OsStr, OsString}, + path::Path, + process::Command, +}; + +use chrono::{DateTime, Utc}; +use db::models::merge::{MergeStatus, PullRequestInfo}; +use serde::Deserialize; +use thiserror::Error; +use utils::shell::resolve_executable_path_blocking; + +use crate::services::git_host::types::{CreatePrRequest, UnifiedPrComment}; + +#[derive(Debug, Clone)] +pub struct AzureRepoInfo { + pub organization_url: String, + pub project: String, + pub project_id: String, + pub repo_name: String, + pub repo_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzPrResponse { + pull_request_id: i64, + status: Option, + closed_date: Option, + repository: Option, + last_merge_commit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzRepository { + web_url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzCommit { + commit_id: Option, +} + +#[derive(Deserialize)] +struct AzThreadsResponse { + value: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThread { + comments: Option>, + thread_context: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThreadContext { + file_path: Option, + right_file_start: Option, +} + +#[derive(Deserialize)] +struct AzFilePosition { + line: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThreadComment { + id: Option, + author: Option, + content: Option, + published_date: Option, + comment_type: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzAuthor { + display_name: Option, +} + +/// Response item from `az repos list` +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzRepoListItem { + id: String, + name: String, + project: AzRepoProject, + remote_url: String, + ssh_url: Option, +} + +#[derive(Deserialize)] +struct AzRepoProject { + id: String, + name: String, +} + +#[derive(Debug, Error)] +pub enum AzCliError { + #[error("Azure CLI (`az`) executable not found or not runnable")] + NotAvailable, + #[error("Azure CLI command failed: {0}")] + CommandFailed(String), + #[error("Azure CLI authentication failed: {0}")] + AuthFailed(String), + #[error("Azure CLI returned unexpected output: {0}")] + UnexpectedOutput(String), +} + +#[derive(Debug, Clone, Default)] +pub struct AzCli; + +impl AzCli { + pub fn new() -> Self { + Self {} + } + + /// Ensure the Azure CLI binary is discoverable. + fn ensure_available(&self) -> Result<(), AzCliError> { + resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; + Ok(()) + } + + fn run(&self, args: I, dir: Option<&Path>) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.ensure_available()?; + let az = resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; + let mut cmd = Command::new(&az); + + if let Some(d) = dir { + cmd.current_dir(d); + } + + for arg in args { + cmd.arg(arg); + } + tracing::debug!("Running Azure CLI command: {:?} {:?}", az, cmd.get_args()); + + let output = cmd + .output() + .map_err(|err| AzCliError::CommandFailed(err.to_string()))?; + + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + // Check for authentication errors + let lower = stderr.to_ascii_lowercase(); + if lower.contains("az login") + || lower.contains("not logged in") + || lower.contains("authentication") + || lower.contains("unauthorized") + || lower.contains("credentials") + || lower.contains("please run 'az login'") + { + return Err(AzCliError::AuthFailed(stderr)); + } + + Err(AzCliError::CommandFailed(stderr)) + } + pub fn get_repo_info( + &self, + repo_path: &Path, + remote_url: &str, + ) -> Result { + let raw = self.run( + ["repos", "list", "--detect", "true", "--output", "json"], + Some(repo_path), + )?; + + let repos: Vec = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse repos list: {e}; raw: {raw}")) + })?; + + // Find the repo that matches our remote URL (check both HTTPS and SSH) + let is_ssh = remote_url.starts_with("git@") || remote_url.starts_with("ssh://"); + let repo = repos + .into_iter() + .find(|r| { + if is_ssh { + r.ssh_url + .as_ref() + .map(|ssh| Self::urls_match(ssh, remote_url)) + .unwrap_or(false) + } else { + Self::urls_match(&r.remote_url, remote_url) + } + }) + .ok_or_else(|| { + AzCliError::UnexpectedOutput(format!( + "No repo found matching remote URL: {}", + remote_url + )) + })?; + + let organization_url = + Self::extract_organization_url(&repo.remote_url).ok_or_else(|| { + AzCliError::UnexpectedOutput(format!( + "Could not extract organization URL from: {}", + repo.remote_url + )) + })?; + + tracing::debug!( + "Got Azure DevOps repo info: org_url='{}', project='{}' ({}), repo='{}' ({})", + organization_url, + repo.project.name, + repo.project.id, + repo.name, + repo.id + ); + + Ok(AzureRepoInfo { + organization_url, + project: repo.project.name, + project_id: repo.project.id, + repo_name: repo.name, + repo_id: repo.id, + }) + } + + fn urls_match(url1: &str, url2: &str) -> bool { + let normalize = |url: &str| { + let mut s = url.to_lowercase(); + // Normalize ssh:// prefix to scp-style + if let Some(rest) = s.strip_prefix("ssh://") { + s = rest.to_string(); + } + s.trim_end_matches('/').trim_end_matches(".git").to_string() + }; + normalize(url1) == normalize(url2) + } + + /// Extract the organization URL from a remote URL. + /// Returns the base URL that can be used with Azure CLI commands. + fn extract_organization_url(url: &str) -> Option { + // dev.azure.com format: https://dev.azure.com/{org}/... -> https://dev.azure.com/{org} + if url.contains("dev.azure.com") { + let parts: Vec<&str> = url.split('/').collect(); + let azure_idx = parts.iter().position(|&p| p.contains("dev.azure.com"))?; + let org = parts.get(azure_idx + 1)?; + return Some(format!("https://dev.azure.com/{}", org)); + } + + // Legacy format: https://{org}.visualstudio.com/... -> https://{org}.visualstudio.com + if url.contains(".visualstudio.com") { + let parts: Vec<&str> = url.split('/').collect(); + for part in parts.iter() { + if part.contains(".visualstudio.com") { + return Some(format!("https://{}", part)); + } + } + } + + None + } + + pub fn create_pr( + &self, + request: &CreatePrRequest, + organization_url: &str, + project: &str, + repo_name: &str, + ) -> Result { + let body = request.body.as_deref().unwrap_or(""); + + let mut args: Vec = Vec::with_capacity(20); + args.push(OsString::from("repos")); + args.push(OsString::from("pr")); + args.push(OsString::from("create")); + args.push(OsString::from("--organization")); + args.push(OsString::from(organization_url)); + args.push(OsString::from("--project")); + args.push(OsString::from(project)); + args.push(OsString::from("--repository")); + args.push(OsString::from(repo_name)); + args.push(OsString::from("--source-branch")); + args.push(OsString::from(&request.head_branch)); + args.push(OsString::from("--target-branch")); + args.push(OsString::from(&request.base_branch)); + args.push(OsString::from("--title")); + args.push(OsString::from(&request.title)); + args.push(OsString::from("--description")); + args.push(OsString::from(body)); + args.push(OsString::from("--output")); + args.push(OsString::from("json")); + + if request.draft.unwrap_or(false) { + args.push(OsString::from("--draft")); + } + + let raw = self.run(args, None)?; + Self::parse_pr_response(&raw) + } + + pub fn check_auth(&self) -> Result<(), AzCliError> { + match self.run(["account", "show"], None) { + Ok(_) => Ok(()), + Err(AzCliError::CommandFailed(msg)) => Err(AzCliError::AuthFailed(msg)), + Err(err) => Err(err), + } + } + + pub fn view_pr(&self, pr_url: &str) -> Result { + let (organization, pr_id) = Self::parse_pr_url(pr_url).ok_or_else(|| { + AzCliError::UnexpectedOutput(format!("Could not parse Azure DevOps PR URL: {pr_url}")) + })?; + + let org_url = format!("https://dev.azure.com/{}", organization); + + let raw = self.run( + [ + "repos", + "pr", + "show", + "--id", + &pr_id.to_string(), + "--organization", + &org_url, + "--output", + "json", + ], + None, + )?; + + Self::parse_pr_response(&raw) + } + + pub fn list_prs_for_branch( + &self, + organization_url: &str, + project: &str, + repo_name: &str, + branch: &str, + ) -> Result, AzCliError> { + let raw = self.run( + [ + "repos", + "pr", + "list", + "--organization", + organization_url, + "--project", + project, + "--repository", + repo_name, + "--source-branch", + branch, + "--status", + "all", + "--output", + "json", + ], + None, + )?; + + Self::parse_pr_list_response(&raw) + } + + pub fn get_pr_threads( + &self, + organization_url: &str, + project_id: &str, + repo_id: &str, + pr_id: i64, + ) -> Result, AzCliError> { + let mut args: Vec = Vec::with_capacity(16); + args.push(OsString::from("devops")); + args.push(OsString::from("invoke")); + args.push(OsString::from("--area")); + args.push(OsString::from("git")); + args.push(OsString::from("--resource")); + args.push(OsString::from("pullRequestThreads")); + args.push(OsString::from("--route-parameters")); + args.push(OsString::from(format!("project={}", project_id))); + args.push(OsString::from(format!("repositoryId={}", repo_id))); + args.push(OsString::from(format!("pullRequestId={}", pr_id))); + args.push(OsString::from("--organization")); + args.push(OsString::from(organization_url)); + args.push(OsString::from("--api-version")); + args.push(OsString::from("7.0")); + args.push(OsString::from("--output")); + args.push(OsString::from("json")); + + let raw = self.run(args, None)?; + Self::parse_pr_threads(&raw) + } + + /// Parse PR URL to extract organization and PR ID. + /// + /// Only extracts the minimal info needed for `az repos pr show`. + /// Format: `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}` + pub fn parse_pr_url(url: &str) -> Option<(String, i64)> { + let url_lower = url.to_lowercase(); + + if url_lower.contains("dev.azure.com") && url_lower.contains("/pullrequest/") { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") + && parts.len() > pr_idx + 1 + { + let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; + if let Some(azure_idx) = parts.iter().position(|&p| p.contains("dev.azure.com")) + && parts.len() > azure_idx + 1 + { + let organization = parts[azure_idx + 1].to_string(); + return Some((organization, pr_id)); + } + } + } + + // Legacy format: https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id} + if url_lower.contains(".visualstudio.com") && url_lower.contains("/pullrequest/") { + let parts: Vec<&str> = url.split('/').collect(); + for part in parts.iter() { + if let Some(org) = part.strip_suffix(".visualstudio.com") + && let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") + && parts.len() > pr_idx + 1 + { + let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; + return Some((org.to_string(), pr_id)); + } + } + } + + None + } +} + +impl AzCli { + /// Parse PR response from Azure CLI. + /// Works for both `az repos pr create` and `az repos pr show`. + fn parse_pr_response(raw: &str) -> Result { + let pr: AzPrResponse = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse PR response: {e}; raw: {raw}")) + })?; + Ok(Self::az_pr_to_info(pr)) + } + + fn parse_pr_list_response(raw: &str) -> Result, AzCliError> { + let prs: Vec = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse PR list: {e}; raw: {raw}")) + })?; + Ok(prs.into_iter().map(Self::az_pr_to_info).collect()) + } + + /// Convert Azure PR response to PullRequestInfo. + fn az_pr_to_info(pr: AzPrResponse) -> PullRequestInfo { + let url = pr + .repository + .and_then(|r| r.web_url) + .map(|u| format!("{}/pullrequest/{}", u, pr.pull_request_id)) + .unwrap_or_else(|| format!("pullrequest/{}", pr.pull_request_id)); + + let status = pr.status.as_deref().unwrap_or("active"); + let merged_at = pr + .closed_date + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let merge_commit_sha = pr.last_merge_commit.and_then(|c| c.commit_id); + + PullRequestInfo { + number: pr.pull_request_id, + url, + status: Self::map_azure_status(status), + merged_at, + merge_commit_sha, + } + } + + fn parse_pr_threads(raw: &str) -> Result, AzCliError> { + // REST API returns { "value": [...threads...] } wrapper + let response: AzThreadsResponse = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse threads: {e}; raw: {raw}")) + })?; + let threads = response.value; + + let mut comments = Vec::new(); + + for thread in threads { + let file_path = thread + .thread_context + .as_ref() + .and_then(|c| c.file_path.clone()); + let line = thread + .thread_context + .as_ref() + .and_then(|c| c.right_file_start.as_ref()) + .and_then(|p| p.line); + + if let Some(thread_comments) = thread.comments { + for c in thread_comments { + // Skip system-generated comments + if c.comment_type.as_deref() == Some("system") { + continue; + } + + let id = c.id.unwrap_or(0); + let author = c + .author + .and_then(|a| a.display_name) + .unwrap_or_else(|| "unknown".to_string()); + let body = c.content.unwrap_or_default(); + let created_at = c + .published_date + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + if let Some(ref path) = file_path { + comments.push(UnifiedPrComment::Review { + id, + author, + author_association: None, + body, + created_at, + url: None, + path: path.clone(), + line, + diff_hunk: None, + }); + } else { + comments.push(UnifiedPrComment::General { + id: id.to_string(), + author, + author_association: None, + body, + created_at, + url: None, + }); + } + } + } + } + + comments.sort_by_key(|c| c.created_at()); + Ok(comments) + } + + /// Map Azure DevOps PR status to MergeStatus + fn map_azure_status(status: &str) -> MergeStatus { + match status.to_lowercase().as_str() { + "active" => MergeStatus::Open, + "completed" => MergeStatus::Merged, + "abandoned" => MergeStatus::Closed, + _ => MergeStatus::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pr_url() { + // dev.azure.com format + let (org, id) = AzCli::parse_pr_url( + "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123", + ) + .unwrap(); + assert_eq!(org, "myorg"); + assert_eq!(id, 123); + } + + #[test] + fn test_parse_pr_url_visualstudio() { + // Legacy visualstudio.com format + let (org, id) = AzCli::parse_pr_url( + "https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/456", + ) + .unwrap(); + assert_eq!(org, "myorg"); + assert_eq!(id, 456); + } + + #[test] + fn test_parse_pr_url_invalid() { + // GitHub URL should return None + assert!(AzCli::parse_pr_url("https://github.com/owner/repo/pull/123").is_none()); + // Missing pullrequest path + assert!(AzCli::parse_pr_url("https://dev.azure.com/myorg/myproject/_git/myrepo").is_none()); + } + + #[test] + fn test_map_azure_status() { + assert!(matches!( + AzCli::map_azure_status("active"), + MergeStatus::Open + )); + assert!(matches!( + AzCli::map_azure_status("completed"), + MergeStatus::Merged + )); + assert!(matches!( + AzCli::map_azure_status("abandoned"), + MergeStatus::Closed + )); + assert!(matches!( + AzCli::map_azure_status("unknown"), + MergeStatus::Unknown + )); + } + + #[test] + fn test_urls_match() { + // Exact match + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Trailing slash + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo/", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // .git suffix + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo.git", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Case insensitive + assert!(AzCli::urls_match( + "https://dev.azure.com/MyOrg/MyProject/_git/MyRepo", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Different repos should not match + assert!(!AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/repo1", + "https://dev.azure.com/myorg/myproject/_git/repo2" + )); + + // SSH URLs + assert!(AzCli::urls_match( + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + )); + + // SSH URL with ssh:// prefix should match scp-style + assert!(AzCli::urls_match( + "ssh://git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + )); + } + + #[test] + fn test_extract_organization_url_dev_azure() { + let org_url = + AzCli::extract_organization_url("https://dev.azure.com/myorg/myproject/_git/myrepo") + .unwrap(); + assert_eq!(org_url, "https://dev.azure.com/myorg"); + } + + #[test] + fn test_extract_organization_url_visualstudio() { + let org_url = + AzCli::extract_organization_url("https://myorg.visualstudio.com/myproject/_git/myrepo") + .unwrap(); + assert_eq!(org_url, "https://myorg.visualstudio.com"); + } + + #[test] + fn test_extract_organization_url_invalid() { + assert!(AzCli::extract_organization_url("https://github.com/owner/repo").is_none()); + } +} diff --git a/crates/services/src/services/git_host/azure/mod.rs b/crates/services/src/services/git_host/azure/mod.rs new file mode 100644 index 00000000..9e37859a --- /dev/null +++ b/crates/services/src/services/git_host/azure/mod.rs @@ -0,0 +1,281 @@ +//! Azure DevOps hosting service implementation. + +mod cli; + +use std::{path::Path, time::Duration}; + +use async_trait::async_trait; +use backon::{ExponentialBuilder, Retryable}; +pub use cli::AzCli; +use cli::{AzCliError, AzureRepoInfo}; +use db::models::merge::PullRequestInfo; +use tokio::task; +use tracing::info; + +use super::{ + GitHostProvider, + types::{CreatePrRequest, GitHostError, ProviderKind, UnifiedPrComment}, +}; + +#[derive(Debug, Clone)] +pub struct AzureDevOpsProvider { + az_cli: AzCli, +} + +impl AzureDevOpsProvider { + pub fn new() -> Result { + Ok(Self { + az_cli: AzCli::new(), + }) + } + + async fn get_repo_info( + &self, + repo_path: &Path, + remote_url: &str, + ) -> Result { + let cli = self.az_cli.clone(); + let path = repo_path.to_path_buf(); + let url = remote_url.to_string(); + task::spawn_blocking(move || cli.get_repo_info(&path, &url)) + .await + .map_err(|err| GitHostError::Repository(format!("Failed to get repo info: {err}")))? + .map_err(Into::into) + } + + async fn check_auth(&self) -> Result<(), GitHostError> { + let cli = self.az_cli.clone(); + task::spawn_blocking(move || cli.check_auth()) + .await + .map_err(|err| { + GitHostError::Repository(format!( + "Failed to execute Azure CLI for auth check: {err}" + )) + })? + .map_err(|err| match err { + AzCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::AzureDevOps, + }, + AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg), + AzCliError::CommandFailed(msg) => { + GitHostError::Repository(format!("Azure CLI auth check failed: {msg}")) + } + AzCliError::UnexpectedOutput(msg) => GitHostError::Repository(format!( + "Unexpected output from Azure CLI auth check: {msg}" + )), + }) + } +} + +impl From for GitHostError { + fn from(error: AzCliError) -> Self { + match &error { + AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), + AzCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::AzureDevOps, + }, + AzCliError::CommandFailed(msg) => { + let lower = msg.to_ascii_lowercase(); + if lower.contains("403") || lower.contains("forbidden") { + GitHostError::InsufficientPermissions(msg.clone()) + } else if lower.contains("404") || lower.contains("not found") { + GitHostError::RepoNotFoundOrNoAccess(msg.clone()) + } else { + GitHostError::PullRequest(msg.clone()) + } + } + AzCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), + } + } +} + +#[async_trait] +impl GitHostProvider for AzureDevOpsProvider { + async fn create_pr( + &self, + repo_path: &Path, + remote_url: &str, + request: &CreatePrRequest, + ) -> Result { + // Check auth first + self.check_auth().await?; + + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + let request_clone = request.clone(); + + (|| async { + let cli = cli.clone(); + let request = request_clone.clone(); + let organization_url = repo_info.organization_url.clone(); + let project = repo_info.project.clone(); + let repo_name = repo_info.repo_name.clone(); + + let cli_result = task::spawn_blocking(move || { + cli.create_pr(&request, &organization_url, &project, &repo_name) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for PR creation: {err}" + )) + })? + .map_err(GitHostError::from)?; + + info!( + "Created Azure DevOps PR #{} for branch {}", + cli_result.number, request_clone.head_branch + ); + + Ok(cli_result) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_status(&self, pr_url: &str) -> Result { + let cli = self.az_cli.clone(); + let url = pr_url.to_string(); + + (|| async { + let cli = cli.clone(); + let url = url.clone(); + + let pr = task::spawn_blocking(move || cli.view_pr(&url)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for viewing PR: {err}" + )) + })?; + pr.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|err: &GitHostError| err.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + let branch = branch_name.to_string(); + + (|| async { + let cli = cli.clone(); + let organization_url = repo_info.organization_url.clone(); + let project = repo_info.project.clone(); + let repo_name = repo_info.repo_name.clone(); + let branch = branch.clone(); + + let prs = task::spawn_blocking(move || { + cli.list_prs_for_branch(&organization_url, &project, &repo_name, &branch) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for listing PRs: {err}" + )) + })?; + prs.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_comments( + &self, + repo_path: &Path, + remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + + (|| async { + let cli = cli.clone(); + let organization_url = repo_info.organization_url.clone(); + let project_id = repo_info.project_id.clone(); + let repo_id = repo_info.repo_id.clone(); + + let comments = task::spawn_blocking(move || { + cli.get_pr_threads(&organization_url, &project_id, &repo_id, pr_number) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for fetching PR comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + fn provider_kind(&self) -> ProviderKind { + ProviderKind::AzureDevOps + } +} diff --git a/crates/services/src/services/git_host/detection.rs b/crates/services/src/services/git_host/detection.rs new file mode 100644 index 00000000..392f85d9 --- /dev/null +++ b/crates/services/src/services/git_host/detection.rs @@ -0,0 +1,178 @@ +//! Git hosting provider detection from repository URLs. + +use super::types::ProviderKind; + +/// Detect the git hosting provider from a remote URL. +/// +/// Supports: +/// - GitHub.com: `https://github.com/owner/repo` or `git@github.com:owner/repo.git` +/// - GitHub Enterprise: URLs containing `github.` (e.g., `https://github.company.com/owner/repo`) +/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo` or legacy `https://org.visualstudio.com/...` +pub fn detect_provider_from_url(url: &str) -> ProviderKind { + let url_lower = url.to_lowercase(); + + if url_lower.contains("github.com") { + return ProviderKind::GitHub; + } + + // Check Azure patterns before GHE to avoid false positives + if url_lower.contains("dev.azure.com") + || url_lower.contains(".visualstudio.com") + || url_lower.contains("ssh.dev.azure.com") + { + return ProviderKind::AzureDevOps; + } + + // /_git/ is unique to Azure DevOps + if url_lower.contains("/_git/") { + return ProviderKind::AzureDevOps; + } + + // GitHub Enterprise (contains "github." but not the Azure patterns above) + if url_lower.contains("github.") { + return ProviderKind::GitHub; + } + + ProviderKind::Unknown +} + +/// Detect the git hosting provider from a PR URL. +/// +/// Supports: +/// - GitHub: `https://github.com/owner/repo/pull/123` +/// - GitHub Enterprise: `https://github.company.com/owner/repo/pull/123` +/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo/pullrequest/123` +#[cfg(test)] +fn detect_provider_from_pr_url(pr_url: &str) -> ProviderKind { + let url_lower = pr_url.to_lowercase(); + + // GitHub pattern: contains /pull/ in the path + if url_lower.contains("/pull/") { + // Could be github.com or GHE + if url_lower.contains("github.com") || url_lower.contains("github.") { + return ProviderKind::GitHub; + } + } + + // Azure DevOps pattern: contains /pullrequest/ in the path + if url_lower.contains("/pullrequest/") { + return ProviderKind::AzureDevOps; + } + + // Fall back to general URL detection + detect_provider_from_url(pr_url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_github_com_https() { + assert_eq!( + detect_provider_from_url("https://github.com/owner/repo"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("https://github.com/owner/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_github_com_ssh() { + assert_eq!( + detect_provider_from_url("git@github.com:owner/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_github_enterprise() { + assert_eq!( + detect_provider_from_url("https://github.company.com/owner/repo"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("https://github.acme.corp/team/project"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("git@github.internal.io:org/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_azure_devops_https() { + assert_eq!( + detect_provider_from_url("https://dev.azure.com/org/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_ssh() { + assert_eq!( + detect_provider_from_url("git@ssh.dev.azure.com:v3/org/project/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_legacy_visualstudio() { + assert_eq!( + detect_provider_from_url("https://org.visualstudio.com/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_git_path() { + // Any URL with /_git/ is Azure DevOps + assert_eq!( + detect_provider_from_url("https://custom.domain.com/org/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_unknown_provider() { + assert_eq!( + detect_provider_from_url("https://gitlab.com/owner/repo"), + ProviderKind::Unknown + ); + assert_eq!( + detect_provider_from_url("https://bitbucket.org/owner/repo"), + ProviderKind::Unknown + ); + } + + #[test] + fn test_pr_url_github() { + assert_eq!( + detect_provider_from_pr_url("https://github.com/owner/repo/pull/123"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_pr_url("https://github.company.com/owner/repo/pull/456"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_pr_url_azure() { + assert_eq!( + detect_provider_from_pr_url( + "https://dev.azure.com/org/project/_git/repo/pullrequest/123" + ), + ProviderKind::AzureDevOps + ); + assert_eq!( + detect_provider_from_pr_url( + "https://org.visualstudio.com/project/_git/repo/pullrequest/456" + ), + ProviderKind::AzureDevOps + ); + } +} diff --git a/crates/services/src/services/github/cli.rs b/crates/services/src/services/git_host/github/cli.rs similarity index 69% rename from crates/services/src/services/github/cli.rs rename to crates/services/src/services/git_host/github/cli.rs index 11a5734f..78705b87 100644 --- a/crates/services/src/services/github/cli.rs +++ b/crates/services/src/services/git_host/github/cli.rs @@ -1,8 +1,7 @@ //! Minimal helpers around the GitHub CLI (`gh`). //! -//! This module deliberately mirrors the ergonomics of `git_cli.rs` so we can -//! plug in the GitHub CLI for operations the REST client does not cover well. -//! Future work will flesh out richer error handling and testing. +//! This module provides low-level access to the GitHub CLI for operations +//! the REST client does not cover well. use std::{ ffi::{OsStr, OsString}, @@ -13,55 +12,80 @@ use std::{ use chrono::{DateTime, Utc}; use db::models::merge::{MergeStatus, PullRequestInfo}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::Deserialize; use tempfile::NamedTempFile; use thiserror::Error; -use ts_rs::TS; use utils::shell::resolve_executable_path_blocking; -use crate::services::github::{CreatePrRequest, GitHubRepoInfo}; +use crate::services::git_host::types::{ + CreatePrRequest, PrComment, PrCommentAuthor, PrReviewComment, ReviewCommentUser, +}; -/// Author information for a PR comment -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PrCommentAuthor { - pub login: String, +#[derive(Debug, Clone)] +pub struct GitHubRepoInfo { + pub owner: String, + pub repo_name: String, } -/// A single comment on a GitHub PR -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PrComment { - pub id: String, - pub author: PrCommentAuthor, - pub author_association: String, - pub body: String, - pub created_at: DateTime, - pub url: String, +struct GhCommentResponse { + id: String, + author: Option, + #[serde(default)] + author_association: String, + #[serde(default)] + body: String, + created_at: Option>, + #[serde(default)] + url: String, } -/// User information for a review comment (from API response) -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct ReviewCommentUser { - pub login: String, +#[derive(Deserialize)] +struct GhCommentsWrapper { + comments: Vec, } -/// An inline review comment on a GitHub PR (from gh api) -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PrReviewComment { - pub id: i64, - pub user: ReviewCommentUser, - pub body: String, - pub created_at: DateTime, - pub html_url: String, - pub path: String, - pub line: Option, - pub side: Option, - pub diff_hunk: String, - pub author_association: String, +#[derive(Deserialize)] +struct GhUserLogin { + login: Option, +} + +#[derive(Deserialize)] +struct GhReviewCommentResponse { + id: i64, + user: Option, + #[serde(default)] + body: String, + created_at: Option>, + #[serde(default)] + html_url: String, + #[serde(default)] + path: String, + line: Option, + side: Option, + #[serde(default)] + diff_hunk: String, + #[serde(default)] + author_association: String, +} + +#[derive(Deserialize)] +struct GhMergeCommit { + oid: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GhPrResponse { + number: i64, + url: String, + #[serde(default)] + state: String, + merged_at: Option>, + merge_commit: Option, } -/// High-level errors originating from the GitHub CLI. #[derive(Debug, Error)] pub enum GhCliError { #[error("GitHub CLI (`gh`) executable not found or not runnable")] @@ -74,7 +98,6 @@ pub enum GhCliError { UnexpectedOutput(String), } -/// Newtype wrapper for invoking the `gh` command. #[derive(Debug, Clone, Default)] pub struct GhCli; @@ -132,6 +155,7 @@ impl GhCli { Err(GhCliError::CommandFailed(stderr)) } + /// Get repository info (owner and name) from a local repository path. pub fn get_repo_info(&self, repo_path: &Path) -> Result { let raw = self.run(["repo", "view", "--json", "owner,name"], Some(repo_path))?; @@ -159,7 +183,8 @@ impl GhCli { pub fn create_pr( &self, request: &CreatePrRequest, - repo_info: &GitHubRepoInfo, + owner: &str, + repo_name: &str, ) -> Result { // Write body to temp file to avoid shell escaping and length issues let body = request.body.as_deref().unwrap_or(""); @@ -173,10 +198,7 @@ impl GhCli { args.push(OsString::from("pr")); args.push(OsString::from("create")); args.push(OsString::from("--repo")); - args.push(OsString::from(format!( - "{}/{}", - repo_info.owner, repo_info.repo_name - ))); + args.push(OsString::from(format!("{}/{}", owner, repo_name))); args.push(OsString::from("--head")); args.push(OsString::from(&request.head_branch)); args.push(OsString::from("--base")); @@ -325,101 +347,96 @@ impl GhCli { } fn parse_pr_view(raw: &str) -> Result { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + let pr: GhPrResponse = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr view response: {err}; raw: {raw}" )) })?; - Self::extract_pr_info(&value).ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr view response missing required fields: {value:#?}" - )) - }) + Ok(Self::pr_response_to_info(pr)) } fn parse_pr_list(raw: &str) -> Result, GhCliError> { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + let prs: Vec = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr list response: {err}; raw: {raw}" )) })?; - let arr = value.as_array().ok_or_else(|| { - GhCliError::UnexpectedOutput(format!("gh pr list response is not an array: {value:#?}")) - })?; - arr.iter() - .map(|item| { - Self::extract_pr_info(item).ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr list item missing required fields: {item:#?}" - )) - }) - }) - .collect() + Ok(prs.into_iter().map(Self::pr_response_to_info).collect()) } - fn parse_pr_comments(raw: &str) -> Result, GhCliError> { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse gh pr view --json comments response: {err}; raw: {raw}" - )) - })?; - let comments_arr = value - .get("comments") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr view --json comments response missing 'comments' array: {value:#?}" - )) - })?; - comments_arr - .iter() - .map(|item| { - serde_json::from_value(item.clone()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse PR comment: {err}; item: {item:#?}" - )) - }) - }) - .collect() - } - - fn parse_pr_review_comments(raw: &str) -> Result, GhCliError> { - serde_json::from_str(raw.trim()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse review comments API response: {err}; raw: {raw}" - )) - }) - } - - fn extract_pr_info(value: &Value) -> Option { - let number = value.get("number")?.as_i64()?; - let url = value.get("url")?.as_str()?.to_string(); - let state = value - .get("state") - .and_then(Value::as_str) - .unwrap_or("OPEN") - .to_string(); - let merged_at = value - .get("mergedAt") - .and_then(Value::as_str) - .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&Utc)); - let merge_commit_sha = value - .get("mergeCommit") - .and_then(|v| v.get("oid")) - .and_then(Value::as_str) - .map(|s| s.to_string()); - Some(PullRequestInfo { - number, - url, + fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo { + let state = if pr.state.is_empty() { + "OPEN" + } else { + &pr.state + }; + PullRequestInfo { + number: pr.number, + url: pr.url, status: match state.to_ascii_uppercase().as_str() { "OPEN" => MergeStatus::Open, "MERGED" => MergeStatus::Merged, "CLOSED" => MergeStatus::Closed, _ => MergeStatus::Unknown, }, - merged_at, - merge_commit_sha, - }) + merged_at: pr.merged_at, + merge_commit_sha: pr.merge_commit.and_then(|c| c.oid), + } + } + + fn parse_pr_comments(raw: &str) -> Result, GhCliError> { + let wrapper: GhCommentsWrapper = serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh pr view --json comments response: {err}; raw: {raw}" + )) + })?; + + Ok(wrapper + .comments + .into_iter() + .map(|c| PrComment { + id: c.id, + author: PrCommentAuthor { + login: c + .author + .and_then(|a| a.login) + .unwrap_or_else(|| "unknown".to_string()), + }, + author_association: c.author_association, + body: c.body, + created_at: c.created_at.unwrap_or_else(Utc::now), + url: c.url, + }) + .collect()) + } + + fn parse_pr_review_comments(raw: &str) -> Result, GhCliError> { + let items: Vec = + serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse review comments API response: {err}; raw: {raw}" + )) + })?; + + Ok(items + .into_iter() + .map(|c| PrReviewComment { + id: c.id, + user: ReviewCommentUser { + login: c + .user + .and_then(|u| u.login) + .unwrap_or_else(|| "unknown".to_string()), + }, + body: c.body, + created_at: c.created_at.unwrap_or_else(Utc::now), + html_url: c.html_url, + path: c.path, + line: c.line, + side: c.side, + diff_hunk: c.diff_hunk, + author_association: c.author_association, + }) + .collect()) } } diff --git a/crates/services/src/services/git_host/github/mod.rs b/crates/services/src/services/git_host/github/mod.rs new file mode 100644 index 00000000..ec75f227 --- /dev/null +++ b/crates/services/src/services/git_host/github/mod.rs @@ -0,0 +1,368 @@ +//! GitHub hosting service implementation. + +mod cli; + +use std::{path::Path, time::Duration}; + +use async_trait::async_trait; +use backon::{ExponentialBuilder, Retryable}; +pub use cli::GhCli; +use cli::{GhCliError, GitHubRepoInfo}; +use db::models::merge::PullRequestInfo; +use tokio::task; +use tracing::info; + +use super::{ + GitHostProvider, + types::{CreatePrRequest, GitHostError, ProviderKind, UnifiedPrComment}, +}; + +#[derive(Debug, Clone)] +pub struct GitHubProvider { + gh_cli: GhCli, +} + +impl GitHubProvider { + pub fn new() -> Result { + Ok(Self { + gh_cli: GhCli::new(), + }) + } + + async fn get_repo_info(&self, repo_path: &Path) -> Result { + let cli = self.gh_cli.clone(); + let path = repo_path.to_path_buf(); + task::spawn_blocking(move || cli.get_repo_info(&path)) + .await + .map_err(|err| GitHostError::Repository(format!("Failed to get repo info: {err}")))? + .map_err(Into::into) + } + + async fn check_auth(&self) -> Result<(), GitHostError> { + let cli = self.gh_cli.clone(); + task::spawn_blocking(move || cli.check_auth()) + .await + .map_err(|err| { + GitHostError::Repository(format!( + "Failed to execute GitHub CLI for auth check: {err}" + )) + })? + .map_err(|err| match err { + GhCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::GitHub, + }, + GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg), + GhCliError::CommandFailed(msg) => { + GitHostError::Repository(format!("GitHub CLI auth check failed: {msg}")) + } + GhCliError::UnexpectedOutput(msg) => GitHostError::Repository(format!( + "Unexpected output from GitHub CLI auth check: {msg}" + )), + }) + } + + async fn fetch_general_comments( + &self, + cli: &GhCli, + owner: &str, + repo: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let cli = cli.clone(); + let owner = owner.to_string(); + let repo = repo.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = owner.clone(); + let repo = repo.clone(); + + let comments = + task::spawn_blocking(move || cli.get_pr_comments(&owner, &repo, pr_number)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for fetching PR comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn fetch_review_comments( + &self, + cli: &GhCli, + owner: &str, + repo: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let cli = cli.clone(); + let owner = owner.to_string(); + let repo = repo.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = owner.clone(); + let repo = repo.clone(); + + let comments = + task::spawn_blocking(move || cli.get_pr_review_comments(&owner, &repo, pr_number)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for fetching review comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } +} + +impl From for GitHostError { + fn from(error: GhCliError) -> Self { + match &error { + GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), + GhCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::GitHub, + }, + GhCliError::CommandFailed(msg) => { + let lower = msg.to_ascii_lowercase(); + if lower.contains("403") || lower.contains("forbidden") { + GitHostError::InsufficientPermissions(msg.clone()) + } else if lower.contains("404") || lower.contains("not found") { + GitHostError::RepoNotFoundOrNoAccess(msg.clone()) + } else { + GitHostError::PullRequest(msg.clone()) + } + } + GhCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), + } + } +} + +#[async_trait] +impl GitHostProvider for GitHubProvider { + async fn create_pr( + &self, + repo_path: &Path, + _remote_url: &str, + request: &CreatePrRequest, + ) -> Result { + // Check auth first + self.check_auth().await?; + + let repo_info = self.get_repo_info(repo_path).await?; + + let cli = self.gh_cli.clone(); + let request_clone = request.clone(); + + (|| async { + let cli = cli.clone(); + let request = request_clone.clone(); + let owner = repo_info.owner.clone(); + let repo_name = repo_info.repo_name.clone(); + + let cli_result = + task::spawn_blocking(move || cli.create_pr(&request, &owner, &repo_name)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for PR creation: {err}" + )) + })? + .map_err(GitHostError::from)?; + + info!( + "Created GitHub PR #{} for branch {}", + cli_result.number, request_clone.head_branch + ); + + Ok(cli_result) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_status(&self, pr_url: &str) -> Result { + let cli = self.gh_cli.clone(); + let url = pr_url.to_string(); + + (|| async { + let cli = cli.clone(); + let url = url.clone(); + let pr = task::spawn_blocking(move || cli.view_pr(&url)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for viewing PR: {err}" + )) + })?; + pr.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|err: &GitHostError| err.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + _remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path).await?; + + let cli = self.gh_cli.clone(); + let branch = branch_name.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = repo_info.owner.clone(); + let repo_name = repo_info.repo_name.clone(); + let branch = branch.clone(); + + let prs = + task::spawn_blocking(move || cli.list_prs_for_branch(&owner, &repo_name, &branch)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for listing PRs: {err}" + )) + })?; + prs.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_comments( + &self, + repo_path: &Path, + _remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path).await?; + + // Fetch both types of comments in parallel + let cli1 = self.gh_cli.clone(); + let cli2 = self.gh_cli.clone(); + + let (general_result, review_result) = tokio::join!( + self.fetch_general_comments(&cli1, &repo_info.owner, &repo_info.repo_name, pr_number), + self.fetch_review_comments(&cli2, &repo_info.owner, &repo_info.repo_name, pr_number) + ); + + let general_comments = general_result?; + let review_comments = review_result?; + + // Convert and merge into unified timeline + let mut unified: Vec = Vec::new(); + + for c in general_comments { + unified.push(UnifiedPrComment::General { + id: c.id, + author: c.author.login, + author_association: Some(c.author_association), + body: c.body, + created_at: c.created_at, + url: Some(c.url), + }); + } + + for c in review_comments { + unified.push(UnifiedPrComment::Review { + id: c.id, + author: c.user.login, + author_association: Some(c.author_association), + body: c.body, + created_at: c.created_at, + url: Some(c.html_url), + path: c.path, + line: c.line, + diff_hunk: Some(c.diff_hunk), + }); + } + + // Sort by creation time + unified.sort_by_key(|c| c.created_at()); + + Ok(unified) + } + + fn provider_kind(&self) -> ProviderKind { + ProviderKind::GitHub + } +} diff --git a/crates/services/src/services/git_host/mod.rs b/crates/services/src/services/git_host/mod.rs new file mode 100644 index 00000000..3029d243 --- /dev/null +++ b/crates/services/src/services/git_host/mod.rs @@ -0,0 +1,63 @@ +mod detection; +mod types; + +pub mod azure; +pub mod github; + +use std::path::Path; + +use async_trait::async_trait; +use db::models::merge::PullRequestInfo; +use detection::detect_provider_from_url; +use enum_dispatch::enum_dispatch; +pub use types::{ + CreatePrRequest, GitHostError, PrComment, PrCommentAuthor, PrReviewComment, ProviderKind, + ReviewCommentUser, UnifiedPrComment, +}; + +use self::{azure::AzureDevOpsProvider, github::GitHubProvider}; + +#[async_trait] +#[enum_dispatch(GitHostService)] +pub trait GitHostProvider: Send + Sync { + async fn create_pr( + &self, + repo_path: &Path, + remote_url: &str, + request: &CreatePrRequest, + ) -> Result; + + async fn get_pr_status(&self, pr_url: &str) -> Result; + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError>; + + async fn get_pr_comments( + &self, + repo_path: &Path, + remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError>; + + fn provider_kind(&self) -> ProviderKind; +} + +#[enum_dispatch] +pub enum GitHostService { + GitHub(GitHubProvider), + AzureDevOps(AzureDevOpsProvider), +} + +impl GitHostService { + pub fn from_url(url: &str) -> Result { + match detect_provider_from_url(url) { + ProviderKind::GitHub => Ok(Self::GitHub(GitHubProvider::new()?)), + ProviderKind::AzureDevOps => Ok(Self::AzureDevOps(AzureDevOpsProvider::new()?)), + ProviderKind::Unknown => Err(GitHostError::UnsupportedProvider), + } + } +} diff --git a/crates/services/src/services/git_host/types.rs b/crates/services/src/services/git_host/types.rs new file mode 100644 index 00000000..a347e52d --- /dev/null +++ b/crates/services/src/services/git_host/types.rs @@ -0,0 +1,133 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + GitHub, + AzureDevOps, + Unknown, +} + +impl std::fmt::Display for ProviderKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderKind::GitHub => write!(f, "GitHub"), + ProviderKind::AzureDevOps => write!(f, "Azure DevOps"), + ProviderKind::Unknown => write!(f, "Unknown"), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreatePrRequest { + pub title: String, + pub body: Option, + pub head_branch: String, + pub base_branch: String, + pub draft: Option, +} + +#[derive(Debug, Error)] +pub enum GitHostError { + #[error("Repository error: {0}")] + Repository(String), + #[error("Pull request error: {0}")] + PullRequest(String), + #[error("Authentication failed: {0}")] + AuthFailed(String), + #[error("Insufficient permissions: {0}")] + InsufficientPermissions(String), + #[error("Repository not found or no access: {0}")] + RepoNotFoundOrNoAccess(String), + #[error("{provider} CLI is not installed or not available in PATH")] + CliNotInstalled { provider: ProviderKind }, + #[error("Unsupported git hosting provider")] + UnsupportedProvider, + #[error("CLI returned unexpected output: {0}")] + UnexpectedOutput(String), +} + +impl GitHostError { + pub fn should_retry(&self) -> bool { + !matches!( + self, + GitHostError::AuthFailed(_) + | GitHostError::InsufficientPermissions(_) + | GitHostError::RepoNotFoundOrNoAccess(_) + | GitHostError::CliNotInstalled { .. } + | GitHostError::UnsupportedProvider + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct PrCommentAuthor { + pub login: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct PrComment { + pub id: String, + pub author: PrCommentAuthor, + pub author_association: String, + pub body: String, + pub created_at: DateTime, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct ReviewCommentUser { + pub login: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct PrReviewComment { + pub id: i64, + pub user: ReviewCommentUser, + pub body: String, + pub created_at: DateTime, + pub html_url: String, + pub path: String, + pub line: Option, + pub side: Option, + pub diff_hunk: String, + pub author_association: String, +} + +#[derive(Debug, Clone, Serialize, TS)] +#[serde(tag = "comment_type", rename_all = "snake_case")] +#[ts(tag = "comment_type", rename_all = "snake_case")] +pub enum UnifiedPrComment { + General { + id: String, + author: String, + author_association: Option, + body: String, + created_at: DateTime, + url: Option, + }, + Review { + id: i64, + author: String, + author_association: Option, + body: String, + created_at: DateTime, + url: Option, + path: String, + line: Option, + diff_hunk: Option, + }, +} + +impl UnifiedPrComment { + pub fn created_at(&self) -> DateTime { + match self { + UnifiedPrComment::General { created_at, .. } => *created_at, + UnifiedPrComment::Review { created_at, .. } => *created_at, + } + } +} diff --git a/crates/services/src/services/github.rs b/crates/services/src/services/github.rs deleted file mode 100644 index 238666ff..00000000 --- a/crates/services/src/services/github.rs +++ /dev/null @@ -1,424 +0,0 @@ -use std::{path::Path, time::Duration}; - -use backon::{ExponentialBuilder, Retryable}; -use chrono::{DateTime, Utc}; -use db::models::merge::PullRequestInfo; -use serde::Serialize; -use thiserror::Error; -use tokio::task; -use tracing::info; -use ts_rs::TS; - -mod cli; - -use cli::{GhCli, GhCliError, PrComment, PrReviewComment}; -pub use cli::{PrCommentAuthor, ReviewCommentUser}; - -/// Unified PR comment that can be either a general comment or review comment -#[derive(Debug, Clone, Serialize, TS)] -#[serde(tag = "comment_type", rename_all = "snake_case")] -#[ts(tag = "comment_type", rename_all = "snake_case")] -pub enum UnifiedPrComment { - /// General PR comment (conversation) - General { - id: String, - author: String, - author_association: String, - body: String, - created_at: DateTime, - url: String, - }, - /// Inline review comment (on code) - Review { - id: i64, - author: String, - author_association: String, - body: String, - created_at: DateTime, - url: String, - path: String, - line: Option, - diff_hunk: String, - }, -} - -impl UnifiedPrComment { - fn created_at(&self) -> DateTime { - match self { - UnifiedPrComment::General { created_at, .. } => *created_at, - UnifiedPrComment::Review { created_at, .. } => *created_at, - } - } -} - -#[derive(Debug, Error)] -pub enum GitHubServiceError { - #[error("Repository error: {0}")] - Repository(String), - #[error("Pull request error: {0}")] - PullRequest(String), - #[error("GitHub authentication failed: {0}")] - AuthFailed(GhCliError), - #[error("Insufficient permissions: {0}")] - InsufficientPermissions(GhCliError), - #[error("GitHub repository not found or no access: {0}")] - RepoNotFoundOrNoAccess(GhCliError), - #[error( - "GitHub CLI is not installed or not available in PATH. Please install it from https://cli.github.com/ and authenticate with 'gh auth login'" - )] - GhCliNotInstalled(GhCliError), -} - -impl From for GitHubServiceError { - fn from(error: GhCliError) -> Self { - match &error { - GhCliError::AuthFailed(_) => Self::AuthFailed(error), - GhCliError::NotAvailable => Self::GhCliNotInstalled(error), - GhCliError::CommandFailed(msg) => { - let lower = msg.to_ascii_lowercase(); - if lower.contains("403") || lower.contains("forbidden") { - Self::InsufficientPermissions(error) - } else if lower.contains("404") || lower.contains("not found") { - Self::RepoNotFoundOrNoAccess(error) - } else { - Self::PullRequest(msg.to_string()) - } - } - GhCliError::UnexpectedOutput(msg) => Self::PullRequest(msg.to_string()), - } - } -} - -impl GitHubServiceError { - pub fn should_retry(&self) -> bool { - !matches!( - self, - GitHubServiceError::AuthFailed(_) - | GitHubServiceError::InsufficientPermissions(_) - | GitHubServiceError::RepoNotFoundOrNoAccess(_) - | GitHubServiceError::GhCliNotInstalled(_) - ) - } -} - -#[derive(Debug, Clone)] -pub struct GitHubRepoInfo { - pub owner: String, - pub repo_name: String, -} - -#[derive(Debug, Clone)] -pub struct CreatePrRequest { - pub title: String, - pub body: Option, - pub head_branch: String, - pub base_branch: String, - pub draft: Option, -} - -#[derive(Debug, Clone)] -pub struct GitHubService { - gh_cli: GhCli, -} - -impl GitHubService { - /// Create a new GitHub service with authentication - pub fn new() -> Result { - Ok(Self { - gh_cli: GhCli::new(), - }) - } - - pub async fn get_repo_info( - &self, - repo_path: &Path, - ) -> Result { - let cli = self.gh_cli.clone(); - let path = repo_path.to_path_buf(); - task::spawn_blocking(move || cli.get_repo_info(&path)) - .await - .map_err(|err| { - GitHubServiceError::Repository(format!("Failed to get repo info: {err}")) - })? - .map_err(Into::into) - } - - pub async fn check_token(&self) -> Result<(), GitHubServiceError> { - let cli = self.gh_cli.clone(); - task::spawn_blocking(move || cli.check_auth()) - .await - .map_err(|err| { - GitHubServiceError::Repository(format!( - "Failed to execute GitHub CLI for auth check: {err}" - )) - })? - .map_err(|err| match err { - GhCliError::NotAvailable => GitHubServiceError::GhCliNotInstalled(err), - GhCliError::AuthFailed(_) => GitHubServiceError::AuthFailed(err), - GhCliError::CommandFailed(msg) => { - GitHubServiceError::Repository(format!("GitHub CLI auth check failed: {msg}")) - } - GhCliError::UnexpectedOutput(msg) => GitHubServiceError::Repository(format!( - "Unexpected output from GitHub CLI auth check: {msg}" - )), - }) - } - - /// Create a pull request on GitHub - pub async fn create_pr( - &self, - repo_info: &GitHubRepoInfo, - request: &CreatePrRequest, - ) -> Result { - (|| async { self.create_pr_via_cli(repo_info, request).await }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - async fn create_pr_via_cli( - &self, - repo_info: &GitHubRepoInfo, - request: &CreatePrRequest, - ) -> Result { - let cli = self.gh_cli.clone(); - let request_clone = request.clone(); - let repo_clone = repo_info.clone(); - let cli_result = task::spawn_blocking(move || cli.create_pr(&request_clone, &repo_clone)) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for PR creation: {err}" - )) - })? - .map_err(GitHubServiceError::from)?; - - info!( - "Created GitHub PR #{} for branch {} in {}/{}", - cli_result.number, request.head_branch, repo_info.owner, repo_info.repo_name - ); - - Ok(cli_result) - } - - pub async fn update_pr_status( - &self, - pr_url: &str, - ) -> Result { - (|| async { - let cli = self.gh_cli.clone(); - let url = pr_url.to_string(); - let pr = task::spawn_blocking(move || cli.view_pr(&url)) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for viewing PR at {pr_url}: {err}" - )) - })?; - let pr = pr.map_err(GitHubServiceError::from)?; - Ok(pr) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|err: &GitHubServiceError| err.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - /// List all pull requests for a branch (including closed/merged) - pub async fn list_all_prs_for_branch( - &self, - repo_info: &GitHubRepoInfo, - branch_name: &str, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let branch = branch_name.to_string(); - let cli = self.gh_cli.clone(); - let prs = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - let branch = branch.clone(); - move || cli.list_prs_for_branch(&owner, &repo, &branch) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for listing PRs on branch '{branch_name}': {err}" - )) - })?; - let prs = prs.map_err(GitHubServiceError::from)?; - Ok(prs) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - /// Fetch all comments (both general and review) for a pull request - pub async fn get_pr_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - // Fetch both types of comments in parallel - let (general_result, review_result) = tokio::join!( - self.fetch_general_comments(repo_info, pr_number), - self.fetch_review_comments(repo_info, pr_number) - ); - - let general_comments = general_result?; - let review_comments = review_result?; - - // Convert and merge into unified timeline - let mut unified: Vec = Vec::new(); - - for c in general_comments { - unified.push(UnifiedPrComment::General { - id: c.id, - author: c.author.login, - author_association: c.author_association, - body: c.body, - created_at: c.created_at, - url: c.url, - }); - } - - for c in review_comments { - unified.push(UnifiedPrComment::Review { - id: c.id, - author: c.user.login, - author_association: c.author_association, - body: c.body, - created_at: c.created_at, - url: c.html_url, - path: c.path, - line: c.line, - diff_hunk: c.diff_hunk, - }); - } - - // Sort by creation time - unified.sort_by_key(|c| c.created_at()); - - Ok(unified) - } - - async fn fetch_general_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let cli = self.gh_cli.clone(); - let comments = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - move || cli.get_pr_comments(&owner, &repo, pr_number) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for fetching PR #{pr_number} comments: {err}" - )) - })?; - comments.map_err(GitHubServiceError::from) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - async fn fetch_review_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let cli = self.gh_cli.clone(); - let comments = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - move || cli.get_pr_review_comments(&owner, &repo, pr_number) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for fetching PR #{pr_number} review comments: {err}" - )) - })?; - comments.map_err(GitHubServiceError::from) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } -} diff --git a/crates/services/src/services/mod.rs b/crates/services/src/services/mod.rs index 9cf85c98..0498f1e4 100644 --- a/crates/services/src/services/mod.rs +++ b/crates/services/src/services/mod.rs @@ -10,7 +10,7 @@ pub mod file_search_cache; pub mod filesystem; pub mod filesystem_watcher; pub mod git; -pub mod github; +pub mod git_host; pub mod image; pub mod notification; pub mod oauth_credentials; diff --git a/crates/services/src/services/pr_monitor.rs b/crates/services/src/services/pr_monitor.rs index e2724240..75e6b91a 100644 --- a/crates/services/src/services/pr_monitor.rs +++ b/crates/services/src/services/pr_monitor.rs @@ -16,21 +16,21 @@ use tracing::{debug, error, info}; use crate::services::{ analytics::AnalyticsContext, - github::{GitHubService, GitHubServiceError}, + git_host::{self, GitHostError, GitHostProvider}, share::SharePublisher, }; #[derive(Debug, Error)] enum PrMonitorError { #[error(transparent)] - GitHubServiceError(#[from] GitHubServiceError), + GitHostError(#[from] GitHostError), #[error(transparent)] WorkspaceError(#[from] WorkspaceError), #[error(transparent)] Sqlx(#[from] SqlxError), } -/// Service to monitor GitHub PRs and update task status when they are merged +/// Service to monitor PRs and update task status when they are merged pub struct PrMonitorService { db: DBService, poll_interval: Duration, @@ -95,12 +95,8 @@ impl PrMonitorService { /// Check the status of a specific PR async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> { - // GitHubService now uses gh CLI, no token needed - let github_service = GitHubService::new()?; - - let pr_status = github_service - .update_pr_status(&pr_merge.pr_info.url) - .await?; + let git_host = git_host::GitHostService::from_url(&pr_merge.pr_info.url)?; + let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?; debug!( "PR #{} status: {:?} (was open)", @@ -109,7 +105,7 @@ impl PrMonitorService { // Update the PR status in the database if !matches!(&pr_status.status, MergeStatus::Open) { - // Update merge status with the latest information from GitHub + // Update merge status with the latest information from git host Merge::update_status( &self.db.pool, pr_merge.id, diff --git a/crates/services/src/services/share.rs b/crates/services/src/services/share.rs index 92172faf..6f57b2b3 100644 --- a/crates/services/src/services/share.rs +++ b/crates/services/src/services/share.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ RemoteClientError, - services::{git::GitServiceError, github::GitHubServiceError}, + services::{git::GitServiceError, git_host::GitHostError}, }; #[derive(Debug, Error)] @@ -39,7 +39,7 @@ pub enum ShareError { #[error(transparent)] Git(#[from] GitServiceError), #[error(transparent)] - GitHub(#[from] GitHubServiceError), + GitHost(#[from] GitHostError), #[error("share authentication missing or expired")] MissingAuth, #[error("invalid user ID format")] diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index 639ce66c..f781f2a8 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -177,19 +177,25 @@ const CreatePRDialogImpl = NiceModal.create( if (result.error) { if ( - result.error.type === 'github_cli_not_installed' || - result.error.type === 'github_cli_not_logged_in' + result.error.type === 'cli_not_installed' || + result.error.type === 'cli_not_logged_in' ) { - if (isMacEnvironment) { + // Only show setup dialog for GitHub CLI on Mac + if (result.error.provider === 'git_hub' && isMacEnvironment) { await showGhCliSetupDialog(); } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); + const providerName = + result.error.provider === 'git_hub' + ? 'GitHub' + : result.error.provider === 'azure_dev_ops' + ? 'Azure DevOps' + : 'Git host'; + const action = + result.error.type === 'cli_not_installed' + ? 'not installed' + : 'not logged in'; + setError(`${providerName} CLI is ${action}`); + setGhCliHelp(null); } return; } else if ( diff --git a/frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx b/frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx similarity index 86% rename from frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx rename to frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx index 0e0af2a8..613f0e82 100644 --- a/frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx +++ b/frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx @@ -14,15 +14,15 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { MessageSquare, AlertCircle, Loader2 } from 'lucide-react'; import { usePrComments } from '@/hooks/usePrComments'; -import { GitHubCommentCard } from '@/components/ui/github-comment-card'; +import { PrCommentCard } from '@/components/ui/pr-comment-card'; import type { UnifiedPrComment } from 'shared/types'; -export interface GitHubCommentsDialogProps { +export interface PrCommentsDialogProps { attemptId: string; repoId: string; } -export interface GitHubCommentsDialogResult { +export interface PrCommentsDialogResult { comments: UnifiedPrComment[]; } @@ -32,7 +32,7 @@ function getCommentId(comment: UnifiedPrComment): string { : comment.id.toString(); } -const GitHubCommentsDialogImpl = NiceModal.create( +const PrCommentsDialogImpl = NiceModal.create( ({ attemptId, repoId }) => { const { t } = useTranslation(['tasks', 'common']); const modal = useModal(); @@ -109,7 +109,7 @@ const GitHubCommentsDialogImpl = NiceModal.create( - {t('tasks:githubComments.dialog.title')} + {t('tasks:prComments.dialog.title')} @@ -126,13 +126,13 @@ const GitHubCommentsDialogImpl = NiceModal.create( ) : comments.length === 0 ? (

- {t('tasks:githubComments.dialog.noComments')} + {t('tasks:prComments.dialog.noComments')}

) : ( <>
- {t('tasks:githubComments.dialog.selectedCount', { + {t('tasks:prComments.dialog.selectedCount', { selected: selectedIds.size, total: comments.length, })} @@ -143,8 +143,8 @@ const GitHubCommentsDialogImpl = NiceModal.create( onClick={isAllSelected ? deselectAll : selectAll} > {isAllSelected - ? t('tasks:githubComments.dialog.deselectAll') - : t('tasks:githubComments.dialog.selectAll')} + ? t('tasks:prComments.dialog.deselectAll') + : t('tasks:prComments.dialog.selectAll')}
@@ -160,7 +160,7 @@ const GitHubCommentsDialogImpl = NiceModal.create( onCheckedChange={() => toggleSelection(id)} className="mt-3" /> - ( {t('common:buttons.cancel')} @@ -219,17 +219,17 @@ function getErrorMessage(error: unknown): string { if (errorData?.type === 'no_pr_attached') { return 'No PR is attached to this task attempt. Create a PR first to see comments.'; } - if (errorData?.type === 'github_cli_not_installed') { - return 'GitHub CLI is not installed. Please install it to fetch PR comments.'; + if (errorData?.type === 'cli_not_installed') { + return 'CLI is not installed. Please install it to fetch PR comments.'; } - if (errorData?.type === 'github_cli_not_logged_in') { - return 'GitHub CLI is not logged in. Please run "gh auth login" to authenticate.'; + if (errorData?.type === 'cli_not_logged_in') { + return 'CLI is not logged in. Please authenticate to fetch PR comments.'; } } return 'Failed to load PR comments. Please try again.'; } -export const GitHubCommentsDialog = defineModal< - GitHubCommentsDialogProps, - GitHubCommentsDialogResult ->(GitHubCommentsDialogImpl); +export const PrCommentsDialog = defineModal< + PrCommentsDialogProps, + PrCommentsDialogResult +>(PrCommentsDialogImpl); diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index 4b776b76..c35f6420 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -60,8 +60,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { queueApi } from '@/lib/api'; import type { QueueStatus } from 'shared/types'; import { imagesApi, attemptsApi } from '@/lib/api'; -import { GitHubCommentsDialog } from '@/components/dialogs/tasks/GitHubCommentsDialog'; -import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/github-comment-node'; +import { PrCommentsDialog } from '@/components/dialogs/tasks/PrCommentsDialog'; +import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/pr-comment-node'; import type { Session } from 'shared/types'; interface TaskFollowUpSectionProps { @@ -574,13 +574,13 @@ export function TaskFollowUpSection({ [handlePasteFiles] ); - // Handler for GitHub comments insertion - const handleGitHubCommentClick = useCallback(async () => { + // Handler for PR comments insertion + const handlePrCommentClick = useCallback(async () => { if (!workspaceId) return; const repoId = getSelectedRepoId(); if (!repoId) return; - const result = await GitHubCommentsDialog.show({ + const result = await PrCommentsDialog.show({ attemptId: workspaceId, repoId, }); @@ -829,14 +829,14 @@ export function TaskFollowUpSection({ - {/* GitHub Comments button */} + {/* PR Comments button */} diff --git a/frontend/src/components/ui/github-comment-card.tsx b/frontend/src/components/ui/pr-comment-card.tsx similarity index 92% rename from frontend/src/components/ui/github-comment-card.tsx rename to frontend/src/components/ui/pr-comment-card.tsx index 24af811d..88725cd2 100644 --- a/frontend/src/components/ui/github-comment-card.tsx +++ b/frontend/src/components/ui/pr-comment-card.tsx @@ -2,16 +2,16 @@ import { MessageSquare, Code, ExternalLink } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -export interface GitHubCommentCardProps { +export interface PrCommentCardProps { author: string; body: string; createdAt: string; - url: string; + url?: string | null; // Optional review-specific fields commentType?: 'general' | 'review'; path?: string; line?: number | null; - diffHunk?: string; + diffHunk?: string | null; /** Display variant: 'compact' for inline chip, 'full' for inline card, 'list' for block card */ variant: 'compact' | 'full' | 'list'; onClick?: (e: React.MouseEvent) => void; @@ -71,7 +71,7 @@ function CompactCard({ onClick, onDoubleClick, className, -}: GitHubCommentCardProps) { +}: PrCommentCardProps) { const { t } = useTranslation('tasks'); const isReview = commentType === 'review'; const Icon = isReview ? Code : MessageSquare; @@ -87,7 +87,7 @@ function CompactCard({ onDoubleClick={onDoubleClick} role="button" tabIndex={0} - title={`@${author}: ${body}\n\n${t('githubComments.card.tooltip')}`} + title={`@${author}: ${body}\n\n${t('prComments.card.tooltip')}`} > @{author} @@ -113,7 +113,7 @@ function FullCard({ onClick, variant, className, -}: GitHubCommentCardProps) { +}: PrCommentCardProps) { const { t } = useTranslation('tasks'); const isReview = commentType === 'review'; const Icon = isReview ? Code : MessageSquare; @@ -136,7 +136,7 @@ function FullCard({ @{author} {isReview && ( - {t('githubComments.card.review')} + {t('prComments.card.review')} )}
@@ -150,7 +150,7 @@ function FullCard({ window.open(url, '_blank', 'noopener,noreferrer'); }} className="hover:text-foreground transition-colors" - aria-label="Open in GitHub" + aria-label="Open in browser" > @@ -178,11 +178,11 @@ function FullCard({ } /** - * GitHubCommentCard - Shared presentational component for GitHub PR comments + * PrCommentCard - Shared presentational component for PR comments * * @param variant - 'compact' for inline chip, 'full' for inline card, 'list' for block card */ -export function GitHubCommentCard(props: GitHubCommentCardProps) { +export function PrCommentCard(props: PrCommentCardProps) { if (props.variant === 'compact') { return ; } diff --git a/frontend/src/components/ui/wysiwyg.tsx b/frontend/src/components/ui/wysiwyg.tsx index 3882b9a8..e57a9c81 100644 --- a/frontend/src/components/ui/wysiwyg.tsx +++ b/frontend/src/components/ui/wysiwyg.tsx @@ -8,10 +8,10 @@ import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPl import { TRANSFORMERS, type Transformer } from '@lexical/markdown'; import { ImageNode, IMAGE_TRANSFORMER } from './wysiwyg/nodes/image-node'; import { - GitHubCommentNode, - GITHUB_COMMENT_TRANSFORMER, - GITHUB_COMMENT_EXPORT_TRANSFORMER, -} from './wysiwyg/nodes/github-comment-node'; + PrCommentNode, + PR_COMMENT_TRANSFORMER, + PR_COMMENT_EXPORT_TRANSFORMER, +} from './wysiwyg/nodes/pr-comment-node'; import { CODE_BLOCK_TRANSFORMER } from './wysiwyg/transformers/code-block-transformer'; import { TABLE_TRANSFORMER } from './wysiwyg/transformers/table-transformer'; import { @@ -161,7 +161,7 @@ function WYSIWYGEditor({ CodeHighlightNode, LinkNode, ImageNode, - GitHubCommentNode, + PrCommentNode, TableNode, TableRowNode, TableCellNode, @@ -170,13 +170,13 @@ function WYSIWYGEditor({ [] ); - // Extended transformers with image, GitHub comment, and code block support (memoized to prevent unnecessary re-renders) + // Extended transformers with image, PR comment, and code block support (memoized to prevent unnecessary re-renders) const extendedTransformers: Transformer[] = useMemo( () => [ TABLE_TRANSFORMER, IMAGE_TRANSFORMER, - GITHUB_COMMENT_EXPORT_TRANSFORMER, // Export transformer for DecoratorNode (must be before import transformer) - GITHUB_COMMENT_TRANSFORMER, // Import transformer for fenced code block + PR_COMMENT_EXPORT_TRANSFORMER, // Export transformer for DecoratorNode (must be before import transformer) + PR_COMMENT_TRANSFORMER, // Import transformer for fenced code block CODE_BLOCK_TRANSFORMER, ...TRANSFORMERS, ], diff --git a/frontend/src/components/ui/wysiwyg/nodes/github-comment-node.tsx b/frontend/src/components/ui/wysiwyg/nodes/pr-comment-node.tsx similarity index 68% rename from frontend/src/components/ui/wysiwyg/nodes/github-comment-node.tsx rename to frontend/src/components/ui/wysiwyg/nodes/pr-comment-node.tsx index 98173de6..a7f5ab51 100644 --- a/frontend/src/components/ui/wysiwyg/nodes/github-comment-node.tsx +++ b/frontend/src/components/ui/wysiwyg/nodes/pr-comment-node.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { NodeKey, SerializedLexicalNode, Spread } from 'lexical'; -import { GitHubCommentCard } from '@/components/ui/github-comment-card'; +import { PrCommentCard } from '@/components/ui/pr-comment-card'; import { createDecoratorNode, type DecoratorNodeConfig, @@ -17,19 +17,19 @@ export interface NormalizedComment { author: string; body: string; created_at: string; - url: string; + url?: string | null; // Review-specific (optional) path?: string; line?: number | null; - diff_hunk?: string; + diff_hunk?: string | null; } -export type SerializedGitHubCommentNode = Spread< +export type SerializedPrCommentNode = Spread< NormalizedComment, SerializedLexicalNode >; -function GitHubCommentComponent({ +function PrCommentComponent({ data, onDoubleClickEdit, }: { @@ -41,14 +41,16 @@ function GitHubCommentComponent({ (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - // Open GitHub URL in new tab - window.open(data.url, '_blank', 'noopener,noreferrer'); + // Open URL in new tab if available + if (data.url) { + window.open(data.url, '_blank', 'noopener,noreferrer'); + } }, [data.url] ); return ( - = { serialize: (data) => JSON.stringify(data, null, 2), deserialize: (content) => JSON.parse(content), validate: (data) => - !!(data.id && data.comment_type && data.author && data.body && data.url), + !!(data.id && data.comment_type && data.author && data.body), }, - component: GitHubCommentComponent, + component: PrCommentComponent, exportDOM: (data) => { const span = document.createElement('span'); - span.setAttribute('data-github-comment-id', data.id); - span.textContent = `GitHub comment by @${data.author}: ${data.body}`; + span.setAttribute('data-pr-comment-id', data.id); + span.textContent = `PR comment by @${data.author}: ${data.body}`; return span; }, }; const result = createDecoratorNode(config); -export const GitHubCommentNode = result.Node; -export type GitHubCommentNodeInstance = - GeneratedDecoratorNode; -export const $createGitHubCommentNode = result.createNode; -export const $isGitHubCommentNode = result.isNode; -export const [GITHUB_COMMENT_EXPORT_TRANSFORMER, GITHUB_COMMENT_TRANSFORMER] = +export const PrCommentNode = result.Node; +export type PrCommentNodeInstance = GeneratedDecoratorNode; +export const $createPrCommentNode = result.createNode; +export const $isPrCommentNode = result.isNode; +export const [PR_COMMENT_EXPORT_TRANSFORMER, PR_COMMENT_TRANSFORMER] = result.transformers; diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 4647352e..148e164e 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -496,10 +496,10 @@ "description": "You need to sign in before you can share tasks. We will redirect you to the sign-in page.", "action": "Go to sign in" }, - "githubRequired": { - "title": "Connect GitHub", - "description": "Connect your GitHub account to share tasks. This project must have a remote repository on GitHub.", - "action": "Connect GitHub" + "gitProviderRequired": { + "title": "Connect Git Provider", + "description": "Connect your Git provider account to share tasks. This project must have a remote repository.", + "action": "Connect" }, "linkProjectRequired": { "description": "Link this project to an organization before sharing tasks.", @@ -513,8 +513,8 @@ "closeButton": "Close" }, "createPrDialog": { - "title": "Create GitHub Pull Request", - "description": "Create a pull request for this task attempt on GitHub.", + "title": "Create Pull Request", + "description": "Create a pull request for this task attempt.", "titleLabel": "Title", "titlePlaceholder": "Enter PR title", "descriptionLabel": "Description (optional)", @@ -527,9 +527,9 @@ "creating": "Creating...", "createButton": "Create PR", "errors": { - "insufficientPermissions": "Insufficient permissions. Please ensure the GitHub CLI has the necessary permissions.", + "insufficientPermissions": "Insufficient permissions. Please ensure the CLI has the necessary permissions.", "repoNotFoundOrNoAccess": "Repository not found or no access. Please check your repository access and ensure you are authenticated.", - "failedToCreate": "Failed to create GitHub PR", + "failedToCreate": "Failed to create PR", "gitCliNotLoggedIn": "Git is not authenticated. Run \"gh auth login\" (or configure Git credentials) and try again.", "gitCliNotInstalled": "Git CLI is not installed. Install Git to create a PR.", "targetBranchNotFound": "Target branch '{{branch}}' does not exist on remote. Please ensure the branch exists before creating a pull request." @@ -569,9 +569,9 @@ "finish": "Finish" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "Select GitHub Comments", + "title": "Select PR Comments", "noComments": "No comments found on this PR", "selectAll": "Select All", "deselectAll": "Deselect All", @@ -580,7 +580,7 @@ }, "card": { "review": "Review", - "tooltip": "Click to view on GitHub, double-click to edit" + "tooltip": "Click to view, double-click to edit" } }, "taskFormDialog": { diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index bd3904cc..594619a5 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -97,10 +97,10 @@ "description": "Debes iniciar sesión antes de poder compartir tareas. Te redirigiremos a la página de inicio de sesión.", "action": "Ir a iniciar sesión" }, - "githubRequired": { - "title": "Conecta GitHub", - "description": "Conecta tu cuenta de GitHub para que podamos publicar tareas compartidas por ti.", - "action": "Conectar GitHub" + "gitProviderRequired": { + "title": "Conectar proveedor de Git", + "description": "Conecta tu cuenta de proveedor de Git para compartir tareas. Este proyecto debe tener un repositorio remoto.", + "action": "Conectar" }, "linkProjectRequired": { "description": "Vincula este proyecto a una organización antes de compartir tareas.", @@ -114,8 +114,8 @@ "closeButton": "Cerrar" }, "createPrDialog": { - "title": "Crear Pull Request de GitHub", - "description": "Crea un pull request para este intento de tarea en GitHub.", + "title": "Crear Pull Request", + "description": "Crea un pull request para este intento de tarea.", "titleLabel": "Título", "titlePlaceholder": "Ingresar título del PR", "descriptionLabel": "Descripción (opcional)", @@ -128,9 +128,9 @@ "creating": "Creando...", "createButton": "Crear PR", "errors": { - "insufficientPermissions": "Permisos insuficientes. Por favor asegúrate de que la CLI de GitHub tenga los permisos necesarios.", + "insufficientPermissions": "Permisos insuficientes. Por favor asegúrate de que la CLI tenga los permisos necesarios.", "repoNotFoundOrNoAccess": "Repositorio no encontrado o sin acceso. Por favor verifica el acceso al repositorio y asegúrate de estar autenticado.", - "failedToCreate": "Error al crear PR de GitHub", + "failedToCreate": "Error al crear PR", "gitCliNotLoggedIn": "Git no está autenticado. Ejecuta \"gh auth login\" (o configura las credenciales de Git) e inténtalo de nuevo.", "gitCliNotInstalled": "Git CLI no está instalado. Instala Git para crear una PR.", "targetBranchNotFound": "La rama objetivo '{{branch}}' no existe en el remoto. Por favor, asegúrese de que la rama exista antes de crear una solicitud de extracción." @@ -503,9 +503,9 @@ "finish": "Finalizar" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "Seleccionar comentarios de GitHub", + "title": "Seleccionar comentarios del PR", "noComments": "No se encontraron comentarios en este PR", "selectAll": "Seleccionar todo", "deselectAll": "Deseleccionar todo", @@ -514,7 +514,7 @@ }, "card": { "review": "Revisión", - "tooltip": "Clic para ver en GitHub, doble clic para editar" + "tooltip": "Clic para ver, doble clic para editar" } }, "taskFormDialog": { diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index f6b8a84a..49f8b833 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -97,10 +97,10 @@ "description": "タスクを共有する前にサインインが必要です。サインインページへリダイレクトします。", "action": "サインインへ移動" }, - "githubRequired": { - "title": "GitHub を接続", - "description": "共有タスクを公開できるよう、GitHub アカウントを接続してください。", - "action": "GitHub を接続" + "gitProviderRequired": { + "title": "Git プロバイダーを接続", + "description": "タスクを共有するには、Git プロバイダーアカウントを接続してください。このプロジェクトにはリモートリポジトリが必要です。", + "action": "接続" }, "linkProjectRequired": { "description": "タスクを共有する前に、このプロジェクトを組織にリンクしてください。", @@ -114,8 +114,8 @@ "closeButton": "閉じる" }, "createPrDialog": { - "title": "GitHub プルリクエストを作成", - "description": "このタスク試行のプルリクエストをGitHubで作成します。", + "title": "プルリクエストを作成", + "description": "このタスク試行のプルリクエストを作成します。", "titleLabel": "タイトル", "titlePlaceholder": "PRタイトルを入力", "descriptionLabel": "説明 (オプション)", @@ -128,9 +128,9 @@ "creating": "作成中...", "createButton": "PRを作成", "errors": { - "insufficientPermissions": "権限が不足しています。GitHub CLIに必要な権限があることを確認してください。", + "insufficientPermissions": "権限が不足しています。CLIに必要な権限があることを確認してください。", "repoNotFoundOrNoAccess": "リポジトリが見つからないか、アクセス権がありません。リポジトリへのアクセス権を確認し、認証されていることを確認してください。", - "failedToCreate": "GitHub PRの作成に失敗しました", + "failedToCreate": "PRの作成に失敗しました", "gitCliNotLoggedIn": "Gitが認証されていません。\"gh auth login\" を実行するかGitの認証情報を設定してから再試行してください。", "gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。", "targetBranchNotFound": "ターゲットブランチ '{{branch}}' がリモートに存在しません。プルリクエストを作成する前にブランチが存在することを確認してください。" @@ -503,9 +503,9 @@ "finish": "完了" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "GitHubコメントを選択", + "title": "PRコメントを選択", "noComments": "このPRにコメントはありません", "selectAll": "すべて選択", "deselectAll": "すべて選択解除", @@ -514,7 +514,7 @@ }, "card": { "review": "レビュー", - "tooltip": "クリックでGitHubで表示、ダブルクリックで編集" + "tooltip": "クリックで表示、ダブルクリックで編集" } }, "taskFormDialog": { diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index a1ade51e..c3c9e0e8 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -97,10 +97,10 @@ "description": "작업을 공유하려면 로그인해야 합니다. 로그인 페이지로 이동합니다.", "action": "로그인으로 이동" }, - "githubRequired": { - "title": "GitHub 연결", - "description": "공유 작업을 게시하려면 GitHub 계정을 연결하세요.", - "action": "GitHub 연결" + "gitProviderRequired": { + "title": "Git 제공자 연결", + "description": "작업을 공유하려면 Git 제공자 계정을 연결하세요. 이 프로젝트에는 원격 저장소가 있어야 합니다.", + "action": "연결" }, "linkProjectRequired": { "description": "작업을 공유하기 전에 이 프로젝트를 조직에 연결하세요.", @@ -114,8 +114,8 @@ "closeButton": "닫기" }, "createPrDialog": { - "title": "GitHub Pull Request 생성", - "description": "이 작업 시도에 대한 Pull Request를 GitHub에서 생성합니다.", + "title": "Pull Request 생성", + "description": "이 작업 시도에 대한 Pull Request를 생성합니다.", "titleLabel": "제목", "titlePlaceholder": "PR 제목 입력", "descriptionLabel": "설명 (선택사항)", @@ -128,9 +128,9 @@ "creating": "생성 중...", "createButton": "PR 생성", "errors": { - "insufficientPermissions": "권한이 부족합니다. GitHub CLI에 필요한 권한이 있는지 확인하세요.", + "insufficientPermissions": "권한이 부족합니다. CLI에 필요한 권한이 있는지 확인하세요.", "repoNotFoundOrNoAccess": "저장소를 찾을 수 없거나 액세스 권한이 없습니다. 저장소 액세스를 확인하고 인증되었는지 확인하세요.", - "failedToCreate": "GitHub PR 생성에 실패했습니다", + "failedToCreate": "PR 생성에 실패했습니다", "gitCliNotLoggedIn": "Git이 인증되지 않았습니다. \"gh auth login\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.", "gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요.", "targetBranchNotFound": "대상 브랜치 '{{branch}}'이(가) 원격에 존재하지 않습니다. 풀 리퀘스트를 생성하기 전에 브랜치가 존재하는지 확인하세요." @@ -503,9 +503,9 @@ "finish": "완료" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "GitHub 댓글 선택", + "title": "PR 댓글 선택", "noComments": "이 PR에 댓글이 없습니다", "selectAll": "모두 선택", "deselectAll": "모두 선택 해제", @@ -514,7 +514,7 @@ }, "card": { "review": "리뷰", - "tooltip": "클릭하여 GitHub에서 보기, 더블 클릭하여 편집" + "tooltip": "클릭하여 보기, 더블 클릭하여 편집" } }, "taskFormDialog": { diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index f8ce4599..e0d48463 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -430,10 +430,10 @@ "description": "您需要登录才能共享任务。我们将重定向您到登录页面。", "action": "前往登录" }, - "githubRequired": { - "title": "连接 GitHub", - "description": "连接您的 GitHub 账户以共享任务。此项目必须在 GitHub 上有远程仓库。", - "action": "连接 GitHub" + "gitProviderRequired": { + "title": "连接 Git 提供商", + "description": "连接您的 Git 提供商账户以共享任务。此项目必须有远程仓库。", + "action": "连接" }, "linkProjectRequired": { "description": "在共享任务之前,将此项目链接到组织。", @@ -447,8 +447,8 @@ "closeButton": "关闭" }, "createPrDialog": { - "title": "创建 GitHub 拉取请求", - "description": "在 GitHub 上为此任务尝试创建拉取请求。", + "title": "创建拉取请求", + "description": "为此任务尝试创建拉取请求。", "titleLabel": "标题", "titlePlaceholder": "输入 PR 标题", "descriptionLabel": "描述(可选)", @@ -461,9 +461,9 @@ "creating": "创建中...", "createButton": "创建 PR", "errors": { - "insufficientPermissions": "权限不足。请确保 GitHub CLI 具有必要的权限。", + "insufficientPermissions": "权限不足。请确保 CLI 具有必要的权限。", "repoNotFoundOrNoAccess": "未找到仓库或无访问权限。请检查您的仓库访问权限并确保您已通过身份验证。", - "failedToCreate": "创建 GitHub PR 失败", + "failedToCreate": "创建 PR 失败", "gitCliNotLoggedIn": "Git 未通过身份验证。运行 gh auth login(或配置 Git 凭据)然后重试。", "gitCliNotInstalled": "未安装 Git CLI。安装 Git 以创建 PR。", "targetBranchNotFound": "远程上不存在目标分支 {{branch}}。请在创建拉取请求之前确保该分支存在。" @@ -503,9 +503,9 @@ "finish": "完成" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "选择 GitHub 评论", + "title": "选择 PR 评论", "noComments": "此 PR 未找到评论", "selectAll": "全选", "deselectAll": "取消全选", @@ -514,7 +514,7 @@ }, "card": { "review": "审查", - "tooltip": "点击在 GitHub 上查看,双击编辑" + "tooltip": "点击查看,双击编辑" } }, "taskFormDialog": { diff --git a/frontend/src/i18n/locales/zh-Hant/tasks.json b/frontend/src/i18n/locales/zh-Hant/tasks.json index 63f69132..d9a99dfb 100644 --- a/frontend/src/i18n/locales/zh-Hant/tasks.json +++ b/frontend/src/i18n/locales/zh-Hant/tasks.json @@ -430,10 +430,10 @@ "description": "您需要登入才能分享任務。我們會將您導向登入頁面。", "action": "前往登入" }, - "githubRequired": { - "title": "連結 GitHub", - "description": "連結您的 GitHub 帳號以分享任務。此專案必須在 GitHub 上有遠端儲存庫。", - "action": "連結 GitHub" + "gitProviderRequired": { + "title": "連結 Git 提供者", + "description": "連結您的 Git 提供者帳號以分享任務。此專案必須有遠端儲存庫。", + "action": "連結" }, "linkProjectRequired": { "description": "分享任務前,請先將此專案連結到組織。", @@ -447,8 +447,8 @@ "closeButton": "關閉" }, "createPrDialog": { - "title": "建立 GitHub PR", - "description": "在 GitHub 上為此任務嘗試建立 PR。", + "title": "建立 PR", + "description": "為此任務嘗試建立 PR。", "titleLabel": "標題", "titlePlaceholder": "輸入 PR 標題", "descriptionLabel": "描述(選填)", @@ -461,9 +461,9 @@ "creating": "建立中...", "createButton": "建立 PR", "errors": { - "insufficientPermissions": "權限不足。請確認 GitHub CLI 具有必要權限。", + "insufficientPermissions": "權限不足。請確認 CLI 具有必要權限。", "repoNotFoundOrNoAccess": "找不到儲存庫或沒有存取權。請檢查儲存庫權限並確保已完成驗證。", - "failedToCreate": "建立 GitHub PR 失敗", + "failedToCreate": "建立 PR 失敗", "gitCliNotLoggedIn": "Git 尚未驗證。請執行 gh auth login(或設定 Git 憑證)後重試。", "gitCliNotInstalled": "未安裝 Git CLI。請安裝 Git 以建立 PR。", "targetBranchNotFound": "遠端不存在目標分支 {{branch}}。建立 PR 前請確認該分支存在。" @@ -503,9 +503,9 @@ "finish": "完成" } }, - "githubComments": { + "prComments": { "dialog": { - "title": "選擇 GitHub 評論", + "title": "選擇 PR 評論", "noComments": "此 PR 沒有評論", "selectAll": "全選", "deselectAll": "取消全選", @@ -514,7 +514,7 @@ }, "card": { "review": "審查", - "tooltip": "點擊在 GitHub 上查看,雙擊編輯" + "tooltip": "點擊查看,雙擊編輯" } }, "taskFormDialog": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1031414a..152358a3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,7 +6,7 @@ import { Config, CreateFollowUpAttempt, EditorType, - CreateGitHubPrRequest, + CreatePrApiRequest, CreateTask, CreateAndStartTaskRequest, CreateTaskAttemptBody, @@ -71,7 +71,7 @@ import { ListInvitationsResponse, OpenEditorResponse, OpenEditorRequest, - CreatePrError, + PrError, Scratch, ScratchType, CreateScratch, @@ -718,13 +718,13 @@ export const attemptsApi = { createPR: async ( attemptId: string, - data: CreateGitHubPrRequest - ): Promise> => { + data: CreatePrApiRequest + ): Promise> => { const response = await makeRequest(`/api/task-attempts/${attemptId}/pr`, { method: 'POST', body: JSON.stringify(data), }); - return handleApiResponseAsResult(response); + return handleApiResponseAsResult(response); }, startDevServer: async (attemptId: string): Promise => { diff --git a/shared/types.ts b/shared/types.ts index 2334887c..7c3004eb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -242,7 +242,7 @@ export type ShareTaskResponse = { shared_task_id: string, }; export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: ExecutorProfileId, repos: Array, }; -export type CreateGitHubPrRequest = { title: string, body: string | null, target_branch: string | null, draft: boolean | null, repo_id: string, auto_generate_description: boolean, }; +export type CreatePrApiRequest = { title: string, body: string | null, target_branch: string | null, draft: boolean | null, repo_id: string, auto_generate_description: boolean, }; export type ImageResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; @@ -266,7 +266,7 @@ export type GitOperationError = { "type": "merge_conflicts", message: string, op export type PushError = { "type": "force_push_required" }; -export type CreatePrError = { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "target_branch_not_found", branch: string, }; +export type PrError = { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "cli_not_logged_in", provider: ProviderKind, } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "target_branch_not_found", branch: string, } | { "type": "unsupported_provider" }; export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, /** @@ -292,11 +292,13 @@ export type AttachExistingPrRequest = { repo_id: string, }; export type PrCommentsResponse = { comments: Array, }; -export type GetPrCommentsError = { "type": "no_pr_attached" } | { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" }; +export type GetPrCommentsError = { "type": "no_pr_attached" } | { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "cli_not_logged_in", provider: ProviderKind, }; export type GetPrCommentsQuery = { repo_id: string, }; -export type UnifiedPrComment = { "comment_type": "general", id: string, author: string, author_association: string, body: string, created_at: string, url: string, } | { "comment_type": "review", id: bigint, author: string, author_association: string, body: string, created_at: string, url: string, path: string, line: bigint | null, diff_hunk: string, }; +export type UnifiedPrComment = { "comment_type": "general", id: string, author: string, author_association: string | null, body: string, created_at: string, url: string | null, } | { "comment_type": "review", id: bigint, author: string, author_association: string | null, body: string, created_at: string, url: string | null, path: string, line: bigint | null, diff_hunk: string | null, }; + +export type ProviderKind = "git_hub" | "azure_dev_ops" | "unknown"; export type RepoBranchStatus = { repo_id: string, repo_name: string, commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, /** @@ -553,7 +555,7 @@ export type PatchType = { "type": "NORMALIZED_ENTRY", "content": NormalizedEntry export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; -export const DEFAULT_PR_DESCRIPTION_PROMPT = `Update the GitHub PR that was just created with a better title and description. +export const DEFAULT_PR_DESCRIPTION_PROMPT = `Update the PR that was just created with a better title and description. The PR number is #{pr_number} and the URL is {pr_url}. Analyze the changes in this branch and write: @@ -564,4 +566,4 @@ Analyze the changes in this branch and write: - Any important implementation details - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" -Use \`gh pr edit\` to update the PR.`; \ No newline at end of file +Use the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps).`; \ No newline at end of file