diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 905ae03d..0e7023f9 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -93,7 +93,6 @@ fn generate_types_content() -> String { server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), - services::services::github::GitHubServiceError::decl(), services::services::config::Config::decl(), services::services::config::NotificationConfig::decl(), services::services::config::ThemeMode::decl(), @@ -138,6 +137,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::gh_cli_setup::GhCliSetupError::decl(), server::routes::task_attempts::RebaseTaskAttemptRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), + server::routes::task_attempts::CreatePrError::decl(), server::routes::task_attempts::CommitInfo::decl(), server::routes::task_attempts::BranchStatus::decl(), services::services::git::ConflictOp::decl(), diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 0d817a27..12caa5b9 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -36,7 +36,7 @@ use git2::BranchType; use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, - git::{ConflictOp, WorktreeResetOptions}, + git::{ConflictOp, GitCliError, GitServiceError, WorktreeResetOptions}, github::{CreatePrRequest, GitHubService, GitHubServiceError}, }; use sqlx::Error as SqlxError; @@ -605,11 +605,21 @@ pub async fn push_task_attempt_branch( Ok(ResponseJson(ApiResponse::success(()))) } +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +pub enum CreatePrError { + GithubCliNotInstalled, + GithubCliNotLoggedIn, + GitCliNotLoggedIn, + GitCliNotInstalled, +} + pub async fn create_github_pr( Extension(task_attempt): Extension, State(deployment): State, Json(request): Json, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let github_config = deployment.config().read().await.github.clone(); // Get the task attempt to access the stored target branch let target_branch = request.target_branch.unwrap_or_else(|| { @@ -642,13 +652,18 @@ pub async fn create_github_pr( .push_to_github(&workspace_path, &task_attempt.branch) { tracing::error!("Failed to push branch to GitHub: {}", e); - let gh_e = GitHubServiceError::from(e); - if gh_e.is_api_data() { - return Ok(ResponseJson(ApiResponse::error_with_data(gh_e))); - } else { - return Ok(ResponseJson(ApiResponse::error( - format!("Failed to push branch to GitHub: {}", gh_e).as_str(), - ))); + match e { + GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotLoggedIn, + ))); + } + GitServiceError::GitCLI(GitCliError::NotAvailable) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotInstalled, + ))); + } + _ => return Err(ApiError::GitService(e)), } } @@ -723,12 +738,14 @@ pub async fn create_github_pr( task_attempt.id, e ); - if e.is_api_data() { - Ok(ResponseJson(ApiResponse::error_with_data(e))) - } else { - Ok(ResponseJson(ApiResponse::error( - format!("Failed to create PR: {}", e).as_str(), - ))) + match &e { + GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( + ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled), + )), + GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( + ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn), + )), + _ => Err(ApiError::GitHubService(e)), } } } diff --git a/crates/services/src/services/github.rs b/crates/services/src/services/github.rs index d6ac6d7e..063f9a80 100644 --- a/crates/services/src/services/github.rs +++ b/crates/services/src/services/github.rs @@ -3,96 +3,61 @@ use std::time::Duration; use backon::{ExponentialBuilder, Retryable}; use db::models::merge::PullRequestInfo; use regex::Regex; -use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::task; use tracing::info; -use ts_rs::TS; mod cli; use cli::{GhCli, GhCliError}; -use crate::services::git::{GitCliError, GitServiceError}; - -#[derive(Debug, Error, Serialize, Deserialize, TS)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[ts(use_ts_enum)] +#[derive(Debug, Error)] pub enum GitHubServiceError { - #[ts(skip)] #[error("Repository error: {0}")] Repository(String), - #[ts(skip)] #[error("Pull request error: {0}")] PullRequest(String), - #[error("GitHub token is invalid or expired.")] - TokenInvalid, - #[error("Insufficient permissions")] - InsufficientPermissions, - #[error("GitHub repository not found or no access")] - RepoNotFoundOrNoAccess, + #[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, - #[ts(skip)] - #[serde(skip)] - #[error(transparent)] - GitService(GitServiceError), + GhCliNotInstalled(GhCliError), } impl From for GitHubServiceError { fn from(error: GhCliError) -> Self { - match error { - GhCliError::AuthFailed(_) => Self::TokenInvalid, - GhCliError::NotAvailable => Self::GhCliNotInstalled, + 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 + Self::InsufficientPermissions(error) } else if lower.contains("404") || lower.contains("not found") { - Self::RepoNotFoundOrNoAccess + Self::RepoNotFoundOrNoAccess(error) } else { - Self::PullRequest(msg) + Self::PullRequest(msg.to_string()) } } - GhCliError::UnexpectedOutput(msg) => Self::PullRequest(msg), - } - } -} - -impl From for GitHubServiceError { - fn from(error: GitServiceError) -> Self { - match error { - GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => Self::TokenInvalid, - GitServiceError::GitCLI(GitCliError::CommandFailed(msg)) => { - let lower = msg.to_ascii_lowercase(); - if lower.contains("the requested url returned error: 403") { - Self::InsufficientPermissions - } else if lower.contains("the requested url returned error: 404") { - Self::RepoNotFoundOrNoAccess - } else { - Self::GitService(GitServiceError::GitCLI(GitCliError::CommandFailed(msg))) - } - } - other => Self::GitService(other), + GhCliError::UnexpectedOutput(msg) => Self::PullRequest(msg.to_string()), } } } impl GitHubServiceError { - pub fn is_api_data(&self) -> bool { - matches!( - self, - GitHubServiceError::TokenInvalid - | GitHubServiceError::InsufficientPermissions - | GitHubServiceError::RepoNotFoundOrNoAccess - | GitHubServiceError::GhCliNotInstalled - ) - } - pub fn should_retry(&self) -> bool { - !self.is_api_data() + !matches!( + self, + GitHubServiceError::AuthFailed(_) + | GitHubServiceError::InsufficientPermissions(_) + | GitHubServiceError::RepoNotFoundOrNoAccess(_) + | GitHubServiceError::GhCliNotInstalled(_) + ) } } @@ -168,8 +133,8 @@ impl GitHubService { )) })? .map_err(|err| match err { - GhCliError::NotAvailable => GitHubServiceError::GhCliNotInstalled, - GhCliError::AuthFailed(_) => GitHubServiceError::TokenInvalid, + GhCliError::NotAvailable => GitHubServiceError::GhCliNotInstalled(err), + GhCliError::AuthFailed(_) => GitHubServiceError::AuthFailed(err), GhCliError::CommandFailed(msg) => { GitHubServiceError::Repository(format!("GitHub CLI auth check failed: {msg}")) } diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index 72cffcec..f34c8068 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -16,12 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { attemptsApi } from '@/lib/api.ts'; import { useTranslation } from 'react-i18next'; -import { - GitBranch, - GitHubServiceError, - TaskAttempt, - TaskWithAttemptStatus, -} from 'shared/types'; +import { GitBranch, TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; import { projectsApi } from '@/lib/api.ts'; import { Loader2 } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; @@ -166,49 +161,34 @@ const CreatePRDialogImpl = NiceModal.create( }; if (result.error) { - switch (result.error) { - case GitHubServiceError.GH_CLI_NOT_INSTALLED: { - if (isMacEnvironment) { - await showGhCliSetupDialog(); - } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); - } - return; - } - case GitHubServiceError.TOKEN_INVALID: { - if (isMacEnvironment) { - await showGhCliSetupDialog(); - } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); - } - return; - } - case GitHubServiceError.INSUFFICIENT_PERMISSIONS: - setError(t('createPrDialog.errors.insufficientPermissions')); - setGhCliHelp(null); - return; - case GitHubServiceError.REPO_NOT_FOUND_OR_NO_ACCESS: - setError(t('createPrDialog.errors.repoNotFoundOrNoAccess')); - setGhCliHelp(null); - return; - default: - setError( - result.message || t('createPrDialog.errors.failedToCreate') + if ( + result.error.type === 'github_cli_not_installed' || + result.error.type === 'github_cli_not_logged_in' + ) { + if (isMacEnvironment) { + await showGhCliSetupDialog(); + } else { + const ui = mapGhCliErrorToUi( + 'SETUP_HELPER_NOT_SUPPORTED', + defaultGhCliErrorMessage, + t ); - setGhCliHelp(null); - return; + setGhCliHelp(ui.variant ? ui : null); + setError(ui.variant ? null : ui.message); + } + return; + } else if ( + result.error.type === 'git_cli_not_installed' || + result.error.type === 'git_cli_not_logged_in' + ) { + const gitCliErrorKey = + result.error.type === 'git_cli_not_logged_in' + ? 'createPrDialog.errors.gitCliNotLoggedIn' + : 'createPrDialog.errors.gitCliNotInstalled'; + + setError(result.message || t(gitCliErrorKey)); + setGhCliHelp(null); + return; } } diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index e9b2ce94..8e9898e0 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -341,7 +341,9 @@ "errors": { "insufficientPermissions": "Insufficient permissions. Please ensure the GitHub 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 GitHub 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." }, "loginRequired": { "title": "Sign in to create a pull request", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 35a0dcb4..17d914a4 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -117,7 +117,9 @@ "errors": { "insufficientPermissions": "Permisos insuficientes. Por favor asegúrate de que la CLI de GitHub 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 de GitHub", + "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." }, "loginRequired": { "title": "Inicia sesión para crear un pull request", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index d6226e38..8e2edb57 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -117,7 +117,9 @@ "errors": { "insufficientPermissions": "権限が不足しています。GitHub CLIに必要な権限があることを確認してください。", "repoNotFoundOrNoAccess": "リポジトリが見つからないか、アクセス権がありません。リポジトリへのアクセス権を確認し、認証されていることを確認してください。", - "failedToCreate": "GitHub PRの作成に失敗しました" + "failedToCreate": "GitHub PRの作成に失敗しました", + "gitCliNotLoggedIn": "Gitが認証されていません。\"gh auth login\" を実行するかGitの認証情報を設定してから再試行してください。", + "gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。" }, "loginRequired": { "title": "プルリクエストを作成するにはサインインしてください", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index a7735332..2704a3f8 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -117,7 +117,9 @@ "errors": { "insufficientPermissions": "권한이 부족합니다. GitHub CLI에 필요한 권한이 있는지 확인하세요.", "repoNotFoundOrNoAccess": "저장소를 찾을 수 없거나 액세스 권한이 없습니다. 저장소 액세스를 확인하고 인증되었는지 확인하세요.", - "failedToCreate": "GitHub PR 생성에 실패했습니다" + "failedToCreate": "GitHub PR 생성에 실패했습니다", + "gitCliNotLoggedIn": "Git이 인증되지 않았습니다. \"gh auth login\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.", + "gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요." }, "loginRequired": { "title": "Pull Request를 만들려면 로그인하세요", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8cf32c69..c1b75497 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -32,7 +32,6 @@ import { UpdateTask, UpdateTag, UserSystemInfo, - GitHubServiceError, UpdateRetryFollowUpDraftRequest, McpServerQuery, UpdateMcpServersBody, @@ -71,6 +70,7 @@ import { CommitCompareResult, OpenEditorResponse, OpenEditorRequest, + CreatePrError, } from 'shared/types'; // Re-export types for convenience @@ -615,12 +615,12 @@ export const attemptsApi = { createPR: async ( attemptId: string, data: CreateGitHubPrRequest - ): Promise> => { + ): 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 6c83bdcc..d4d21c6c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -174,8 +174,6 @@ export type CreateGitHubPrRequest = { title: string, body: string | null, target export type ImageResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; -export enum GitHubServiceError { TOKEN_INVALID = "TOKEN_INVALID", INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", REPO_NOT_FOUND_OR_NO_ACCESS = "REPO_NOT_FOUND_OR_NO_ACCESS", GH_CLI_NOT_INSTALLED = "GH_CLI_NOT_INSTALLED" } - export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, }; @@ -300,6 +298,8 @@ export type RebaseTaskAttemptRequest = { old_base_branch: string | null, new_bas export type GitOperationError = { "type": "merge_conflicts", message: string, op: ConflictOp, } | { "type": "rebase_in_progress" }; +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" }; + export type CommitInfo = { sha: string, subject: string, }; 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,