Decouple git from github errors (#1347)

* Decouple git from github errors

* Fix git error display (vibe-kanban 7352dadc)

After the last few commits git cli not logged in error does not get displayed to the user. Network tab shows this:
{
    "success": false,
    "data": null,
    "error_data": {
        "type": "git_cli_not_logged_in"
    },
    "message": null
}
This commit is contained in:
Alex Netsch
2025-11-20 15:53:36 +00:00
committed by GitHub
parent 037302c62f
commit 1933bb463c
10 changed files with 102 additions and 132 deletions

View File

@@ -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(),

View File

@@ -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<TaskAttempt>,
State(deployment): State<DeploymentImpl>,
Json(request): Json<CreateGitHubPrRequest>,
) -> Result<ResponseJson<ApiResponse<String, GitHubServiceError>>, ApiError> {
) -> Result<ResponseJson<ApiResponse<String, CreatePrError>>, 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)),
}
}
}

View File

@@ -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<GhCliError> 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<GitServiceError> 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}"))
}

View File

@@ -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<CreatePRDialogProps>(
};
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;
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "プルリクエストを作成するにはサインインしてください",

View File

@@ -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를 만들려면 로그인하세요",

View File

@@ -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<Result<string, GitHubServiceError>> => {
): Promise<Result<string, CreatePrError>> => {
const response = await makeRequest(`/api/task-attempts/${attemptId}/pr`, {
method: 'POST',
body: JSON.stringify(data),
});
return handleApiResponseAsResult<string, GitHubServiceError>(response);
return handleApiResponseAsResult<string, CreatePrError>(response);
},
startDevServer: async (attemptId: string): Promise<void> => {

View File

@@ -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<Merge>,