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:
Alex Netsch
2026-01-09 15:50:34 +00:00
committed by GitHub
parent 3ad03a0a99
commit 29595c1929
33 changed files with 2196 additions and 782 deletions

View File

@@ -28,6 +28,7 @@ dirs = "5.0"
git2 = { workspace = true }
tempfile = "3.21"
async-trait = { workspace = true }
enum_dispatch = "0.3.13"
rust-embed = "8.2"
ignore = "0.4"
regex = "1.11.1"

View File

@@ -1601,9 +1601,29 @@ impl GitService {
) -> Result<String, GitServiceError> {
let repo = Repository::open(repo_path)?;
let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference();
let default_remote = self.default_remote_name(&repo);
self.get_remote_from_branch_ref(&repo, &branch_ref)
.map(|r| r.name().unwrap_or(&default_remote).to_string())
self.get_remote_from_branch_ref(&repo, &branch_ref)?
.name()
.map(|name| name.to_string())
.ok_or_else(|| {
GitServiceError::InvalidRepository(format!(
"Remote for branch '{branch_name}' has no name"
))
})
}
/// Get the remote URL for a branch. For remote-tracking branches, uses the branch's remote.
/// For local branches or if remote detection fails, falls back to the default remote.
pub fn get_remote_url_from_branch_or_default(
&self,
repo_path: &Path,
branch_name: &str,
) -> Result<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>(
@@ -1631,7 +1651,7 @@ impl GitService {
})
}
pub fn push_to_github(
pub fn push_to_remote(
&self,
worktree_path: &Path,
branch_name: &str,
@@ -1649,7 +1669,7 @@ impl GitService {
.ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?;
let git_cli = GitCli::new();
if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name, force) {
tracing::error!("Push to GitHub failed: {}", e);
tracing::error!("Push to remote failed: {}", e);
return Err(e.into());
}

View File

@@ -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.
// Handles rename/copy scores like `R100` by matching the first letter.
fn parse_name_status(output: &str) -> Vec<StatusDiffEntry> {

View 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());
}
}

View 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
}
}

View 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
);
}
}

View File

@@ -1,8 +1,7 @@
//! Minimal helpers around the GitHub CLI (`gh`).
//!
//! This module deliberately mirrors the ergonomics of `git_cli.rs` so we can
//! plug in the GitHub CLI for operations the REST client does not cover well.
//! Future work will flesh out richer error handling and testing.
//! This module provides low-level access to the GitHub CLI for operations
//! the REST client does not cover well.
use std::{
ffi::{OsStr, OsString},
@@ -13,55 +12,80 @@ use std::{
use chrono::{DateTime, Utc};
use db::models::merge::{MergeStatus, PullRequestInfo};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde::Deserialize;
use tempfile::NamedTempFile;
use thiserror::Error;
use ts_rs::TS;
use utils::shell::resolve_executable_path_blocking;
use crate::services::github::{CreatePrRequest, GitHubRepoInfo};
use crate::services::git_host::types::{
CreatePrRequest, PrComment, PrCommentAuthor, PrReviewComment, ReviewCommentUser,
};
/// Author information for a PR comment
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PrCommentAuthor {
pub login: String,
#[derive(Debug, Clone)]
pub struct GitHubRepoInfo {
pub owner: String,
pub repo_name: String,
}
/// A single comment on a GitHub PR
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrComment {
pub id: String,
pub author: PrCommentAuthor,
pub author_association: String,
pub body: String,
pub created_at: DateTime<Utc>,
pub url: String,
struct GhCommentResponse {
id: String,
author: Option<GhUserLogin>,
#[serde(default)]
author_association: String,
#[serde(default)]
body: String,
created_at: Option<DateTime<Utc>>,
#[serde(default)]
url: String,
}
/// User information for a review comment (from API response)
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct ReviewCommentUser {
pub login: String,
#[derive(Deserialize)]
struct GhCommentsWrapper {
comments: Vec<GhCommentResponse>,
}
/// An inline review comment on a GitHub PR (from gh api)
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PrReviewComment {
pub id: i64,
pub user: ReviewCommentUser,
pub body: String,
pub created_at: DateTime<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(Deserialize)]
struct GhUserLogin {
login: Option<String>,
}
#[derive(Deserialize)]
struct GhReviewCommentResponse {
id: i64,
user: Option<GhUserLogin>,
#[serde(default)]
body: String,
created_at: Option<DateTime<Utc>>,
#[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)]
pub enum GhCliError {
#[error("GitHub CLI (`gh`) executable not found or not runnable")]
@@ -74,7 +98,6 @@ pub enum GhCliError {
UnexpectedOutput(String),
}
/// Newtype wrapper for invoking the `gh` command.
#[derive(Debug, Clone, Default)]
pub struct GhCli;
@@ -132,6 +155,7 @@ impl GhCli {
Err(GhCliError::CommandFailed(stderr))
}
/// Get repository info (owner and name) from a local repository path.
pub fn get_repo_info(&self, repo_path: &Path) -> Result<GitHubRepoInfo, GhCliError> {
let raw = self.run(["repo", "view", "--json", "owner,name"], Some(repo_path))?;
@@ -159,7 +183,8 @@ impl GhCli {
pub fn create_pr(
&self,
request: &CreatePrRequest,
repo_info: &GitHubRepoInfo,
owner: &str,
repo_name: &str,
) -> Result<PullRequestInfo, GhCliError> {
// Write body to temp file to avoid shell escaping and length issues
let body = request.body.as_deref().unwrap_or("");
@@ -173,10 +198,7 @@ impl GhCli {
args.push(OsString::from("pr"));
args.push(OsString::from("create"));
args.push(OsString::from("--repo"));
args.push(OsString::from(format!(
"{}/{}",
repo_info.owner, repo_info.repo_name
)));
args.push(OsString::from(format!("{}/{}", owner, repo_name)));
args.push(OsString::from("--head"));
args.push(OsString::from(&request.head_branch));
args.push(OsString::from("--base"));
@@ -325,101 +347,96 @@ impl GhCli {
}
fn parse_pr_view(raw: &str) -> Result<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!(
"Failed to parse gh pr view response: {err}; raw: {raw}"
))
})?;
Self::extract_pr_info(&value).ok_or_else(|| {
GhCliError::UnexpectedOutput(format!(
"gh pr view response missing required fields: {value:#?}"
))
})
Ok(Self::pr_response_to_info(pr))
}
fn parse_pr_list(raw: &str) -> Result<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!(
"Failed to parse gh pr list response: {err}; raw: {raw}"
))
})?;
let arr = value.as_array().ok_or_else(|| {
GhCliError::UnexpectedOutput(format!("gh pr list response is not an array: {value:#?}"))
})?;
arr.iter()
.map(|item| {
Self::extract_pr_info(item).ok_or_else(|| {
GhCliError::UnexpectedOutput(format!(
"gh pr list item missing required fields: {item:#?}"
))
})
})
.collect()
Ok(prs.into_iter().map(Self::pr_response_to_info).collect())
}
fn parse_pr_comments(raw: &str) -> Result<Vec<PrComment>, GhCliError> {
let value: Value = serde_json::from_str(raw.trim()).map_err(|err| {
GhCliError::UnexpectedOutput(format!(
"Failed to parse gh pr view --json comments response: {err}; raw: {raw}"
))
})?;
let comments_arr = value
.get("comments")
.and_then(|v| v.as_array())
.ok_or_else(|| {
GhCliError::UnexpectedOutput(format!(
"gh pr view --json comments response missing 'comments' array: {value:#?}"
))
})?;
comments_arr
.iter()
.map(|item| {
serde_json::from_value(item.clone()).map_err(|err| {
GhCliError::UnexpectedOutput(format!(
"Failed to parse PR comment: {err}; item: {item:#?}"
))
})
})
.collect()
}
fn parse_pr_review_comments(raw: &str) -> Result<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,
fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo {
let state = if pr.state.is_empty() {
"OPEN"
} else {
&pr.state
};
PullRequestInfo {
number: pr.number,
url: pr.url,
status: match state.to_ascii_uppercase().as_str() {
"OPEN" => MergeStatus::Open,
"MERGED" => MergeStatus::Merged,
"CLOSED" => MergeStatus::Closed,
_ => MergeStatus::Unknown,
},
merged_at,
merge_commit_sha,
})
merged_at: pr.merged_at,
merge_commit_sha: pr.merge_commit.and_then(|c| c.oid),
}
}
fn parse_pr_comments(raw: &str) -> Result<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())
}
}

