Add Azure DevOps repository support for PR operations (Vibe Kanban) (#1808)
* 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<AzPrResponse>`
- `parse_pr_threads()` - now deserializes into `Vec<AzThread>`
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<String>`
- `diff_hunk: String` → `diff_hunk: Option<String>`
- `author_association: String` → `author_association: Option<String>`
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 <remote>` 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<PullRequestInfo, GitHostError>;
async fn get_pr_status(&self, pr_url: &str) -> Result<PullRequestInfo, GitHostError>;
async fn list_prs_for_branch(&self, repo_path: &Path, branch_name: &str) -> Result<Vec<PullRequestInfo>, GitHostError>;
async fn get_pr_comments(&self, repo_path: &Path, pr_number: i64) -> Result<Vec<UnifiedPrComment>, GitHostError>;
fn provider_kind(&self) -> ProviderKind;
}
pub enum GitHostService {
GitHub(GitHubProvider),
AzureDevOps(AzureDevOpsProvider),
}
impl GitHostService {
pub fn from_url(url: &str) -> Result<Self, GitHostError>
}
```
**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<AttachPrResponse>` to `ApiResponse<AttachPrResponse, PrError>`
- 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
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4788,6 +4788,7 @@ dependencies = [
|
|||||||
"db",
|
"db",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"dunce",
|
"dunce",
|
||||||
|
"enum_dispatch",
|
||||||
"executors",
|
"executors",
|
||||||
"fst",
|
"fst",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ fn generate_types_content() -> String {
|
|||||||
server::routes::shared_tasks::AssignSharedTaskRequest::decl(),
|
server::routes::shared_tasks::AssignSharedTaskRequest::decl(),
|
||||||
server::routes::tasks::ShareTaskResponse::decl(),
|
server::routes::tasks::ShareTaskResponse::decl(),
|
||||||
server::routes::tasks::CreateAndStartTaskRequest::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::ImageResponse::decl(),
|
||||||
server::routes::images::ImageMetadata::decl(),
|
server::routes::images::ImageMetadata::decl(),
|
||||||
server::routes::task_attempts::CreateTaskAttemptBody::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::AbortConflictsRequest::decl(),
|
||||||
server::routes::task_attempts::GitOperationError::decl(),
|
server::routes::task_attempts::GitOperationError::decl(),
|
||||||
server::routes::task_attempts::PushError::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::BranchStatus::decl(),
|
||||||
server::routes::task_attempts::RunScriptError::decl(),
|
server::routes::task_attempts::RunScriptError::decl(),
|
||||||
server::routes::task_attempts::DeleteWorkspaceError::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::PrCommentsResponse::decl(),
|
||||||
server::routes::task_attempts::pr::GetPrCommentsError::decl(),
|
server::routes::task_attempts::pr::GetPrCommentsError::decl(),
|
||||||
server::routes::task_attempts::pr::GetPrCommentsQuery::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::RepoBranchStatus::decl(),
|
||||||
server::routes::task_attempts::UpdateWorkspace::decl(),
|
server::routes::task_attempts::UpdateWorkspace::decl(),
|
||||||
server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(),
|
server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use services::services::{
|
|||||||
config::{ConfigError, EditorOpenError},
|
config::{ConfigError, EditorOpenError},
|
||||||
container::ContainerError,
|
container::ContainerError,
|
||||||
git::GitServiceError,
|
git::GitServiceError,
|
||||||
github::GitHubServiceError,
|
git_host::GitHostError,
|
||||||
image::ImageError,
|
image::ImageError,
|
||||||
project::ProjectServiceError,
|
project::ProjectServiceError,
|
||||||
remote_client::RemoteClientError,
|
remote_client::RemoteClientError,
|
||||||
@@ -45,7 +45,7 @@ pub enum ApiError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
GitService(#[from] GitServiceError),
|
GitService(#[from] GitServiceError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
GitHubService(#[from] GitHubServiceError),
|
GitHost(#[from] GitHostError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Deployment(#[from] DeploymentError),
|
Deployment(#[from] DeploymentError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -120,7 +120,7 @@ impl IntoResponse for ApiError {
|
|||||||
}
|
}
|
||||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError"),
|
_ => (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::Deployment(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DeploymentError"),
|
||||||
ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"),
|
ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"),
|
||||||
ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"),
|
ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"),
|
||||||
@@ -302,7 +302,7 @@ impl From<ShareError> for ApiError {
|
|||||||
"GitHub token is required to fetch repository metadata for sharing".to_string(),
|
"GitHub token is required to fetch repository metadata for sharing".to_string(),
|
||||||
),
|
),
|
||||||
ShareError::Git(err) => ApiError::GitService(err),
|
ShareError::Git(err) => ApiError::GitService(err),
|
||||||
ShareError::GitHub(err) => ApiError::GitHubService(err),
|
ShareError::GitHost(err) => ApiError::GitHost(err),
|
||||||
ShareError::MissingAuth => ApiError::Unauthorized,
|
ShareError::MissingAuth => ApiError::Unauthorized,
|
||||||
ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()),
|
ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()),
|
||||||
ShareError::InvalidOrganizationId => {
|
ShareError::InvalidOrganizationId => {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use services::services::{
|
use services::services::{
|
||||||
container::ContainerService,
|
container::ContainerService,
|
||||||
git::{ConflictOp, GitCliError, GitServiceError},
|
git::{ConflictOp, GitCliError, GitServiceError},
|
||||||
github::GitHubService,
|
|
||||||
workspace_manager::WorkspaceManager,
|
workspace_manager::WorkspaceManager,
|
||||||
};
|
};
|
||||||
use sqlx::Error as SqlxError;
|
use sqlx::Error as SqlxError;
|
||||||
@@ -526,9 +525,6 @@ pub async fn push_task_attempt_branch(
|
|||||||
) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {
|
) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {
|
||||||
let pool = &deployment.db().pool;
|
let pool = &deployment.db().pool;
|
||||||
|
|
||||||
let github_service = GitHubService::new()?;
|
|
||||||
github_service.check_token().await?;
|
|
||||||
|
|
||||||
let workspace_repo =
|
let workspace_repo =
|
||||||
WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)
|
WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)
|
||||||
.await?
|
.await?
|
||||||
@@ -547,7 +543,7 @@ pub async fn push_task_attempt_branch(
|
|||||||
|
|
||||||
match deployment
|
match deployment
|
||||||
.git()
|
.git()
|
||||||
.push_to_github(&worktree_path, &workspace.branch, false)
|
.push_to_remote(&worktree_path, &workspace.branch, false)
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(ResponseJson(ApiResponse::success(()))),
|
Ok(_) => Ok(ResponseJson(ApiResponse::success(()))),
|
||||||
Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson(
|
Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson(
|
||||||
@@ -564,9 +560,6 @@ pub async fn force_push_task_attempt_branch(
|
|||||||
) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {
|
) -> Result<ResponseJson<ApiResponse<(), PushError>>, ApiError> {
|
||||||
let pool = &deployment.db().pool;
|
let pool = &deployment.db().pool;
|
||||||
|
|
||||||
let github_service = GitHubService::new()?;
|
|
||||||
github_service.check_token().await?;
|
|
||||||
|
|
||||||
let workspace_repo =
|
let workspace_repo =
|
||||||
WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)
|
WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id)
|
||||||
.await?
|
.await?
|
||||||
@@ -585,7 +578,7 @@ pub async fn force_push_task_attempt_branch(
|
|||||||
|
|
||||||
deployment
|
deployment
|
||||||
.git()
|
.git()
|
||||||
.push_to_github(&worktree_path, &workspace.branch, true)?;
|
.push_to_remote(&worktree_path, &workspace.branch, true)?;
|
||||||
Ok(ResponseJson(ApiResponse::success(())))
|
Ok(ResponseJson(ApiResponse::success(())))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,7 +1731,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
|||||||
.route("/push/force", post(force_push_task_attempt_branch))
|
.route("/push/force", post(force_push_task_attempt_branch))
|
||||||
.route("/rebase", post(rebase_task_attempt))
|
.route("/rebase", post(rebase_task_attempt))
|
||||||
.route("/conflicts/abort", post(abort_conflicts_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/attach", post(pr::attach_existing_pr))
|
||||||
.route("/pr/comments", get(pr::get_pr_comments))
|
.route("/pr/comments", get(pr::get_pr_comments))
|
||||||
.route("/open-editor", post(open_task_attempt_in_editor))
|
.route("/open-editor", post(open_task_attempt_in_editor))
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use services::services::{
|
use services::services::{
|
||||||
container::ContainerService,
|
container::ContainerService,
|
||||||
git::{GitCliError, GitServiceError},
|
git::{GitCliError, GitServiceError},
|
||||||
github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment},
|
git_host::{
|
||||||
|
self, CreatePrRequest, GitHostError, GitHostProvider, ProviderKind, UnifiedPrComment,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use utils::response::ApiResponse;
|
use utils::response::ApiResponse;
|
||||||
@@ -33,7 +35,7 @@ use uuid::Uuid;
|
|||||||
use crate::{DeploymentImpl, error::ApiError};
|
use crate::{DeploymentImpl, error::ApiError};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||||
pub struct CreateGitHubPrRequest {
|
pub struct CreatePrApiRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub body: Option<String>,
|
pub body: Option<String>,
|
||||||
pub target_branch: Option<String>,
|
pub target_branch: Option<String>,
|
||||||
@@ -46,12 +48,13 @@ pub struct CreateGitHubPrRequest {
|
|||||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
#[ts(tag = "type", rename_all = "snake_case")]
|
#[ts(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum CreatePrError {
|
pub enum PrError {
|
||||||
GithubCliNotInstalled,
|
CliNotInstalled { provider: ProviderKind },
|
||||||
GithubCliNotLoggedIn,
|
CliNotLoggedIn { provider: ProviderKind },
|
||||||
GitCliNotLoggedIn,
|
GitCliNotLoggedIn,
|
||||||
GitCliNotInstalled,
|
GitCliNotInstalled,
|
||||||
TargetBranchNotFound { branch: String },
|
TargetBranchNotFound { branch: String },
|
||||||
|
UnsupportedProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, TS)]
|
#[derive(Debug, Serialize, TS)]
|
||||||
@@ -77,8 +80,8 @@ pub struct PrCommentsResponse {
|
|||||||
#[ts(tag = "type", rename_all = "snake_case")]
|
#[ts(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum GetPrCommentsError {
|
pub enum GetPrCommentsError {
|
||||||
NoPrAttached,
|
NoPrAttached,
|
||||||
GithubCliNotInstalled,
|
CliNotInstalled { provider: ProviderKind },
|
||||||
GithubCliNotLoggedIn,
|
CliNotLoggedIn { provider: ProviderKind },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
@@ -86,7 +89,7 @@ pub struct GetPrCommentsQuery {
|
|||||||
pub repo_id: Uuid,
|
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}.
|
The PR number is #{pr_number} and the URL is {pr_url}.
|
||||||
|
|
||||||
Analyze the changes in this branch and write:
|
Analyze the changes in this branch and write:
|
||||||
@@ -97,7 +100,7 @@ Analyze the changes in this branch and write:
|
|||||||
- Any important implementation details
|
- Any important implementation details
|
||||||
- At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)"
|
- 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(
|
async fn trigger_pr_description_follow_up(
|
||||||
deployment: &DeploymentImpl,
|
deployment: &DeploymentImpl,
|
||||||
@@ -190,11 +193,11 @@ async fn trigger_pr_description_follow_up(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_github_pr(
|
pub async fn create_pr(
|
||||||
Extension(workspace): Extension<Workspace>,
|
Extension(workspace): Extension<Workspace>,
|
||||||
State(deployment): State<DeploymentImpl>,
|
State(deployment): State<DeploymentImpl>,
|
||||||
Json(request): Json<CreateGitHubPrRequest>,
|
Json(request): Json<CreatePrApiRequest>,
|
||||||
) -> Result<ResponseJson<ApiResponse<String, CreatePrError>>, ApiError> {
|
) -> Result<ResponseJson<ApiResponse<String, PrError>>, ApiError> {
|
||||||
let pool = &deployment.db().pool;
|
let pool = &deployment.db().pool;
|
||||||
|
|
||||||
let workspace_repo =
|
let workspace_repo =
|
||||||
@@ -206,7 +209,7 @@ pub async fn create_github_pr(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(RepoError::NotFound)?;
|
.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 {
|
let target_branch = if let Some(branch) = request.target_branch {
|
||||||
branch
|
branch
|
||||||
} else {
|
} else {
|
||||||
@@ -218,7 +221,7 @@ pub async fn create_github_pr(
|
|||||||
.ensure_container_exists(&workspace)
|
.ensure_container_exists(&workspace)
|
||||||
.await?;
|
.await?;
|
||||||
let workspace_path = PathBuf::from(&container_ref);
|
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
|
match deployment
|
||||||
.git()
|
.git()
|
||||||
@@ -226,40 +229,40 @@ pub async fn create_github_pr(
|
|||||||
{
|
{
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
return Ok(ResponseJson(ApiResponse::error_with_data(
|
return Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
CreatePrError::TargetBranchNotFound {
|
PrError::TargetBranchNotFound {
|
||||||
branch: target_branch.clone(),
|
branch: target_branch.clone(),
|
||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => {
|
Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => {
|
||||||
return Ok(ResponseJson(ApiResponse::error_with_data(
|
return Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
CreatePrError::GitCliNotLoggedIn,
|
PrError::GitCliNotLoggedIn,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => {
|
Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => {
|
||||||
return Ok(ResponseJson(ApiResponse::error_with_data(
|
return Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
CreatePrError::GitCliNotInstalled,
|
PrError::GitCliNotInstalled,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => return Err(ApiError::GitService(e)),
|
Err(e) => return Err(ApiError::GitService(e)),
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push the branch to GitHub first
|
// Push the branch to remote first
|
||||||
if let Err(e) = deployment
|
if let Err(e) = deployment
|
||||||
.git()
|
.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 {
|
match e {
|
||||||
GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => {
|
GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => {
|
||||||
return Ok(ResponseJson(ApiResponse::error_with_data(
|
return Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
CreatePrError::GitCliNotLoggedIn,
|
PrError::GitCliNotLoggedIn,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
GitServiceError::GitCLI(GitCliError::NotAvailable) => {
|
GitServiceError::GitCLI(GitCliError::NotAvailable) => {
|
||||||
return Ok(ResponseJson(ApiResponse::error_with_data(
|
return Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
CreatePrError::GitCliNotInstalled,
|
PrError::GitCliNotInstalled,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
_ => return Err(ApiError::GitService(e)),
|
_ => return Err(ApiError::GitService(e)),
|
||||||
@@ -285,7 +288,29 @@ pub async fn create_github_pr(
|
|||||||
} else {
|
} else {
|
||||||
target_branch
|
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 {
|
let pr_request = CreatePrRequest {
|
||||||
title: request.title.clone(),
|
title: request.title.clone(),
|
||||||
body: request.body.clone(),
|
body: request.body.clone(),
|
||||||
@@ -293,9 +318,11 @@ pub async fn create_github_pr(
|
|||||||
base_branch: norm_target_branch_name.clone(),
|
base_branch: norm_target_branch_name.clone(),
|
||||||
draft: request.draft,
|
draft: request.draft,
|
||||||
};
|
};
|
||||||
let github_service = GitHubService::new()?;
|
|
||||||
let repo_info = github_service.get_repo_info(&repo_path).await?;
|
match git_host
|
||||||
match github_service.create_pr(&repo_info, &pr_request).await {
|
.create_pr(&repo_path, &remote_url, &pr_request)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(pr_info) => {
|
Ok(pr_info) => {
|
||||||
// Update the workspace with PR information
|
// Update the workspace with PR information
|
||||||
if let Err(e) = Merge::create_pr(
|
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 {
|
if let Err(e) = utils::browser::open_browser(&pr_info.url).await {
|
||||||
tracing::warn!("Failed to open PR in browser: {}", e);
|
tracing::warn!("Failed to open PR in browser: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment
|
deployment
|
||||||
.track_if_analytics_allowed(
|
.track_if_analytics_allowed(
|
||||||
"github_pr_created",
|
"pr_created",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"workspace_id": workspace.id.to_string(),
|
"workspace_id": workspace.id.to_string(),
|
||||||
|
"provider": format!("{:?}", provider),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -345,18 +374,21 @@ pub async fn create_github_pr(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Failed to create GitHub PR for attempt {}: {}",
|
"Failed to create PR for attempt {} using {:?}: {}",
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
provider,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
match &e {
|
match &e {
|
||||||
GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson(
|
GitHostError::CliNotInstalled { provider } => Ok(ResponseJson(
|
||||||
ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled),
|
ApiResponse::error_with_data(PrError::CliNotInstalled {
|
||||||
|
provider: *provider,
|
||||||
|
}),
|
||||||
)),
|
)),
|
||||||
GitHubServiceError::AuthFailed(_) => Ok(ResponseJson(
|
GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn),
|
PrError::CliNotLoggedIn { provider },
|
||||||
)),
|
))),
|
||||||
_ => Err(ApiError::GitHubService(e)),
|
_ => Err(ApiError::GitHost(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +398,7 @@ pub async fn attach_existing_pr(
|
|||||||
Extension(workspace): Extension<Workspace>,
|
Extension(workspace): Extension<Workspace>,
|
||||||
State(deployment): State<DeploymentImpl>,
|
State(deployment): State<DeploymentImpl>,
|
||||||
Json(request): Json<AttachExistingPrRequest>,
|
Json(request): Json<AttachExistingPrRequest>,
|
||||||
) -> Result<ResponseJson<ApiResponse<AttachPrResponse>>, ApiError> {
|
) -> Result<ResponseJson<ApiResponse<AttachPrResponse, PrError>>, ApiError> {
|
||||||
let pool = &deployment.db().pool;
|
let pool = &deployment.db().pool;
|
||||||
|
|
||||||
let task = workspace
|
let task = workspace
|
||||||
@@ -394,13 +426,45 @@ pub async fn attach_existing_pr(
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
let github_service = GitHubService::new()?;
|
let remote_url = deployment
|
||||||
let repo_info = github_service.get_repo_info(&repo.path).await?;
|
.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)
|
// List all PRs for branch (open, closed, and merged)
|
||||||
let prs = github_service
|
let prs = match git_host
|
||||||
.list_all_prs_for_branch(&repo_info, &workspace.branch)
|
.list_prs_for_branch(&repo.path, &remote_url, &workspace.branch)
|
||||||
.await?;
|
.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)
|
// Take the first PR (prefer open, but also accept merged/closed)
|
||||||
if let Some(pr_info) = prs.into_iter().next() {
|
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 remote_url = deployment
|
||||||
let repo_info = github_service.get_repo_info(&repo.path).await?;
|
.git()
|
||||||
|
.get_remote_url_from_branch_or_default(&repo.path, &workspace_repo.target_branch)?;
|
||||||
|
|
||||||
// Fetch comments from GitHub
|
let git_host = match git_host::GitHostService::from_url(&remote_url) {
|
||||||
match github_service
|
Ok(host) => host,
|
||||||
.get_pr_comments(&repo_info, pr_info.number)
|
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
|
.await
|
||||||
{
|
{
|
||||||
Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse {
|
Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse {
|
||||||
@@ -515,13 +591,15 @@ pub async fn get_pr_comments(
|
|||||||
e
|
e
|
||||||
);
|
);
|
||||||
match &e {
|
match &e {
|
||||||
GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson(
|
GitHostError::CliNotInstalled { provider } => Ok(ResponseJson(
|
||||||
ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled),
|
ApiResponse::error_with_data(GetPrCommentsError::CliNotInstalled {
|
||||||
|
provider: *provider,
|
||||||
|
}),
|
||||||
)),
|
)),
|
||||||
GitHubServiceError::AuthFailed(_) => Ok(ResponseJson(
|
GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data(
|
||||||
ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn),
|
GetPrCommentsError::CliNotLoggedIn { provider },
|
||||||
)),
|
))),
|
||||||
_ => Err(ApiError::GitHubService(e)),
|
_ => Err(ApiError::GitHost(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dirs = "5.0"
|
|||||||
git2 = { workspace = true }
|
git2 = { workspace = true }
|
||||||
tempfile = "3.21"
|
tempfile = "3.21"
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
enum_dispatch = "0.3.13"
|
||||||
rust-embed = "8.2"
|
rust-embed = "8.2"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
|||||||
@@ -1601,9 +1601,29 @@ impl GitService {
|
|||||||
) -> Result<String, GitServiceError> {
|
) -> Result<String, GitServiceError> {
|
||||||
let repo = Repository::open(repo_path)?;
|
let repo = Repository::open(repo_path)?;
|
||||||
let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference();
|
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)?
|
||||||
self.get_remote_from_branch_ref(&repo, &branch_ref)
|
.name()
|
||||||
.map(|r| r.name().unwrap_or(&default_remote).to_string())
|
.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<String, GitServiceError> {
|
||||||
|
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>(
|
fn get_remote_from_branch_ref<'a>(
|
||||||
@@ -1631,7 +1651,7 @@ impl GitService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_to_github(
|
pub fn push_to_remote(
|
||||||
&self,
|
&self,
|
||||||
worktree_path: &Path,
|
worktree_path: &Path,
|
||||||
branch_name: &str,
|
branch_name: &str,
|
||||||
@@ -1649,7 +1669,7 @@ impl GitService {
|
|||||||
.ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?;
|
.ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?;
|
||||||
let git_cli = GitCli::new();
|
let git_cli = GitCli::new();
|
||||||
if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name, force) {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -415,6 +415,21 @@ impl GitCli {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_remote_url(
|
||||||
|
&self,
|
||||||
|
repo_path: &Path,
|
||||||
|
remote_name: &str,
|
||||||
|
) -> Result<String, GitCliError> {
|
||||||
|
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<String, GitCliError> {
|
||||||
|
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.
|
// Parse `git diff --name-status` output into structured entries.
|
||||||
// Handles rename/copy scores like `R100` by matching the first letter.
|
// Handles rename/copy scores like `R100` by matching the first letter.
|
||||||
fn parse_name_status(output: &str) -> Vec<StatusDiffEntry> {
|
fn parse_name_status(output: &str) -> Vec<StatusDiffEntry> {
|
||||||
|
|||||||
684
crates/services/src/services/git_host/azure/cli.rs
Normal file
684
crates/services/src/services/git_host/azure/cli.rs
Normal file
@@ -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<String>,
|
||||||
|
closed_date: Option<String>,
|
||||||
|
repository: Option<AzRepository>,
|
||||||
|
last_merge_commit: Option<AzCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzRepository {
|
||||||
|
web_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzCommit {
|
||||||
|
commit_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AzThreadsResponse {
|
||||||
|
value: Vec<AzThread>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzThread {
|
||||||
|
comments: Option<Vec<AzThreadComment>>,
|
||||||
|
thread_context: Option<AzThreadContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzThreadContext {
|
||||||
|
file_path: Option<String>,
|
||||||
|
right_file_start: Option<AzFilePosition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AzFilePosition {
|
||||||
|
line: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzThreadComment {
|
||||||
|
id: Option<i64>,
|
||||||
|
author: Option<AzAuthor>,
|
||||||
|
content: Option<String>,
|
||||||
|
published_date: Option<String>,
|
||||||
|
comment_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AzAuthor {
|
||||||
|
display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<I, S>(&self, args: I, dir: Option<&Path>) -> Result<String, AzCliError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
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<AzureRepoInfo, AzCliError> {
|
||||||
|
let raw = self.run(
|
||||||
|
["repos", "list", "--detect", "true", "--output", "json"],
|
||||||
|
Some(repo_path),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let repos: Vec<AzRepoListItem> = 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<String> {
|
||||||
|
// 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<PullRequestInfo, AzCliError> {
|
||||||
|
let body = request.body.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
let mut args: Vec<OsString> = 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<PullRequestInfo, AzCliError> {
|
||||||
|
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<Vec<PullRequestInfo>, 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<Vec<UnifiedPrComment>, AzCliError> {
|
||||||
|
let mut args: Vec<OsString> = 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<PullRequestInfo, AzCliError> {
|
||||||
|
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<Vec<PullRequestInfo>, AzCliError> {
|
||||||
|
let prs: Vec<AzPrResponse> = 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<Vec<UnifiedPrComment>, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
281
crates/services/src/services/git_host/azure/mod.rs
Normal file
281
crates/services/src/services/git_host/azure/mod.rs
Normal file
@@ -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<Self, GitHostError> {
|
||||||
|
Ok(Self {
|
||||||
|
az_cli: AzCli::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_repo_info(
|
||||||
|
&self,
|
||||||
|
repo_path: &Path,
|
||||||
|
remote_url: &str,
|
||||||
|
) -> Result<AzureRepoInfo, GitHostError> {
|
||||||
|
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<AzCliError> 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<PullRequestInfo, GitHostError> {
|
||||||
|
// 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<PullRequestInfo, GitHostError> {
|
||||||
|
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<Vec<PullRequestInfo>, 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<Vec<UnifiedPrComment>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
178
crates/services/src/services/git_host/detection.rs
Normal file
178
crates/services/src/services/git_host/detection.rs
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Minimal helpers around the GitHub CLI (`gh`).
|
//! Minimal helpers around the GitHub CLI (`gh`).
|
||||||
//!
|
//!
|
||||||
//! This module deliberately mirrors the ergonomics of `git_cli.rs` so we can
|
//! This module provides low-level access to the GitHub CLI for operations
|
||||||
//! plug in the GitHub CLI for operations the REST client does not cover well.
|
//! the REST client does not cover well.
|
||||||
//! Future work will flesh out richer error handling and testing.
|
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
ffi::{OsStr, OsString},
|
ffi::{OsStr, OsString},
|
||||||
@@ -13,55 +12,80 @@ use std::{
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use db::models::merge::{MergeStatus, PullRequestInfo};
|
use db::models::merge::{MergeStatus, PullRequestInfo};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use ts_rs::TS;
|
|
||||||
use utils::shell::resolve_executable_path_blocking;
|
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)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
pub struct GitHubRepoInfo {
|
||||||
pub struct PrCommentAuthor {
|
pub owner: String,
|
||||||
pub login: String,
|
pub repo_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single comment on a GitHub PR
|
#[derive(Deserialize)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PrComment {
|
struct GhCommentResponse {
|
||||||
pub id: String,
|
id: String,
|
||||||
pub author: PrCommentAuthor,
|
author: Option<GhUserLogin>,
|
||||||
pub author_association: String,
|
#[serde(default)]
|
||||||
pub body: String,
|
author_association: String,
|
||||||
pub created_at: DateTime<Utc>,
|
#[serde(default)]
|
||||||
pub url: String,
|
body: String,
|
||||||
|
created_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User information for a review comment (from API response)
|
#[derive(Deserialize)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
struct GhCommentsWrapper {
|
||||||
pub struct ReviewCommentUser {
|
comments: Vec<GhCommentResponse>,
|
||||||
pub login: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An inline review comment on a GitHub PR (from gh api)
|
#[derive(Deserialize)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
struct GhUserLogin {
|
||||||
pub struct PrReviewComment {
|
login: Option<String>,
|
||||||
pub id: i64,
|
}
|
||||||
pub user: ReviewCommentUser,
|
|
||||||
pub body: String,
|
#[derive(Deserialize)]
|
||||||
pub created_at: DateTime<Utc>,
|
struct GhReviewCommentResponse {
|
||||||
pub html_url: String,
|
id: i64,
|
||||||
pub path: String,
|
user: Option<GhUserLogin>,
|
||||||
pub line: Option<i64>,
|
#[serde(default)]
|
||||||
pub side: Option<String>,
|
body: String,
|
||||||
pub diff_hunk: String,
|
created_at: Option<DateTime<Utc>>,
|
||||||
pub author_association: String,
|
#[serde(default)]
|
||||||
|
html_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
path: String,
|
||||||
|
line: Option<i64>,
|
||||||
|
side: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
diff_hunk: String,
|
||||||
|
#[serde(default)]
|
||||||
|
author_association: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GhMergeCommit {
|
||||||
|
oid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GhPrResponse {
|
||||||
|
number: i64,
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
state: String,
|
||||||
|
merged_at: Option<DateTime<Utc>>,
|
||||||
|
merge_commit: Option<GhMergeCommit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level errors originating from the GitHub CLI.
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum GhCliError {
|
pub enum GhCliError {
|
||||||
#[error("GitHub CLI (`gh`) executable not found or not runnable")]
|
#[error("GitHub CLI (`gh`) executable not found or not runnable")]
|
||||||
@@ -74,7 +98,6 @@ pub enum GhCliError {
|
|||||||
UnexpectedOutput(String),
|
UnexpectedOutput(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Newtype wrapper for invoking the `gh` command.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct GhCli;
|
pub struct GhCli;
|
||||||
|
|
||||||
@@ -132,6 +155,7 @@ impl GhCli {
|
|||||||
Err(GhCliError::CommandFailed(stderr))
|
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<GitHubRepoInfo, GhCliError> {
|
pub fn get_repo_info(&self, repo_path: &Path) -> Result<GitHubRepoInfo, GhCliError> {
|
||||||
let raw = self.run(["repo", "view", "--json", "owner,name"], Some(repo_path))?;
|
let raw = self.run(["repo", "view", "--json", "owner,name"], Some(repo_path))?;
|
||||||
|
|
||||||
@@ -159,7 +183,8 @@ impl GhCli {
|
|||||||
pub fn create_pr(
|
pub fn create_pr(
|
||||||
&self,
|
&self,
|
||||||
request: &CreatePrRequest,
|
request: &CreatePrRequest,
|
||||||
repo_info: &GitHubRepoInfo,
|
owner: &str,
|
||||||
|
repo_name: &str,
|
||||||
) -> Result<PullRequestInfo, GhCliError> {
|
) -> Result<PullRequestInfo, GhCliError> {
|
||||||
// Write body to temp file to avoid shell escaping and length issues
|
// Write body to temp file to avoid shell escaping and length issues
|
||||||
let body = request.body.as_deref().unwrap_or("");
|
let body = request.body.as_deref().unwrap_or("");
|
||||||
@@ -173,10 +198,7 @@ impl GhCli {
|
|||||||
args.push(OsString::from("pr"));
|
args.push(OsString::from("pr"));
|
||||||
args.push(OsString::from("create"));
|
args.push(OsString::from("create"));
|
||||||
args.push(OsString::from("--repo"));
|
args.push(OsString::from("--repo"));
|
||||||
args.push(OsString::from(format!(
|
args.push(OsString::from(format!("{}/{}", owner, repo_name)));
|
||||||
"{}/{}",
|
|
||||||
repo_info.owner, repo_info.repo_name
|
|
||||||
)));
|
|
||||||
args.push(OsString::from("--head"));
|
args.push(OsString::from("--head"));
|
||||||
args.push(OsString::from(&request.head_branch));
|
args.push(OsString::from(&request.head_branch));
|
||||||
args.push(OsString::from("--base"));
|
args.push(OsString::from("--base"));
|
||||||
@@ -325,101 +347,96 @@ impl GhCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pr_view(raw: &str) -> Result<PullRequestInfo, GhCliError> {
|
fn parse_pr_view(raw: &str) -> Result<PullRequestInfo, GhCliError> {
|
||||||
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!(
|
GhCliError::UnexpectedOutput(format!(
|
||||||
"Failed to parse gh pr view response: {err}; raw: {raw}"
|
"Failed to parse gh pr view response: {err}; raw: {raw}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
Self::extract_pr_info(&value).ok_or_else(|| {
|
Ok(Self::pr_response_to_info(pr))
|
||||||
GhCliError::UnexpectedOutput(format!(
|
|
||||||
"gh pr view response missing required fields: {value:#?}"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pr_list(raw: &str) -> Result<Vec<PullRequestInfo>, GhCliError> {
|
fn parse_pr_list(raw: &str) -> Result<Vec<PullRequestInfo>, GhCliError> {
|
||||||
let value: Value = serde_json::from_str(raw.trim()).map_err(|err| {
|
let prs: Vec<GhPrResponse> = serde_json::from_str(raw.trim()).map_err(|err| {
|
||||||
GhCliError::UnexpectedOutput(format!(
|
GhCliError::UnexpectedOutput(format!(
|
||||||
"Failed to parse gh pr list response: {err}; raw: {raw}"
|
"Failed to parse gh pr list response: {err}; raw: {raw}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let arr = value.as_array().ok_or_else(|| {
|
Ok(prs.into_iter().map(Self::pr_response_to_info).collect())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pr_comments(raw: &str) -> Result<Vec<PrComment>, GhCliError> {
|
fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo {
|
||||||
let value: Value = serde_json::from_str(raw.trim()).map_err(|err| {
|
let state = if pr.state.is_empty() {
|
||||||
GhCliError::UnexpectedOutput(format!(
|
"OPEN"
|
||||||
"Failed to parse gh pr view --json comments response: {err}; raw: {raw}"
|
} else {
|
||||||
))
|
&pr.state
|
||||||
})?;
|
};
|
||||||
let comments_arr = value
|
PullRequestInfo {
|
||||||
.get("comments")
|
number: pr.number,
|
||||||
.and_then(|v| v.as_array())
|
url: pr.url,
|
||||||
.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<Vec<PrReviewComment>, 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<PullRequestInfo> {
|
|
||||||
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,
|
|
||||||
status: match state.to_ascii_uppercase().as_str() {
|
status: match state.to_ascii_uppercase().as_str() {
|
||||||
"OPEN" => MergeStatus::Open,
|
"OPEN" => MergeStatus::Open,
|
||||||
"MERGED" => MergeStatus::Merged,
|
"MERGED" => MergeStatus::Merged,
|
||||||
"CLOSED" => MergeStatus::Closed,
|
"CLOSED" => MergeStatus::Closed,
|
||||||
_ => MergeStatus::Unknown,
|
_ => MergeStatus::Unknown,
|
||||||
},
|
},
|
||||||
merged_at,
|
merged_at: pr.merged_at,
|
||||||
merge_commit_sha,
|
merge_commit_sha: pr.merge_commit.and_then(|c| c.oid),
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pr_comments(raw: &str) -> Result<Vec<PrComment>, 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<Vec<PrReviewComment>, GhCliError> {
|
||||||
|
let items: Vec<GhReviewCommentResponse> =
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
368
crates/services/src/services/git_host/github/mod.rs
Normal file
368
crates/services/src/services/git_host/github/mod.rs
Normal file
@@ -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<Self, GitHostError> {
|
||||||
|
Ok(Self {
|
||||||
|
gh_cli: GhCli::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_repo_info(&self, repo_path: &Path) -> Result<GitHubRepoInfo, GitHostError> {
|
||||||
|
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<Vec<super::types::PrComment>, 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<Vec<super::types::PrReviewComment>, 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<GhCliError> 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<PullRequestInfo, GitHostError> {
|
||||||
|
// 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<PullRequestInfo, GitHostError> {
|
||||||
|
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<Vec<PullRequestInfo>, 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<Vec<UnifiedPrComment>, 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<UnifiedPrComment> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/services/src/services/git_host/mod.rs
Normal file
63
crates/services/src/services/git_host/mod.rs
Normal file
@@ -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<PullRequestInfo, GitHostError>;
|
||||||
|
|
||||||
|
async fn get_pr_status(&self, pr_url: &str) -> Result<PullRequestInfo, GitHostError>;
|
||||||
|
|
||||||
|
async fn list_prs_for_branch(
|
||||||
|
&self,
|
||||||
|
repo_path: &Path,
|
||||||
|
remote_url: &str,
|
||||||
|
branch_name: &str,
|
||||||
|
) -> Result<Vec<PullRequestInfo>, GitHostError>;
|
||||||
|
|
||||||
|
async fn get_pr_comments(
|
||||||
|
&self,
|
||||||
|
repo_path: &Path,
|
||||||
|
remote_url: &str,
|
||||||
|
pr_number: i64,
|
||||||
|
) -> Result<Vec<UnifiedPrComment>, GitHostError>;
|
||||||
|
|
||||||
|
fn provider_kind(&self) -> ProviderKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[enum_dispatch]
|
||||||
|
pub enum GitHostService {
|
||||||
|
GitHub(GitHubProvider),
|
||||||
|
AzureDevOps(AzureDevOpsProvider),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitHostService {
|
||||||
|
pub fn from_url(url: &str) -> Result<Self, GitHostError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/services/src/services/git_host/types.rs
Normal file
133
crates/services/src/services/git_host/types.rs
Normal file
@@ -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<String>,
|
||||||
|
pub head_branch: String,
|
||||||
|
pub base_branch: String,
|
||||||
|
pub draft: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
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<Utc>,
|
||||||
|
pub html_url: String,
|
||||||
|
pub path: String,
|
||||||
|
pub line: Option<i64>,
|
||||||
|
pub side: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
body: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
url: Option<String>,
|
||||||
|
},
|
||||||
|
Review {
|
||||||
|
id: i64,
|
||||||
|
author: String,
|
||||||
|
author_association: Option<String>,
|
||||||
|
body: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
url: Option<String>,
|
||||||
|
path: String,
|
||||||
|
line: Option<i64>,
|
||||||
|
diff_hunk: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnifiedPrComment {
|
||||||
|
pub fn created_at(&self) -> DateTime<Utc> {
|
||||||
|
match self {
|
||||||
|
UnifiedPrComment::General { created_at, .. } => *created_at,
|
||||||
|
UnifiedPrComment::Review { created_at, .. } => *created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Utc>,
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
/// Inline review comment (on code)
|
|
||||||
Review {
|
|
||||||
id: i64,
|
|
||||||
author: String,
|
|
||||||
author_association: String,
|
|
||||||
body: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
url: String,
|
|
||||||
path: String,
|
|
||||||
line: Option<i64>,
|
|
||||||
diff_hunk: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UnifiedPrComment {
|
|
||||||
fn created_at(&self) -> DateTime<Utc> {
|
|
||||||
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<GhCliError> 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<String>,
|
|
||||||
pub head_branch: String,
|
|
||||||
pub base_branch: String,
|
|
||||||
pub draft: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GitHubService {
|
|
||||||
gh_cli: GhCli,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitHubService {
|
|
||||||
/// Create a new GitHub service with authentication
|
|
||||||
pub fn new() -> Result<Self, GitHubServiceError> {
|
|
||||||
Ok(Self {
|
|
||||||
gh_cli: GhCli::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_repo_info(
|
|
||||||
&self,
|
|
||||||
repo_path: &Path,
|
|
||||||
) -> Result<GitHubRepoInfo, GitHubServiceError> {
|
|
||||||
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<PullRequestInfo, GitHubServiceError> {
|
|
||||||
(|| 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<PullRequestInfo, GitHubServiceError> {
|
|
||||||
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<PullRequestInfo, GitHubServiceError> {
|
|
||||||
(|| 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<Vec<PullRequestInfo>, 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<Vec<UnifiedPrComment>, 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<UnifiedPrComment> = 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<Vec<PrComment>, 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<Vec<PrReviewComment>, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ pub mod file_search_cache;
|
|||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
pub mod filesystem_watcher;
|
pub mod filesystem_watcher;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod github;
|
pub mod git_host;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth_credentials;
|
pub mod oauth_credentials;
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ use tracing::{debug, error, info};
|
|||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
analytics::AnalyticsContext,
|
analytics::AnalyticsContext,
|
||||||
github::{GitHubService, GitHubServiceError},
|
git_host::{self, GitHostError, GitHostProvider},
|
||||||
share::SharePublisher,
|
share::SharePublisher,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
enum PrMonitorError {
|
enum PrMonitorError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
GitHubServiceError(#[from] GitHubServiceError),
|
GitHostError(#[from] GitHostError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
WorkspaceError(#[from] WorkspaceError),
|
WorkspaceError(#[from] WorkspaceError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Sqlx(#[from] SqlxError),
|
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 {
|
pub struct PrMonitorService {
|
||||||
db: DBService,
|
db: DBService,
|
||||||
poll_interval: Duration,
|
poll_interval: Duration,
|
||||||
@@ -95,12 +95,8 @@ impl PrMonitorService {
|
|||||||
|
|
||||||
/// Check the status of a specific PR
|
/// Check the status of a specific PR
|
||||||
async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> {
|
async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> {
|
||||||
// GitHubService now uses gh CLI, no token needed
|
let git_host = git_host::GitHostService::from_url(&pr_merge.pr_info.url)?;
|
||||||
let github_service = GitHubService::new()?;
|
let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?;
|
||||||
|
|
||||||
let pr_status = github_service
|
|
||||||
.update_pr_status(&pr_merge.pr_info.url)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"PR #{} status: {:?} (was open)",
|
"PR #{} status: {:?} (was open)",
|
||||||
@@ -109,7 +105,7 @@ impl PrMonitorService {
|
|||||||
|
|
||||||
// Update the PR status in the database
|
// Update the PR status in the database
|
||||||
if !matches!(&pr_status.status, MergeStatus::Open) {
|
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(
|
Merge::update_status(
|
||||||
&self.db.pool,
|
&self.db.pool,
|
||||||
pr_merge.id,
|
pr_merge.id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
RemoteClientError,
|
RemoteClientError,
|
||||||
services::{git::GitServiceError, github::GitHubServiceError},
|
services::{git::GitServiceError, git_host::GitHostError},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -39,7 +39,7 @@ pub enum ShareError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Git(#[from] GitServiceError),
|
Git(#[from] GitServiceError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
GitHub(#[from] GitHubServiceError),
|
GitHost(#[from] GitHostError),
|
||||||
#[error("share authentication missing or expired")]
|
#[error("share authentication missing or expired")]
|
||||||
MissingAuth,
|
MissingAuth,
|
||||||
#[error("invalid user ID format")]
|
#[error("invalid user ID format")]
|
||||||
|
|||||||
@@ -177,19 +177,25 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
|
|||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (
|
if (
|
||||||
result.error.type === 'github_cli_not_installed' ||
|
result.error.type === 'cli_not_installed' ||
|
||||||
result.error.type === 'github_cli_not_logged_in'
|
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();
|
await showGhCliSetupDialog();
|
||||||
} else {
|
} else {
|
||||||
const ui = mapGhCliErrorToUi(
|
const providerName =
|
||||||
'SETUP_HELPER_NOT_SUPPORTED',
|
result.error.provider === 'git_hub'
|
||||||
defaultGhCliErrorMessage,
|
? 'GitHub'
|
||||||
t
|
: result.error.provider === 'azure_dev_ops'
|
||||||
);
|
? 'Azure DevOps'
|
||||||
setGhCliHelp(ui.variant ? ui : null);
|
: 'Git host';
|
||||||
setError(ui.variant ? null : ui.message);
|
const action =
|
||||||
|
result.error.type === 'cli_not_installed'
|
||||||
|
? 'not installed'
|
||||||
|
: 'not logged in';
|
||||||
|
setError(`${providerName} CLI is ${action}`);
|
||||||
|
setGhCliHelp(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { MessageSquare, AlertCircle, Loader2 } from 'lucide-react';
|
import { MessageSquare, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import { usePrComments } from '@/hooks/usePrComments';
|
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';
|
import type { UnifiedPrComment } from 'shared/types';
|
||||||
|
|
||||||
export interface GitHubCommentsDialogProps {
|
export interface PrCommentsDialogProps {
|
||||||
attemptId: string;
|
attemptId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubCommentsDialogResult {
|
export interface PrCommentsDialogResult {
|
||||||
comments: UnifiedPrComment[];
|
comments: UnifiedPrComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ function getCommentId(comment: UnifiedPrComment): string {
|
|||||||
: comment.id.toString();
|
: comment.id.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
const PrCommentsDialogImpl = NiceModal.create<PrCommentsDialogProps>(
|
||||||
({ attemptId, repoId }) => {
|
({ attemptId, repoId }) => {
|
||||||
const { t } = useTranslation(['tasks', 'common']);
|
const { t } = useTranslation(['tasks', 'common']);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
@@ -109,7 +109,7 @@ const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
|||||||
<DialogHeader className="px-4 py-3 border-b">
|
<DialogHeader className="px-4 py-3 border-b">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
{t('tasks:githubComments.dialog.title')}
|
{t('tasks:prComments.dialog.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
|||||||
</div>
|
</div>
|
||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<p className="text-center text-muted-foreground py-8">
|
||||||
{t('tasks:githubComments.dialog.noComments')}
|
{t('tasks:prComments.dialog.noComments')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{t('tasks:githubComments.dialog.selectedCount', {
|
{t('tasks:prComments.dialog.selectedCount', {
|
||||||
selected: selectedIds.size,
|
selected: selectedIds.size,
|
||||||
total: comments.length,
|
total: comments.length,
|
||||||
})}
|
})}
|
||||||
@@ -143,8 +143,8 @@ const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
|||||||
onClick={isAllSelected ? deselectAll : selectAll}
|
onClick={isAllSelected ? deselectAll : selectAll}
|
||||||
>
|
>
|
||||||
{isAllSelected
|
{isAllSelected
|
||||||
? t('tasks:githubComments.dialog.deselectAll')
|
? t('tasks:prComments.dialog.deselectAll')
|
||||||
: t('tasks:githubComments.dialog.selectAll')}
|
: t('tasks:prComments.dialog.selectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -160,7 +160,7 @@ const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
|||||||
onCheckedChange={() => toggleSelection(id)}
|
onCheckedChange={() => toggleSelection(id)}
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
/>
|
/>
|
||||||
<GitHubCommentCard
|
<PrCommentCard
|
||||||
author={comment.author}
|
author={comment.author}
|
||||||
body={comment.body}
|
body={comment.body}
|
||||||
createdAt={comment.created_at}
|
createdAt={comment.created_at}
|
||||||
@@ -201,7 +201,7 @@ const GitHubCommentsDialogImpl = NiceModal.create<GitHubCommentsDialogProps>(
|
|||||||
{t('common:buttons.cancel')}
|
{t('common:buttons.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirm} disabled={selectedIds.size === 0}>
|
<Button onClick={handleConfirm} disabled={selectedIds.size === 0}>
|
||||||
{t('tasks:githubComments.dialog.add')}
|
{t('tasks:prComments.dialog.add')}
|
||||||
{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}
|
{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -219,17 +219,17 @@ function getErrorMessage(error: unknown): string {
|
|||||||
if (errorData?.type === 'no_pr_attached') {
|
if (errorData?.type === 'no_pr_attached') {
|
||||||
return 'No PR is attached to this task attempt. Create a PR first to see comments.';
|
return 'No PR is attached to this task attempt. Create a PR first to see comments.';
|
||||||
}
|
}
|
||||||
if (errorData?.type === 'github_cli_not_installed') {
|
if (errorData?.type === 'cli_not_installed') {
|
||||||
return 'GitHub CLI is not installed. Please install it to fetch PR comments.';
|
return 'CLI is not installed. Please install it to fetch PR comments.';
|
||||||
}
|
}
|
||||||
if (errorData?.type === 'github_cli_not_logged_in') {
|
if (errorData?.type === 'cli_not_logged_in') {
|
||||||
return 'GitHub CLI is not logged in. Please run "gh auth login" to authenticate.';
|
return 'CLI is not logged in. Please authenticate to fetch PR comments.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'Failed to load PR comments. Please try again.';
|
return 'Failed to load PR comments. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitHubCommentsDialog = defineModal<
|
export const PrCommentsDialog = defineModal<
|
||||||
GitHubCommentsDialogProps,
|
PrCommentsDialogProps,
|
||||||
GitHubCommentsDialogResult
|
PrCommentsDialogResult
|
||||||
>(GitHubCommentsDialogImpl);
|
>(PrCommentsDialogImpl);
|
||||||
@@ -60,8 +60,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { queueApi } from '@/lib/api';
|
import { queueApi } from '@/lib/api';
|
||||||
import type { QueueStatus } from 'shared/types';
|
import type { QueueStatus } from 'shared/types';
|
||||||
import { imagesApi, attemptsApi } from '@/lib/api';
|
import { imagesApi, attemptsApi } from '@/lib/api';
|
||||||
import { GitHubCommentsDialog } from '@/components/dialogs/tasks/GitHubCommentsDialog';
|
import { PrCommentsDialog } from '@/components/dialogs/tasks/PrCommentsDialog';
|
||||||
import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/github-comment-node';
|
import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/pr-comment-node';
|
||||||
import type { Session } from 'shared/types';
|
import type { Session } from 'shared/types';
|
||||||
|
|
||||||
interface TaskFollowUpSectionProps {
|
interface TaskFollowUpSectionProps {
|
||||||
@@ -574,13 +574,13 @@ export function TaskFollowUpSection({
|
|||||||
[handlePasteFiles]
|
[handlePasteFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for GitHub comments insertion
|
// Handler for PR comments insertion
|
||||||
const handleGitHubCommentClick = useCallback(async () => {
|
const handlePrCommentClick = useCallback(async () => {
|
||||||
if (!workspaceId) return;
|
if (!workspaceId) return;
|
||||||
const repoId = getSelectedRepoId();
|
const repoId = getSelectedRepoId();
|
||||||
if (!repoId) return;
|
if (!repoId) return;
|
||||||
|
|
||||||
const result = await GitHubCommentsDialog.show({
|
const result = await PrCommentsDialog.show({
|
||||||
attemptId: workspaceId,
|
attemptId: workspaceId,
|
||||||
repoId,
|
repoId,
|
||||||
});
|
});
|
||||||
@@ -829,14 +829,14 @@ export function TaskFollowUpSection({
|
|||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* GitHub Comments button */}
|
{/* PR Comments button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleGitHubCommentClick}
|
onClick={handlePrCommentClick}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
title="Insert GitHub comment"
|
title="Insert PR comment"
|
||||||
aria-label="Insert GitHub comment"
|
aria-label="Insert PR comment"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import { MessageSquare, Code, ExternalLink } from 'lucide-react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface GitHubCommentCardProps {
|
export interface PrCommentCardProps {
|
||||||
author: string;
|
author: string;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
url: string;
|
url?: string | null;
|
||||||
// Optional review-specific fields
|
// Optional review-specific fields
|
||||||
commentType?: 'general' | 'review';
|
commentType?: 'general' | 'review';
|
||||||
path?: string;
|
path?: string;
|
||||||
line?: number | null;
|
line?: number | null;
|
||||||
diffHunk?: string;
|
diffHunk?: string | null;
|
||||||
/** Display variant: 'compact' for inline chip, 'full' for inline card, 'list' for block card */
|
/** Display variant: 'compact' for inline chip, 'full' for inline card, 'list' for block card */
|
||||||
variant: 'compact' | 'full' | 'list';
|
variant: 'compact' | 'full' | 'list';
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
@@ -71,7 +71,7 @@ function CompactCard({
|
|||||||
onClick,
|
onClick,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
className,
|
className,
|
||||||
}: GitHubCommentCardProps) {
|
}: PrCommentCardProps) {
|
||||||
const { t } = useTranslation('tasks');
|
const { t } = useTranslation('tasks');
|
||||||
const isReview = commentType === 'review';
|
const isReview = commentType === 'review';
|
||||||
const Icon = isReview ? Code : MessageSquare;
|
const Icon = isReview ? Code : MessageSquare;
|
||||||
@@ -87,7 +87,7 @@ function CompactCard({
|
|||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
title={`@${author}: ${body}\n\n${t('githubComments.card.tooltip')}`}
|
title={`@${author}: ${body}\n\n${t('prComments.card.tooltip')}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<Icon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs font-medium flex-shrink-0">@{author}</span>
|
<span className="text-xs font-medium flex-shrink-0">@{author}</span>
|
||||||
@@ -113,7 +113,7 @@ function FullCard({
|
|||||||
onClick,
|
onClick,
|
||||||
variant,
|
variant,
|
||||||
className,
|
className,
|
||||||
}: GitHubCommentCardProps) {
|
}: PrCommentCardProps) {
|
||||||
const { t } = useTranslation('tasks');
|
const { t } = useTranslation('tasks');
|
||||||
const isReview = commentType === 'review';
|
const isReview = commentType === 'review';
|
||||||
const Icon = isReview ? Code : MessageSquare;
|
const Icon = isReview ? Code : MessageSquare;
|
||||||
@@ -136,7 +136,7 @@ function FullCard({
|
|||||||
<span className="font-medium text-sm">@{author}</span>
|
<span className="font-medium text-sm">@{author}</span>
|
||||||
{isReview && (
|
{isReview && (
|
||||||
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">
|
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">
|
||||||
{t('githubComments.card.review')}
|
{t('prComments.card.review')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@ function FullCard({
|
|||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}}
|
}}
|
||||||
className="hover:text-foreground transition-colors"
|
className="hover:text-foreground transition-colors"
|
||||||
aria-label="Open in GitHub"
|
aria-label="Open in browser"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -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
|
* @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') {
|
if (props.variant === 'compact') {
|
||||||
return <CompactCard {...props} />;
|
return <CompactCard {...props} />;
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,10 @@ import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPl
|
|||||||
import { TRANSFORMERS, type Transformer } from '@lexical/markdown';
|
import { TRANSFORMERS, type Transformer } from '@lexical/markdown';
|
||||||
import { ImageNode, IMAGE_TRANSFORMER } from './wysiwyg/nodes/image-node';
|
import { ImageNode, IMAGE_TRANSFORMER } from './wysiwyg/nodes/image-node';
|
||||||
import {
|
import {
|
||||||
GitHubCommentNode,
|
PrCommentNode,
|
||||||
GITHUB_COMMENT_TRANSFORMER,
|
PR_COMMENT_TRANSFORMER,
|
||||||
GITHUB_COMMENT_EXPORT_TRANSFORMER,
|
PR_COMMENT_EXPORT_TRANSFORMER,
|
||||||
} from './wysiwyg/nodes/github-comment-node';
|
} from './wysiwyg/nodes/pr-comment-node';
|
||||||
import { CODE_BLOCK_TRANSFORMER } from './wysiwyg/transformers/code-block-transformer';
|
import { CODE_BLOCK_TRANSFORMER } from './wysiwyg/transformers/code-block-transformer';
|
||||||
import { TABLE_TRANSFORMER } from './wysiwyg/transformers/table-transformer';
|
import { TABLE_TRANSFORMER } from './wysiwyg/transformers/table-transformer';
|
||||||
import {
|
import {
|
||||||
@@ -161,7 +161,7 @@ function WYSIWYGEditor({
|
|||||||
CodeHighlightNode,
|
CodeHighlightNode,
|
||||||
LinkNode,
|
LinkNode,
|
||||||
ImageNode,
|
ImageNode,
|
||||||
GitHubCommentNode,
|
PrCommentNode,
|
||||||
TableNode,
|
TableNode,
|
||||||
TableRowNode,
|
TableRowNode,
|
||||||
TableCellNode,
|
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(
|
const extendedTransformers: Transformer[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
TABLE_TRANSFORMER,
|
TABLE_TRANSFORMER,
|
||||||
IMAGE_TRANSFORMER,
|
IMAGE_TRANSFORMER,
|
||||||
GITHUB_COMMENT_EXPORT_TRANSFORMER, // Export transformer for DecoratorNode (must be before import transformer)
|
PR_COMMENT_EXPORT_TRANSFORMER, // Export transformer for DecoratorNode (must be before import transformer)
|
||||||
GITHUB_COMMENT_TRANSFORMER, // Import transformer for fenced code block
|
PR_COMMENT_TRANSFORMER, // Import transformer for fenced code block
|
||||||
CODE_BLOCK_TRANSFORMER,
|
CODE_BLOCK_TRANSFORMER,
|
||||||
...TRANSFORMERS,
|
...TRANSFORMERS,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
import { NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
||||||
import { GitHubCommentCard } from '@/components/ui/github-comment-card';
|
import { PrCommentCard } from '@/components/ui/pr-comment-card';
|
||||||
import {
|
import {
|
||||||
createDecoratorNode,
|
createDecoratorNode,
|
||||||
type DecoratorNodeConfig,
|
type DecoratorNodeConfig,
|
||||||
@@ -17,19 +17,19 @@ export interface NormalizedComment {
|
|||||||
author: string;
|
author: string;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
url: string;
|
url?: string | null;
|
||||||
// Review-specific (optional)
|
// Review-specific (optional)
|
||||||
path?: string;
|
path?: string;
|
||||||
line?: number | null;
|
line?: number | null;
|
||||||
diff_hunk?: string;
|
diff_hunk?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SerializedGitHubCommentNode = Spread<
|
export type SerializedPrCommentNode = Spread<
|
||||||
NormalizedComment,
|
NormalizedComment,
|
||||||
SerializedLexicalNode
|
SerializedLexicalNode
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function GitHubCommentComponent({
|
function PrCommentComponent({
|
||||||
data,
|
data,
|
||||||
onDoubleClickEdit,
|
onDoubleClickEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -41,14 +41,16 @@ function GitHubCommentComponent({
|
|||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
// Open GitHub URL in new tab
|
// Open URL in new tab if available
|
||||||
window.open(data.url, '_blank', 'noopener,noreferrer');
|
if (data.url) {
|
||||||
|
window.open(data.url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[data.url]
|
[data.url]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GitHubCommentCard
|
<PrCommentCard
|
||||||
author={data.author}
|
author={data.author}
|
||||||
body={data.body}
|
body={data.body}
|
||||||
createdAt={data.created_at}
|
createdAt={data.created_at}
|
||||||
@@ -72,23 +74,22 @@ const config: DecoratorNodeConfig<NormalizedComment> = {
|
|||||||
serialize: (data) => JSON.stringify(data, null, 2),
|
serialize: (data) => JSON.stringify(data, null, 2),
|
||||||
deserialize: (content) => JSON.parse(content),
|
deserialize: (content) => JSON.parse(content),
|
||||||
validate: (data) =>
|
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) => {
|
exportDOM: (data) => {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.setAttribute('data-github-comment-id', data.id);
|
span.setAttribute('data-pr-comment-id', data.id);
|
||||||
span.textContent = `GitHub comment by @${data.author}: ${data.body}`;
|
span.textContent = `PR comment by @${data.author}: ${data.body}`;
|
||||||
return span;
|
return span;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = createDecoratorNode(config);
|
const result = createDecoratorNode(config);
|
||||||
|
|
||||||
export const GitHubCommentNode = result.Node;
|
export const PrCommentNode = result.Node;
|
||||||
export type GitHubCommentNodeInstance =
|
export type PrCommentNodeInstance = GeneratedDecoratorNode<NormalizedComment>;
|
||||||
GeneratedDecoratorNode<NormalizedComment>;
|
export const $createPrCommentNode = result.createNode;
|
||||||
export const $createGitHubCommentNode = result.createNode;
|
export const $isPrCommentNode = result.isNode;
|
||||||
export const $isGitHubCommentNode = result.isNode;
|
export const [PR_COMMENT_EXPORT_TRANSFORMER, PR_COMMENT_TRANSFORMER] =
|
||||||
export const [GITHUB_COMMENT_EXPORT_TRANSFORMER, GITHUB_COMMENT_TRANSFORMER] =
|
|
||||||
result.transformers;
|
result.transformers;
|
||||||
@@ -496,10 +496,10 @@
|
|||||||
"description": "You need to sign in before you can share tasks. We will redirect you to the sign-in page.",
|
"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"
|
"action": "Go to sign in"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "Connect GitHub",
|
"title": "Connect Git Provider",
|
||||||
"description": "Connect your GitHub account to share tasks. This project must have a remote repository on GitHub.",
|
"description": "Connect your Git provider account to share tasks. This project must have a remote repository.",
|
||||||
"action": "Connect GitHub"
|
"action": "Connect"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "Link this project to an organization before sharing tasks.",
|
"description": "Link this project to an organization before sharing tasks.",
|
||||||
@@ -513,8 +513,8 @@
|
|||||||
"closeButton": "Close"
|
"closeButton": "Close"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "Create GitHub Pull Request",
|
"title": "Create Pull Request",
|
||||||
"description": "Create a pull request for this task attempt on GitHub.",
|
"description": "Create a pull request for this task attempt.",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
"titlePlaceholder": "Enter PR title",
|
"titlePlaceholder": "Enter PR title",
|
||||||
"descriptionLabel": "Description (optional)",
|
"descriptionLabel": "Description (optional)",
|
||||||
@@ -527,9 +527,9 @@
|
|||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
"createButton": "Create PR",
|
"createButton": "Create PR",
|
||||||
"errors": {
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"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"
|
"finish": "Finish"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Select GitHub Comments",
|
"title": "Select PR Comments",
|
||||||
"noComments": "No comments found on this PR",
|
"noComments": "No comments found on this PR",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
"deselectAll": "Deselect All",
|
"deselectAll": "Deselect All",
|
||||||
@@ -580,7 +580,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
"tooltip": "Click to view on GitHub, double-click to edit"
|
"tooltip": "Click to view, double-click to edit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -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.",
|
"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"
|
"action": "Ir a iniciar sesión"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "Conecta GitHub",
|
"title": "Conectar proveedor de Git",
|
||||||
"description": "Conecta tu cuenta de GitHub para que podamos publicar tareas compartidas por ti.",
|
"description": "Conecta tu cuenta de proveedor de Git para compartir tareas. Este proyecto debe tener un repositorio remoto.",
|
||||||
"action": "Conectar GitHub"
|
"action": "Conectar"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "Vincula este proyecto a una organización antes de compartir tareas.",
|
"description": "Vincula este proyecto a una organización antes de compartir tareas.",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"closeButton": "Cerrar"
|
"closeButton": "Cerrar"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "Crear Pull Request de GitHub",
|
"title": "Crear Pull Request",
|
||||||
"description": "Crea un pull request para este intento de tarea en GitHub.",
|
"description": "Crea un pull request para este intento de tarea.",
|
||||||
"titleLabel": "Título",
|
"titleLabel": "Título",
|
||||||
"titlePlaceholder": "Ingresar título del PR",
|
"titlePlaceholder": "Ingresar título del PR",
|
||||||
"descriptionLabel": "Descripción (opcional)",
|
"descriptionLabel": "Descripción (opcional)",
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
"creating": "Creando...",
|
"creating": "Creando...",
|
||||||
"createButton": "Crear PR",
|
"createButton": "Crear PR",
|
||||||
"errors": {
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"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"
|
"finish": "Finalizar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Seleccionar comentarios de GitHub",
|
"title": "Seleccionar comentarios del PR",
|
||||||
"noComments": "No se encontraron comentarios en este PR",
|
"noComments": "No se encontraron comentarios en este PR",
|
||||||
"selectAll": "Seleccionar todo",
|
"selectAll": "Seleccionar todo",
|
||||||
"deselectAll": "Deseleccionar todo",
|
"deselectAll": "Deseleccionar todo",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "Revisión",
|
"review": "Revisión",
|
||||||
"tooltip": "Clic para ver en GitHub, doble clic para editar"
|
"tooltip": "Clic para ver, doble clic para editar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -97,10 +97,10 @@
|
|||||||
"description": "タスクを共有する前にサインインが必要です。サインインページへリダイレクトします。",
|
"description": "タスクを共有する前にサインインが必要です。サインインページへリダイレクトします。",
|
||||||
"action": "サインインへ移動"
|
"action": "サインインへ移動"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "GitHub を接続",
|
"title": "Git プロバイダーを接続",
|
||||||
"description": "共有タスクを公開できるよう、GitHub アカウントを接続してください。",
|
"description": "タスクを共有するには、Git プロバイダーアカウントを接続してください。このプロジェクトにはリモートリポジトリが必要です。",
|
||||||
"action": "GitHub を接続"
|
"action": "接続"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "タスクを共有する前に、このプロジェクトを組織にリンクしてください。",
|
"description": "タスクを共有する前に、このプロジェクトを組織にリンクしてください。",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"closeButton": "閉じる"
|
"closeButton": "閉じる"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "GitHub プルリクエストを作成",
|
"title": "プルリクエストを作成",
|
||||||
"description": "このタスク試行のプルリクエストをGitHubで作成します。",
|
"description": "このタスク試行のプルリクエストを作成します。",
|
||||||
"titleLabel": "タイトル",
|
"titleLabel": "タイトル",
|
||||||
"titlePlaceholder": "PRタイトルを入力",
|
"titlePlaceholder": "PRタイトルを入力",
|
||||||
"descriptionLabel": "説明 (オプション)",
|
"descriptionLabel": "説明 (オプション)",
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
"creating": "作成中...",
|
"creating": "作成中...",
|
||||||
"createButton": "PRを作成",
|
"createButton": "PRを作成",
|
||||||
"errors": {
|
"errors": {
|
||||||
"insufficientPermissions": "権限が不足しています。GitHub CLIに必要な権限があることを確認してください。",
|
"insufficientPermissions": "権限が不足しています。CLIに必要な権限があることを確認してください。",
|
||||||
"repoNotFoundOrNoAccess": "リポジトリが見つからないか、アクセス権がありません。リポジトリへのアクセス権を確認し、認証されていることを確認してください。",
|
"repoNotFoundOrNoAccess": "リポジトリが見つからないか、アクセス権がありません。リポジトリへのアクセス権を確認し、認証されていることを確認してください。",
|
||||||
"failedToCreate": "GitHub PRの作成に失敗しました",
|
"failedToCreate": "PRの作成に失敗しました",
|
||||||
"gitCliNotLoggedIn": "Gitが認証されていません。\"gh auth login\" を実行するかGitの認証情報を設定してから再試行してください。",
|
"gitCliNotLoggedIn": "Gitが認証されていません。\"gh auth login\" を実行するかGitの認証情報を設定してから再試行してください。",
|
||||||
"gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。",
|
"gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。",
|
||||||
"targetBranchNotFound": "ターゲットブランチ '{{branch}}' がリモートに存在しません。プルリクエストを作成する前にブランチが存在することを確認してください。"
|
"targetBranchNotFound": "ターゲットブランチ '{{branch}}' がリモートに存在しません。プルリクエストを作成する前にブランチが存在することを確認してください。"
|
||||||
@@ -503,9 +503,9 @@
|
|||||||
"finish": "完了"
|
"finish": "完了"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "GitHubコメントを選択",
|
"title": "PRコメントを選択",
|
||||||
"noComments": "このPRにコメントはありません",
|
"noComments": "このPRにコメントはありません",
|
||||||
"selectAll": "すべて選択",
|
"selectAll": "すべて選択",
|
||||||
"deselectAll": "すべて選択解除",
|
"deselectAll": "すべて選択解除",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "レビュー",
|
"review": "レビュー",
|
||||||
"tooltip": "クリックでGitHubで表示、ダブルクリックで編集"
|
"tooltip": "クリックで表示、ダブルクリックで編集"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -97,10 +97,10 @@
|
|||||||
"description": "작업을 공유하려면 로그인해야 합니다. 로그인 페이지로 이동합니다.",
|
"description": "작업을 공유하려면 로그인해야 합니다. 로그인 페이지로 이동합니다.",
|
||||||
"action": "로그인으로 이동"
|
"action": "로그인으로 이동"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "GitHub 연결",
|
"title": "Git 제공자 연결",
|
||||||
"description": "공유 작업을 게시하려면 GitHub 계정을 연결하세요.",
|
"description": "작업을 공유하려면 Git 제공자 계정을 연결하세요. 이 프로젝트에는 원격 저장소가 있어야 합니다.",
|
||||||
"action": "GitHub 연결"
|
"action": "연결"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "작업을 공유하기 전에 이 프로젝트를 조직에 연결하세요.",
|
"description": "작업을 공유하기 전에 이 프로젝트를 조직에 연결하세요.",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"closeButton": "닫기"
|
"closeButton": "닫기"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "GitHub Pull Request 생성",
|
"title": "Pull Request 생성",
|
||||||
"description": "이 작업 시도에 대한 Pull Request를 GitHub에서 생성합니다.",
|
"description": "이 작업 시도에 대한 Pull Request를 생성합니다.",
|
||||||
"titleLabel": "제목",
|
"titleLabel": "제목",
|
||||||
"titlePlaceholder": "PR 제목 입력",
|
"titlePlaceholder": "PR 제목 입력",
|
||||||
"descriptionLabel": "설명 (선택사항)",
|
"descriptionLabel": "설명 (선택사항)",
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
"creating": "생성 중...",
|
"creating": "생성 중...",
|
||||||
"createButton": "PR 생성",
|
"createButton": "PR 생성",
|
||||||
"errors": {
|
"errors": {
|
||||||
"insufficientPermissions": "권한이 부족합니다. GitHub CLI에 필요한 권한이 있는지 확인하세요.",
|
"insufficientPermissions": "권한이 부족합니다. CLI에 필요한 권한이 있는지 확인하세요.",
|
||||||
"repoNotFoundOrNoAccess": "저장소를 찾을 수 없거나 액세스 권한이 없습니다. 저장소 액세스를 확인하고 인증되었는지 확인하세요.",
|
"repoNotFoundOrNoAccess": "저장소를 찾을 수 없거나 액세스 권한이 없습니다. 저장소 액세스를 확인하고 인증되었는지 확인하세요.",
|
||||||
"failedToCreate": "GitHub PR 생성에 실패했습니다",
|
"failedToCreate": "PR 생성에 실패했습니다",
|
||||||
"gitCliNotLoggedIn": "Git이 인증되지 않았습니다. \"gh auth login\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.",
|
"gitCliNotLoggedIn": "Git이 인증되지 않았습니다. \"gh auth login\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.",
|
||||||
"gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요.",
|
"gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요.",
|
||||||
"targetBranchNotFound": "대상 브랜치 '{{branch}}'이(가) 원격에 존재하지 않습니다. 풀 리퀘스트를 생성하기 전에 브랜치가 존재하는지 확인하세요."
|
"targetBranchNotFound": "대상 브랜치 '{{branch}}'이(가) 원격에 존재하지 않습니다. 풀 리퀘스트를 생성하기 전에 브랜치가 존재하는지 확인하세요."
|
||||||
@@ -503,9 +503,9 @@
|
|||||||
"finish": "완료"
|
"finish": "완료"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "GitHub 댓글 선택",
|
"title": "PR 댓글 선택",
|
||||||
"noComments": "이 PR에 댓글이 없습니다",
|
"noComments": "이 PR에 댓글이 없습니다",
|
||||||
"selectAll": "모두 선택",
|
"selectAll": "모두 선택",
|
||||||
"deselectAll": "모두 선택 해제",
|
"deselectAll": "모두 선택 해제",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "리뷰",
|
"review": "리뷰",
|
||||||
"tooltip": "클릭하여 GitHub에서 보기, 더블 클릭하여 편집"
|
"tooltip": "클릭하여 보기, 더블 클릭하여 편집"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -430,10 +430,10 @@
|
|||||||
"description": "您需要登录才能共享任务。我们将重定向您到登录页面。",
|
"description": "您需要登录才能共享任务。我们将重定向您到登录页面。",
|
||||||
"action": "前往登录"
|
"action": "前往登录"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "连接 GitHub",
|
"title": "连接 Git 提供商",
|
||||||
"description": "连接您的 GitHub 账户以共享任务。此项目必须在 GitHub 上有远程仓库。",
|
"description": "连接您的 Git 提供商账户以共享任务。此项目必须有远程仓库。",
|
||||||
"action": "连接 GitHub"
|
"action": "连接"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "在共享任务之前,将此项目链接到组织。",
|
"description": "在共享任务之前,将此项目链接到组织。",
|
||||||
@@ -447,8 +447,8 @@
|
|||||||
"closeButton": "关闭"
|
"closeButton": "关闭"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "创建 GitHub 拉取请求",
|
"title": "创建拉取请求",
|
||||||
"description": "在 GitHub 上为此任务尝试创建拉取请求。",
|
"description": "为此任务尝试创建拉取请求。",
|
||||||
"titleLabel": "标题",
|
"titleLabel": "标题",
|
||||||
"titlePlaceholder": "输入 PR 标题",
|
"titlePlaceholder": "输入 PR 标题",
|
||||||
"descriptionLabel": "描述(可选)",
|
"descriptionLabel": "描述(可选)",
|
||||||
@@ -461,9 +461,9 @@
|
|||||||
"creating": "创建中...",
|
"creating": "创建中...",
|
||||||
"createButton": "创建 PR",
|
"createButton": "创建 PR",
|
||||||
"errors": {
|
"errors": {
|
||||||
"insufficientPermissions": "权限不足。请确保 GitHub CLI 具有必要的权限。",
|
"insufficientPermissions": "权限不足。请确保 CLI 具有必要的权限。",
|
||||||
"repoNotFoundOrNoAccess": "未找到仓库或无访问权限。请检查您的仓库访问权限并确保您已通过身份验证。",
|
"repoNotFoundOrNoAccess": "未找到仓库或无访问权限。请检查您的仓库访问权限并确保您已通过身份验证。",
|
||||||
"failedToCreate": "创建 GitHub PR 失败",
|
"failedToCreate": "创建 PR 失败",
|
||||||
"gitCliNotLoggedIn": "Git 未通过身份验证。运行 gh auth login(或配置 Git 凭据)然后重试。",
|
"gitCliNotLoggedIn": "Git 未通过身份验证。运行 gh auth login(或配置 Git 凭据)然后重试。",
|
||||||
"gitCliNotInstalled": "未安装 Git CLI。安装 Git 以创建 PR。",
|
"gitCliNotInstalled": "未安装 Git CLI。安装 Git 以创建 PR。",
|
||||||
"targetBranchNotFound": "远程上不存在目标分支 {{branch}}。请在创建拉取请求之前确保该分支存在。"
|
"targetBranchNotFound": "远程上不存在目标分支 {{branch}}。请在创建拉取请求之前确保该分支存在。"
|
||||||
@@ -503,9 +503,9 @@
|
|||||||
"finish": "完成"
|
"finish": "完成"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "选择 GitHub 评论",
|
"title": "选择 PR 评论",
|
||||||
"noComments": "此 PR 未找到评论",
|
"noComments": "此 PR 未找到评论",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deselectAll": "取消全选",
|
"deselectAll": "取消全选",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "审查",
|
"review": "审查",
|
||||||
"tooltip": "点击在 GitHub 上查看,双击编辑"
|
"tooltip": "点击查看,双击编辑"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -430,10 +430,10 @@
|
|||||||
"description": "您需要登入才能分享任務。我們會將您導向登入頁面。",
|
"description": "您需要登入才能分享任務。我們會將您導向登入頁面。",
|
||||||
"action": "前往登入"
|
"action": "前往登入"
|
||||||
},
|
},
|
||||||
"githubRequired": {
|
"gitProviderRequired": {
|
||||||
"title": "連結 GitHub",
|
"title": "連結 Git 提供者",
|
||||||
"description": "連結您的 GitHub 帳號以分享任務。此專案必須在 GitHub 上有遠端儲存庫。",
|
"description": "連結您的 Git 提供者帳號以分享任務。此專案必須有遠端儲存庫。",
|
||||||
"action": "連結 GitHub"
|
"action": "連結"
|
||||||
},
|
},
|
||||||
"linkProjectRequired": {
|
"linkProjectRequired": {
|
||||||
"description": "分享任務前,請先將此專案連結到組織。",
|
"description": "分享任務前,請先將此專案連結到組織。",
|
||||||
@@ -447,8 +447,8 @@
|
|||||||
"closeButton": "關閉"
|
"closeButton": "關閉"
|
||||||
},
|
},
|
||||||
"createPrDialog": {
|
"createPrDialog": {
|
||||||
"title": "建立 GitHub PR",
|
"title": "建立 PR",
|
||||||
"description": "在 GitHub 上為此任務嘗試建立 PR。",
|
"description": "為此任務嘗試建立 PR。",
|
||||||
"titleLabel": "標題",
|
"titleLabel": "標題",
|
||||||
"titlePlaceholder": "輸入 PR 標題",
|
"titlePlaceholder": "輸入 PR 標題",
|
||||||
"descriptionLabel": "描述(選填)",
|
"descriptionLabel": "描述(選填)",
|
||||||
@@ -461,9 +461,9 @@
|
|||||||
"creating": "建立中...",
|
"creating": "建立中...",
|
||||||
"createButton": "建立 PR",
|
"createButton": "建立 PR",
|
||||||
"errors": {
|
"errors": {
|
||||||
"insufficientPermissions": "權限不足。請確認 GitHub CLI 具有必要權限。",
|
"insufficientPermissions": "權限不足。請確認 CLI 具有必要權限。",
|
||||||
"repoNotFoundOrNoAccess": "找不到儲存庫或沒有存取權。請檢查儲存庫權限並確保已完成驗證。",
|
"repoNotFoundOrNoAccess": "找不到儲存庫或沒有存取權。請檢查儲存庫權限並確保已完成驗證。",
|
||||||
"failedToCreate": "建立 GitHub PR 失敗",
|
"failedToCreate": "建立 PR 失敗",
|
||||||
"gitCliNotLoggedIn": "Git 尚未驗證。請執行 gh auth login(或設定 Git 憑證)後重試。",
|
"gitCliNotLoggedIn": "Git 尚未驗證。請執行 gh auth login(或設定 Git 憑證)後重試。",
|
||||||
"gitCliNotInstalled": "未安裝 Git CLI。請安裝 Git 以建立 PR。",
|
"gitCliNotInstalled": "未安裝 Git CLI。請安裝 Git 以建立 PR。",
|
||||||
"targetBranchNotFound": "遠端不存在目標分支 {{branch}}。建立 PR 前請確認該分支存在。"
|
"targetBranchNotFound": "遠端不存在目標分支 {{branch}}。建立 PR 前請確認該分支存在。"
|
||||||
@@ -503,9 +503,9 @@
|
|||||||
"finish": "完成"
|
"finish": "完成"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"githubComments": {
|
"prComments": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "選擇 GitHub 評論",
|
"title": "選擇 PR 評論",
|
||||||
"noComments": "此 PR 沒有評論",
|
"noComments": "此 PR 沒有評論",
|
||||||
"selectAll": "全選",
|
"selectAll": "全選",
|
||||||
"deselectAll": "取消全選",
|
"deselectAll": "取消全選",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"review": "審查",
|
"review": "審查",
|
||||||
"tooltip": "點擊在 GitHub 上查看,雙擊編輯"
|
"tooltip": "點擊查看,雙擊編輯"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskFormDialog": {
|
"taskFormDialog": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Config,
|
Config,
|
||||||
CreateFollowUpAttempt,
|
CreateFollowUpAttempt,
|
||||||
EditorType,
|
EditorType,
|
||||||
CreateGitHubPrRequest,
|
CreatePrApiRequest,
|
||||||
CreateTask,
|
CreateTask,
|
||||||
CreateAndStartTaskRequest,
|
CreateAndStartTaskRequest,
|
||||||
CreateTaskAttemptBody,
|
CreateTaskAttemptBody,
|
||||||
@@ -71,7 +71,7 @@ import {
|
|||||||
ListInvitationsResponse,
|
ListInvitationsResponse,
|
||||||
OpenEditorResponse,
|
OpenEditorResponse,
|
||||||
OpenEditorRequest,
|
OpenEditorRequest,
|
||||||
CreatePrError,
|
PrError,
|
||||||
Scratch,
|
Scratch,
|
||||||
ScratchType,
|
ScratchType,
|
||||||
CreateScratch,
|
CreateScratch,
|
||||||
@@ -718,13 +718,13 @@ export const attemptsApi = {
|
|||||||
|
|
||||||
createPR: async (
|
createPR: async (
|
||||||
attemptId: string,
|
attemptId: string,
|
||||||
data: CreateGitHubPrRequest
|
data: CreatePrApiRequest
|
||||||
): Promise<Result<string, CreatePrError>> => {
|
): Promise<Result<string, PrError>> => {
|
||||||
const response = await makeRequest(`/api/task-attempts/${attemptId}/pr`, {
|
const response = await makeRequest(`/api/task-attempts/${attemptId}/pr`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
return handleApiResponseAsResult<string, CreatePrError>(response);
|
return handleApiResponseAsResult<string, PrError>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
startDevServer: async (attemptId: string): Promise<void> => {
|
startDevServer: async (attemptId: string): Promise<void> => {
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export type ShareTaskResponse = { shared_task_id: string, };
|
|||||||
|
|
||||||
export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: ExecutorProfileId, repos: Array<WorkspaceRepoInput>, };
|
export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: ExecutorProfileId, repos: Array<WorkspaceRepoInput>, };
|
||||||
|
|
||||||
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, };
|
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 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<Merge>,
|
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<Merge>,
|
||||||
/**
|
/**
|
||||||
@@ -292,11 +292,13 @@ export type AttachExistingPrRequest = { repo_id: string, };
|
|||||||
|
|
||||||
export type PrCommentsResponse = { comments: Array<UnifiedPrComment>, };
|
export type PrCommentsResponse = { comments: Array<UnifiedPrComment>, };
|
||||||
|
|
||||||
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 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<Merge>,
|
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<Merge>,
|
||||||
/**
|
/**
|
||||||
@@ -553,7 +555,7 @@ export type PatchType = { "type": "NORMALIZED_ENTRY", "content": NormalizedEntry
|
|||||||
|
|
||||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
export type JsonValue = number | string | boolean | Array<JsonValue> | { [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}.
|
The PR number is #{pr_number} and the URL is {pr_url}.
|
||||||
|
|
||||||
Analyze the changes in this branch and write:
|
Analyze the changes in this branch and write:
|
||||||
@@ -564,4 +566,4 @@ Analyze the changes in this branch and write:
|
|||||||
- Any important implementation details
|
- Any important implementation details
|
||||||
- At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)"
|
- 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).`;
|
||||||
Reference in New Issue
Block a user