diff --git a/Cargo.lock b/Cargo.lock index 413d9b93..fda87c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4788,6 +4788,7 @@ dependencies = [ "db", "dirs 5.0.1", "dunce", + "enum_dispatch", "executors", "fst", "futures", diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index c80934a6..1e977ddd 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -119,7 +119,7 @@ fn generate_types_content() -> String { server::routes::shared_tasks::AssignSharedTaskRequest::decl(), server::routes::tasks::ShareTaskResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), - server::routes::task_attempts::pr::CreateGitHubPrRequest::decl(), + server::routes::task_attempts::pr::CreatePrApiRequest::decl(), server::routes::images::ImageResponse::decl(), server::routes::images::ImageMetadata::decl(), server::routes::task_attempts::CreateTaskAttemptBody::decl(), @@ -131,7 +131,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::AbortConflictsRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), server::routes::task_attempts::PushError::decl(), - server::routes::task_attempts::pr::CreatePrError::decl(), + server::routes::task_attempts::pr::PrError::decl(), server::routes::task_attempts::BranchStatus::decl(), server::routes::task_attempts::RunScriptError::decl(), server::routes::task_attempts::DeleteWorkspaceError::decl(), @@ -140,7 +140,8 @@ fn generate_types_content() -> String { server::routes::task_attempts::pr::PrCommentsResponse::decl(), server::routes::task_attempts::pr::GetPrCommentsError::decl(), server::routes::task_attempts::pr::GetPrCommentsQuery::decl(), - services::services::github::UnifiedPrComment::decl(), + services::services::git_host::UnifiedPrComment::decl(), + services::services::git_host::ProviderKind::decl(), server::routes::task_attempts::RepoBranchStatus::decl(), server::routes::task_attempts::UpdateWorkspace::decl(), server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index d26680ea..06b5ce0a 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -16,7 +16,7 @@ use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, git::GitServiceError, - github::GitHubServiceError, + git_host::GitHostError, image::ImageError, project::ProjectServiceError, remote_client::RemoteClientError, @@ -45,7 +45,7 @@ pub enum ApiError { #[error(transparent)] GitService(#[from] GitServiceError), #[error(transparent)] - GitHubService(#[from] GitHubServiceError), + GitHost(#[from] GitHostError), #[error(transparent)] Deployment(#[from] DeploymentError), #[error(transparent)] @@ -120,7 +120,7 @@ impl IntoResponse for ApiError { } _ => (StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError"), }, - ApiError::GitHubService(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHubServiceError"), + ApiError::GitHost(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHostError"), ApiError::Deployment(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DeploymentError"), ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"), ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"), @@ -302,7 +302,7 @@ impl From for ApiError { "GitHub token is required to fetch repository metadata for sharing".to_string(), ), ShareError::Git(err) => ApiError::GitService(err), - ShareError::GitHub(err) => ApiError::GitHubService(err), + ShareError::GitHost(err) => ApiError::GitHost(err), ShareError::MissingAuth => ApiError::Unauthorized, ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()), ShareError::InvalidOrganizationId => { diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 862db213..6c68ad84 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -47,7 +47,6 @@ use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, git::{ConflictOp, GitCliError, GitServiceError}, - github::GitHubService, workspace_manager::WorkspaceManager, }; use sqlx::Error as SqlxError; @@ -526,9 +525,6 @@ pub async fn push_task_attempt_branch( ) -> Result>, ApiError> { let pool = &deployment.db().pool; - let github_service = GitHubService::new()?; - github_service.check_token().await?; - let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? @@ -547,7 +543,7 @@ pub async fn push_task_attempt_branch( match deployment .git() - .push_to_github(&worktree_path, &workspace.branch, false) + .push_to_remote(&worktree_path, &workspace.branch, false) { Ok(_) => Ok(ResponseJson(ApiResponse::success(()))), Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson( @@ -564,9 +560,6 @@ pub async fn force_push_task_attempt_branch( ) -> Result>, ApiError> { let pool = &deployment.db().pool; - let github_service = GitHubService::new()?; - github_service.check_token().await?; - let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? @@ -585,7 +578,7 @@ pub async fn force_push_task_attempt_branch( deployment .git() - .push_to_github(&worktree_path, &workspace.branch, true)?; + .push_to_remote(&worktree_path, &workspace.branch, true)?; Ok(ResponseJson(ApiResponse::success(()))) } @@ -1738,7 +1731,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/push/force", post(force_push_task_attempt_branch)) .route("/rebase", post(rebase_task_attempt)) .route("/conflicts/abort", post(abort_conflicts_task_attempt)) - .route("/pr", post(pr::create_github_pr)) + .route("/pr", post(pr::create_pr)) .route("/pr/attach", post(pr::attach_existing_pr)) .route("/pr/comments", get(pr::get_pr_comments)) .route("/open-editor", post(open_task_attempt_in_editor)) diff --git a/crates/server/src/routes/task_attempts/pr.rs b/crates/server/src/routes/task_attempts/pr.rs index 0c04da9c..d9fb1129 100644 --- a/crates/server/src/routes/task_attempts/pr.rs +++ b/crates/server/src/routes/task_attempts/pr.rs @@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, git::{GitCliError, GitServiceError}, - github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment}, + git_host::{ + self, CreatePrRequest, GitHostError, GitHostProvider, ProviderKind, UnifiedPrComment, + }, }; use ts_rs::TS; use utils::response::ApiResponse; @@ -33,7 +35,7 @@ use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] -pub struct CreateGitHubPrRequest { +pub struct CreatePrApiRequest { pub title: String, pub body: Option, pub target_branch: Option, @@ -46,12 +48,13 @@ pub struct CreateGitHubPrRequest { #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] -pub enum CreatePrError { - GithubCliNotInstalled, - GithubCliNotLoggedIn, +pub enum PrError { + CliNotInstalled { provider: ProviderKind }, + CliNotLoggedIn { provider: ProviderKind }, GitCliNotLoggedIn, GitCliNotInstalled, TargetBranchNotFound { branch: String }, + UnsupportedProvider, } #[derive(Debug, Serialize, TS)] @@ -77,8 +80,8 @@ pub struct PrCommentsResponse { #[ts(tag = "type", rename_all = "snake_case")] pub enum GetPrCommentsError { NoPrAttached, - GithubCliNotInstalled, - GithubCliNotLoggedIn, + CliNotInstalled { provider: ProviderKind }, + CliNotLoggedIn { provider: ProviderKind }, } #[derive(Debug, Deserialize, TS)] @@ -86,7 +89,7 @@ pub struct GetPrCommentsQuery { pub repo_id: Uuid, } -pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the GitHub PR that was just created with a better title and description. +pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the PR that was just created with a better title and description. The PR number is #{pr_number} and the URL is {pr_url}. Analyze the changes in this branch and write: @@ -97,7 +100,7 @@ Analyze the changes in this branch and write: - Any important implementation details - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" -Use `gh pr edit` to update the PR."#; +Use the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps)."#; async fn trigger_pr_description_follow_up( deployment: &DeploymentImpl, @@ -190,11 +193,11 @@ async fn trigger_pr_description_follow_up( Ok(()) } -pub async fn create_github_pr( +pub async fn create_pr( Extension(workspace): Extension, State(deployment): State, - Json(request): Json, -) -> Result>, ApiError> { + Json(request): Json, +) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = @@ -206,7 +209,7 @@ pub async fn create_github_pr( .await? .ok_or(RepoError::NotFound)?; - let repo_path = repo.path; + let repo_path = repo.path.clone(); let target_branch = if let Some(branch) = request.target_branch { branch } else { @@ -218,7 +221,7 @@ pub async fn create_github_pr( .ensure_container_exists(&workspace) .await?; let workspace_path = PathBuf::from(&container_ref); - let worktree_path = workspace_path.join(repo.name); + let worktree_path = workspace_path.join(&repo.name); match deployment .git() @@ -226,40 +229,40 @@ pub async fn create_github_pr( { Ok(false) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::TargetBranchNotFound { + PrError::TargetBranchNotFound { branch: target_branch.clone(), }, ))); } Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, + PrError::GitCliNotLoggedIn, ))); } Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, + PrError::GitCliNotInstalled, ))); } Err(e) => return Err(ApiError::GitService(e)), Ok(true) => {} } - // Push the branch to GitHub first + // Push the branch to remote first if let Err(e) = deployment .git() - .push_to_github(&worktree_path, &workspace.branch, false) + .push_to_remote(&worktree_path, &workspace.branch, false) { - tracing::error!("Failed to push branch to GitHub: {}", e); + tracing::error!("Failed to push branch to remote: {}", e); match e { GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, + PrError::GitCliNotLoggedIn, ))); } GitServiceError::GitCLI(GitCliError::NotAvailable) => { return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, + PrError::GitCliNotInstalled, ))); } _ => return Err(ApiError::GitService(e)), @@ -285,7 +288,29 @@ pub async fn create_github_pr( } else { target_branch }; - // Create the PR using GitHub service + + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo_path, &workspace.branch)?; + + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::UnsupportedProvider) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::UnsupportedProvider, + ))); + } + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); + + // Create the PR let pr_request = CreatePrRequest { title: request.title.clone(), body: request.body.clone(), @@ -293,9 +318,11 @@ pub async fn create_github_pr( base_branch: norm_target_branch_name.clone(), draft: request.draft, }; - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo_path).await?; - match github_service.create_pr(&repo_info, &pr_request).await { + + match git_host + .create_pr(&repo_path, &remote_url, &pr_request) + .await + { Ok(pr_info) => { // Update the workspace with PR information if let Err(e) = Merge::create_pr( @@ -315,11 +342,13 @@ pub async fn create_github_pr( if let Err(e) = utils::browser::open_browser(&pr_info.url).await { tracing::warn!("Failed to open PR in browser: {}", e); } + deployment .track_if_analytics_allowed( - "github_pr_created", + "pr_created", serde_json::json!({ "workspace_id": workspace.id.to_string(), + "provider": format!("{:?}", provider), }), ) .await; @@ -345,18 +374,21 @@ pub async fn create_github_pr( } Err(e) => { tracing::error!( - "Failed to create GitHub PR for attempt {}: {}", + "Failed to create PR for attempt {} using {:?}: {}", workspace.id, + provider, e ); match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled), + GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( + ApiResponse::error_with_data(PrError::CliNotInstalled { + provider: *provider, + }), )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), + GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotLoggedIn { provider }, + ))), + _ => Err(ApiError::GitHost(e)), } } } @@ -366,7 +398,7 @@ pub async fn attach_existing_pr( Extension(workspace): Extension, State(deployment): State, Json(request): Json, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let pool = &deployment.db().pool; let task = workspace @@ -394,13 +426,45 @@ pub async fn attach_existing_pr( }))); } - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo.path).await?; + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo.path, &workspace_repo.target_branch)?; + + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::UnsupportedProvider) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::UnsupportedProvider, + ))); + } + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); // List all PRs for branch (open, closed, and merged) - let prs = github_service - .list_all_prs_for_branch(&repo_info, &workspace.branch) - .await?; + let prs = match git_host + .list_prs_for_branch(&repo.path, &remote_url, &workspace.branch) + .await + { + Ok(prs) => prs, + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotInstalled { provider }, + ))); + } + Err(GitHostError::AuthFailed(_)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + PrError::CliNotLoggedIn { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; // Take the first PR (prefer open, but also accept merged/closed) if let Some(pr_info) = prs.into_iter().next() { @@ -496,12 +560,24 @@ pub async fn get_pr_comments( } }; - let github_service = GitHubService::new()?; - let repo_info = github_service.get_repo_info(&repo.path).await?; + let remote_url = deployment + .git() + .get_remote_url_from_branch_or_default(&repo.path, &workspace_repo.target_branch)?; - // Fetch comments from GitHub - match github_service - .get_pr_comments(&repo_info, pr_info.number) + let git_host = match git_host::GitHostService::from_url(&remote_url) { + Ok(host) => host, + Err(GitHostError::CliNotInstalled { provider }) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + GetPrCommentsError::CliNotInstalled { provider }, + ))); + } + Err(e) => return Err(ApiError::GitHost(e)), + }; + + let provider = git_host.provider_kind(); + + match git_host + .get_pr_comments(&repo.path, &remote_url, pr_info.number) .await { Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse { @@ -515,13 +591,15 @@ pub async fn get_pr_comments( e ); match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled), + GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( + ApiResponse::error_with_data(GetPrCommentsError::CliNotInstalled { + provider: *provider, + }), )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), + GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( + GetPrCommentsError::CliNotLoggedIn { provider }, + ))), + _ => Err(ApiError::GitHost(e)), } } } diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 2cad13d3..1817cf05 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -28,6 +28,7 @@ dirs = "5.0" git2 = { workspace = true } tempfile = "3.21" async-trait = { workspace = true } +enum_dispatch = "0.3.13" rust-embed = "8.2" ignore = "0.4" regex = "1.11.1" diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index d4e4047e..5562ec0a 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1601,9 +1601,29 @@ impl GitService { ) -> Result { let repo = Repository::open(repo_path)?; let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference(); - let default_remote = self.default_remote_name(&repo); - self.get_remote_from_branch_ref(&repo, &branch_ref) - .map(|r| r.name().unwrap_or(&default_remote).to_string()) + self.get_remote_from_branch_ref(&repo, &branch_ref)? + .name() + .map(|name| name.to_string()) + .ok_or_else(|| { + GitServiceError::InvalidRepository(format!( + "Remote for branch '{branch_name}' has no name" + )) + }) + } + + /// Get the remote URL for a branch. For remote-tracking branches, uses the branch's remote. + /// For local branches or if remote detection fails, falls back to the default remote. + pub fn get_remote_url_from_branch_or_default( + &self, + repo_path: &Path, + branch_name: &str, + ) -> Result { + let remote_name = self + .get_remote_name_from_branch_name(repo_path, branch_name) + .unwrap_or(self.default_remote_name(&Repository::open(repo_path)?)); + let cli = GitCli::new(); + cli.get_remote_url(repo_path, &remote_name) + .map_err(GitServiceError::GitCLI) } fn get_remote_from_branch_ref<'a>( @@ -1631,7 +1651,7 @@ impl GitService { }) } - pub fn push_to_github( + pub fn push_to_remote( &self, worktree_path: &Path, branch_name: &str, @@ -1649,7 +1669,7 @@ impl GitService { .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; let git_cli = GitCli::new(); if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name, force) { - tracing::error!("Push to GitHub failed: {}", e); + tracing::error!("Push to remote failed: {}", e); return Err(e.into()); } diff --git a/crates/services/src/services/git/cli.rs b/crates/services/src/services/git/cli.rs index 2909a09e..d3b4d56c 100644 --- a/crates/services/src/services/git/cli.rs +++ b/crates/services/src/services/git/cli.rs @@ -415,6 +415,21 @@ impl GitCli { } } + pub fn get_remote_url( + &self, + repo_path: &Path, + remote_name: &str, + ) -> Result { + let output = self.git(repo_path, ["remote", "get-url", remote_name])?; + Ok(output.trim().to_string()) + } + + /// Get the default remote name (first remote listed, or "origin" as fallback). + pub fn default_remote_name(&self, repo_path: &Path) -> Result { + let output = self.git(repo_path, ["remote"])?; + Ok(output.lines().next().unwrap_or("origin").to_string()) + } + // Parse `git diff --name-status` output into structured entries. // Handles rename/copy scores like `R100` by matching the first letter. fn parse_name_status(output: &str) -> Vec { diff --git a/crates/services/src/services/git_host/azure/cli.rs b/crates/services/src/services/git_host/azure/cli.rs new file mode 100644 index 00000000..cb5ad420 --- /dev/null +++ b/crates/services/src/services/git_host/azure/cli.rs @@ -0,0 +1,684 @@ +//! Minimal helpers around the Azure CLI (`az repos`). +//! +//! This module provides low-level access to the Azure CLI for Azure DevOps +//! repository and pull request operations. + +use std::{ + ffi::{OsStr, OsString}, + path::Path, + process::Command, +}; + +use chrono::{DateTime, Utc}; +use db::models::merge::{MergeStatus, PullRequestInfo}; +use serde::Deserialize; +use thiserror::Error; +use utils::shell::resolve_executable_path_blocking; + +use crate::services::git_host::types::{CreatePrRequest, UnifiedPrComment}; + +#[derive(Debug, Clone)] +pub struct AzureRepoInfo { + pub organization_url: String, + pub project: String, + pub project_id: String, + pub repo_name: String, + pub repo_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzPrResponse { + pull_request_id: i64, + status: Option, + closed_date: Option, + repository: Option, + last_merge_commit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzRepository { + web_url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzCommit { + commit_id: Option, +} + +#[derive(Deserialize)] +struct AzThreadsResponse { + value: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThread { + comments: Option>, + thread_context: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThreadContext { + file_path: Option, + right_file_start: Option, +} + +#[derive(Deserialize)] +struct AzFilePosition { + line: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzThreadComment { + id: Option, + author: Option, + content: Option, + published_date: Option, + comment_type: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzAuthor { + display_name: Option, +} + +/// Response item from `az repos list` +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AzRepoListItem { + id: String, + name: String, + project: AzRepoProject, + remote_url: String, + ssh_url: Option, +} + +#[derive(Deserialize)] +struct AzRepoProject { + id: String, + name: String, +} + +#[derive(Debug, Error)] +pub enum AzCliError { + #[error("Azure CLI (`az`) executable not found or not runnable")] + NotAvailable, + #[error("Azure CLI command failed: {0}")] + CommandFailed(String), + #[error("Azure CLI authentication failed: {0}")] + AuthFailed(String), + #[error("Azure CLI returned unexpected output: {0}")] + UnexpectedOutput(String), +} + +#[derive(Debug, Clone, Default)] +pub struct AzCli; + +impl AzCli { + pub fn new() -> Self { + Self {} + } + + /// Ensure the Azure CLI binary is discoverable. + fn ensure_available(&self) -> Result<(), AzCliError> { + resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; + Ok(()) + } + + fn run(&self, args: I, dir: Option<&Path>) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.ensure_available()?; + let az = resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; + let mut cmd = Command::new(&az); + + if let Some(d) = dir { + cmd.current_dir(d); + } + + for arg in args { + cmd.arg(arg); + } + tracing::debug!("Running Azure CLI command: {:?} {:?}", az, cmd.get_args()); + + let output = cmd + .output() + .map_err(|err| AzCliError::CommandFailed(err.to_string()))?; + + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + // Check for authentication errors + let lower = stderr.to_ascii_lowercase(); + if lower.contains("az login") + || lower.contains("not logged in") + || lower.contains("authentication") + || lower.contains("unauthorized") + || lower.contains("credentials") + || lower.contains("please run 'az login'") + { + return Err(AzCliError::AuthFailed(stderr)); + } + + Err(AzCliError::CommandFailed(stderr)) + } + pub fn get_repo_info( + &self, + repo_path: &Path, + remote_url: &str, + ) -> Result { + let raw = self.run( + ["repos", "list", "--detect", "true", "--output", "json"], + Some(repo_path), + )?; + + let repos: Vec = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse repos list: {e}; raw: {raw}")) + })?; + + // Find the repo that matches our remote URL (check both HTTPS and SSH) + let is_ssh = remote_url.starts_with("git@") || remote_url.starts_with("ssh://"); + let repo = repos + .into_iter() + .find(|r| { + if is_ssh { + r.ssh_url + .as_ref() + .map(|ssh| Self::urls_match(ssh, remote_url)) + .unwrap_or(false) + } else { + Self::urls_match(&r.remote_url, remote_url) + } + }) + .ok_or_else(|| { + AzCliError::UnexpectedOutput(format!( + "No repo found matching remote URL: {}", + remote_url + )) + })?; + + let organization_url = + Self::extract_organization_url(&repo.remote_url).ok_or_else(|| { + AzCliError::UnexpectedOutput(format!( + "Could not extract organization URL from: {}", + repo.remote_url + )) + })?; + + tracing::debug!( + "Got Azure DevOps repo info: org_url='{}', project='{}' ({}), repo='{}' ({})", + organization_url, + repo.project.name, + repo.project.id, + repo.name, + repo.id + ); + + Ok(AzureRepoInfo { + organization_url, + project: repo.project.name, + project_id: repo.project.id, + repo_name: repo.name, + repo_id: repo.id, + }) + } + + fn urls_match(url1: &str, url2: &str) -> bool { + let normalize = |url: &str| { + let mut s = url.to_lowercase(); + // Normalize ssh:// prefix to scp-style + if let Some(rest) = s.strip_prefix("ssh://") { + s = rest.to_string(); + } + s.trim_end_matches('/').trim_end_matches(".git").to_string() + }; + normalize(url1) == normalize(url2) + } + + /// Extract the organization URL from a remote URL. + /// Returns the base URL that can be used with Azure CLI commands. + fn extract_organization_url(url: &str) -> Option { + // dev.azure.com format: https://dev.azure.com/{org}/... -> https://dev.azure.com/{org} + if url.contains("dev.azure.com") { + let parts: Vec<&str> = url.split('/').collect(); + let azure_idx = parts.iter().position(|&p| p.contains("dev.azure.com"))?; + let org = parts.get(azure_idx + 1)?; + return Some(format!("https://dev.azure.com/{}", org)); + } + + // Legacy format: https://{org}.visualstudio.com/... -> https://{org}.visualstudio.com + if url.contains(".visualstudio.com") { + let parts: Vec<&str> = url.split('/').collect(); + for part in parts.iter() { + if part.contains(".visualstudio.com") { + return Some(format!("https://{}", part)); + } + } + } + + None + } + + pub fn create_pr( + &self, + request: &CreatePrRequest, + organization_url: &str, + project: &str, + repo_name: &str, + ) -> Result { + let body = request.body.as_deref().unwrap_or(""); + + let mut args: Vec = Vec::with_capacity(20); + args.push(OsString::from("repos")); + args.push(OsString::from("pr")); + args.push(OsString::from("create")); + args.push(OsString::from("--organization")); + args.push(OsString::from(organization_url)); + args.push(OsString::from("--project")); + args.push(OsString::from(project)); + args.push(OsString::from("--repository")); + args.push(OsString::from(repo_name)); + args.push(OsString::from("--source-branch")); + args.push(OsString::from(&request.head_branch)); + args.push(OsString::from("--target-branch")); + args.push(OsString::from(&request.base_branch)); + args.push(OsString::from("--title")); + args.push(OsString::from(&request.title)); + args.push(OsString::from("--description")); + args.push(OsString::from(body)); + args.push(OsString::from("--output")); + args.push(OsString::from("json")); + + if request.draft.unwrap_or(false) { + args.push(OsString::from("--draft")); + } + + let raw = self.run(args, None)?; + Self::parse_pr_response(&raw) + } + + pub fn check_auth(&self) -> Result<(), AzCliError> { + match self.run(["account", "show"], None) { + Ok(_) => Ok(()), + Err(AzCliError::CommandFailed(msg)) => Err(AzCliError::AuthFailed(msg)), + Err(err) => Err(err), + } + } + + pub fn view_pr(&self, pr_url: &str) -> Result { + let (organization, pr_id) = Self::parse_pr_url(pr_url).ok_or_else(|| { + AzCliError::UnexpectedOutput(format!("Could not parse Azure DevOps PR URL: {pr_url}")) + })?; + + let org_url = format!("https://dev.azure.com/{}", organization); + + let raw = self.run( + [ + "repos", + "pr", + "show", + "--id", + &pr_id.to_string(), + "--organization", + &org_url, + "--output", + "json", + ], + None, + )?; + + Self::parse_pr_response(&raw) + } + + pub fn list_prs_for_branch( + &self, + organization_url: &str, + project: &str, + repo_name: &str, + branch: &str, + ) -> Result, AzCliError> { + let raw = self.run( + [ + "repos", + "pr", + "list", + "--organization", + organization_url, + "--project", + project, + "--repository", + repo_name, + "--source-branch", + branch, + "--status", + "all", + "--output", + "json", + ], + None, + )?; + + Self::parse_pr_list_response(&raw) + } + + pub fn get_pr_threads( + &self, + organization_url: &str, + project_id: &str, + repo_id: &str, + pr_id: i64, + ) -> Result, AzCliError> { + let mut args: Vec = Vec::with_capacity(16); + args.push(OsString::from("devops")); + args.push(OsString::from("invoke")); + args.push(OsString::from("--area")); + args.push(OsString::from("git")); + args.push(OsString::from("--resource")); + args.push(OsString::from("pullRequestThreads")); + args.push(OsString::from("--route-parameters")); + args.push(OsString::from(format!("project={}", project_id))); + args.push(OsString::from(format!("repositoryId={}", repo_id))); + args.push(OsString::from(format!("pullRequestId={}", pr_id))); + args.push(OsString::from("--organization")); + args.push(OsString::from(organization_url)); + args.push(OsString::from("--api-version")); + args.push(OsString::from("7.0")); + args.push(OsString::from("--output")); + args.push(OsString::from("json")); + + let raw = self.run(args, None)?; + Self::parse_pr_threads(&raw) + } + + /// Parse PR URL to extract organization and PR ID. + /// + /// Only extracts the minimal info needed for `az repos pr show`. + /// Format: `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}` + pub fn parse_pr_url(url: &str) -> Option<(String, i64)> { + let url_lower = url.to_lowercase(); + + if url_lower.contains("dev.azure.com") && url_lower.contains("/pullrequest/") { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") + && parts.len() > pr_idx + 1 + { + let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; + if let Some(azure_idx) = parts.iter().position(|&p| p.contains("dev.azure.com")) + && parts.len() > azure_idx + 1 + { + let organization = parts[azure_idx + 1].to_string(); + return Some((organization, pr_id)); + } + } + } + + // Legacy format: https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id} + if url_lower.contains(".visualstudio.com") && url_lower.contains("/pullrequest/") { + let parts: Vec<&str> = url.split('/').collect(); + for part in parts.iter() { + if let Some(org) = part.strip_suffix(".visualstudio.com") + && let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") + && parts.len() > pr_idx + 1 + { + let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; + return Some((org.to_string(), pr_id)); + } + } + } + + None + } +} + +impl AzCli { + /// Parse PR response from Azure CLI. + /// Works for both `az repos pr create` and `az repos pr show`. + fn parse_pr_response(raw: &str) -> Result { + let pr: AzPrResponse = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse PR response: {e}; raw: {raw}")) + })?; + Ok(Self::az_pr_to_info(pr)) + } + + fn parse_pr_list_response(raw: &str) -> Result, AzCliError> { + let prs: Vec = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse PR list: {e}; raw: {raw}")) + })?; + Ok(prs.into_iter().map(Self::az_pr_to_info).collect()) + } + + /// Convert Azure PR response to PullRequestInfo. + fn az_pr_to_info(pr: AzPrResponse) -> PullRequestInfo { + let url = pr + .repository + .and_then(|r| r.web_url) + .map(|u| format!("{}/pullrequest/{}", u, pr.pull_request_id)) + .unwrap_or_else(|| format!("pullrequest/{}", pr.pull_request_id)); + + let status = pr.status.as_deref().unwrap_or("active"); + let merged_at = pr + .closed_date + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let merge_commit_sha = pr.last_merge_commit.and_then(|c| c.commit_id); + + PullRequestInfo { + number: pr.pull_request_id, + url, + status: Self::map_azure_status(status), + merged_at, + merge_commit_sha, + } + } + + fn parse_pr_threads(raw: &str) -> Result, AzCliError> { + // REST API returns { "value": [...threads...] } wrapper + let response: AzThreadsResponse = serde_json::from_str(raw.trim()).map_err(|e| { + AzCliError::UnexpectedOutput(format!("Failed to parse threads: {e}; raw: {raw}")) + })?; + let threads = response.value; + + let mut comments = Vec::new(); + + for thread in threads { + let file_path = thread + .thread_context + .as_ref() + .and_then(|c| c.file_path.clone()); + let line = thread + .thread_context + .as_ref() + .and_then(|c| c.right_file_start.as_ref()) + .and_then(|p| p.line); + + if let Some(thread_comments) = thread.comments { + for c in thread_comments { + // Skip system-generated comments + if c.comment_type.as_deref() == Some("system") { + continue; + } + + let id = c.id.unwrap_or(0); + let author = c + .author + .and_then(|a| a.display_name) + .unwrap_or_else(|| "unknown".to_string()); + let body = c.content.unwrap_or_default(); + let created_at = c + .published_date + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + if let Some(ref path) = file_path { + comments.push(UnifiedPrComment::Review { + id, + author, + author_association: None, + body, + created_at, + url: None, + path: path.clone(), + line, + diff_hunk: None, + }); + } else { + comments.push(UnifiedPrComment::General { + id: id.to_string(), + author, + author_association: None, + body, + created_at, + url: None, + }); + } + } + } + } + + comments.sort_by_key(|c| c.created_at()); + Ok(comments) + } + + /// Map Azure DevOps PR status to MergeStatus + fn map_azure_status(status: &str) -> MergeStatus { + match status.to_lowercase().as_str() { + "active" => MergeStatus::Open, + "completed" => MergeStatus::Merged, + "abandoned" => MergeStatus::Closed, + _ => MergeStatus::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pr_url() { + // dev.azure.com format + let (org, id) = AzCli::parse_pr_url( + "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123", + ) + .unwrap(); + assert_eq!(org, "myorg"); + assert_eq!(id, 123); + } + + #[test] + fn test_parse_pr_url_visualstudio() { + // Legacy visualstudio.com format + let (org, id) = AzCli::parse_pr_url( + "https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/456", + ) + .unwrap(); + assert_eq!(org, "myorg"); + assert_eq!(id, 456); + } + + #[test] + fn test_parse_pr_url_invalid() { + // GitHub URL should return None + assert!(AzCli::parse_pr_url("https://github.com/owner/repo/pull/123").is_none()); + // Missing pullrequest path + assert!(AzCli::parse_pr_url("https://dev.azure.com/myorg/myproject/_git/myrepo").is_none()); + } + + #[test] + fn test_map_azure_status() { + assert!(matches!( + AzCli::map_azure_status("active"), + MergeStatus::Open + )); + assert!(matches!( + AzCli::map_azure_status("completed"), + MergeStatus::Merged + )); + assert!(matches!( + AzCli::map_azure_status("abandoned"), + MergeStatus::Closed + )); + assert!(matches!( + AzCli::map_azure_status("unknown"), + MergeStatus::Unknown + )); + } + + #[test] + fn test_urls_match() { + // Exact match + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Trailing slash + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo/", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // .git suffix + assert!(AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/myrepo.git", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Case insensitive + assert!(AzCli::urls_match( + "https://dev.azure.com/MyOrg/MyProject/_git/MyRepo", + "https://dev.azure.com/myorg/myproject/_git/myrepo" + )); + + // Different repos should not match + assert!(!AzCli::urls_match( + "https://dev.azure.com/myorg/myproject/_git/repo1", + "https://dev.azure.com/myorg/myproject/_git/repo2" + )); + + // SSH URLs + assert!(AzCli::urls_match( + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + )); + + // SSH URL with ssh:// prefix should match scp-style + assert!(AzCli::urls_match( + "ssh://git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + )); + } + + #[test] + fn test_extract_organization_url_dev_azure() { + let org_url = + AzCli::extract_organization_url("https://dev.azure.com/myorg/myproject/_git/myrepo") + .unwrap(); + assert_eq!(org_url, "https://dev.azure.com/myorg"); + } + + #[test] + fn test_extract_organization_url_visualstudio() { + let org_url = + AzCli::extract_organization_url("https://myorg.visualstudio.com/myproject/_git/myrepo") + .unwrap(); + assert_eq!(org_url, "https://myorg.visualstudio.com"); + } + + #[test] + fn test_extract_organization_url_invalid() { + assert!(AzCli::extract_organization_url("https://github.com/owner/repo").is_none()); + } +} diff --git a/crates/services/src/services/git_host/azure/mod.rs b/crates/services/src/services/git_host/azure/mod.rs new file mode 100644 index 00000000..9e37859a --- /dev/null +++ b/crates/services/src/services/git_host/azure/mod.rs @@ -0,0 +1,281 @@ +//! Azure DevOps hosting service implementation. + +mod cli; + +use std::{path::Path, time::Duration}; + +use async_trait::async_trait; +use backon::{ExponentialBuilder, Retryable}; +pub use cli::AzCli; +use cli::{AzCliError, AzureRepoInfo}; +use db::models::merge::PullRequestInfo; +use tokio::task; +use tracing::info; + +use super::{ + GitHostProvider, + types::{CreatePrRequest, GitHostError, ProviderKind, UnifiedPrComment}, +}; + +#[derive(Debug, Clone)] +pub struct AzureDevOpsProvider { + az_cli: AzCli, +} + +impl AzureDevOpsProvider { + pub fn new() -> Result { + Ok(Self { + az_cli: AzCli::new(), + }) + } + + async fn get_repo_info( + &self, + repo_path: &Path, + remote_url: &str, + ) -> Result { + let cli = self.az_cli.clone(); + let path = repo_path.to_path_buf(); + let url = remote_url.to_string(); + task::spawn_blocking(move || cli.get_repo_info(&path, &url)) + .await + .map_err(|err| GitHostError::Repository(format!("Failed to get repo info: {err}")))? + .map_err(Into::into) + } + + async fn check_auth(&self) -> Result<(), GitHostError> { + let cli = self.az_cli.clone(); + task::spawn_blocking(move || cli.check_auth()) + .await + .map_err(|err| { + GitHostError::Repository(format!( + "Failed to execute Azure CLI for auth check: {err}" + )) + })? + .map_err(|err| match err { + AzCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::AzureDevOps, + }, + AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg), + AzCliError::CommandFailed(msg) => { + GitHostError::Repository(format!("Azure CLI auth check failed: {msg}")) + } + AzCliError::UnexpectedOutput(msg) => GitHostError::Repository(format!( + "Unexpected output from Azure CLI auth check: {msg}" + )), + }) + } +} + +impl From for GitHostError { + fn from(error: AzCliError) -> Self { + match &error { + AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), + AzCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::AzureDevOps, + }, + AzCliError::CommandFailed(msg) => { + let lower = msg.to_ascii_lowercase(); + if lower.contains("403") || lower.contains("forbidden") { + GitHostError::InsufficientPermissions(msg.clone()) + } else if lower.contains("404") || lower.contains("not found") { + GitHostError::RepoNotFoundOrNoAccess(msg.clone()) + } else { + GitHostError::PullRequest(msg.clone()) + } + } + AzCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), + } + } +} + +#[async_trait] +impl GitHostProvider for AzureDevOpsProvider { + async fn create_pr( + &self, + repo_path: &Path, + remote_url: &str, + request: &CreatePrRequest, + ) -> Result { + // Check auth first + self.check_auth().await?; + + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + let request_clone = request.clone(); + + (|| async { + let cli = cli.clone(); + let request = request_clone.clone(); + let organization_url = repo_info.organization_url.clone(); + let project = repo_info.project.clone(); + let repo_name = repo_info.repo_name.clone(); + + let cli_result = task::spawn_blocking(move || { + cli.create_pr(&request, &organization_url, &project, &repo_name) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for PR creation: {err}" + )) + })? + .map_err(GitHostError::from)?; + + info!( + "Created Azure DevOps PR #{} for branch {}", + cli_result.number, request_clone.head_branch + ); + + Ok(cli_result) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_status(&self, pr_url: &str) -> Result { + let cli = self.az_cli.clone(); + let url = pr_url.to_string(); + + (|| async { + let cli = cli.clone(); + let url = url.clone(); + + let pr = task::spawn_blocking(move || cli.view_pr(&url)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for viewing PR: {err}" + )) + })?; + pr.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|err: &GitHostError| err.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + let branch = branch_name.to_string(); + + (|| async { + let cli = cli.clone(); + let organization_url = repo_info.organization_url.clone(); + let project = repo_info.project.clone(); + let repo_name = repo_info.repo_name.clone(); + let branch = branch.clone(); + + let prs = task::spawn_blocking(move || { + cli.list_prs_for_branch(&organization_url, &project, &repo_name, &branch) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for listing PRs: {err}" + )) + })?; + prs.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_comments( + &self, + repo_path: &Path, + remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path, remote_url).await?; + + let cli = self.az_cli.clone(); + + (|| async { + let cli = cli.clone(); + let organization_url = repo_info.organization_url.clone(); + let project_id = repo_info.project_id.clone(); + let repo_id = repo_info.repo_id.clone(); + + let comments = task::spawn_blocking(move || { + cli.get_pr_threads(&organization_url, &project_id, &repo_id, pr_number) + }) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute Azure CLI for fetching PR comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "Azure DevOps API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + fn provider_kind(&self) -> ProviderKind { + ProviderKind::AzureDevOps + } +} diff --git a/crates/services/src/services/git_host/detection.rs b/crates/services/src/services/git_host/detection.rs new file mode 100644 index 00000000..392f85d9 --- /dev/null +++ b/crates/services/src/services/git_host/detection.rs @@ -0,0 +1,178 @@ +//! Git hosting provider detection from repository URLs. + +use super::types::ProviderKind; + +/// Detect the git hosting provider from a remote URL. +/// +/// Supports: +/// - GitHub.com: `https://github.com/owner/repo` or `git@github.com:owner/repo.git` +/// - GitHub Enterprise: URLs containing `github.` (e.g., `https://github.company.com/owner/repo`) +/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo` or legacy `https://org.visualstudio.com/...` +pub fn detect_provider_from_url(url: &str) -> ProviderKind { + let url_lower = url.to_lowercase(); + + if url_lower.contains("github.com") { + return ProviderKind::GitHub; + } + + // Check Azure patterns before GHE to avoid false positives + if url_lower.contains("dev.azure.com") + || url_lower.contains(".visualstudio.com") + || url_lower.contains("ssh.dev.azure.com") + { + return ProviderKind::AzureDevOps; + } + + // /_git/ is unique to Azure DevOps + if url_lower.contains("/_git/") { + return ProviderKind::AzureDevOps; + } + + // GitHub Enterprise (contains "github." but not the Azure patterns above) + if url_lower.contains("github.") { + return ProviderKind::GitHub; + } + + ProviderKind::Unknown +} + +/// Detect the git hosting provider from a PR URL. +/// +/// Supports: +/// - GitHub: `https://github.com/owner/repo/pull/123` +/// - GitHub Enterprise: `https://github.company.com/owner/repo/pull/123` +/// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo/pullrequest/123` +#[cfg(test)] +fn detect_provider_from_pr_url(pr_url: &str) -> ProviderKind { + let url_lower = pr_url.to_lowercase(); + + // GitHub pattern: contains /pull/ in the path + if url_lower.contains("/pull/") { + // Could be github.com or GHE + if url_lower.contains("github.com") || url_lower.contains("github.") { + return ProviderKind::GitHub; + } + } + + // Azure DevOps pattern: contains /pullrequest/ in the path + if url_lower.contains("/pullrequest/") { + return ProviderKind::AzureDevOps; + } + + // Fall back to general URL detection + detect_provider_from_url(pr_url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_github_com_https() { + assert_eq!( + detect_provider_from_url("https://github.com/owner/repo"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("https://github.com/owner/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_github_com_ssh() { + assert_eq!( + detect_provider_from_url("git@github.com:owner/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_github_enterprise() { + assert_eq!( + detect_provider_from_url("https://github.company.com/owner/repo"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("https://github.acme.corp/team/project"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_url("git@github.internal.io:org/repo.git"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_azure_devops_https() { + assert_eq!( + detect_provider_from_url("https://dev.azure.com/org/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_ssh() { + assert_eq!( + detect_provider_from_url("git@ssh.dev.azure.com:v3/org/project/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_legacy_visualstudio() { + assert_eq!( + detect_provider_from_url("https://org.visualstudio.com/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_azure_devops_git_path() { + // Any URL with /_git/ is Azure DevOps + assert_eq!( + detect_provider_from_url("https://custom.domain.com/org/project/_git/repo"), + ProviderKind::AzureDevOps + ); + } + + #[test] + fn test_unknown_provider() { + assert_eq!( + detect_provider_from_url("https://gitlab.com/owner/repo"), + ProviderKind::Unknown + ); + assert_eq!( + detect_provider_from_url("https://bitbucket.org/owner/repo"), + ProviderKind::Unknown + ); + } + + #[test] + fn test_pr_url_github() { + assert_eq!( + detect_provider_from_pr_url("https://github.com/owner/repo/pull/123"), + ProviderKind::GitHub + ); + assert_eq!( + detect_provider_from_pr_url("https://github.company.com/owner/repo/pull/456"), + ProviderKind::GitHub + ); + } + + #[test] + fn test_pr_url_azure() { + assert_eq!( + detect_provider_from_pr_url( + "https://dev.azure.com/org/project/_git/repo/pullrequest/123" + ), + ProviderKind::AzureDevOps + ); + assert_eq!( + detect_provider_from_pr_url( + "https://org.visualstudio.com/project/_git/repo/pullrequest/456" + ), + ProviderKind::AzureDevOps + ); + } +} diff --git a/crates/services/src/services/github/cli.rs b/crates/services/src/services/git_host/github/cli.rs similarity index 69% rename from crates/services/src/services/github/cli.rs rename to crates/services/src/services/git_host/github/cli.rs index 11a5734f..78705b87 100644 --- a/crates/services/src/services/github/cli.rs +++ b/crates/services/src/services/git_host/github/cli.rs @@ -1,8 +1,7 @@ //! Minimal helpers around the GitHub CLI (`gh`). //! -//! This module deliberately mirrors the ergonomics of `git_cli.rs` so we can -//! plug in the GitHub CLI for operations the REST client does not cover well. -//! Future work will flesh out richer error handling and testing. +//! This module provides low-level access to the GitHub CLI for operations +//! the REST client does not cover well. use std::{ ffi::{OsStr, OsString}, @@ -13,55 +12,80 @@ use std::{ use chrono::{DateTime, Utc}; use db::models::merge::{MergeStatus, PullRequestInfo}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::Deserialize; use tempfile::NamedTempFile; use thiserror::Error; -use ts_rs::TS; use utils::shell::resolve_executable_path_blocking; -use crate::services::github::{CreatePrRequest, GitHubRepoInfo}; +use crate::services::git_host::types::{ + CreatePrRequest, PrComment, PrCommentAuthor, PrReviewComment, ReviewCommentUser, +}; -/// Author information for a PR comment -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PrCommentAuthor { - pub login: String, +#[derive(Debug, Clone)] +pub struct GitHubRepoInfo { + pub owner: String, + pub repo_name: String, } -/// A single comment on a GitHub PR -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PrComment { - pub id: String, - pub author: PrCommentAuthor, - pub author_association: String, - pub body: String, - pub created_at: DateTime, - pub url: String, +struct GhCommentResponse { + id: String, + author: Option, + #[serde(default)] + author_association: String, + #[serde(default)] + body: String, + created_at: Option>, + #[serde(default)] + url: String, } -/// User information for a review comment (from API response) -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct ReviewCommentUser { - pub login: String, +#[derive(Deserialize)] +struct GhCommentsWrapper { + comments: Vec, } -/// An inline review comment on a GitHub PR (from gh api) -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PrReviewComment { - pub id: i64, - pub user: ReviewCommentUser, - pub body: String, - pub created_at: DateTime, - pub html_url: String, - pub path: String, - pub line: Option, - pub side: Option, - pub diff_hunk: String, - pub author_association: String, +#[derive(Deserialize)] +struct GhUserLogin { + login: Option, +} + +#[derive(Deserialize)] +struct GhReviewCommentResponse { + id: i64, + user: Option, + #[serde(default)] + body: String, + created_at: Option>, + #[serde(default)] + html_url: String, + #[serde(default)] + path: String, + line: Option, + side: Option, + #[serde(default)] + diff_hunk: String, + #[serde(default)] + author_association: String, +} + +#[derive(Deserialize)] +struct GhMergeCommit { + oid: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GhPrResponse { + number: i64, + url: String, + #[serde(default)] + state: String, + merged_at: Option>, + merge_commit: Option, } -/// High-level errors originating from the GitHub CLI. #[derive(Debug, Error)] pub enum GhCliError { #[error("GitHub CLI (`gh`) executable not found or not runnable")] @@ -74,7 +98,6 @@ pub enum GhCliError { UnexpectedOutput(String), } -/// Newtype wrapper for invoking the `gh` command. #[derive(Debug, Clone, Default)] pub struct GhCli; @@ -132,6 +155,7 @@ impl GhCli { Err(GhCliError::CommandFailed(stderr)) } + /// Get repository info (owner and name) from a local repository path. pub fn get_repo_info(&self, repo_path: &Path) -> Result { let raw = self.run(["repo", "view", "--json", "owner,name"], Some(repo_path))?; @@ -159,7 +183,8 @@ impl GhCli { pub fn create_pr( &self, request: &CreatePrRequest, - repo_info: &GitHubRepoInfo, + owner: &str, + repo_name: &str, ) -> Result { // Write body to temp file to avoid shell escaping and length issues let body = request.body.as_deref().unwrap_or(""); @@ -173,10 +198,7 @@ impl GhCli { args.push(OsString::from("pr")); args.push(OsString::from("create")); args.push(OsString::from("--repo")); - args.push(OsString::from(format!( - "{}/{}", - repo_info.owner, repo_info.repo_name - ))); + args.push(OsString::from(format!("{}/{}", owner, repo_name))); args.push(OsString::from("--head")); args.push(OsString::from(&request.head_branch)); args.push(OsString::from("--base")); @@ -325,101 +347,96 @@ impl GhCli { } fn parse_pr_view(raw: &str) -> Result { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + let pr: GhPrResponse = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr view response: {err}; raw: {raw}" )) })?; - Self::extract_pr_info(&value).ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr view response missing required fields: {value:#?}" - )) - }) + Ok(Self::pr_response_to_info(pr)) } fn parse_pr_list(raw: &str) -> Result, GhCliError> { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + let prs: Vec = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr list response: {err}; raw: {raw}" )) })?; - let arr = value.as_array().ok_or_else(|| { - GhCliError::UnexpectedOutput(format!("gh pr list response is not an array: {value:#?}")) - })?; - arr.iter() - .map(|item| { - Self::extract_pr_info(item).ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr list item missing required fields: {item:#?}" - )) - }) - }) - .collect() + Ok(prs.into_iter().map(Self::pr_response_to_info).collect()) } - fn parse_pr_comments(raw: &str) -> Result, GhCliError> { - let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse gh pr view --json comments response: {err}; raw: {raw}" - )) - })?; - let comments_arr = value - .get("comments") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - GhCliError::UnexpectedOutput(format!( - "gh pr view --json comments response missing 'comments' array: {value:#?}" - )) - })?; - comments_arr - .iter() - .map(|item| { - serde_json::from_value(item.clone()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse PR comment: {err}; item: {item:#?}" - )) - }) - }) - .collect() - } - - fn parse_pr_review_comments(raw: &str) -> Result, GhCliError> { - serde_json::from_str(raw.trim()).map_err(|err| { - GhCliError::UnexpectedOutput(format!( - "Failed to parse review comments API response: {err}; raw: {raw}" - )) - }) - } - - fn extract_pr_info(value: &Value) -> Option { - let number = value.get("number")?.as_i64()?; - let url = value.get("url")?.as_str()?.to_string(); - let state = value - .get("state") - .and_then(Value::as_str) - .unwrap_or("OPEN") - .to_string(); - let merged_at = value - .get("mergedAt") - .and_then(Value::as_str) - .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&Utc)); - let merge_commit_sha = value - .get("mergeCommit") - .and_then(|v| v.get("oid")) - .and_then(Value::as_str) - .map(|s| s.to_string()); - Some(PullRequestInfo { - number, - url, + fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo { + let state = if pr.state.is_empty() { + "OPEN" + } else { + &pr.state + }; + PullRequestInfo { + number: pr.number, + url: pr.url, status: match state.to_ascii_uppercase().as_str() { "OPEN" => MergeStatus::Open, "MERGED" => MergeStatus::Merged, "CLOSED" => MergeStatus::Closed, _ => MergeStatus::Unknown, }, - merged_at, - merge_commit_sha, - }) + merged_at: pr.merged_at, + merge_commit_sha: pr.merge_commit.and_then(|c| c.oid), + } + } + + fn parse_pr_comments(raw: &str) -> Result, GhCliError> { + let wrapper: GhCommentsWrapper = serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh pr view --json comments response: {err}; raw: {raw}" + )) + })?; + + Ok(wrapper + .comments + .into_iter() + .map(|c| PrComment { + id: c.id, + author: PrCommentAuthor { + login: c + .author + .and_then(|a| a.login) + .unwrap_or_else(|| "unknown".to_string()), + }, + author_association: c.author_association, + body: c.body, + created_at: c.created_at.unwrap_or_else(Utc::now), + url: c.url, + }) + .collect()) + } + + fn parse_pr_review_comments(raw: &str) -> Result, GhCliError> { + let items: Vec = + serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse review comments API response: {err}; raw: {raw}" + )) + })?; + + Ok(items + .into_iter() + .map(|c| PrReviewComment { + id: c.id, + user: ReviewCommentUser { + login: c + .user + .and_then(|u| u.login) + .unwrap_or_else(|| "unknown".to_string()), + }, + body: c.body, + created_at: c.created_at.unwrap_or_else(Utc::now), + html_url: c.html_url, + path: c.path, + line: c.line, + side: c.side, + diff_hunk: c.diff_hunk, + author_association: c.author_association, + }) + .collect()) } } diff --git a/crates/services/src/services/git_host/github/mod.rs b/crates/services/src/services/git_host/github/mod.rs new file mode 100644 index 00000000..ec75f227 --- /dev/null +++ b/crates/services/src/services/git_host/github/mod.rs @@ -0,0 +1,368 @@ +//! GitHub hosting service implementation. + +mod cli; + +use std::{path::Path, time::Duration}; + +use async_trait::async_trait; +use backon::{ExponentialBuilder, Retryable}; +pub use cli::GhCli; +use cli::{GhCliError, GitHubRepoInfo}; +use db::models::merge::PullRequestInfo; +use tokio::task; +use tracing::info; + +use super::{ + GitHostProvider, + types::{CreatePrRequest, GitHostError, ProviderKind, UnifiedPrComment}, +}; + +#[derive(Debug, Clone)] +pub struct GitHubProvider { + gh_cli: GhCli, +} + +impl GitHubProvider { + pub fn new() -> Result { + Ok(Self { + gh_cli: GhCli::new(), + }) + } + + async fn get_repo_info(&self, repo_path: &Path) -> Result { + let cli = self.gh_cli.clone(); + let path = repo_path.to_path_buf(); + task::spawn_blocking(move || cli.get_repo_info(&path)) + .await + .map_err(|err| GitHostError::Repository(format!("Failed to get repo info: {err}")))? + .map_err(Into::into) + } + + async fn check_auth(&self) -> Result<(), GitHostError> { + let cli = self.gh_cli.clone(); + task::spawn_blocking(move || cli.check_auth()) + .await + .map_err(|err| { + GitHostError::Repository(format!( + "Failed to execute GitHub CLI for auth check: {err}" + )) + })? + .map_err(|err| match err { + GhCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::GitHub, + }, + GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg), + GhCliError::CommandFailed(msg) => { + GitHostError::Repository(format!("GitHub CLI auth check failed: {msg}")) + } + GhCliError::UnexpectedOutput(msg) => GitHostError::Repository(format!( + "Unexpected output from GitHub CLI auth check: {msg}" + )), + }) + } + + async fn fetch_general_comments( + &self, + cli: &GhCli, + owner: &str, + repo: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let cli = cli.clone(); + let owner = owner.to_string(); + let repo = repo.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = owner.clone(); + let repo = repo.clone(); + + let comments = + task::spawn_blocking(move || cli.get_pr_comments(&owner, &repo, pr_number)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for fetching PR comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn fetch_review_comments( + &self, + cli: &GhCli, + owner: &str, + repo: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let cli = cli.clone(); + let owner = owner.to_string(); + let repo = repo.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = owner.clone(); + let repo = repo.clone(); + + let comments = + task::spawn_blocking(move || cli.get_pr_review_comments(&owner, &repo, pr_number)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for fetching review comments: {err}" + )) + })?; + comments.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } +} + +impl From for GitHostError { + fn from(error: GhCliError) -> Self { + match &error { + GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), + GhCliError::NotAvailable => GitHostError::CliNotInstalled { + provider: ProviderKind::GitHub, + }, + GhCliError::CommandFailed(msg) => { + let lower = msg.to_ascii_lowercase(); + if lower.contains("403") || lower.contains("forbidden") { + GitHostError::InsufficientPermissions(msg.clone()) + } else if lower.contains("404") || lower.contains("not found") { + GitHostError::RepoNotFoundOrNoAccess(msg.clone()) + } else { + GitHostError::PullRequest(msg.clone()) + } + } + GhCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), + } + } +} + +#[async_trait] +impl GitHostProvider for GitHubProvider { + async fn create_pr( + &self, + repo_path: &Path, + _remote_url: &str, + request: &CreatePrRequest, + ) -> Result { + // Check auth first + self.check_auth().await?; + + let repo_info = self.get_repo_info(repo_path).await?; + + let cli = self.gh_cli.clone(); + let request_clone = request.clone(); + + (|| async { + let cli = cli.clone(); + let request = request_clone.clone(); + let owner = repo_info.owner.clone(); + let repo_name = repo_info.repo_name.clone(); + + let cli_result = + task::spawn_blocking(move || cli.create_pr(&request, &owner, &repo_name)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for PR creation: {err}" + )) + })? + .map_err(GitHostError::from)?; + + info!( + "Created GitHub PR #{} for branch {}", + cli_result.number, request_clone.head_branch + ); + + Ok(cli_result) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_status(&self, pr_url: &str) -> Result { + let cli = self.gh_cli.clone(); + let url = pr_url.to_string(); + + (|| async { + let cli = cli.clone(); + let url = url.clone(); + let pr = task::spawn_blocking(move || cli.view_pr(&url)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for viewing PR: {err}" + )) + })?; + pr.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|err: &GitHostError| err.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + _remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path).await?; + + let cli = self.gh_cli.clone(); + let branch = branch_name.to_string(); + + (|| async { + let cli = cli.clone(); + let owner = repo_info.owner.clone(); + let repo_name = repo_info.repo_name.clone(); + let branch = branch.clone(); + + let prs = + task::spawn_blocking(move || cli.list_prs_for_branch(&owner, &repo_name, &branch)) + .await + .map_err(|err| { + GitHostError::PullRequest(format!( + "Failed to execute GitHub CLI for listing PRs: {err}" + )) + })?; + prs.map_err(GitHostError::from) + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &GitHostError| e.should_retry()) + .notify(|err: &GitHostError, dur: Duration| { + tracing::warn!( + "GitHub API call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + err + ); + }) + .await + } + + async fn get_pr_comments( + &self, + repo_path: &Path, + _remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError> { + let repo_info = self.get_repo_info(repo_path).await?; + + // Fetch both types of comments in parallel + let cli1 = self.gh_cli.clone(); + let cli2 = self.gh_cli.clone(); + + let (general_result, review_result) = tokio::join!( + self.fetch_general_comments(&cli1, &repo_info.owner, &repo_info.repo_name, pr_number), + self.fetch_review_comments(&cli2, &repo_info.owner, &repo_info.repo_name, pr_number) + ); + + let general_comments = general_result?; + let review_comments = review_result?; + + // Convert and merge into unified timeline + let mut unified: Vec = Vec::new(); + + for c in general_comments { + unified.push(UnifiedPrComment::General { + id: c.id, + author: c.author.login, + author_association: Some(c.author_association), + body: c.body, + created_at: c.created_at, + url: Some(c.url), + }); + } + + for c in review_comments { + unified.push(UnifiedPrComment::Review { + id: c.id, + author: c.user.login, + author_association: Some(c.author_association), + body: c.body, + created_at: c.created_at, + url: Some(c.html_url), + path: c.path, + line: c.line, + diff_hunk: Some(c.diff_hunk), + }); + } + + // Sort by creation time + unified.sort_by_key(|c| c.created_at()); + + Ok(unified) + } + + fn provider_kind(&self) -> ProviderKind { + ProviderKind::GitHub + } +} diff --git a/crates/services/src/services/git_host/mod.rs b/crates/services/src/services/git_host/mod.rs new file mode 100644 index 00000000..3029d243 --- /dev/null +++ b/crates/services/src/services/git_host/mod.rs @@ -0,0 +1,63 @@ +mod detection; +mod types; + +pub mod azure; +pub mod github; + +use std::path::Path; + +use async_trait::async_trait; +use db::models::merge::PullRequestInfo; +use detection::detect_provider_from_url; +use enum_dispatch::enum_dispatch; +pub use types::{ + CreatePrRequest, GitHostError, PrComment, PrCommentAuthor, PrReviewComment, ProviderKind, + ReviewCommentUser, UnifiedPrComment, +}; + +use self::{azure::AzureDevOpsProvider, github::GitHubProvider}; + +#[async_trait] +#[enum_dispatch(GitHostService)] +pub trait GitHostProvider: Send + Sync { + async fn create_pr( + &self, + repo_path: &Path, + remote_url: &str, + request: &CreatePrRequest, + ) -> Result; + + async fn get_pr_status(&self, pr_url: &str) -> Result; + + async fn list_prs_for_branch( + &self, + repo_path: &Path, + remote_url: &str, + branch_name: &str, + ) -> Result, GitHostError>; + + async fn get_pr_comments( + &self, + repo_path: &Path, + remote_url: &str, + pr_number: i64, + ) -> Result, GitHostError>; + + fn provider_kind(&self) -> ProviderKind; +} + +#[enum_dispatch] +pub enum GitHostService { + GitHub(GitHubProvider), + AzureDevOps(AzureDevOpsProvider), +} + +impl GitHostService { + pub fn from_url(url: &str) -> Result { + match detect_provider_from_url(url) { + ProviderKind::GitHub => Ok(Self::GitHub(GitHubProvider::new()?)), + ProviderKind::AzureDevOps => Ok(Self::AzureDevOps(AzureDevOpsProvider::new()?)), + ProviderKind::Unknown => Err(GitHostError::UnsupportedProvider), + } + } +} diff --git a/crates/services/src/services/git_host/types.rs b/crates/services/src/services/git_host/types.rs new file mode 100644 index 00000000..a347e52d --- /dev/null +++ b/crates/services/src/services/git_host/types.rs @@ -0,0 +1,133 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + GitHub, + AzureDevOps, + Unknown, +} + +impl std::fmt::Display for ProviderKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderKind::GitHub => write!(f, "GitHub"), + ProviderKind::AzureDevOps => write!(f, "Azure DevOps"), + ProviderKind::Unknown => write!(f, "Unknown"), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreatePrRequest { + pub title: String, + pub body: Option, + pub head_branch: String, + pub base_branch: String, + pub draft: Option, +} + +#[derive(Debug, Error)] +pub enum GitHostError { + #[error("Repository error: {0}")] + Repository(String), + #[error("Pull request error: {0}")] + PullRequest(String), + #[error("Authentication failed: {0}")] + AuthFailed(String), + #[error("Insufficient permissions: {0}")] + InsufficientPermissions(String), + #[error("Repository not found or no access: {0}")] + RepoNotFoundOrNoAccess(String), + #[error("{provider} CLI is not installed or not available in PATH")] + CliNotInstalled { provider: ProviderKind }, + #[error("Unsupported git hosting provider")] + UnsupportedProvider, + #[error("CLI returned unexpected output: {0}")] + UnexpectedOutput(String), +} + +impl GitHostError { + pub fn should_retry(&self) -> bool { + !matches!( + self, + GitHostError::AuthFailed(_) + | GitHostError::InsufficientPermissions(_) + | GitHostError::RepoNotFoundOrNoAccess(_) + | GitHostError::CliNotInstalled { .. } + | GitHostError::UnsupportedProvider + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct PrCommentAuthor { + pub login: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct PrComment { + pub id: String, + pub author: PrCommentAuthor, + pub author_association: String, + pub body: String, + pub created_at: DateTime, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct ReviewCommentUser { + pub login: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct PrReviewComment { + pub id: i64, + pub user: ReviewCommentUser, + pub body: String, + pub created_at: DateTime, + pub html_url: String, + pub path: String, + pub line: Option, + pub side: Option, + pub diff_hunk: String, + pub author_association: String, +} + +#[derive(Debug, Clone, Serialize, TS)] +#[serde(tag = "comment_type", rename_all = "snake_case")] +#[ts(tag = "comment_type", rename_all = "snake_case")] +pub enum UnifiedPrComment { + General { + id: String, + author: String, + author_association: Option, + body: String, + created_at: DateTime, + url: Option, + }, + Review { + id: i64, + author: String, + author_association: Option, + body: String, + created_at: DateTime, + url: Option, + path: String, + line: Option, + diff_hunk: Option, + }, +} + +impl UnifiedPrComment { + pub fn created_at(&self) -> DateTime { + match self { + UnifiedPrComment::General { created_at, .. } => *created_at, + UnifiedPrComment::Review { created_at, .. } => *created_at, + } + } +} diff --git a/crates/services/src/services/github.rs b/crates/services/src/services/github.rs deleted file mode 100644 index 238666ff..00000000 --- a/crates/services/src/services/github.rs +++ /dev/null @@ -1,424 +0,0 @@ -use std::{path::Path, time::Duration}; - -use backon::{ExponentialBuilder, Retryable}; -use chrono::{DateTime, Utc}; -use db::models::merge::PullRequestInfo; -use serde::Serialize; -use thiserror::Error; -use tokio::task; -use tracing::info; -use ts_rs::TS; - -mod cli; - -use cli::{GhCli, GhCliError, PrComment, PrReviewComment}; -pub use cli::{PrCommentAuthor, ReviewCommentUser}; - -/// Unified PR comment that can be either a general comment or review comment -#[derive(Debug, Clone, Serialize, TS)] -#[serde(tag = "comment_type", rename_all = "snake_case")] -#[ts(tag = "comment_type", rename_all = "snake_case")] -pub enum UnifiedPrComment { - /// General PR comment (conversation) - General { - id: String, - author: String, - author_association: String, - body: String, - created_at: DateTime, - url: String, - }, - /// Inline review comment (on code) - Review { - id: i64, - author: String, - author_association: String, - body: String, - created_at: DateTime, - url: String, - path: String, - line: Option, - diff_hunk: String, - }, -} - -impl UnifiedPrComment { - fn created_at(&self) -> DateTime { - match self { - UnifiedPrComment::General { created_at, .. } => *created_at, - UnifiedPrComment::Review { created_at, .. } => *created_at, - } - } -} - -#[derive(Debug, Error)] -pub enum GitHubServiceError { - #[error("Repository error: {0}")] - Repository(String), - #[error("Pull request error: {0}")] - PullRequest(String), - #[error("GitHub authentication failed: {0}")] - AuthFailed(GhCliError), - #[error("Insufficient permissions: {0}")] - InsufficientPermissions(GhCliError), - #[error("GitHub repository not found or no access: {0}")] - RepoNotFoundOrNoAccess(GhCliError), - #[error( - "GitHub CLI is not installed or not available in PATH. Please install it from https://cli.github.com/ and authenticate with 'gh auth login'" - )] - GhCliNotInstalled(GhCliError), -} - -impl From for GitHubServiceError { - fn from(error: GhCliError) -> Self { - match &error { - GhCliError::AuthFailed(_) => Self::AuthFailed(error), - GhCliError::NotAvailable => Self::GhCliNotInstalled(error), - GhCliError::CommandFailed(msg) => { - let lower = msg.to_ascii_lowercase(); - if lower.contains("403") || lower.contains("forbidden") { - Self::InsufficientPermissions(error) - } else if lower.contains("404") || lower.contains("not found") { - Self::RepoNotFoundOrNoAccess(error) - } else { - Self::PullRequest(msg.to_string()) - } - } - GhCliError::UnexpectedOutput(msg) => Self::PullRequest(msg.to_string()), - } - } -} - -impl GitHubServiceError { - pub fn should_retry(&self) -> bool { - !matches!( - self, - GitHubServiceError::AuthFailed(_) - | GitHubServiceError::InsufficientPermissions(_) - | GitHubServiceError::RepoNotFoundOrNoAccess(_) - | GitHubServiceError::GhCliNotInstalled(_) - ) - } -} - -#[derive(Debug, Clone)] -pub struct GitHubRepoInfo { - pub owner: String, - pub repo_name: String, -} - -#[derive(Debug, Clone)] -pub struct CreatePrRequest { - pub title: String, - pub body: Option, - pub head_branch: String, - pub base_branch: String, - pub draft: Option, -} - -#[derive(Debug, Clone)] -pub struct GitHubService { - gh_cli: GhCli, -} - -impl GitHubService { - /// Create a new GitHub service with authentication - pub fn new() -> Result { - Ok(Self { - gh_cli: GhCli::new(), - }) - } - - pub async fn get_repo_info( - &self, - repo_path: &Path, - ) -> Result { - let cli = self.gh_cli.clone(); - let path = repo_path.to_path_buf(); - task::spawn_blocking(move || cli.get_repo_info(&path)) - .await - .map_err(|err| { - GitHubServiceError::Repository(format!("Failed to get repo info: {err}")) - })? - .map_err(Into::into) - } - - pub async fn check_token(&self) -> Result<(), GitHubServiceError> { - let cli = self.gh_cli.clone(); - task::spawn_blocking(move || cli.check_auth()) - .await - .map_err(|err| { - GitHubServiceError::Repository(format!( - "Failed to execute GitHub CLI for auth check: {err}" - )) - })? - .map_err(|err| match err { - GhCliError::NotAvailable => GitHubServiceError::GhCliNotInstalled(err), - GhCliError::AuthFailed(_) => GitHubServiceError::AuthFailed(err), - GhCliError::CommandFailed(msg) => { - GitHubServiceError::Repository(format!("GitHub CLI auth check failed: {msg}")) - } - GhCliError::UnexpectedOutput(msg) => GitHubServiceError::Repository(format!( - "Unexpected output from GitHub CLI auth check: {msg}" - )), - }) - } - - /// Create a pull request on GitHub - pub async fn create_pr( - &self, - repo_info: &GitHubRepoInfo, - request: &CreatePrRequest, - ) -> Result { - (|| async { self.create_pr_via_cli(repo_info, request).await }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - async fn create_pr_via_cli( - &self, - repo_info: &GitHubRepoInfo, - request: &CreatePrRequest, - ) -> Result { - let cli = self.gh_cli.clone(); - let request_clone = request.clone(); - let repo_clone = repo_info.clone(); - let cli_result = task::spawn_blocking(move || cli.create_pr(&request_clone, &repo_clone)) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for PR creation: {err}" - )) - })? - .map_err(GitHubServiceError::from)?; - - info!( - "Created GitHub PR #{} for branch {} in {}/{}", - cli_result.number, request.head_branch, repo_info.owner, repo_info.repo_name - ); - - Ok(cli_result) - } - - pub async fn update_pr_status( - &self, - pr_url: &str, - ) -> Result { - (|| async { - let cli = self.gh_cli.clone(); - let url = pr_url.to_string(); - let pr = task::spawn_blocking(move || cli.view_pr(&url)) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for viewing PR at {pr_url}: {err}" - )) - })?; - let pr = pr.map_err(GitHubServiceError::from)?; - Ok(pr) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|err: &GitHubServiceError| err.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - /// List all pull requests for a branch (including closed/merged) - pub async fn list_all_prs_for_branch( - &self, - repo_info: &GitHubRepoInfo, - branch_name: &str, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let branch = branch_name.to_string(); - let cli = self.gh_cli.clone(); - let prs = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - let branch = branch.clone(); - move || cli.list_prs_for_branch(&owner, &repo, &branch) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for listing PRs on branch '{branch_name}': {err}" - )) - })?; - let prs = prs.map_err(GitHubServiceError::from)?; - Ok(prs) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - /// Fetch all comments (both general and review) for a pull request - pub async fn get_pr_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - // Fetch both types of comments in parallel - let (general_result, review_result) = tokio::join!( - self.fetch_general_comments(repo_info, pr_number), - self.fetch_review_comments(repo_info, pr_number) - ); - - let general_comments = general_result?; - let review_comments = review_result?; - - // Convert and merge into unified timeline - let mut unified: Vec = Vec::new(); - - for c in general_comments { - unified.push(UnifiedPrComment::General { - id: c.id, - author: c.author.login, - author_association: c.author_association, - body: c.body, - created_at: c.created_at, - url: c.url, - }); - } - - for c in review_comments { - unified.push(UnifiedPrComment::Review { - id: c.id, - author: c.user.login, - author_association: c.author_association, - body: c.body, - created_at: c.created_at, - url: c.html_url, - path: c.path, - line: c.line, - diff_hunk: c.diff_hunk, - }); - } - - // Sort by creation time - unified.sort_by_key(|c| c.created_at()); - - Ok(unified) - } - - async fn fetch_general_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let cli = self.gh_cli.clone(); - let comments = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - move || cli.get_pr_comments(&owner, &repo, pr_number) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for fetching PR #{pr_number} comments: {err}" - )) - })?; - comments.map_err(GitHubServiceError::from) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - async fn fetch_review_comments( - &self, - repo_info: &GitHubRepoInfo, - pr_number: i64, - ) -> Result, GitHubServiceError> { - (|| async { - let owner = repo_info.owner.clone(); - let repo = repo_info.repo_name.clone(); - let cli = self.gh_cli.clone(); - let comments = task::spawn_blocking({ - let owner = owner.clone(); - let repo = repo.clone(); - move || cli.get_pr_review_comments(&owner, &repo, pr_number) - }) - .await - .map_err(|err| { - GitHubServiceError::PullRequest(format!( - "Failed to execute GitHub CLI for fetching PR #{pr_number} review comments: {err}" - )) - })?; - comments.map_err(GitHubServiceError::from) - }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|e: &GitHubServiceError| e.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } -} diff --git a/crates/services/src/services/mod.rs b/crates/services/src/services/mod.rs index 9cf85c98..0498f1e4 100644 --- a/crates/services/src/services/mod.rs +++ b/crates/services/src/services/mod.rs @@ -10,7 +10,7 @@ pub mod file_search_cache; pub mod filesystem; pub mod filesystem_watcher; pub mod git; -pub mod github; +pub mod git_host; pub mod image; pub mod notification; pub mod oauth_credentials; diff --git a/crates/services/src/services/pr_monitor.rs b/crates/services/src/services/pr_monitor.rs index e2724240..75e6b91a 100644 --- a/crates/services/src/services/pr_monitor.rs +++ b/crates/services/src/services/pr_monitor.rs @@ -16,21 +16,21 @@ use tracing::{debug, error, info}; use crate::services::{ analytics::AnalyticsContext, - github::{GitHubService, GitHubServiceError}, + git_host::{self, GitHostError, GitHostProvider}, share::SharePublisher, }; #[derive(Debug, Error)] enum PrMonitorError { #[error(transparent)] - GitHubServiceError(#[from] GitHubServiceError), + GitHostError(#[from] GitHostError), #[error(transparent)] WorkspaceError(#[from] WorkspaceError), #[error(transparent)] Sqlx(#[from] SqlxError), } -/// Service to monitor GitHub PRs and update task status when they are merged +/// Service to monitor PRs and update task status when they are merged pub struct PrMonitorService { db: DBService, poll_interval: Duration, @@ -95,12 +95,8 @@ impl PrMonitorService { /// Check the status of a specific PR async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> { - // GitHubService now uses gh CLI, no token needed - let github_service = GitHubService::new()?; - - let pr_status = github_service - .update_pr_status(&pr_merge.pr_info.url) - .await?; + let git_host = git_host::GitHostService::from_url(&pr_merge.pr_info.url)?; + let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?; debug!( "PR #{} status: {:?} (was open)", @@ -109,7 +105,7 @@ impl PrMonitorService { // Update the PR status in the database if !matches!(&pr_status.status, MergeStatus::Open) { - // Update merge status with the latest information from GitHub + // Update merge status with the latest information from git host Merge::update_status( &self.db.pool, pr_merge.id, diff --git a/crates/services/src/services/share.rs b/crates/services/src/services/share.rs index 92172faf..6f57b2b3 100644 --- a/crates/services/src/services/share.rs +++ b/crates/services/src/services/share.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ RemoteClientError, - services::{git::GitServiceError, github::GitHubServiceError}, + services::{git::GitServiceError, git_host::GitHostError}, }; #[derive(Debug, Error)] @@ -39,7 +39,7 @@ pub enum ShareError { #[error(transparent)] Git(#[from] GitServiceError), #[error(transparent)] - GitHub(#[from] GitHubServiceError), + GitHost(#[from] GitHostError), #[error("share authentication missing or expired")] MissingAuth, #[error("invalid user ID format")] diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index 639ce66c..f781f2a8 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -177,19 +177,25 @@ const CreatePRDialogImpl = NiceModal.create( if (result.error) { if ( - result.error.type === 'github_cli_not_installed' || - result.error.type === 'github_cli_not_logged_in' + result.error.type === 'cli_not_installed' || + result.error.type === 'cli_not_logged_in' ) { - if (isMacEnvironment) { + // Only show setup dialog for GitHub CLI on Mac + if (result.error.provider === 'git_hub' && isMacEnvironment) { await showGhCliSetupDialog(); } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); + const providerName = + result.error.provider === 'git_hub' + ? 'GitHub' + : result.error.provider === 'azure_dev_ops' + ? 'Azure DevOps' + : 'Git host'; + const action = + result.error.type === 'cli_not_installed' + ? 'not installed' + : 'not logged in'; + setError(`${providerName} CLI is ${action}`); + setGhCliHelp(null); } return; } else if ( diff --git a/frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx b/frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx similarity index 86% rename from frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx rename to frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx index 0e0af2a8..613f0e82 100644 --- a/frontend/src/components/dialogs/tasks/GitHubCommentsDialog.tsx +++ b/frontend/src/components/dialogs/tasks/PrCommentsDialog.tsx @@ -14,15 +14,15 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { MessageSquare, AlertCircle, Loader2 } from 'lucide-react'; import { usePrComments } from '@/hooks/usePrComments'; -import { GitHubCommentCard } from '@/components/ui/github-comment-card'; +import { PrCommentCard } from '@/components/ui/pr-comment-card'; import type { UnifiedPrComment } from 'shared/types'; -export interface GitHubCommentsDialogProps { +export interface PrCommentsDialogProps { attemptId: string; repoId: string; } -export interface GitHubCommentsDialogResult { +export interface PrCommentsDialogResult { comments: UnifiedPrComment[]; } @@ -32,7 +32,7 @@ function getCommentId(comment: UnifiedPrComment): string { : comment.id.toString(); } -const GitHubCommentsDialogImpl = NiceModal.create( +const PrCommentsDialogImpl = NiceModal.create( ({ attemptId, repoId }) => { const { t } = useTranslation(['tasks', 'common']); const modal = useModal(); @@ -109,7 +109,7 @@ const GitHubCommentsDialogImpl = NiceModal.create( - {t('tasks:githubComments.dialog.title')} + {t('tasks:prComments.dialog.title')} @@ -126,13 +126,13 @@ const GitHubCommentsDialogImpl = NiceModal.create( ) : comments.length === 0 ? (

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

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