View 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
}
}

View 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),
}
}
}

View 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,
}
}
}

View File

@@ -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
}
}

View File

@@ -10,7 +10,7 @@ pub mod file_search_cache;
pub mod filesystem;
pub mod filesystem_watcher;
pub mod git;
pub mod github;
pub mod git_host;
pub mod image;
pub mod notification;
pub mod oauth_credentials;

View File

@@ -16,21 +16,21 @@ use tracing::{debug, error, info};
use crate::services::{
analytics::AnalyticsContext,
github::{GitHubService, GitHubServiceError},
git_host::{self, GitHostError, GitHostProvider},
share::SharePublisher,
};
#[derive(Debug, Error)]
enum PrMonitorError {
#[error(transparent)]
GitHubServiceError(#[from] GitHubServiceError),
GitHostError(#[from] GitHostError),
#[error(transparent)]
WorkspaceError(#[from] WorkspaceError),
#[error(transparent)]
Sqlx(#[from] SqlxError),
}
/// Service to monitor GitHub PRs and update task status when they are merged
/// Service to monitor PRs and update task status when they are merged
pub struct PrMonitorService {
db: DBService,
poll_interval: Duration,
@@ -95,12 +95,8 @@ impl PrMonitorService {
/// Check the status of a specific PR
async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> {
// GitHubService now uses gh CLI, no token needed
let github_service = GitHubService::new()?;
let pr_status = github_service
.update_pr_status(&pr_merge.pr_info.url)
.await?;
let git_host = git_host::GitHostService::from_url(&pr_merge.pr_info.url)?;
let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?;
debug!(
"PR #{} status: {:?} (was open)",
@@ -109,7 +105,7 @@ impl PrMonitorService {
// Update the PR status in the database
if !matches!(&pr_status.status, MergeStatus::Open) {
// Update merge status with the latest information from GitHub
// Update merge status with the latest information from git host
Merge::update_status(
&self.db.pool,
pr_merge.id,

View File

@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::{
RemoteClientError,
services::{git::GitServiceError, github::GitHubServiceError},
services::{git::GitServiceError, git_host::GitHostError},
};
#[derive(Debug, Error)]
@@ -39,7 +39,7 @@ pub enum ShareError {
#[error(transparent)]
Git(#[from] GitServiceError),
#[error(transparent)]
GitHub(#[from] GitHubServiceError),
GitHost(#[from] GitHostError),
#[error("share authentication missing or expired")]
MissingAuth,
#[error("invalid user ID format")]