diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d3b53e3265..a8a2f1b5fa 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -24,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/health" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" @@ -379,11 +380,26 @@ func registerSecondaryRoutes( docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") + registerHealthRoutes(p) + registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) registerE2EResetRoutes(p.router, p.taskRepo, p.log) } +// registerHealthRoutes sets up the system health endpoint with all health checkers. +func registerHealthRoutes(p routeParams) { + var githubProvider health.GitHubStatusProvider + if p.services.GitHub != nil { + githubProvider = p.services.GitHub + } + healthSvc := health.NewService(p.log, + health.NewGitHubChecker(githubProvider), + health.NewAgentChecker(p.agentSettingsController), + ) + health.RegisterRoutes(p.router, healthSvc, p.log) +} + // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. func registerMCPAndDebugRoutes( p routeParams, diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 2956871840..21a3fd3771 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -56,6 +56,20 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil } +// HasAvailableAgents returns true if at least one agent is detected as installed. +func (c *Controller) HasAvailableAgents(ctx context.Context) (bool, error) { + results, err := c.detectAgents(ctx) + if err != nil { + return false, err + } + for _, r := range results { + if r.Available { + return true, nil + } + } + return false, nil +} + func (c *Controller) InvalidateDiscoveryCache() { if c.discovery != nil { c.discovery.InvalidateCache() diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index f6163d32d6..6c41e517f2 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -26,18 +26,45 @@ func GHAvailable() bool { } func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) { + // Treat any non-zero exit as "not authenticated". This avoids parsing + // locale-dependent error messages and handles multi-account scenarios + // where a secondary account has an invalid token. GHAvailable() already + // guards binary existence before this method is called. _, err := c.run(ctx, "auth", "status", "--hostname", "github.com") - if err != nil { - // gh auth status exits non-zero when not authenticated. - // The error message includes stderr from the gh CLI. - errMsg := err.Error() - if strings.Contains(errMsg, "not logged") || strings.Contains(errMsg, "no accounts") { - return false, nil - } - // Unexpected error (binary not found, network issue, etc.) - return false, err + if err == nil { + return true, nil + } + // Propagate context errors so callers can distinguish cancellation/timeout + // from a genuine "not authenticated" result. + if ctx.Err() != nil { + return false, ctx.Err() + } + return false, nil +} + +// RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting. +func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics { + cmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + exitCode := 0 + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + output := stderr.String() + if output == "" { + output = stdout.String() + } + return &AuthDiagnostics{ + Command: "gh auth status --hostname github.com", + Output: output, + ExitCode: exitCode, } - return true, nil } func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) { diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 1ea432abbb..85ecb35cd9 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -190,9 +190,17 @@ type GitHubRepo struct { // GitHubStatus represents GitHub connection status. type GitHubStatus struct { - Authenticated bool `json:"authenticated"` - Username string `json:"username"` - AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + Authenticated bool `json:"authenticated"` + Username string `json:"username"` + AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` +} + +// AuthDiagnostics captures the output of gh auth status for troubleshooting. +type AuthDiagnostics struct { + Command string `json:"command"` + Output string `json:"output"` + ExitCode int `json:"exit_code"` } // CreateReviewWatchRequest is the request body for creating a review watch. diff --git a/apps/backend/internal/github/provider.go b/apps/backend/internal/github/provider.go index d19541cff3..fcb04f9c1c 100644 --- a/apps/backend/internal/github/provider.go +++ b/apps/backend/internal/github/provider.go @@ -29,7 +29,7 @@ func Provide( log.Warn("GitHub client not available: " + err.Error()) } - svc := NewService(client, authMethod, store, eventBus, log) + svc := NewService(client, authMethod, secrets, store, eventBus, log) cleanup := func() error { return nil } return svc, cleanup, nil diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 57812303ed..6b76739672 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -16,18 +17,21 @@ import ( // Service coordinates GitHub integration operations. type Service struct { + mu sync.Mutex client Client authMethod string + secrets SecretProvider store *Store eventBus bus.EventBus logger *logger.Logger } // NewService creates a new GitHub service. -func NewService(client Client, authMethod string, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { +func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { return &Service{ client: client, authMethod: authMethod, + secrets: secrets, store: store, eventBus: eventBus, logger: log, @@ -39,28 +43,81 @@ func (s *Service) Client() Client { return s.client } +// IsAuthenticated returns whether the service has a working GitHub client. +func (s *Service) IsAuthenticated() bool { + return s.client != nil +} + +// AuthMethod returns the authentication method ("gh_cli", "pat", or "none"). +func (s *Service) AuthMethod() string { + return s.authMethod +} + // GetStatus returns the current GitHub connection status. +// If no client exists, it retries client creation to pick up auth changes made after startup. func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { - status := &GitHubStatus{ - AuthMethod: s.authMethod, - } if s.client == nil { + s.retryClientCreation(ctx) + } + + s.mu.Lock() + client := s.client + authMethod := s.authMethod + s.mu.Unlock() + + status := &GitHubStatus{ + AuthMethod: authMethod, + } + if client == nil { + status.Diagnostics = runGHDiagnostics(ctx) return status, nil } - ok, err := s.client.IsAuthenticated(ctx) + ok, err := client.IsAuthenticated(ctx) if err != nil { return status, nil } status.Authenticated = ok if ok { - user, err := s.client.GetAuthenticatedUser(ctx) + user, err := client.GetAuthenticatedUser(ctx) if err == nil { status.Username = user } + } else { + status.Diagnostics = runGHDiagnostics(ctx) } return status, nil } +// retryClientCreation attempts to create a GitHub client when none exists. +func (s *Service) retryClientCreation(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + if s.client != nil { + return // another goroutine already recovered + } + client, authMethod, err := NewClient(ctx, s.secrets, s.logger) + if err != nil { + s.logger.Debug("GitHub client retry failed", zap.Error(err)) + return + } + s.client = client + s.authMethod = authMethod + s.logger.Info("GitHub client recovered after retry", + zap.String("auth_method", authMethod)) +} + +// runGHDiagnostics runs gh auth status if the gh CLI is available. +func runGHDiagnostics(ctx context.Context) *AuthDiagnostics { + if !GHAvailable() { + return &AuthDiagnostics{ + Command: "gh auth status", + Output: "gh CLI is not installed. Install it from https://cli.github.com", + ExitCode: -1, + } + } + return NewGHClient().RunAuthDiagnostics(ctx) +} + // SubmitReview submits a review on a pull request. func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { if s.client == nil { diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go new file mode 100644 index 0000000000..8b58452158 --- /dev/null +++ b/apps/backend/internal/health/checks.go @@ -0,0 +1,83 @@ +package health + +import "context" + +// GitHubStatusProvider abstracts the GitHub service status check. +type GitHubStatusProvider interface { + IsAuthenticated() bool + AuthMethod() string +} + +// GitHubChecker checks GitHub CLI/auth availability. +type GitHubChecker struct { + provider GitHubStatusProvider +} + +// NewGitHubChecker creates a checker for GitHub integration status. +// The provider may be nil if the GitHub service was not initialized. +func NewGitHubChecker(provider GitHubStatusProvider) *GitHubChecker { + return &GitHubChecker{provider: provider} +} + +func (c *GitHubChecker) Check(_ context.Context) []Issue { + if c.provider == nil { + return []Issue{{ + ID: "github_unavailable", + Category: "github", + Title: "GitHub integration unavailable", + Message: "Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "Configure GitHub", + }} + } + if !c.provider.IsAuthenticated() { + return []Issue{{ + ID: "github_not_authenticated", + Category: "github", + Title: "GitHub not authenticated", + Message: "Run 'gh auth login' or add a GITHUB_TOKEN secret.", + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "Configure GitHub", + }} + } + return nil +} + +// AgentDiscoveryProvider abstracts the agent discovery check. +type AgentDiscoveryProvider interface { + HasAvailableAgents(ctx context.Context) (bool, error) +} + +// AgentChecker checks whether any AI agents are detected. +type AgentChecker struct { + provider AgentDiscoveryProvider +} + +// NewAgentChecker creates a checker for agent availability. +func NewAgentChecker(provider AgentDiscoveryProvider) *AgentChecker { + return &AgentChecker{provider: provider} +} + +func (c *AgentChecker) Check(ctx context.Context) []Issue { + if c.provider == nil { + return nil + } + available, err := c.provider.HasAvailableAgents(ctx) + if err != nil { + return nil + } + if !available { + return []Issue{{ + ID: "no_agents", + Category: "agents", + Title: "No AI agents detected", + Message: "Install an AI coding agent (e.g. Claude Code, Codex) to start using KanDev.", + Severity: SeverityWarning, + FixURL: "/settings/agents", + FixLabel: "Configure Agents", + }} + } + return nil +} diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go new file mode 100644 index 0000000000..f92518668d --- /dev/null +++ b/apps/backend/internal/health/checks_test.go @@ -0,0 +1,108 @@ +package health + +import ( + "context" + "fmt" + "testing" +) + +// --- GitHubChecker tests --- + +type mockGitHubProvider struct { + authenticated bool + authMethod string +} + +func (m *mockGitHubProvider) IsAuthenticated() bool { return m.authenticated } +func (m *mockGitHubProvider) AuthMethod() string { return m.authMethod } + +func TestGitHubChecker_NilProvider(t *testing.T) { + checker := NewGitHubChecker(nil) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_unavailable" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "github_unavailable") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } + if issues[0].Category != "github" { + t.Errorf("category = %q, want %q", issues[0].Category, "github") + } +} + +func TestGitHubChecker_NotAuthenticated(t *testing.T) { + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: false, authMethod: "gh_cli"}) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_not_authenticated" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "github_not_authenticated") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } +} + +func TestGitHubChecker_Authenticated(t *testing.T) { + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +// --- AgentChecker tests --- + +type mockAgentProvider struct { + available bool + err error +} + +func (m *mockAgentProvider) HasAvailableAgents(_ context.Context) (bool, error) { + return m.available, m.err +} + +func TestAgentChecker_Available(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{available: true}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +func TestAgentChecker_NotAvailable(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{available: false}) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "no_agents" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "no_agents") + } + if issues[0].Category != "agents" { + t.Errorf("category = %q, want %q", issues[0].Category, "agents") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } +} + +func TestAgentChecker_Error(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{err: fmt.Errorf("discovery failed")}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues on error, got %d", len(issues)) + } +} + +func TestAgentChecker_NilProvider(t *testing.T) { + checker := NewAgentChecker(nil) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues for nil provider, got %d", len(issues)) + } +} diff --git a/apps/backend/internal/health/handler.go b/apps/backend/internal/health/handler.go new file mode 100644 index 0000000000..b53aabe248 --- /dev/null +++ b/apps/backend/internal/health/handler.go @@ -0,0 +1,22 @@ +package health + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +// RegisterRoutes registers the system health endpoint. +func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + api := router.Group("/api/v1/system") + api.GET("/health", handleGetHealth(svc)) + log.Debug("Registered System Health handlers (HTTP)") +} + +func handleGetHealth(svc *Service) gin.HandlerFunc { + return func(c *gin.Context) { + result := svc.RunChecks(c.Request.Context()) + c.JSON(http.StatusOK, result) + } +} diff --git a/apps/backend/internal/health/service.go b/apps/backend/internal/health/service.go new file mode 100644 index 0000000000..8790cf7d76 --- /dev/null +++ b/apps/backend/internal/health/service.go @@ -0,0 +1,37 @@ +package health + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Service runs all registered health checks. +type Service struct { + checkers []Checker + logger *logger.Logger +} + +// NewService creates a health service with the given checkers. +func NewService(log *logger.Logger, checkers ...Checker) *Service { + return &Service{checkers: checkers, logger: log} +} + +// RunChecks executes all checkers and returns the aggregated result. +func (s *Service) RunChecks(ctx context.Context) *Response { + var issues []Issue + for _, c := range s.checkers { + issues = append(issues, c.Check(ctx)...) + } + healthy := true + for _, issue := range issues { + if issue.Severity == SeverityError || issue.Severity == SeverityWarning { + healthy = false + break + } + } + if issues == nil { + issues = []Issue{} + } + return &Response{Healthy: healthy, Issues: issues} +} diff --git a/apps/backend/internal/health/service_test.go b/apps/backend/internal/health/service_test.go new file mode 100644 index 0000000000..31008a036b --- /dev/null +++ b/apps/backend/internal/health/service_test.go @@ -0,0 +1,100 @@ +package health + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + OutputPath: "stderr", + }) + return log +} + +func TestRunChecks_NoCheckers(t *testing.T) { + svc := NewService(newTestLogger()) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true with no checkers") + } + if len(result.Issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_AllHealthy(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(&mockGitHubProvider{authenticated: true}), + NewAgentChecker(&mockAgentProvider{available: true}), + ) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true") + } + if len(result.Issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_WithWarnings(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(nil), // will produce a warning + NewAgentChecker(&mockAgentProvider{available: true}), + ) + result := svc.RunChecks(context.Background()) + if result.Healthy { + t.Error("expected healthy = false with warnings") + } + if len(result.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(result.Issues)) + } + if result.Issues[0].ID != "github_unavailable" { + t.Errorf("issue ID = %q, want %q", result.Issues[0].ID, "github_unavailable") + } +} + +func TestRunChecks_MultipleIssues(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(nil), // warning + NewAgentChecker(&mockAgentProvider{available: false}), // warning + ) + result := svc.RunChecks(context.Background()) + if result.Healthy { + t.Error("expected healthy = false") + } + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_InfoOnlyIsHealthy(t *testing.T) { + // A checker that returns only info-severity issues + infoChecker := &staticChecker{issues: []Issue{{ + ID: "info_only", + Category: "system", + Title: "Info message", + Severity: SeverityInfo, + }}} + svc := NewService(newTestLogger(), infoChecker) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true with info-only issues") + } + if len(result.Issues) != 1 { + t.Errorf("expected 1 issue, got %d", len(result.Issues)) + } +} + +// staticChecker is a test helper that returns fixed issues. +type staticChecker struct { + issues []Issue +} + +func (c *staticChecker) Check(_ context.Context) []Issue { + return c.issues +} diff --git a/apps/backend/internal/health/types.go b/apps/backend/internal/health/types.go new file mode 100644 index 0000000000..b2c99f7473 --- /dev/null +++ b/apps/backend/internal/health/types.go @@ -0,0 +1,34 @@ +package health + +import "context" + +// Severity indicates the importance of a health issue. +type Severity string + +const ( + SeverityWarning Severity = "warning" + SeverityError Severity = "error" + SeverityInfo Severity = "info" +) + +// Issue represents a single system health issue. +type Issue struct { + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Message string `json:"message"` + Severity Severity `json:"severity"` + FixURL string `json:"fix_url"` + FixLabel string `json:"fix_label"` +} + +// Response is the API response from /api/v1/system/health. +type Response struct { + Healthy bool `json:"healthy"` + Issues []Issue `json:"issues"` +} + +// Checker is the interface each health check implements. +type Checker interface { + Check(ctx context.Context) []Issue +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 1c94f3bced..55d371c553 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -1,12 +1,38 @@ "use client"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { IconCheck, IconX, IconRefresh } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; +import type { AuthDiagnostics } from "@/lib/types/github"; + +function DiagnosticsOutput({ diagnostics }: { diagnostics: AuthDiagnostics }) { + return ( +
{diagnostics.command}
+ + A non-zero exit code means the command failed. Review the output below for details. +
+ )} +
+ {diagnostics.output.trim()}
+
+ gh auth login or add a GITHUB_TOKEN
- secret
-
+ gh auth login or add a GITHUB_TOKEN
+ secret
+
+
+