diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 4612f563b9..8c23b0ab09 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" + "github.com/kandev/kandev/internal/github" lspinstaller "github.com/kandev/kandev/internal/lsp/installer" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationservice "github.com/kandev/kandev/internal/notifications/service" @@ -53,6 +54,7 @@ func provideGateway( agentRegistry *registry.Registry, notificationRepo notificationstore.Repository, taskRepo *sqliterepo.Repository, + githubSvc *github.Service, ) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, error) { gateway, err := gateways.Provide(log) if err != nil { @@ -86,6 +88,11 @@ func provideGateway( shellHandlers.RegisterHandlers(gateway.Dispatcher) gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, log) + if githubSvc != nil { + gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch string) { + githubSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + }) + } gitHandlers.RegisterHandlers(gateway.Dispatcher) passthroughHandlers := agenthandlers.NewPassthroughHandlers(lifecycleMgr, log) diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 6a82253119..808a0c28a5 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -24,6 +24,7 @@ import ( editorhandlers "github.com/kandev/kandev/internal/editors/handlers" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" + "github.com/kandev/kandev/internal/github" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" @@ -246,6 +247,20 @@ func newSessionStateChangedHandler(gateway *gateways.Gateway, log *logger.Logger } } +// newGitHubTaskPRUpdatedHandler returns an event bus handler that broadcasts +// github.task_pr.updated events to all WebSocket clients. +func newGitHubTaskPRUpdatedHandler(gateway *gateways.Gateway, log *logger.Logger) func(context.Context, *bus.Event) error { + return func(_ context.Context, event *bus.Event) error { + notification, err := ws.NewNotification(ws.ActionGitHubTaskPRUpdated, event.Data) + if err != nil { + log.Error("Failed to create github.task_pr.updated notification", zap.Error(err)) + return nil + } + gateway.Hub.Broadcast(notification) + return nil + } +} + // routeParams holds all dependencies needed for HTTP and WebSocket route registration. type routeParams struct { router *gin.Engine @@ -342,6 +357,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered Sprites handlers (HTTP + WebSocket)") } + if p.services.GitHub != nil { + github.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.GitHub, p.log) + p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.dockerClient, p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 791bb38a83..e8c8365836 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -24,6 +24,9 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + // GitHub integration + githubpkg "github.com/kandev/kandev/internal/github" + // Agent infrastructure "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/lifecycle" @@ -41,6 +44,9 @@ import ( // Orchestrator "github.com/kandev/kandev/internal/orchestrator" + // Repository cloning + "github.com/kandev/kandev/internal/repoclone" + // Secrets "github.com/kandev/kandev/internal/secrets" @@ -201,7 +207,7 @@ func startServices( //nolint:cyclop return false } - services, agentSettingsController, err := provideServices(cfg, log, repos, eventBus, agentRegistry) + services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry) if err != nil { log.Error("Failed to initialize services", zap.Error(err)) return false @@ -280,18 +286,39 @@ func startAgentInfrastructure( lifecycleMgr.SetWorkspaceInfoProvider(services.Task) log.Info("Workspace info provider configured for session recovery") + // ============================================ + // REPO CLONER + // ============================================ + repoCloner := repoclone.NewCloner(repoclone.Config{ + BasePath: cfg.RepoClone.BasePath, + }, repoclone.DetectGitProtocol(), log) + log.Info("Repository cloner configured", + zap.String("base_path", cfg.RepoClone.BasePath)) + // ============================================ // ORCHESTRATOR // ============================================ log.Info("Initializing Orchestrator...") orchestratorSvc, msgCreator, err := provideOrchestrator(log, eventBus, repos.Task, services.Task, services.User, - lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator, repos.Secrets) + lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator, repos.Secrets, repoCloner) if err != nil { log.Error("Failed to initialize orchestrator", zap.Error(err)) return false } + // Wire GitHub service into orchestrator for PR auto-detection on push + if services.GitHub != nil { + orchestratorSvc.SetGitHubService(services.GitHub) + log.Info("GitHub service configured for orchestrator (PR auto-detection enabled)") + + // Start GitHub background poller + ghPoller := githubpkg.NewPoller(services.GitHub, eventBus, log) + ghPoller.Start(ctx) + addCleanup(func() error { ghPoller.Stop(); return nil }) + log.Info("GitHub poller started") + } + return startGatewayAndServe(ctx, cfg, log, eventBus, dockerClient, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, runCleanups) } @@ -320,7 +347,7 @@ func startGatewayAndServe( gateway, _, notificationCtrl, err := provideGateway( ctx, log, eventBus, services.Task, services.User, orchestratorSvc, lifecycleMgr, agentRegistry, - repos.Notification, repos.Task, + repos.Notification, repos.Task, services.GitHub, ) if err != nil { log.Error("Failed to initialize WebSocket gateway", zap.Error(err)) @@ -444,6 +471,13 @@ func subscribeEventBusHandlers(eventBus bus.EventBus, gateway *gateways.Gateway, } else { log.Debug("Subscribed to task_session.state_changed events for WebSocket broadcasting") } + + // GitHub task-PR update broadcasts + if _, err := eventBus.Subscribe(events.GitHubTaskPRUpdated, newGitHubTaskPRUpdatedHandler(gateway, log)); err != nil { + log.Error("Failed to subscribe to github.task_pr.updated events", zap.Error(err)) + } else { + log.Debug("Subscribed to github.task_pr.updated events for WebSocket broadcasting") + } } // awaitShutdown waits for an OS signal then performs graceful shutdown. diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 6ef82069b1..39e96475b8 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -3,13 +3,16 @@ package main import ( "context" "errors" + "fmt" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + taskmodels "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -29,6 +32,7 @@ func provideOrchestrator( workflowSvc *workflowservice.Service, worktreeRecreator *worktree.Recreator, secretStore secrets.SecretStore, + repoCloner *repoclone.Cloner, ) (*orchestrator.Service, *messageCreatorAdapter, error) { if lifecycleMgr == nil { return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)") @@ -56,6 +60,19 @@ func provideOrchestrator( orchestratorSvc.SetWorktreeRecreator(newWorktreeRecreatorAdapter(worktreeRecreator)) } + // Wire review task creator for auto-creating tasks from review watch PRs + orchestratorSvc.SetReviewTaskCreator(&reviewTaskCreatorAdapter{svc: taskSvc}) + + // Wire repository resolver for auto-cloning repos during review task creation + if repoCloner != nil { + orchestratorSvc.SetRepositoryResolver(&repositoryResolverAdapter{ + cloner: repoCloner, + protocol: repoclone.DetectGitProtocol(), + taskSvc: taskSvc, + logger: log, + }) + } + return orchestratorSvc, msgCreator, nil } @@ -79,3 +96,69 @@ func (a *orchestratorWorkflowStepGetterAdapter) GetNextStepByPosition(ctx contex func (a *orchestratorWorkflowStepGetterAdapter) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { return a.svc.GetPreviousStepByPosition(ctx, workflowID, currentPosition) } + +// reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface. +type reviewTaskCreatorAdapter struct { + svc *taskservice.Service +} + +// CreateReviewTask implements orchestrator.ReviewTaskCreator. +func (a *reviewTaskCreatorAdapter) CreateReviewTask(ctx context.Context, req *orchestrator.ReviewTaskRequest) (*taskmodels.Task, error) { + var repos []taskservice.TaskRepositoryInput + for _, r := range req.Repositories { + repos = append(repos, taskservice.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) + } + return a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Title: req.Title, + Description: req.Description, + Metadata: req.Metadata, + Repositories: repos, + }) +} + +// repositoryResolverAdapter resolves GitHub repos by cloning + finding/creating DB records. +type repositoryResolverAdapter struct { + cloner *repoclone.Cloner + protocol string + taskSvc *taskservice.Service + logger *logger.Logger +} + +// ResolveForReview implements orchestrator.RepositoryResolver. +func (a *repositoryResolverAdapter) ResolveForReview( + ctx context.Context, workspaceID, provider, owner, name, defaultBranch string, +) (string, string, error) { + cloneURL, err := repoclone.CloneURL(provider, owner, name, a.protocol) + if err != nil { + return "", "", fmt.Errorf("unsupported provider: %w", err) + } + + localPath, err := a.cloner.EnsureCloned(ctx, cloneURL, owner, name) + if err != nil { + return "", "", fmt.Errorf("clone repository: %w", err) + } + + repo, err := a.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{ + WorkspaceID: workspaceID, + Provider: provider, + ProviderOwner: owner, + ProviderName: name, + DefaultBranch: defaultBranch, + LocalPath: localPath, + }) + if err != nil { + return "", "", fmt.Errorf("find/create repository: %w", err) + } + + baseBranch := defaultBranch + if baseBranch == "" { + baseBranch = repo.DefaultBranch + } + return repo.ID, baseBranch, nil +} diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 496c69aad0..a3db9710fc 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -10,9 +10,12 @@ import ( agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" editorservice "github.com/kandev/kandev/internal/editors/service" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/github" promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/secrets" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -20,7 +23,7 @@ import ( workflowservice "github.com/kandev/kandev/internal/workflow/service" ) -func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus, agentRegistry *registry.Registry) (*Services, *agentsettingscontroller.Controller, error) { +func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry) (*Services, *agentsettingscontroller.Controller, error) { // Load custom TUI agents from DB into registry before discovery loadCustomTUIAgents(context.Background(), repos, agentRegistry, log) @@ -69,12 +72,20 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // Wire workflow provider to workflow service for export/import workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc}) + // Initialize GitHub service + secretsAdapter := &githubSecretAdapter{store: repos.Secrets} + githubSvc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), secretsAdapter, eventBus, log) + if err != nil { + log.Warn("GitHub service initialization failed (non-fatal)", zap.Error(err)) + } + return &Services{ Task: taskSvc, User: userSvc, Editor: editorSvc, Prompts: promptSvc, Workflow: workflowSvc, + GitHub: githubSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -132,6 +143,31 @@ func (a *startStepResolverAdapter) ResolveStartStep(ctx context.Context, workflo return step.ID, nil } +// githubSecretAdapter adapts secrets.SecretStore to github.SecretProvider. +type githubSecretAdapter struct { + store secrets.SecretStore +} + +func (a *githubSecretAdapter) List(ctx context.Context) ([]*github.SecretListItem, error) { + items, err := a.store.List(ctx) + if err != nil { + return nil, err + } + result := make([]*github.SecretListItem, len(items)) + for i, item := range items { + result[i] = &github.SecretListItem{ + ID: item.ID, + Name: item.Name, + HasValue: item.HasValue, + } + } + return result, nil +} + +func (a *githubSecretAdapter) Reveal(ctx context.Context, id string) (string, error) { + return a.store.Reveal(ctx, id) +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index b3cb77bb3f..0a1a31dbf8 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -5,6 +5,7 @@ import ( analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" + "github.com/kandev/kandev/internal/github" notificationservice "github.com/kandev/kandev/internal/notifications/service" notificationstore "github.com/kandev/kandev/internal/notifications/store" promptservice "github.com/kandev/kandev/internal/prompts/service" @@ -37,4 +38,5 @@ type Services struct { Notification *notificationservice.Service Prompts *promptservice.Service Workflow *workflowservice.Service + GitHub *github.Service } diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index ac9efdca14..c3d617d590 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -4,6 +4,7 @@ package handlers import ( "context" "fmt" + "time" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agentctl/client" @@ -12,11 +13,16 @@ import ( "go.uber.org/zap" ) +// PRCreatedCallback is called after a PR is successfully created. +// Parameters: ctx, sessionID, taskID, prURL, branch. +type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch string) + // GitHandlers provides WebSocket handlers for git worktree operations. // Operations are executed via agentctl which runs in the worktree context. type GitHandlers struct { lifecycleMgr *lifecycle.Manager logger *logger.Logger + onPRCreated PRCreatedCallback } // NewGitHandlers creates a new GitHandlers instance @@ -27,6 +33,11 @@ func NewGitHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *GitHan } } +// SetOnPRCreated sets a callback invoked after a PR is successfully created. +func (h *GitHandlers) SetOnPRCreated(cb PRCreatedCallback) { + h.onPRCreated = cb +} + // RegisterHandlers registers git handlers with the WebSocket dispatcher func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorktreePull, h.wsPull) @@ -369,6 +380,22 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess return nil, fmt.Errorf("create PR failed: %w", err) } + // On success, notify callback to associate PR with task. + // Use a timeout-bound context so a stuck callback doesn't leak the goroutine. + if result.Success && result.PRURL != "" && h.onPRCreated != nil { + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if ok && execution.TaskID != "" { + sessionID := req.SessionID + taskID := execution.TaskID + prURL := result.PRURL + go func() { + callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "") + }() + } + } + return ws.NewResponse(msg.ID, msg.Action, result) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 551be2c3ab..f80a34a125 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -202,7 +202,9 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, } // ApplyFileDiff applies a unified diff to a file with conflict detection -// Uses git apply for reliable, battle-tested patch application +// Uses git apply for reliable, battle-tested patch application. +// For symlinked files, resolves to the real path and rewrites the diff header +// so that git apply operates on the actual file. func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) { fullPath, err := wt.resolveSafePath(reqPath) if err != nil { @@ -223,9 +225,22 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or return "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) } + // If the file is a symlink, resolve to the real path and rewrite the diff header. + // git apply cannot patch through symlinks — it needs the real file path. + applyPath := reqPath + realPath, evalErr := filepath.EvalSymlinks(fullPath) + if evalErr == nil && realPath != fullPath { + // File is a symlink. Compute the real path relative to workDir. + realRel, relErr := filepath.Rel(cleanWorkDir, realPath) + if relErr == nil { + unifiedDiff = rewriteDiffPaths(unifiedDiff, reqPath, realRel) + applyPath = realRel + } + } + // Write diff to a temporary patch file patchFile := filepath.Join(wt.workDir, ".kandev-patch.tmp") - err = os.WriteFile(patchFile, []byte(unifiedDiff), 0644) + err = os.WriteFile(patchFile, []byte(unifiedDiff), 0o644) if err != nil { return "", fmt.Errorf("failed to write patch file: %w", err) } @@ -234,7 +249,6 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or }() // Use git apply to apply the patch directly to the file - // This is much more reliable than custom diff application cmd := exec.Command("git", "apply", "-p0", "--unidiff-zero", "--whitespace=nowarn", patchFile) cmd.Dir = wt.workDir @@ -243,8 +257,9 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or return "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) } - // Read the updated content - updatedContent, err := os.ReadFile(fullPath) + // Read the updated content (use the real file path for symlinks) + readPath := filepath.Join(cleanWorkDir, applyPath) + updatedContent, err := os.ReadFile(readPath) if err != nil { return "", fmt.Errorf("failed to read updated file: %w", err) } @@ -252,7 +267,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or // Calculate new hash newHash := calculateSHA256(string(updatedContent)) - // Notify with the relative path + // Notify with the original relative path (not the resolved symlink target) relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) wt.addPendingChange(relPath, types.FileOpWrite) @@ -265,6 +280,33 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or return newHash, nil } +// rewriteDiffPaths replaces file paths in unified diff headers. +// Handles both "--- a/old" / "+++ b/new" (with strip prefix) and +// "--- old" / "+++ new" (p0 mode) formats. +func rewriteDiffPaths(diff, oldPath, newPath string) string { + lines := strings.Split(diff, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "--- ") { + lines[i] = replaceDiffPath(line, "--- ", oldPath, newPath) + } else if strings.HasPrefix(line, "+++ ") { + lines[i] = replaceDiffPath(line, "+++ ", oldPath, newPath) + } + } + return strings.Join(lines, "\n") +} + +// replaceDiffPath replaces oldPath with newPath in a diff header line. +func replaceDiffPath(line, prefix, oldPath, newPath string) string { + rest := line[len(prefix):] + // Handle "--- a/path" or "--- path" formats + cleaned := strings.TrimPrefix(rest, "a/") + cleaned = strings.TrimPrefix(cleaned, "b/") + if cleaned == oldPath || filepath.Clean(cleaned) == filepath.Clean(oldPath) { + return prefix + newPath + } + return line +} + // CreateFile creates a new file in the workspace func (wt *WorkspaceTracker) CreateFile(reqPath string) error { fullPath, err := wt.resolveSafePath(reqPath) diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index cb0c5d2919..af9fe15e96 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -519,6 +519,180 @@ func TestFsOpToString(t *testing.T) { } } +func TestRewriteDiffPaths(t *testing.T) { + tests := []struct { + name string + diff string + oldPath string + newPath string + want string + }{ + { + name: "p0 format (no a/ b/ prefix)", + diff: "--- CLAUDE.md\n+++ CLAUDE.md\n@@ -1,2 +1,2 @@\n-old\n+new", + oldPath: "CLAUDE.md", + newPath: "real/CLAUDE.md", + want: "--- real/CLAUDE.md\n+++ real/CLAUDE.md\n@@ -1,2 +1,2 @@\n-old\n+new", + }, + { + name: "with a/ b/ prefix", + diff: "--- a/CLAUDE.md\n+++ b/CLAUDE.md\n@@ -1 +1 @@\n-old\n+new", + oldPath: "CLAUDE.md", + newPath: "real/CLAUDE.md", + want: "--- real/CLAUDE.md\n+++ real/CLAUDE.md\n@@ -1 +1 @@\n-old\n+new", + }, + { + name: "no match leaves diff unchanged", + diff: "--- other.txt\n+++ other.txt\n@@ -1 +1 @@\n-old\n+new", + oldPath: "CLAUDE.md", + newPath: "real/CLAUDE.md", + want: "--- other.txt\n+++ other.txt\n@@ -1 +1 @@\n-old\n+new", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rewriteDiffPaths(tt.diff, tt.oldPath, tt.newPath) + if got != tt.want { + t.Errorf("rewriteDiffPaths() =\n%s\nwant:\n%s", got, tt.want) + } + }) + } +} + +func TestApplyFileDiff_RegularFile(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Resolve symlinks so workDir matches git's toplevel (macOS /var -> /private/var) + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + // Create a tracked file and commit it (git apply needs the file in the index) + original := "line1\nline2\nline3\n" + writeFile(t, repoDir, "test.txt", original) + runGit(t, repoDir, "add", "test.txt") + runGit(t, repoDir, "commit", "-m", "add test.txt") + + // Build a unified diff that changes line2 -> modified + diff := "--- test.txt\n+++ test.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+modified\n line3\n" + + hash, err := wt.ApplyFileDiff("test.txt", diff, "") + if err != nil { + t.Fatalf("ApplyFileDiff failed: %v", err) + } + if hash == "" { + t.Fatal("expected non-empty hash") + } + + // Verify file content + content, err := os.ReadFile(filepath.Join(repoDir, "test.txt")) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + expected := "line1\nmodified\nline3\n" + if string(content) != expected { + t.Errorf("content = %q, want %q", string(content), expected) + } +} + +func TestApplyFileDiff_Symlink(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Resolve symlinks so workDir matches git's toplevel (macOS /var -> /private/var) + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + // Create a real file in a subdirectory + realDir := filepath.Join(repoDir, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("failed to create real dir: %v", err) + } + original := "line1\nline2\nline3\n" + writeFile(t, repoDir, "real/target.md", original) + + // Create a symlink at the repo root pointing to the real file + symlinkPath := filepath.Join(repoDir, "LINK.md") + if err := os.Symlink(filepath.Join(repoDir, "real", "target.md"), symlinkPath); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + // Commit both files so git apply works + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add real file and symlink") + + // Build a diff targeting the symlink path + diff := "--- LINK.md\n+++ LINK.md\n@@ -1,3 +1,3 @@\n line1\n-line2\n+patched\n line3\n" + + hash, err := wt.ApplyFileDiff("LINK.md", diff, "") + if err != nil { + t.Fatalf("ApplyFileDiff through symlink failed: %v", err) + } + if hash == "" { + t.Fatal("expected non-empty hash") + } + + // Verify the real file was patched (not replaced by a regular file) + realContent, err := os.ReadFile(filepath.Join(repoDir, "real", "target.md")) + if err != nil { + t.Fatalf("failed to read real file: %v", err) + } + expected := "line1\npatched\nline3\n" + if string(realContent) != expected { + t.Errorf("real file content = %q, want %q", string(realContent), expected) + } + + // Verify the symlink still exists (not replaced) + linkInfo, err := os.Lstat(symlinkPath) + if err != nil { + t.Fatalf("failed to lstat symlink: %v", err) + } + if linkInfo.Mode()&os.ModeSymlink == 0 { + t.Error("LINK.md is no longer a symlink after patching") + } + + // Verify reading through the symlink gives the same content + symlinkContent, err := os.ReadFile(symlinkPath) + if err != nil { + t.Fatalf("failed to read symlink: %v", err) + } + if string(symlinkContent) != expected { + t.Errorf("symlink content = %q, want %q", string(symlinkContent), expected) + } +} + +func TestApplyFileDiff_ConflictDetection(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Resolve symlinks so workDir matches git's toplevel (macOS /var -> /private/var) + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + writeFile(t, repoDir, "conflict.txt", "original\n") + runGit(t, repoDir, "add", "conflict.txt") + runGit(t, repoDir, "commit", "-m", "add conflict.txt") + + // Get hash of the original content + origHash := calculateSHA256("original\n") + + // Now modify the file behind the diff's back + writeFile(t, repoDir, "conflict.txt", "modified-by-someone-else\n") + + diff := "--- conflict.txt\n+++ conflict.txt\n@@ -1 +1 @@\n-original\n+patched\n" + + _, err := wt.ApplyFileDiff("conflict.txt", diff, origHash) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + if !strings.Contains(err.Error(), "conflict detected") { + t.Errorf("expected 'conflict detected' error, got: %v", err) + } +} + func TestEmitFileChanges(t *testing.T) { t.Run("zero changes sends refresh", func(t *testing.T) { _, wt := setupTestDir(t) diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index ca08656443..7a8147b3ae 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -24,6 +24,7 @@ type Config struct { Logging LoggingConfig `mapstructure:"logging"` RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"` Worktree WorktreeConfig `mapstructure:"worktree"` + RepoClone RepoCloneConfig `mapstructure:"repoClone"` } // ServerConfig holds HTTP server configuration. @@ -96,6 +97,11 @@ type WorktreeConfig struct { CleanupOnRemove bool `mapstructure:"cleanupOnRemove"` // Remove worktree directory on task deletion } +// RepoCloneConfig holds configuration for automatic repository cloning. +type RepoCloneConfig struct { + BasePath string `mapstructure:"basePath"` // Base directory for cloned repos (default: ~/.kandev/repos) +} + // AgentConfig holds agent runtime configuration. // Note: Runtime selection is now per-task based on executor type, not global. // The Standalone runtime (agentctl) always runs as a core service. @@ -213,6 +219,9 @@ func setDefaults(v *viper.Viper) { v.SetDefault("worktree.basePath", "~/.kandev/worktrees") v.SetDefault("worktree.defaultBranch", "main") v.SetDefault("worktree.cleanupOnRemove", true) + + // RepoClone defaults + v.SetDefault("repoClone.basePath", "~/.kandev/repos") } // DefaultDockerHost returns the platform-appropriate Docker socket path. diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 545a64dcdf..97e7dc59b9 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -156,6 +156,15 @@ const ( SessionModeChanged = "session_mode.changed" // Agent session mode changed ) +// Event types for GitHub integration +const ( + GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) + GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) + GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review + GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) + GitHubWatchEvent = "github.watch.event" // Watch created/deleted +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go new file mode 100644 index 0000000000..8beedecaef --- /dev/null +++ b/apps/backend/internal/github/client.go @@ -0,0 +1,47 @@ +package github + +import ( + "context" + "time" +) + +// Client defines the interface for interacting with the GitHub API. +type Client interface { + // IsAuthenticated checks if the client is authenticated with GitHub. + IsAuthenticated(ctx context.Context) (bool, error) + + // GetAuthenticatedUser returns the username of the authenticated user. + GetAuthenticatedUser(ctx context.Context) (string, error) + + // GetPR retrieves a single pull request by number. + GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) + + // FindPRByBranch finds an open PR for the given head branch. + FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) + + // ListAuthoredPRs lists open PRs authored by the authenticated user for a repo. + ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) + + // ListReviewRequestedPRs lists open PRs where the user's review is requested. + // filter is an optional GitHub search qualifier (e.g. "repo:owner/name" or "org:myorg"). + ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) + + // ListUserOrgs returns the GitHub organizations the authenticated user belongs to. + ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) + + // SearchOrgRepos searches repositories in an organization, optionally filtered by a query string. + SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) + + // ListPRReviews lists reviews on a pull request. + ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) + + // ListPRComments lists review comments on a pull request. + // If since is non-nil, only comments updated after that time are returned. + ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) + + // ListCheckRuns lists CI check runs for a given git ref (branch or SHA). + ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) + + // GetPRFeedback fetches aggregated feedback (reviews, comments, checks) for a PR. + GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) +} diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go new file mode 100644 index 0000000000..7c20182508 --- /dev/null +++ b/apps/backend/internal/github/client_helpers.go @@ -0,0 +1,132 @@ +package github + +import ( + "context" + "time" +) + +// getPRFeedback fetches aggregated feedback for a PR using any Client implementation. +// This shared function eliminates duplication between GHClient and PATClient. +func getPRFeedback(ctx context.Context, c Client, owner, repo string, number int) (*PRFeedback, error) { + pr, err := c.GetPR(ctx, owner, repo, number) + if err != nil { + return nil, err + } + reviews, err := c.ListPRReviews(ctx, owner, repo, number) + if err != nil { + return nil, err + } + comments, err := c.ListPRComments(ctx, owner, repo, number, nil) + if err != nil { + return nil, err + } + checks, err := c.ListCheckRuns(ctx, owner, repo, pr.HeadBranch) + if err != nil { + return nil, err + } + hasIssues := hasFailingChecks(checks) || hasChangesRequested(reviews) + return &PRFeedback{ + PR: pr, + Reviews: reviews, + Comments: comments, + Checks: checks, + HasIssues: hasIssues, + }, nil +} + +// convertRawCheckRuns converts raw ghCheckRun structs into the domain CheckRun type. +func convertRawCheckRuns(raw []ghCheckRun) []CheckRun { + checks := make([]CheckRun, len(raw)) + for i, cr := range raw { + conclusion := "" + if cr.Conclusion != nil { + conclusion = *cr.Conclusion + } + output := "" + if cr.Output.Summary != nil { + output = *cr.Output.Summary + } + checks[i] = CheckRun{ + Name: cr.Name, + Status: cr.Status, + Conclusion: conclusion, + HTMLURL: cr.HTMLURL, + Output: output, + StartedAt: parseTimePtr(cr.StartedAt), + CompletedAt: parseTimePtr(cr.CompletedAt), + } + } + return checks +} + +// convertRawComments converts raw ghComment structs into the domain PRComment type. +func convertRawComments(raw []ghComment) []PRComment { + comments := make([]PRComment, len(raw)) + for i, c := range raw { + comments[i] = PRComment{ + ID: c.ID, + Author: c.User.Login, + AuthorAvatar: c.User.AvatarURL, + Body: c.Body, + Path: c.Path, + Line: c.Line, + Side: c.Side, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + InReplyTo: c.InReplyTo, + } + } + return comments +} + +// convertSearchItemToPR converts common search result fields into a PR struct. +func convertSearchItemToPR( + number int, title, htmlURL, state, authorLogin, repositoryURL string, + draft bool, createdAt, updatedAt time.Time, +) *PR { + owner, repo := parseRepoURL(repositoryURL) + return &PR{ + Number: number, + Title: title, + HTMLURL: htmlURL, + State: state, + AuthorLogin: authorLogin, + Draft: draft, + RepoOwner: owner, + RepoName: repo, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +// latestReviewByAuthor returns a map of the most recent review per author. +func latestReviewByAuthor(reviews []PRReview) map[string]PRReview { + latest := make(map[string]PRReview) + for _, r := range reviews { + existing, ok := latest[r.Author] + if !ok || r.CreatedAt.After(existing.CreatedAt) { + latest[r.Author] = r + } + } + return latest +} + +// hasFailingChecks returns true if any completed check run has failed. +func hasFailingChecks(checks []CheckRun) bool { + for _, c := range checks { + if c.Status == checkStatusCompleted && c.Conclusion == checkConclusionFail { + return true + } + } + return false +} + +// hasChangesRequested returns true if any review has requested changes. +func hasChangesRequested(reviews []PRReview) bool { + for _, r := range reviews { + if r.State == reviewStateChangesRequested { + return true + } + } + return false +} diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go new file mode 100644 index 0000000000..e5c85fff6f --- /dev/null +++ b/apps/backend/internal/github/client_helpers_test.go @@ -0,0 +1,208 @@ +package github + +import ( + "testing" + "time" +) + +func TestConvertRawCheckRuns(t *testing.T) { + conclusion := "success" + summary := "All tests passed" + startedAt := "2025-01-15T10:00:00Z" + completedAt := "2025-01-15T10:05:00Z" + + raw := []ghCheckRun{ + { + Name: "ci/test", + Status: "completed", + Conclusion: &conclusion, + HTMLURL: "https://github.com/owner/repo/runs/1", + StartedAt: startedAt, + CompletedAt: completedAt, + Output: struct { + Title *string `json:"title"` + Summary *string `json:"summary"` + }{Summary: &summary}, + }, + { + Name: "ci/lint", + Status: "in_progress", + Conclusion: nil, + HTMLURL: "https://github.com/owner/repo/runs/2", + }, + } + + checks := convertRawCheckRuns(raw) + + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + + // Check first (completed) + if checks[0].Name != "ci/test" { + t.Errorf("expected name ci/test, got %s", checks[0].Name) + } + if checks[0].Conclusion != "success" { + t.Errorf("expected conclusion success, got %s", checks[0].Conclusion) + } + if checks[0].Output != "All tests passed" { + t.Errorf("expected output 'All tests passed', got %s", checks[0].Output) + } + if checks[0].StartedAt == nil { + t.Error("expected non-nil StartedAt") + } + + // Check second (in progress, nil conclusion) + if checks[1].Conclusion != "" { + t.Errorf("expected empty conclusion, got %s", checks[1].Conclusion) + } + if checks[1].StartedAt != nil { + t.Error("expected nil StartedAt for empty string") + } +} + +func TestConvertRawCheckRunsEmpty(t *testing.T) { + checks := convertRawCheckRuns(nil) + if len(checks) != 0 { + t.Errorf("expected empty slice, got %d", len(checks)) + } +} + +func TestConvertRawComments(t *testing.T) { + now := time.Now() + raw := []ghComment{ + { + ID: 1, + Path: "main.go", + Line: 42, + Side: "RIGHT", + Body: "Looks good", + CreatedAt: now, + UpdatedAt: now, + User: struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + }{Login: "alice", AvatarURL: "https://avatar.example.com/alice"}, + }, + } + + comments := convertRawComments(raw) + + if len(comments) != 1 { + t.Fatalf("expected 1 comment, got %d", len(comments)) + } + if comments[0].Author != "alice" { + t.Errorf("expected author alice, got %s", comments[0].Author) + } + if comments[0].Path != "main.go" { + t.Errorf("expected path main.go, got %s", comments[0].Path) + } + if comments[0].Line != 42 { + t.Errorf("expected line 42, got %d", comments[0].Line) + } +} + +func TestConvertSearchItemToPR(t *testing.T) { + now := time.Now() + pr := convertSearchItemToPR( + 42, "Fix bug", "https://github.com/owner/repo/pull/42", "open", + "alice", "https://api.github.com/repos/myorg/myrepo", false, + now, now, + ) + + if pr.Number != 42 { + t.Errorf("expected number 42, got %d", pr.Number) + } + if pr.Title != "Fix bug" { + t.Errorf("expected title 'Fix bug', got %s", pr.Title) + } + if pr.RepoOwner != "myorg" { + t.Errorf("expected owner myorg, got %s", pr.RepoOwner) + } + if pr.RepoName != "myrepo" { + t.Errorf("expected repo myrepo, got %s", pr.RepoName) + } +} + +func TestLatestReviewByAuthor(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + reviews := []PRReview{ + {Author: "alice", State: "COMMENTED", CreatedAt: t1}, + {Author: "alice", State: "APPROVED", CreatedAt: t2}, + {Author: "bob", State: "CHANGES_REQUESTED", CreatedAt: t1}, + } + + latest := latestReviewByAuthor(reviews) + + if len(latest) != 2 { + t.Fatalf("expected 2 authors, got %d", len(latest)) + } + if latest["alice"].State != "APPROVED" { + t.Errorf("expected alice's latest to be APPROVED, got %s", latest["alice"].State) + } + if latest["bob"].State != "CHANGES_REQUESTED" { + t.Errorf("expected bob's latest to be CHANGES_REQUESTED, got %s", latest["bob"].State) + } +} + +func TestLatestReviewByAuthorEmpty(t *testing.T) { + latest := latestReviewByAuthor(nil) + if len(latest) != 0 { + t.Errorf("expected empty map, got %d entries", len(latest)) + } +} + +func TestHasFailingChecks(t *testing.T) { + tests := []struct { + name string + checks []CheckRun + want bool + }{ + {"empty", nil, false}, + {"all passing", []CheckRun{ + {Status: "completed", Conclusion: "success"}, + }, false}, + {"one failing", []CheckRun{ + {Status: "completed", Conclusion: "success"}, + {Status: "completed", Conclusion: "failure"}, + }, true}, + {"in progress", []CheckRun{ + {Status: "in_progress", Conclusion: ""}, + }, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasFailingChecks(tt.checks) + if got != tt.want { + t.Errorf("hasFailingChecks() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHasChangesRequested(t *testing.T) { + tests := []struct { + name string + reviews []PRReview + want bool + }{ + {"empty", nil, false}, + {"approved", []PRReview{{State: "APPROVED"}}, false}, + {"changes requested", []PRReview{ + {State: "APPROVED"}, + {State: "CHANGES_REQUESTED"}, + }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasChangesRequested(tt.reviews) + if got != tt.want { + t.Errorf("hasChangesRequested() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/github/constants.go b/apps/backend/internal/github/constants.go new file mode 100644 index 0000000000..fe17c41e86 --- /dev/null +++ b/apps/backend/internal/github/constants.go @@ -0,0 +1,30 @@ +package github + +// PR review states from the GitHub API. +const ( + reviewStateApproved = "APPROVED" + reviewStateChangesRequested = "CHANGES_REQUESTED" + reviewStatePending = "PENDING" + reviewStateCommented = "COMMENTED" +) + +// Computed (aggregated) review states. +const ( + computedReviewStateApproved = "approved" + computedReviewStateChangesRequested = "changes_requested" + computedReviewStatePending = "pending" +) + +// Check run status and conclusion values from the GitHub API. +const ( + checkStatusCompleted = "completed" + checkStatusPending = "pending" + checkStatusSuccess = "success" + checkConclusionFail = "failure" +) + +// Watch poll interval bounds in seconds (stored in the database). +const ( + defaultWatchPollIntervalSec = 300 + minWatchPollIntervalSec = 60 +) diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go new file mode 100644 index 0000000000..b6ab5b784b --- /dev/null +++ b/apps/backend/internal/github/controller.go @@ -0,0 +1,260 @@ +package github + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Controller handles HTTP endpoints for GitHub integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// NewController creates a new GitHub controller. +func NewController(svc *Service, log *logger.Logger) *Controller { + return &Controller{service: svc, logger: log} +} + +// RegisterHTTPRoutes registers all GitHub HTTP routes. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/github") + api.GET("/status", c.httpGetStatus) + + api.GET("/task-prs", c.httpListTaskPRs) + api.GET("/task-prs/:taskId", c.httpGetTaskPR) + + api.GET("/prs/:owner/:repo/:number", c.httpGetPRFeedback) + + api.GET("/watches/pr", c.httpListPRWatches) + api.DELETE("/watches/pr/:id", c.httpDeletePRWatch) + + api.GET("/watches/review", c.httpListReviewWatches) + api.POST("/watches/review", c.httpCreateReviewWatch) + api.PUT("/watches/review/:id", c.httpUpdateReviewWatch) + api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch) + api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch) + api.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks) + + api.GET("/orgs", c.httpListUserOrgs) + api.GET("/repos/search", c.httpSearchRepos) + + api.GET("/stats", c.httpGetStats) +} + +func (c *Controller) httpGetStatus(ctx *gin.Context) { + status, err := c.service.GetStatus(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, status) +} + +func (c *Controller) httpListTaskPRs(ctx *gin.Context) { + taskIDsParam := ctx.Query("task_ids") + if taskIDsParam == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "task_ids query parameter required"}) + return + } + taskIDs := strings.Split(taskIDsParam, ",") + result, err := c.service.ListTaskPRs(ctx.Request.Context(), taskIDs) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetTaskPR(ctx *gin.Context) { + taskID := ctx.Param("taskId") + tp, err := c.service.GetTaskPR(ctx.Request.Context(), taskID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if tp == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "no PR associated with task"}) + return + } + ctx.JSON(http.StatusOK, tp) +} + +func (c *Controller) httpGetPRFeedback(ctx *gin.Context) { + owner := ctx.Param("owner") + repo := ctx.Param("repo") + numberStr := ctx.Param("number") + number, err := strconv.Atoi(numberStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) + return + } + feedback, err := c.service.GetPRFeedback(ctx.Request.Context(), owner, repo, number) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, feedback) +} + +func (c *Controller) httpListPRWatches(ctx *gin.Context) { + watches, err := c.service.ListActivePRWatches(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpDeletePRWatch(ctx *gin.Context) { + id := ctx.Param("id") + if err := c.service.DeletePRWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpListReviewWatches(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + watches, err := c.service.ListReviewWatches(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { + var req CreateReviewWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + rw, err := c.service.CreateReviewWatch(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, rw) +} + +func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + var req UpdateReviewWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if err := c.service.UpdateReviewWatch(ctx.Request.Context(), id, &req); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + watch, err := c.service.GetReviewWatch(ctx.Request.Context(), id) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if watch == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "review watch not found"}) + return + } + newPRs, err := c.service.CheckReviewWatch(ctx.Request.Context(), watch) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"new_prs": len(newPRs), "prs": newPRs}) +} + +func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + count, err := c.service.TriggerAllReviewChecks(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"new_prs_found": count}) +} + +func (c *Controller) httpListUserOrgs(ctx *gin.Context) { + orgs, err := c.service.ListUserOrgs(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"orgs": orgs}) +} + +func (c *Controller) httpSearchRepos(ctx *gin.Context) { + org := ctx.Query("org") + query := ctx.Query("q") + if org == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "org query parameter required"}) + return + } + repos, err := c.service.SearchOrgRepos(ctx.Request.Context(), org, query, 20) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"repos": repos}) +} + +func (c *Controller) httpGetStats(ctx *gin.Context) { + req := &PRStatsRequest{ + WorkspaceID: ctx.Query("workspace_id"), + } + if s := ctx.Query("start_date"); s != "" { + t, err := time.Parse("2006-01-02", s) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid start_date format, expected YYYY-MM-DD"}) + return + } + req.StartDate = &t + } + if s := ctx.Query("end_date"); s != "" { + t, err := time.Parse("2006-01-02", s) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid end_date format, expected YYYY-MM-DD"}) + return + } + req.EndDate = &t + } + stats, err := c.service.GetPRStats(ctx.Request.Context(), req) + if err != nil { + c.logger.Error("failed to get PR stats", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, stats) +} diff --git a/apps/backend/internal/github/factory.go b/apps/backend/internal/github/factory.go new file mode 100644 index 0000000000..b6bc422436 --- /dev/null +++ b/apps/backend/internal/github/factory.go @@ -0,0 +1,70 @@ +package github + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SecretProvider is the interface the factory uses to look up a GitHub PAT. +type SecretProvider interface { + List(ctx context.Context) ([]*SecretListItem, error) + Reveal(ctx context.Context, id string) (string, error) +} + +// SecretListItem mirrors secrets.SecretListItem to avoid a direct import cycle. +type SecretListItem struct { + ID string `json:"id"` + Name string `json:"name"` + HasValue bool `json:"has_value"` +} + +// NewClient creates a GitHub client using the best available auth method. +// It tries the gh CLI first, then falls back to a PAT from the secret store. +func NewClient(ctx context.Context, secrets SecretProvider, log *logger.Logger) (Client, string, error) { + // Try gh CLI first + if GHAvailable() { + ghClient := NewGHClient() + ok, err := ghClient.IsAuthenticated(ctx) + if err == nil && ok { + log.Info("using gh CLI for GitHub integration") + return ghClient, "gh_cli", nil + } + log.Debug("gh CLI available but not authenticated", zap.Error(err)) + } + + // Fall back to PAT from secrets store + if secrets != nil { + token, err := findGitHubPAT(ctx, secrets) + if err == nil && token != "" { + log.Info("using PAT from secrets store for GitHub integration") + return NewPATClient(token), "pat", nil + } + if err != nil { + log.Debug("failed to find GitHub PAT in secrets", zap.Error(err)) + } + } + + return nil, "none", fmt.Errorf("no GitHub auth: gh CLI not authenticated and no GITHUB_TOKEN secret found") +} + +// findGitHubPAT looks for a secret named "GITHUB_TOKEN" or "github_token". +func findGitHubPAT(ctx context.Context, secrets SecretProvider) (string, error) { + items, err := secrets.List(ctx) + if err != nil { + return "", fmt.Errorf("list secrets: %w", err) + } + for _, item := range items { + if !item.HasValue { + continue + } + name := item.Name + if name == "GITHUB_TOKEN" || name == "github_token" { + return secrets.Reveal(ctx, item.ID) + } + } + return "", nil +} diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go new file mode 100644 index 0000000000..408aecaf0d --- /dev/null +++ b/apps/backend/internal/github/gh_client.go @@ -0,0 +1,411 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// GHClient implements Client using the gh CLI. +type GHClient struct{} + +// NewGHClient creates a new gh CLI-based client. +func NewGHClient() *GHClient { + return &GHClient{} +} + +// GHAvailable checks if the gh CLI is installed and accessible. +func GHAvailable() bool { + _, err := exec.LookPath("gh") + return err == nil +} + +func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) { + _, 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 + } + return true, nil +} + +func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + out, err := c.run(ctx, "api", "user", "-q", ".login") + if err != nil { + return "", fmt.Errorf("get authenticated user: %w", err) + } + return strings.TrimSpace(out), nil +} + +// ghPR is the JSON shape returned by gh pr list/view. +type ghPR struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + MergedAt string `json:"mergedAt"` + ClosedAt string `json:"closedAt"` + Author struct { + Login string `json:"login"` + } `json:"author"` +} + +func (c *GHClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { + out, err := c.run(ctx, "pr", "view", fmt.Sprintf("%d", number), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt") + if err != nil { + return nil, fmt.Errorf("get PR #%d: %w", number, err) + } + var raw ghPR + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("parse PR response: %w", err) + } + return convertGHPR(&raw, owner, repo), nil +} + +func (c *GHClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) { + out, err := c.run(ctx, "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--head", branch, + "--state", "open", + "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt", + "--limit", "1") + if err != nil { + return nil, fmt.Errorf("find PR by branch %q: %w", branch, err) + } + var prs []ghPR + if err := json.Unmarshal([]byte(out), &prs); err != nil { + return nil, fmt.Errorf("parse PR list: %w", err) + } + if len(prs) == 0 { + return nil, nil + } + return convertGHPR(&prs[0], owner, repo), nil +} + +func (c *GHClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) { + out, err := c.run(ctx, "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--author", "@me", + "--state", "open", + "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt") + if err != nil { + return nil, fmt.Errorf("list authored PRs: %w", err) + } + return c.parsePRList(out, owner, repo) +} + +func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) { + query := "type:pr state:open review-requested:@me" + if filter != "" { + query += " " + filter + } + out, err := c.run(ctx, "api", "search/issues", + "-X", "GET", + "-f", "q="+query, + "-f", "per_page=50", + "--jq", ".items") + if err != nil { + return nil, fmt.Errorf("list review-requested PRs: %w", err) + } + return c.parseSearchResults(out) +} + +func (c *GHClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) { + out, err := c.run(ctx, "api", "user/orgs", "--paginate") + if err != nil { + return nil, fmt.Errorf("list user orgs: %w", err) + } + var raw []struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("parse orgs: %w", err) + } + orgs := make([]GitHubOrg, len(raw)) + for i, r := range raw { + orgs[i] = GitHubOrg{Login: r.Login, AvatarURL: r.AvatarURL} + } + return orgs, nil +} + +func (c *GHClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) { + q := "org:" + org + if query != "" { + q += " " + query + } + if limit <= 0 { + limit = 20 + } + out, err := c.run(ctx, "api", "search/repositories", + "-X", "GET", + "-f", "q="+q, + "-f", fmt.Sprintf("per_page=%d", limit), + "--jq", ".items") + if err != nil { + return nil, fmt.Errorf("search org repos: %w", err) + } + return parseGHSearchRepos(out) +} + +func parseGHSearchRepos(data string) ([]GitHubRepo, error) { + var items []struct { + FullName string `json:"full_name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + Private bool `json:"private"` + } + if err := json.Unmarshal([]byte(data), &items); err != nil { + return nil, fmt.Errorf("parse search repos: %w", err) + } + repos := make([]GitHubRepo, len(items)) + for i, item := range items { + repos[i] = GitHubRepo{ + FullName: item.FullName, + Owner: item.Owner.Login, + Name: item.Name, + Private: item.Private, + } + } + return repos, nil +} + +// ghReview is the JSON shape for reviews from gh pr view. +type ghReview struct { + ID int64 `json:"id"` + Author struct { + Login string `json:"login"` + } `json:"author"` + User struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"user"` + State string `json:"state"` + Body string `json:"body"` + SubmittedAt time.Time `json:"submittedAt"` +} + +func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) { + out, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number), + "--paginate") + if err != nil { + return nil, fmt.Errorf("list PR reviews: %w", err) + } + var raw []ghReview + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("parse reviews: %w", err) + } + reviews := make([]PRReview, len(raw)) + for i, r := range raw { + author := r.Author.Login + avatar := "" + if r.User.Login != "" { + author = r.User.Login + avatar = r.User.AvatarURL + } + reviews[i] = PRReview{ + ID: r.ID, + Author: author, + AuthorAvatar: avatar, + State: r.State, + Body: r.Body, + CreatedAt: r.SubmittedAt, + } + } + return reviews, nil +} + +// ghComment is the JSON shape for review comments from the GitHub API. +type ghComment struct { + ID int64 `json:"id"` + Path string `json:"path"` + Line int `json:"line"` + Side string `json:"side"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + InReplyTo *int64 `json:"in_reply_to_id"` + User struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"user"` +} + +func (c *GHClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { + endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number) + if since != nil { + endpoint += "?since=" + since.Format(time.RFC3339) + } + out, err := c.run(ctx, "api", endpoint, "--paginate") + if err != nil { + return nil, fmt.Errorf("list PR comments: %w", err) + } + var raw []ghComment + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("parse comments: %w", err) + } + return convertRawComments(raw), nil +} + +// ghCheckRun is the JSON shape from the check-runs API. +type ghCheckRun struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion *string `json:"conclusion"` + HTMLURL string `json:"html_url"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + Output struct { + Title *string `json:"title"` + Summary *string `json:"summary"` + } `json:"output"` +} + +func (c *GHClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) { + out, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/commits/%s/check-runs", owner, repo, ref), + "--jq", ".check_runs") + if err != nil { + return nil, fmt.Errorf("list check runs: %w", err) + } + var raw []ghCheckRun + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("parse check runs: %w", err) + } + return convertRawCheckRuns(raw), nil +} + +func (c *GHClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { + return getPRFeedback(ctx, c, owner, repo, number) +} + +// run executes a gh CLI command and returns its stdout output. +// Stderr is captured separately to avoid contaminating JSON output. +func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "gh", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String()) + } + return stdout.String(), nil +} + +func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) { + var raw []ghPR + if err := json.Unmarshal([]byte(data), &raw); err != nil { + return nil, fmt.Errorf("parse PR list: %w", err) + } + prs := make([]*PR, len(raw)) + for i := range raw { + prs[i] = convertGHPR(&raw[i], owner, repo) + } + return prs, nil +} + +// ghSearchItem is a PR item from the GitHub search API. +type ghSearchItem struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Draft bool `json:"draft"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + User struct { + Login string `json:"login"` + } `json:"user"` + PullRequest struct { + URL string `json:"url"` + } `json:"pull_request"` + RepositoryURL string `json:"repository_url"` +} + +func (c *GHClient) parseSearchResults(data string) ([]*PR, error) { + var items []ghSearchItem + if err := json.Unmarshal([]byte(data), &items); err != nil { + return nil, fmt.Errorf("parse search results: %w", err) + } + prs := make([]*PR, len(items)) + for i, item := range items { + prs[i] = convertSearchItemToPR( + item.Number, item.Title, item.HTMLURL, item.State, + item.User.Login, item.RepositoryURL, item.Draft, + item.CreatedAt, item.UpdatedAt, + ) + } + return prs, nil +} + +func convertGHPR(raw *ghPR, owner, repo string) *PR { + state := strings.ToLower(raw.State) + if raw.MergedAt != "" { + state = "merged" + } + pr := &PR{ + Number: raw.Number, + Title: raw.Title, + URL: raw.URL, + HTMLURL: raw.URL, + State: state, + HeadBranch: raw.HeadRefName, + BaseBranch: raw.BaseRefName, + AuthorLogin: raw.Author.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.IsDraft, + Mergeable: raw.Mergeable == "MERGEABLE", + Additions: raw.Additions, + Deletions: raw.Deletions, + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, + MergedAt: parseTimePtr(raw.MergedAt), + ClosedAt: parseTimePtr(raw.ClosedAt), + } + return pr +} + +func parseTimePtr(s string) *time.Time { + if s == "" { + return nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + return &t +} + +// parseRepoURL extracts owner and repo from a GitHub API URL like +// "https://api.github.com/repos/owner/repo". +func parseRepoURL(url string) (string, string) { + parts := strings.Split(url, "/") + if len(parts) < 2 { + return "", "" + } + return parts[len(parts)-2], parts[len(parts)-1] +} diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go new file mode 100644 index 0000000000..f2d3bf7add --- /dev/null +++ b/apps/backend/internal/github/gh_client_test.go @@ -0,0 +1,168 @@ +package github + +import ( + "testing" + "time" +) + +func TestParseTimePtr(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + }{ + {"empty string", "", true}, + {"valid RFC3339", "2025-01-15T10:30:00Z", false}, + {"invalid format", "not-a-date", true}, + {"date only", "2025-01-15", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTimePtr(tt.input) + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %v", *got) + } + return + } + if got == nil { + t.Fatal("expected non-nil, got nil") + } + }) + } +} + +func TestParseTimePtrValue(t *testing.T) { + got := parseTimePtr("2025-06-15T14:30:00Z") + if got == nil { + t.Fatal("expected non-nil") + } + expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC) + if !got.Equal(expected) { + t.Errorf("got %v, want %v", *got, expected) + } +} + +func TestParseRepoURL(t *testing.T) { + tests := []struct { + name string + url string + wantOwner string + wantRepo string + }{ + { + "standard API URL", + "https://api.github.com/repos/myorg/myrepo", + "myorg", "myrepo", + }, + { + "short path", + "owner/repo", + "owner", "repo", + }, + { + "single segment returns empty", + "onlyone", + "", "", + }, + { + "empty", + "", + "", "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo := parseRepoURL(tt.url) + if owner != tt.wantOwner { + t.Errorf("owner = %q, want %q", owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("repo = %q, want %q", repo, tt.wantRepo) + } + }) + } +} + +func TestConvertGHPR(t *testing.T) { + raw := &ghPR{ + Number: 42, + Title: "Test PR", + URL: "https://github.com/owner/repo/pull/42", + State: "OPEN", + HeadRefName: "feature-branch", + BaseRefName: "main", + IsDraft: true, + Mergeable: "MERGEABLE", + Additions: 100, + Deletions: 50, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + Author: struct { + Login string `json:"login"` + }{Login: "alice"}, + } + + pr := convertGHPR(raw, "owner", "repo") + + if pr.Number != 42 { + t.Errorf("number = %d, want 42", pr.Number) + } + if pr.State != "open" { + t.Errorf("state = %q, want open", pr.State) + } + if pr.HeadBranch != "feature-branch" { + t.Errorf("head_branch = %q, want feature-branch", pr.HeadBranch) + } + if !pr.Draft { + t.Error("expected draft = true") + } + if !pr.Mergeable { + t.Error("expected mergeable = true") + } + if pr.Additions != 100 { + t.Errorf("additions = %d, want 100", pr.Additions) + } + if pr.MergedAt != nil { + t.Error("expected nil MergedAt") + } +} + +func TestConvertGHPR_Merged(t *testing.T) { + raw := &ghPR{ + Number: 1, + State: "CLOSED", + MergedAt: "2025-01-10T12:00:00Z", + Author: struct { + Login string `json:"login"` + }{Login: "bob"}, + } + + pr := convertGHPR(raw, "owner", "repo") + + if pr.State != "merged" { + t.Errorf("state = %q, want merged", pr.State) + } + if pr.MergedAt == nil { + t.Error("expected non-nil MergedAt") + } +} + +func TestConvertGHPR_NotMergeable(t *testing.T) { + raw := &ghPR{ + Number: 1, + State: "OPEN", + Mergeable: "CONFLICTING", + Author: struct { + Login string `json:"login"` + }{Login: "alice"}, + } + + pr := convertGHPR(raw, "owner", "repo") + + if pr.Mergeable { + t.Error("expected mergeable = false for CONFLICTING") + } +} diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go new file mode 100644 index 0000000000..160b97add2 --- /dev/null +++ b/apps/backend/internal/github/handlers.go @@ -0,0 +1,264 @@ +package github + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes registers HTTP and WebSocket routes for GitHub integration. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := NewController(svc, log) + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc, log) +} + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + dispatcher.RegisterFunc(ws.ActionGitHubStatus, wsStatus(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubTaskPRsList, wsListTaskPRs(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubTaskPRGet, wsGetTaskPR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubPRFeedbackGet, wsGetPRFeedback(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewWatchesList, wsListReviewWatches(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewWatchCreate, wsCreateReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewWatchUpdate, wsUpdateReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewWatchDelete, wsDeleteReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewTrigger, wsTriggerReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubReviewTriggerAll, wsTriggerAllReviewChecks(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubPRWatchesList, wsListPRWatches(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubPRWatchDelete, wsDeletePRWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubStats, wsGetStats(svc, log)) +} + +// parseMap parses the WS message payload into a map for simple field lookups. +// Returns the map and any parse error. A nil map is replaced with an empty map. +func parseMap(msg *ws.Message) (map[string]interface{}, error) { + var m map[string]interface{} + err := msg.ParsePayload(&m) + if m == nil { + m = make(map[string]interface{}) + } + return m, err +} + +// wsDeleteByID returns a WS handler that parses an "id" field from the payload +// and calls deleteFn. Used by both wsDeletePRWatch and wsDeleteReviewWatch. +func wsDeleteByID(deleteFn func(ctx context.Context, id string) error) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + id, _ := payload["id"].(string) + if id == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "id required", nil) + } + if err := deleteFn(ctx, id); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"deleted": true}) + } +} + +func wsStatus(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + status, err := svc.GetStatus(ctx) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, status) + } +} + +func wsListTaskPRs(svc *Service, log *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + taskIDsStr, _ := payload["task_ids"].(string) + if taskIDsStr == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_ids required", nil) + } + taskIDs := strings.Split(taskIDsStr, ",") + result, err := svc.ListTaskPRs(ctx, taskIDs) + if err != nil { + log.Error("ws: list task PRs failed", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, result) + } +} + +func wsGetTaskPR(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + taskID, _ := payload["task_id"].(string) + if taskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_id required", nil) + } + tp, err := svc.GetTaskPR(ctx, taskID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if tp == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "no PR for task", nil) + } + return ws.NewResponse(msg.ID, msg.Action, tp) + } +} + +func wsGetPRFeedback(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + owner, _ := payload["owner"].(string) + repo, _ := payload["repo"].(string) + numberF, _ := payload["number"].(float64) + number := int(numberF) + if owner == "" || repo == "" || number == 0 { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "owner, repo, number required", nil) + } + feedback, err := svc.GetPRFeedback(ctx, owner, repo, number) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, feedback) + } +} + +func wsListReviewWatches(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + watches, err := svc.ListReviewWatches(ctx, workspaceID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, watches) + } +} + +func wsCreateReviewWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateReviewWatchRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + rw, err := svc.CreateReviewWatch(ctx, &req) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, rw) + } +} + +func wsUpdateReviewWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + ID string `json:"id"` + UpdateReviewWatchRequest + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if payload.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "id required", nil) + } + req := payload.UpdateReviewWatchRequest + if err := svc.UpdateReviewWatch(ctx, payload.ID, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"updated": true}) + } +} + +func wsDeleteReviewWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return wsDeleteByID(svc.DeleteReviewWatch) +} + +func wsTriggerReviewWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + id, _ := payload["id"].(string) + if id == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "id required", nil) + } + watch, err := svc.GetReviewWatch(ctx, id) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if watch == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "review watch not found", nil) + } + newPRs, err := svc.CheckReviewWatch(ctx, watch) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"new_prs": len(newPRs), "prs": newPRs}) + } +} + +func wsTriggerAllReviewChecks(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+parseErr.Error(), nil) + } + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + count, err := svc.TriggerAllReviewChecks(ctx, workspaceID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]int{"new_prs_found": count}) + } +} + +func wsListPRWatches(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + watches, err := svc.ListActivePRWatches(ctx) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, watches) + } +} + +func wsDeletePRWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return wsDeleteByID(svc.DeletePRWatch) +} + +func wsGetStats(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req PRStatsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + stats, err := svc.GetPRStats(ctx, &req) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, stats) + } +} diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go new file mode 100644 index 0000000000..8be645d7c4 --- /dev/null +++ b/apps/backend/internal/github/models.go @@ -0,0 +1,245 @@ +// Package github provides GitHub integration for Kandev, including PR monitoring, +// review queue management, and CI/check status tracking. +package github + +import "time" + +// PR represents a GitHub Pull Request. +type PR struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // open, closed, merged + HeadBranch string `json:"head_branch"` + BaseBranch string `json:"base_branch"` + AuthorLogin string `json:"author_login"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + Draft bool `json:"draft"` + Mergeable bool `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` +} + +// PRReview represents a review on a PR. +type PRReview struct { + ID int64 `json:"id"` + Author string `json:"author"` + AuthorAvatar string `json:"author_avatar"` + State string `json:"state"` // APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` +} + +// PRComment represents a review comment on specific code. +type PRComment struct { + ID int64 `json:"id"` + Author string `json:"author"` + AuthorAvatar string `json:"author_avatar"` + Body string `json:"body"` + Path string `json:"path"` + Line int `json:"line"` + Side string `json:"side"` // LEFT, RIGHT + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + InReplyTo *int64 `json:"in_reply_to,omitempty"` +} + +// CheckRun represents a CI check result. +type CheckRun struct { + Name string `json:"name"` + Status string `json:"status"` // queued, in_progress, completed + Conclusion string `json:"conclusion"` // success, failure, neutral, cancelled, timed_out, action_required, skipped + HTMLURL string `json:"html_url"` + Output string `json:"output"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +// PRFeedback aggregates all feedback for a PR (fetched live from GitHub). +type PRFeedback struct { + PR *PR `json:"pr"` + Reviews []PRReview `json:"reviews"` + Comments []PRComment `json:"comments"` + Checks []CheckRun `json:"checks"` + HasIssues bool `json:"has_issues"` +} + +// PRWatch tracks active PR monitoring (session → PR). +type PRWatch struct { + ID string `json:"id" db:"id"` + SessionID string `json:"session_id" db:"session_id"` + TaskID string `json:"task_id" db:"task_id"` + Owner string `json:"owner" db:"owner"` + Repo string `json:"repo" db:"repo"` + PRNumber int `json:"pr_number" db:"pr_number"` + Branch string `json:"branch" db:"branch"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty" db:"last_checked_at"` + LastCommentAt *time.Time `json:"last_comment_at,omitempty" db:"last_comment_at"` + LastCheckStatus string `json:"last_check_status" db:"last_check_status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TaskPR associates a PR with a task. +type TaskPR struct { + ID string `json:"id" db:"id"` + TaskID string `json:"task_id" db:"task_id"` + Owner string `json:"owner" db:"owner"` + Repo string `json:"repo" db:"repo"` + PRNumber int `json:"pr_number" db:"pr_number"` + PRURL string `json:"pr_url" db:"pr_url"` + PRTitle string `json:"pr_title" db:"pr_title"` + HeadBranch string `json:"head_branch" db:"head_branch"` + BaseBranch string `json:"base_branch" db:"base_branch"` + AuthorLogin string `json:"author_login" db:"author_login"` + State string `json:"state" db:"state"` // open, closed, merged + ReviewState string `json:"review_state" db:"review_state"` // approved, changes_requested, pending, "" + ChecksState string `json:"checks_state" db:"checks_state"` // success, failure, pending, "" + ReviewCount int `json:"review_count" db:"review_count"` + PendingReviewCount int `json:"pending_review_count" db:"pending_review_count"` + CommentCount int `json:"comment_count" db:"comment_count"` + Additions int `json:"additions" db:"additions"` + Deletions int `json:"deletions" db:"deletions"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + MergedAt *time.Time `json:"merged_at,omitempty" db:"merged_at"` + ClosedAt *time.Time `json:"closed_at,omitempty" db:"closed_at"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// RepoFilter identifies a GitHub repository for review watch filtering. +type RepoFilter struct { + Owner string `json:"owner"` + Name string `json:"name"` +} + +// ReviewWatch configures periodic polling for PRs needing the user's review. +// Repos holds the list of repositories to monitor. An empty list means all repos. +type ReviewWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + WorkflowID string `json:"workflow_id" db:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` + Repos []RepoFilter `json:"repos" db:"-"` + ReposJSON string `json:"-" db:"repos"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` + LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ReviewPRTask records which PRs have already had tasks created (deduplication). +type ReviewPRTask struct { + ID string `json:"id" db:"id"` + ReviewWatchID string `json:"review_watch_id" db:"review_watch_id"` + RepoOwner string `json:"repo_owner" db:"repo_owner"` + RepoName string `json:"repo_name" db:"repo_name"` + PRNumber int `json:"pr_number" db:"pr_number"` + PRURL string `json:"pr_url" db:"pr_url"` + TaskID string `json:"task_id" db:"task_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// GitHubOrg represents a GitHub organization the authenticated user belongs to. +type GitHubOrg struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` +} + +// GitHubRepo represents a GitHub repository (lightweight, for autocomplete). +type GitHubRepo struct { + FullName string `json:"full_name"` + Owner string `json:"owner"` + Name string `json:"name"` + Private bool `json:"private"` +} + +// 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" +} + +// CreateReviewWatchRequest is the request body for creating a review watch. +type CreateReviewWatchRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Repos []RepoFilter `json:"repos"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"poll_interval_seconds"` +} + +// UpdateReviewWatchRequest is the request body for updating a review watch. +type UpdateReviewWatchRequest struct { + WorkflowID *string `json:"workflow_id,omitempty"` + WorkflowStepID *string `json:"workflow_step_id,omitempty"` + Repos *[]RepoFilter `json:"repos,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` + ExecutorProfileID *string `json:"executor_profile_id,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` +} + +// PRFeedbackEvent is published to the event bus when a PR has new feedback. +type PRFeedbackEvent struct { + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + PRNumber int `json:"pr_number"` + Owner string `json:"owner"` + Repo string `json:"repo"` + NewComments int `json:"new_comments"` + ChecksChanged bool `json:"checks_changed"` + NewCheckStatus string `json:"new_check_status"` + NewReviewState string `json:"new_review_state"` +} + +// NewReviewPREvent is published when a new PR needing review is found. +type NewReviewPREvent struct { + ReviewWatchID string `json:"review_watch_id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + PR *PR `json:"pr"` +} + +// PRStatsRequest defines filters for PR stats queries. +type PRStatsRequest struct { + WorkspaceID string `json:"workspace_id"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +// PRStats holds aggregated PR analytics. +type PRStats struct { + TotalPRsCreated int `json:"total_prs_created"` + TotalPRsReviewed int `json:"total_prs_reviewed"` + TotalComments int `json:"total_comments"` + CIPassRate float64 `json:"ci_pass_rate"` + ApprovalRate float64 `json:"approval_rate"` + AvgTimeToMergeHours float64 `json:"avg_time_to_merge_hours"` + PRsByDay []DailyCount `json:"prs_by_day"` +} + +// DailyCount holds a date and count for chart data. +type DailyCount struct { + Date string `json:"date"` + Count int `json:"count"` +} diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go new file mode 100644 index 0000000000..7408cf1e7c --- /dev/null +++ b/apps/backend/internal/github/pat_client.go @@ -0,0 +1,316 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const githubAPIBase = "https://api.github.com" + +// PATClient implements Client using a GitHub Personal Access Token. +type PATClient struct { + token string + httpClient *http.Client + username string // cached after first GetAuthenticatedUser call +} + +// NewPATClient creates a new PAT-based GitHub client. +func NewPATClient(token string) *PATClient { + return &PATClient{ + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) { + _, err := c.GetAuthenticatedUser(ctx) + return err == nil, nil +} + +func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + if c.username != "" { + return c.username, nil + } + var user struct { + Login string `json:"login"` + } + if err := c.get(ctx, "/user", &user); err != nil { + return "", err + } + c.username = user.Login + return c.username, nil +} + +func (c *PATClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { + var raw patPR + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("get PR #%d: %w", number, err) + } + return convertPatPR(&raw, owner, repo), nil +} + +func (c *PATClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) { + var raw []patPR + endpoint := fmt.Sprintf("/repos/%s/%s/pulls?head=%s:%s&state=open&per_page=1", + owner, repo, owner, branch) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("find PR by branch: %w", err) + } + if len(raw) == 0 { + return nil, nil + } + return convertPatPR(&raw[0], owner, repo), nil +} + +func (c *PATClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) { + user, err := c.GetAuthenticatedUser(ctx) + if err != nil { + return nil, err + } + var raw []patPR + endpoint := fmt.Sprintf("/repos/%s/%s/pulls?state=open&per_page=100", owner, repo) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("list PRs: %w", err) + } + var result []*PR + for i := range raw { + if raw[i].User.Login == user { + result = append(result, convertPatPR(&raw[i], owner, repo)) + } + } + return result, nil +} + +func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) { + query := "type:pr state:open review-requested:@me" + if filter != "" { + query += " " + filter + } + var result struct { + Items []patSearchItem `json:"items"` + } + endpoint := "/search/issues?q=" + url.QueryEscape(query) + "&per_page=50" + if err := c.get(ctx, endpoint, &result); err != nil { + return nil, fmt.Errorf("search review-requested: %w", err) + } + prs := make([]*PR, len(result.Items)) + for i, item := range result.Items { + prs[i] = convertSearchItemToPR( + item.Number, item.Title, item.HTMLURL, item.State, + item.User.Login, item.RepositoryURL, item.Draft, + item.CreatedAt, item.UpdatedAt, + ) + } + return prs, nil +} + +func (c *PATClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) { + var raw []struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } + if err := c.get(ctx, "/user/orgs?per_page=100", &raw); err != nil { + return nil, fmt.Errorf("list user orgs: %w", err) + } + orgs := make([]GitHubOrg, len(raw)) + for i, r := range raw { + orgs[i] = GitHubOrg{Login: r.Login, AvatarURL: r.AvatarURL} + } + return orgs, nil +} + +func (c *PATClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) { + q := "org:" + org + if query != "" { + q += " " + query + } + if limit <= 0 { + limit = 20 + } + var result struct { + Items []struct { + FullName string `json:"full_name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + Private bool `json:"private"` + } `json:"items"` + } + endpoint := fmt.Sprintf("/search/repositories?q=%s&per_page=%d", url.QueryEscape(q), limit) + if err := c.get(ctx, endpoint, &result); err != nil { + return nil, fmt.Errorf("search org repos: %w", err) + } + repos := make([]GitHubRepo, len(result.Items)) + for i, item := range result.Items { + repos[i] = GitHubRepo{ + FullName: item.FullName, + Owner: item.Owner.Login, + Name: item.Name, + Private: item.Private, + } + } + return repos, nil +} + +func (c *PATClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) { + var raw []struct { + ID int64 `json:"id"` + State string `json:"state"` + Body string `json:"body"` + SubmittedAt time.Time `json:"submitted_at"` + User struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"user"` + } + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, err + } + reviews := make([]PRReview, len(raw)) + for i, r := range raw { + reviews[i] = PRReview{ + ID: r.ID, + Author: r.User.Login, + AuthorAvatar: r.User.AvatarURL, + State: r.State, + Body: r.Body, + CreatedAt: r.SubmittedAt, + } + } + return reviews, nil +} + +func (c *PATClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", owner, repo, number) + if since != nil { + endpoint += "&since=" + since.Format(time.RFC3339) + } + var raw []ghComment + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, err + } + return convertRawComments(raw), nil +} + +func (c *PATClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) { + var result struct { + CheckRuns []ghCheckRun `json:"check_runs"` + } + endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs", owner, repo, ref) + if err := c.get(ctx, endpoint, &result); err != nil { + return nil, err + } + return convertRawCheckRuns(result.CheckRuns), nil +} + +func (c *PATClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { + return getPRFeedback(ctx, c, owner, repo, number) +} + +func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error { + url := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("GitHub API %s returned %d: %s", endpoint, resp.StatusCode, string(body)) + } + return json.NewDecoder(resp.Body).Decode(result) +} + +// patPR is the JSON shape from the GitHub REST API for PRs. +type patPR struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Draft bool `json:"draft"` + Mergeable *bool `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *string `json:"merged_at"` + ClosedAt *string `json:"closed_at"` + User struct { + Login string `json:"login"` + } `json:"user"` + Head struct { + Ref string `json:"ref"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + } `json:"base"` +} + +type patSearchItem struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Draft bool `json:"draft"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RepositoryURL string `json:"repository_url"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +func convertPatPR(raw *patPR, owner, repo string) *PR { + state := strings.ToLower(raw.State) + if raw.MergedAt != nil && *raw.MergedAt != "" { + state = "merged" + } + mergeable := false + if raw.Mergeable != nil { + mergeable = *raw.Mergeable + } + pr := &PR{ + Number: raw.Number, + Title: raw.Title, + HTMLURL: raw.HTMLURL, + State: state, + HeadBranch: raw.Head.Ref, + BaseBranch: raw.Base.Ref, + AuthorLogin: raw.User.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.Draft, + Mergeable: mergeable, + Additions: raw.Additions, + Deletions: raw.Deletions, + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, + } + if raw.MergedAt != nil { + pr.MergedAt = parseTimePtr(*raw.MergedAt) + } + if raw.ClosedAt != nil { + pr.ClosedAt = parseTimePtr(*raw.ClosedAt) + } + return pr +} diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go new file mode 100644 index 0000000000..8ea7417eee --- /dev/null +++ b/apps/backend/internal/github/pat_client_test.go @@ -0,0 +1,100 @@ +package github + +import ( + "testing" + "time" +) + +func TestConvertPatPR(t *testing.T) { + raw := &patPR{ + Number: 10, + Title: "Feature Y", + HTMLURL: "https://github.com/org/repo/pull/10", + State: "open", + Draft: false, + Additions: 200, + Deletions: 30, + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + User: struct { + Login string `json:"login"` + }{Login: "bob"}, + Head: struct { + Ref string `json:"ref"` + }{Ref: "feature-y"}, + Base: struct { + Ref string `json:"ref"` + }{Ref: "main"}, + } + + pr := convertPatPR(raw, "org", "repo") + + if pr.Number != 10 { + t.Errorf("number = %d, want 10", pr.Number) + } + if pr.State != "open" { + t.Errorf("state = %q, want open", pr.State) + } + if pr.AuthorLogin != "bob" { + t.Errorf("author = %q, want bob", pr.AuthorLogin) + } + if pr.HeadBranch != "feature-y" { + t.Errorf("head = %q, want feature-y", pr.HeadBranch) + } + if pr.Mergeable { + t.Error("expected mergeable = false when nil") + } + if pr.MergedAt != nil { + t.Error("expected nil MergedAt") + } +} + +func TestConvertPatPR_Merged(t *testing.T) { + mergedAt := "2025-03-05T10:00:00Z" + raw := &patPR{ + Number: 5, + State: "closed", + MergedAt: &mergedAt, + User: struct { + Login string `json:"login"` + }{Login: "alice"}, + Head: struct { + Ref string `json:"ref"` + }{Ref: "fix"}, + Base: struct { + Ref string `json:"ref"` + }{Ref: "main"}, + } + + pr := convertPatPR(raw, "org", "repo") + + if pr.State != "merged" { + t.Errorf("state = %q, want merged", pr.State) + } + if pr.MergedAt == nil { + t.Fatal("expected non-nil MergedAt") + } +} + +func TestConvertPatPR_Mergeable(t *testing.T) { + mergeable := true + raw := &patPR{ + Number: 1, + State: "open", + Mergeable: &mergeable, + User: struct { + Login string `json:"login"` + }{Login: "alice"}, + Head: struct { + Ref string `json:"ref"` + }{Ref: "b"}, + Base: struct { + Ref string `json:"ref"` + }{Ref: "main"}, + } + + pr := convertPatPR(raw, "o", "r") + if !pr.Mergeable { + t.Error("expected mergeable = true") + } +} diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go new file mode 100644 index 0000000000..207d4ced9b --- /dev/null +++ b/apps/backend/internal/github/poller.go @@ -0,0 +1,243 @@ +package github + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +const ( + defaultPRPollInterval = 1 * time.Minute + defaultReviewPollInterval = 5 * time.Minute +) + +// Poller runs background loops for PR monitoring and review queue checking. +type Poller struct { + service *Service + eventBus bus.EventBus + logger *logger.Logger + + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller creates a new background poller. +func NewPoller(svc *Service, eventBus bus.EventBus, log *logger.Logger) *Poller { + return &Poller{ + service: svc, + eventBus: eventBus, + logger: log, + } +} + +// Start begins the background polling loops. +// Calling Start more than once without Stop is a no-op. +func (p *Poller) Start(ctx context.Context) { + if p.started { + return + } + p.started = true + ctx, p.cancel = context.WithCancel(ctx) + + p.wg.Add(2) //nolint:mnd + go p.prMonitorLoop(ctx) + go p.reviewQueueLoop(ctx) + + p.logger.Info("GitHub poller started") +} + +// Stop cancels the polling loops and waits for them to finish. +func (p *Poller) Stop() { + if !p.started { + return + } + if p.cancel != nil { + p.cancel() + } + p.wg.Wait() + p.started = false + p.logger.Info("GitHub poller stopped") +} + +// prMonitorLoop polls PR watches for new feedback. +func (p *Poller) prMonitorLoop(ctx context.Context) { + defer p.wg.Done() + + // Run an initial check immediately so existing watches are evaluated on startup. + p.checkPRWatches(ctx) + + ticker := time.NewTicker(defaultPRPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.checkPRWatches(ctx) + } + } +} + +func (p *Poller) checkPRWatches(ctx context.Context) { + watches, err := p.service.ListActivePRWatches(ctx) + if err != nil { + p.logger.Error("failed to list PR watches", zap.Error(err)) + return + } + for _, watch := range watches { + p.checkSinglePRWatch(ctx, watch) + } +} + +func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { + // PRWatch with pr_number=0 means we're still searching for a PR on this branch. + if watch.PRNumber == 0 { + p.detectPRForWatch(ctx, watch) + return + } + + feedback, hasNew, err := p.service.CheckPRWatch(ctx, watch) + if err != nil { + p.logger.Debug("failed to check PR watch", + zap.String("id", watch.ID), zap.Error(err)) + return + } + if !hasNew || feedback == nil { + return + } + + // Sync task-PR record with latest data + if syncErr := p.service.SyncTaskPR(ctx, watch.TaskID, feedback); syncErr != nil { + p.logger.Error("failed to sync task PR", zap.String("task_id", watch.TaskID), zap.Error(syncErr)) + } + + // Publish feedback event for UI notification + p.publishPRFeedbackEvent(ctx, watch, feedback) +} + +// detectPRForWatch searches GitHub for a PR on the watch's branch. +// If found, updates the watch with the PR number and creates the TaskPR association. +func (p *Poller) detectPRForWatch(ctx context.Context, watch *PRWatch) { + if p.service.client == nil { + return + } + + pr, err := p.service.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch) + if err != nil { + p.logger.Debug("failed to search for PR by branch", + zap.String("watch_id", watch.ID), + zap.String("branch", watch.Branch), + zap.Error(err)) + return + } + + // Update last_checked_at regardless of result + now := time.Now().UTC() + _ = p.service.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, "") + + if pr == nil { + return + } + + // Found a PR — update the watch and create association + if updateErr := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, pr.Number); updateErr != nil { + p.logger.Error("failed to update PR watch with detected PR", + zap.String("watch_id", watch.ID), + zap.Int("pr_number", pr.Number), + zap.Error(updateErr)) + return + } + + if _, assocErr := p.service.AssociatePRWithTask(ctx, watch.TaskID, pr); assocErr != nil { + p.logger.Error("failed to associate detected PR with task", + zap.String("task_id", watch.TaskID), + zap.Int("pr_number", pr.Number), + zap.Error(assocErr)) + return + } + + p.logger.Info("detected PR for session branch", + zap.String("watch_id", watch.ID), + zap.String("branch", watch.Branch), + zap.Int("pr_number", pr.Number)) +} + +func (p *Poller) publishPRFeedbackEvent(ctx context.Context, watch *PRWatch, feedback *PRFeedback) { + if p.eventBus == nil { + return + } + evt := &PRFeedbackEvent{ + SessionID: watch.SessionID, + TaskID: watch.TaskID, + PRNumber: watch.PRNumber, + Owner: watch.Owner, + Repo: watch.Repo, + NewComments: len(feedback.Comments), + ChecksChanged: true, + NewCheckStatus: computeOverallCheckStatus(feedback.Checks), + NewReviewState: computeOverallReviewState(feedback.Reviews), + } + event := bus.NewEvent(events.GitHubPRFeedback, "github_poller", evt) + if err := p.eventBus.Publish(ctx, events.GitHubPRFeedback, event); err != nil { + p.logger.Debug("failed to publish PR feedback event", zap.Error(err)) + } +} + +// reviewQueueLoop polls review watches for new PRs. +func (p *Poller) reviewQueueLoop(ctx context.Context) { + defer p.wg.Done() + + // Run an initial check immediately so existing watches are evaluated on startup. + p.checkReviewWatches(ctx) + + ticker := time.NewTicker(defaultReviewPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.checkReviewWatches(ctx) + } + } +} + +func (p *Poller) checkReviewWatches(ctx context.Context) { + watches, err := p.service.store.ListEnabledReviewWatches(ctx) + if err != nil { + p.logger.Error("failed to list review watches", zap.Error(err)) + return + } + if len(watches) == 0 { + return + } + p.logger.Debug("checking review watches", zap.Int("count", len(watches))) + for _, watch := range watches { + newPRs, err := p.service.CheckReviewWatch(ctx, watch) + if err != nil { + p.logger.Debug("failed to check review watch", + zap.String("id", watch.ID), zap.Error(err)) + continue + } + p.logger.Debug("review watch checked", + zap.String("watch_id", watch.ID), + zap.Int("new_prs", len(newPRs))) + for _, pr := range newPRs { + p.logger.Info("new PR found for review", + zap.String("watch_id", watch.ID), + zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), + zap.Int("pr_number", pr.Number), + zap.String("title", pr.Title)) + p.service.publishNewReviewPREvent(ctx, watch, pr) + } + } +} diff --git a/apps/backend/internal/github/provider.go b/apps/backend/internal/github/provider.go new file mode 100644 index 0000000000..d19541cff3 --- /dev/null +++ b/apps/backend/internal/github/provider.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +// Provide creates the full GitHub integration stack: store, client, and service. +// Returns the service, a cleanup function, and any error. +func Provide( + writer, reader *sqlx.DB, + secrets SecretProvider, + eventBus bus.EventBus, + log *logger.Logger, +) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + + ctx := context.Background() + client, authMethod, err := NewClient(ctx, secrets, log) + if err != nil { + // Not fatal — service works with nil client (unauthenticated). + log.Warn("GitHub client not available: " + err.Error()) + } + + svc := NewService(client, authMethod, 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 new file mode 100644 index 0000000000..85f5f9cf75 --- /dev/null +++ b/apps/backend/internal/github/service.go @@ -0,0 +1,683 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// Service coordinates GitHub integration operations. +type Service struct { + client Client + authMethod string + 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 { + return &Service{ + client: client, + authMethod: authMethod, + store: store, + eventBus: eventBus, + logger: log, + } +} + +// Client returns the underlying GitHub client (may be nil if not authenticated). +func (s *Service) Client() Client { + return s.client +} + +// GetStatus returns the current GitHub connection status. +func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { + status := &GitHubStatus{ + AuthMethod: s.authMethod, + } + if s.client == nil { + return status, nil + } + ok, err := s.client.IsAuthenticated(ctx) + if err != nil { + return status, nil + } + status.Authenticated = ok + if ok { + user, err := s.client.GetAuthenticatedUser(ctx) + if err == nil { + status.Username = user + } + } + return status, nil +} + +// --- PR Watch operations --- + +// CreatePRWatch creates a new PR watch for a session. +func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySession(ctx, sessionID) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil // already watching + } + w := &PRWatch{ + SessionID: sessionID, + TaskID: taskID, + Owner: owner, + Repo: repo, + PRNumber: prNumber, + Branch: branch, + } + if err := s.store.CreatePRWatch(ctx, w); err != nil { + return nil, fmt.Errorf("create PR watch: %w", err) + } + s.logger.Info("created PR watch", + zap.String("session_id", sessionID), + zap.Int("pr_number", prNumber)) + return w, nil +} + +// GetPRWatchBySession returns the PR watch for a session. +func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { + return s.store.GetPRWatchBySession(ctx, sessionID) +} + +// ListActivePRWatches returns all active PR watches. +func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { + return s.store.ListActivePRWatches(ctx) +} + +// DeletePRWatch deletes a PR watch by ID. +func (s *Service) DeletePRWatch(ctx context.Context, id string) error { + return s.store.DeletePRWatch(ctx, id) +} + +// CheckPRWatch fetches latest feedback for a PR watch and determines if there are changes. +func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRFeedback, bool, error) { + if s.client == nil { + return nil, false, fmt.Errorf("github client not available") + } + feedback, err := s.client.GetPRFeedback(ctx, watch.Owner, watch.Repo, watch.PRNumber) + if err != nil { + return nil, false, err + } + + hasNew := false + + // Check for new comments + latestCommentAt := findLatestCommentTime(feedback.Comments) + if latestCommentAt != nil && (watch.LastCommentAt == nil || latestCommentAt.After(*watch.LastCommentAt)) { + hasNew = true + } + + // Check for check status changes + checkStatus := computeOverallCheckStatus(feedback.Checks) + if checkStatus != watch.LastCheckStatus { + hasNew = true + } + + // Update watch timestamps + now := time.Now().UTC() + if err := s.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, latestCommentAt, checkStatus); err != nil { + s.logger.Error("failed to update PR watch timestamps", zap.String("id", watch.ID), zap.Error(err)) + } + + return feedback, hasNew, nil +} + +// EnsurePRWatch creates a PRWatch with pr_number=0 for a session if one doesn't already exist. +// The poller will detect the PR by searching for the branch on GitHub. +func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySession(ctx, sessionID) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + w := &PRWatch{ + SessionID: sessionID, + TaskID: taskID, + Owner: owner, + Repo: repo, + PRNumber: 0, + Branch: branch, + } + if err := s.store.CreatePRWatch(ctx, w); err != nil { + return nil, fmt.Errorf("ensure PR watch: %w", err) + } + s.logger.Info("created PR watch for session (will search for PR)", + zap.String("session_id", sessionID), + zap.String("branch", branch)) + return w, nil +} + +// --- Task-PR association --- + +// AssociatePRWithTask creates a task-PR association. +func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { + existing, err := s.store.GetTaskPR(ctx, taskID) + if err != nil { + return nil, err + } + if existing != nil && existing.PRNumber == pr.Number { + return existing, nil + } + tp := &TaskPR{ + TaskID: taskID, + Owner: pr.RepoOwner, + Repo: pr.RepoName, + PRNumber: pr.Number, + PRURL: pr.HTMLURL, + PRTitle: pr.Title, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + AuthorLogin: pr.AuthorLogin, + State: pr.State, + Additions: pr.Additions, + Deletions: pr.Deletions, + CreatedAt: pr.CreatedAt, + MergedAt: pr.MergedAt, + ClosedAt: pr.ClosedAt, + } + if err := s.store.CreateTaskPR(ctx, tp); err != nil { + return nil, fmt.Errorf("create task PR: %w", err) + } + + // Publish event for UI + if s.eventBus != nil { + event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp) + if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { + s.logger.Debug("failed to publish task PR updated event", zap.Error(err)) + } + } + + s.logger.Info("associated PR with task", + zap.String("task_id", taskID), + zap.Int("pr_number", pr.Number)) + return tp, nil +} + +// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR watch, +// and associates it with the given task. Called after user creates a PR from the UI. +func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL, branch string) { + if s.client == nil { + return + } + owner, repo, prNumber, err := parsePRURL(prURL) + if err != nil { + s.logger.Error("failed to parse PR URL", zap.String("url", prURL), zap.Error(err)) + return + } + + pr, err := s.client.GetPR(ctx, owner, repo, prNumber) + if err != nil { + s.logger.Error("failed to fetch PR after creation", + zap.String("url", prURL), zap.Error(err)) + return + } + + // Create PR watch for ongoing monitoring + if branch == "" { + branch = pr.HeadBranch + } + if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, owner, repo, prNumber, branch); watchErr != nil { + s.logger.Error("failed to create PR watch after PR creation", + zap.String("session_id", sessionID), zap.Error(watchErr)) + } + + // Associate PR with task (persists + publishes WS event) + if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + s.logger.Error("failed to associate PR with task after creation", + zap.String("task_id", taskID), zap.Error(assocErr)) + } +} + +// parsePRURL extracts owner, repo, and PR number from a GitHub PR URL. +// Expected format: https://github.com/{owner}/{repo}/pull/{number} +// Handles trailing slashes, query parameters, and URL fragments. +func parsePRURL(prURL string) (owner, repo string, number int, err error) { + // Strip trailing whitespace/newlines + prURL = strings.TrimSpace(prURL) + + // Find the /pull/ segment + idx := strings.Index(prURL, "/pull/") + if idx < 0 { + return "", "", 0, fmt.Errorf("URL does not contain /pull/: %s", prURL) + } + + // Parse PR number after /pull/, stripping query params, fragments, and trailing slashes + numStr := prURL[idx+len("/pull/"):] + if i := strings.IndexAny(numStr, "?#"); i >= 0 { + numStr = numStr[:i] + } + numStr = strings.TrimRight(numStr, "/") + number, err = strconv.Atoi(numStr) + if err != nil { + return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", prURL, err) + } + + // Parse owner/repo from path before /pull/ + pathBefore := prURL[:idx] + // Remove scheme+host prefix (find last two path segments) + parts := strings.Split(strings.TrimRight(pathBefore, "/"), "/") + if len(parts) < 2 { + return "", "", 0, fmt.Errorf("cannot extract owner/repo from URL: %s", prURL) + } + repo = parts[len(parts)-1] + owner = parts[len(parts)-2] + if owner == "" || repo == "" { + return "", "", 0, fmt.Errorf("empty owner or repo in URL: %s", prURL) + } + return owner, repo, number, nil +} + +// GetTaskPR returns the PR association for a task. +func (s *Service) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { + return s.store.GetTaskPR(ctx, taskID) +} + +// ListTaskPRs returns PR associations for multiple tasks. +func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { + return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs) +} + +// SyncTaskPR updates a TaskPR record with the latest PR data from feedback. +func (s *Service) SyncTaskPR(ctx context.Context, taskID string, feedback *PRFeedback) error { + tp, err := s.store.GetTaskPR(ctx, taskID) + if err != nil || tp == nil { + return err + } + + tp.State = feedback.PR.State + tp.PRTitle = feedback.PR.Title + tp.Additions = feedback.PR.Additions + tp.Deletions = feedback.PR.Deletions + tp.MergedAt = feedback.PR.MergedAt + tp.ClosedAt = feedback.PR.ClosedAt + tp.CommentCount = len(feedback.Comments) + tp.ReviewCount = len(feedback.Reviews) + tp.ReviewState = computeOverallReviewState(feedback.Reviews) + tp.ChecksState = computeOverallCheckStatus(feedback.Checks) + tp.PendingReviewCount = countPendingReviews(feedback.Reviews) + now := time.Now().UTC() + tp.LastSyncedAt = &now + + if err := s.store.UpdateTaskPR(ctx, tp); err != nil { + return fmt.Errorf("update task PR: %w", err) + } + + if s.eventBus != nil { + event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp) + if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { + s.logger.Debug("failed to publish task PR updated event", zap.Error(err)) + } + } + return nil +} + +// --- PR feedback (live) --- + +// GetPRFeedback fetches live PR feedback from GitHub. +func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.GetPRFeedback(ctx, owner, repo, number) +} + +// --- Review Watch operations --- + +// CreateReviewWatch creates a new review watch and triggers an initial poll. +func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchRequest) (*ReviewWatch, error) { + if req.PollIntervalSeconds <= 0 { + req.PollIntervalSeconds = defaultWatchPollIntervalSec + } + if req.PollIntervalSeconds < minWatchPollIntervalSec { + req.PollIntervalSeconds = minWatchPollIntervalSec + } + repos := req.Repos + if repos == nil { + repos = []RepoFilter{} + } + rw := &ReviewWatch{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Repos: repos, + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + Prompt: req.Prompt, + Enabled: true, + PollIntervalSeconds: req.PollIntervalSeconds, + } + if err := s.store.CreateReviewWatch(ctx, rw); err != nil { + return nil, fmt.Errorf("create review watch: %w", err) + } + + // Trigger initial poll in background so the watch starts working immediately + go s.initialReviewCheck(context.Background(), rw) + + return rw, nil +} + +// initialReviewCheck runs a single poll for a newly created review watch. +func (s *Service) initialReviewCheck(ctx context.Context, watch *ReviewWatch) { + newPRs, err := s.CheckReviewWatch(ctx, watch) + if err != nil { + s.logger.Debug("initial review check failed", + zap.String("watch_id", watch.ID), zap.Error(err)) + return + } + for _, pr := range newPRs { + s.publishNewReviewPREvent(ctx, watch, pr) + } + if len(newPRs) > 0 { + s.logger.Info("initial review check found PRs", + zap.String("watch_id", watch.ID), + zap.Int("new_prs", len(newPRs))) + } +} + +// GetReviewWatch returns a review watch by ID. +func (s *Service) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { + return s.store.GetReviewWatch(ctx, id) +} + +// ListReviewWatches returns all review watches for a workspace. +func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + return s.store.ListReviewWatches(ctx, workspaceID) +} + +// UpdateReviewWatch updates a review watch. +func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateReviewWatchRequest) error { + rw, err := s.store.GetReviewWatch(ctx, id) + if err != nil { + return err + } + if rw == nil { + return fmt.Errorf("review watch not found: %s", id) + } + if req.WorkflowID != nil { + rw.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + rw.WorkflowStepID = *req.WorkflowStepID + } + if req.Repos != nil { + rw.Repos = *req.Repos + } + if req.AgentProfileID != nil { + rw.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + rw.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + rw.Prompt = *req.Prompt + } + if req.Enabled != nil { + rw.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + rw.PollIntervalSeconds = *req.PollIntervalSeconds + } + return s.store.UpdateReviewWatch(ctx, rw) +} + +// DeleteReviewWatch deletes a review watch. +func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { + return s.store.DeleteReviewWatch(ctx, id) +} + +// CheckReviewWatch checks for new PRs needing review and returns ones not yet tracked. +// If watch.Repos is empty, all repos are queried. Otherwise, each repo is queried individually. +func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]*PR, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + + s.logger.Debug("checking review watch for pending PRs", + zap.String("watch_id", watch.ID), + zap.Int("repo_filters", len(watch.Repos))) + + prs, err := s.fetchReviewPRs(ctx, watch.Repos) + if err != nil { + return nil, err + } + + s.logger.Debug("fetched review-requested PRs", + zap.String("watch_id", watch.ID), + zap.Int("total_prs", len(prs))) + + // Filter out PRs we already created tasks for + var newPRs []*PR + for _, pr := range prs { + exists, err := s.store.HasReviewPRTask(ctx, watch.ID, pr.RepoOwner, pr.RepoName, pr.Number) + if err != nil { + s.logger.Error("failed to check review PR task", zap.Error(err)) + continue + } + if !exists { + newPRs = append(newPRs, pr) + } + } + + // Update last polled + now := time.Now().UTC() + watch.LastPolledAt = &now + _ = s.store.UpdateReviewWatch(ctx, watch) + + return newPRs, nil +} + +// fetchReviewPRs fetches PRs needing review for the given repo filters. +// An empty repos slice means all repos. Entries with empty Name are org-level filters. +func (s *Service) fetchReviewPRs(ctx context.Context, repos []RepoFilter) ([]*PR, error) { + if len(repos) == 0 { + return s.client.ListReviewRequestedPRs(ctx, "") + } + + var allPRs []*PR + seen := make(map[string]bool) + for _, repo := range repos { + var filter string + if repo.Name == "" { + filter = "org:" + repo.Owner + } else { + filter = fmt.Sprintf("repo:%s/%s", repo.Owner, repo.Name) + } + prs, err := s.client.ListReviewRequestedPRs(ctx, filter) + if err != nil { + s.logger.Error("failed to list review PRs", + zap.String("filter", filter), zap.Error(err)) + continue + } + for _, pr := range prs { + key := fmt.Sprintf("%s/%s#%d", pr.RepoOwner, pr.RepoName, pr.Number) + if !seen[key] { + seen[key] = true + allPRs = append(allPRs, pr) + } + } + } + return allPRs, nil +} + +// ListUserOrgs returns the authenticated user's orgs, prepending their own username. +func (s *Service) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + orgs, err := s.client.ListUserOrgs(ctx) + if err != nil { + return nil, err + } + // Prepend the authenticated user as a pseudo-org (for personal repos). + user, userErr := s.client.GetAuthenticatedUser(ctx) + if userErr == nil && user != "" { + orgs = append([]GitHubOrg{{Login: user}}, orgs...) + } + return orgs, nil +} + +// SearchOrgRepos searches repos in an org for autocomplete. +func (s *Service) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.SearchOrgRepos(ctx, org, query, limit) +} + +// RecordReviewPRTask records that a task was created for a review PR. +func (s *Service) RecordReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL, taskID string) error { + rpt := &ReviewPRTask{ + ReviewWatchID: watchID, + RepoOwner: repoOwner, + RepoName: repoName, + PRNumber: prNumber, + PRURL: prURL, + TaskID: taskID, + } + return s.store.CreateReviewPRTask(ctx, rpt) +} + +// TriggerAllReviewChecks triggers all review watches for a workspace. +func (s *Service) TriggerAllReviewChecks(ctx context.Context, workspaceID string) (int, error) { + watches, err := s.store.ListReviewWatches(ctx, workspaceID) + if err != nil { + return 0, err + } + enabled := 0 + for _, w := range watches { + if w.Enabled { + enabled++ + } + } + s.logger.Info("triggering review checks", + zap.String("workspace_id", workspaceID), + zap.Int("total_watches", len(watches)), + zap.Int("enabled_watches", enabled)) + + totalNew := 0 + for _, watch := range watches { + if !watch.Enabled { + continue + } + newPRs, err := s.CheckReviewWatch(ctx, watch) + if err != nil { + s.logger.Error("failed to check review watch", + zap.String("id", watch.ID), zap.Error(err)) + continue + } + for _, pr := range newPRs { + s.publishNewReviewPREvent(ctx, watch, pr) + } + totalNew += len(newPRs) + } + s.logger.Info("review checks completed", + zap.String("workspace_id", workspaceID), + zap.Int("new_prs_found", totalNew)) + return totalNew, nil +} + +// GetPRStats returns PR statistics. +func (s *Service) GetPRStats(ctx context.Context, req *PRStatsRequest) (*PRStats, error) { + return s.store.GetPRStats(ctx, req) +} + +func (s *Service) publishNewReviewPREvent(ctx context.Context, watch *ReviewWatch, pr *PR) { + if s.eventBus == nil { + return + } + event := bus.NewEvent(events.GitHubNewReviewPR, "github", &NewReviewPREvent{ + ReviewWatchID: watch.ID, + WorkspaceID: watch.WorkspaceID, + WorkflowID: watch.WorkflowID, + WorkflowStepID: watch.WorkflowStepID, + AgentProfileID: watch.AgentProfileID, + ExecutorProfileID: watch.ExecutorProfileID, + Prompt: watch.Prompt, + PR: pr, + }) + if err := s.eventBus.Publish(ctx, events.GitHubNewReviewPR, event); err != nil { + s.logger.Debug("failed to publish new review PR event", zap.Error(err)) + } +} + +func findLatestCommentTime(comments []PRComment) *time.Time { + var latest *time.Time + for _, c := range comments { + t := c.UpdatedAt + if latest == nil || t.After(*latest) { + latest = &t + } + } + return latest +} + +func computeOverallCheckStatus(checks []CheckRun) string { + if len(checks) == 0 { + return "" + } + hasPending := false + for _, c := range checks { + if c.Status == checkStatusCompleted && c.Conclusion == checkConclusionFail { + return checkConclusionFail + } + if c.Status != checkStatusCompleted { + hasPending = true + } + } + if hasPending { + return checkStatusPending + } + return checkStatusSuccess +} + +func computeOverallReviewState(reviews []PRReview) string { + if len(reviews) == 0 { + return "" + } + latest := latestReviewByAuthor(reviews) + changesReq := false + allApproved := true + for _, r := range latest { + if r.State == reviewStateChangesRequested { + changesReq = true + } + if r.State != reviewStateApproved { + allApproved = false + } + } + if changesReq { + return computedReviewStateChangesRequested + } + if allApproved { + return computedReviewStateApproved + } + return computedReviewStatePending +} + +func countPendingReviews(reviews []PRReview) int { + latest := latestReviewByAuthor(reviews) + count := 0 + for _, r := range latest { + if r.State == reviewStatePending || r.State == reviewStateCommented { + count++ + } + } + return count +} diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go new file mode 100644 index 0000000000..898ecfaf84 --- /dev/null +++ b/apps/backend/internal/github/service_test.go @@ -0,0 +1,322 @@ +package github + +import ( + "testing" + "time" +) + +func TestParsePRURL(t *testing.T) { + tests := []struct { + name string + url string + wantOwner string + wantRepo string + wantNumber int + wantErr bool + }{ + { + name: "standard URL", + url: "https://github.com/owner/repo/pull/123", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 123, + }, + { + name: "trailing slash", + url: "https://github.com/owner/repo/pull/456/", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 456, + }, + { + name: "with query params", + url: "https://github.com/owner/repo/pull/789?diff=unified", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 789, + }, + { + name: "with fragment", + url: "https://github.com/owner/repo/pull/42#discussion_r123", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 42, + }, + { + name: "with query and fragment", + url: "https://github.com/owner/repo/pull/10?a=b#frag", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 10, + }, + { + name: "with whitespace", + url: " https://github.com/owner/repo/pull/5 \n", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 5, + }, + { + name: "no pull segment", + url: "https://github.com/owner/repo/issues/1", + wantErr: true, + }, + { + name: "invalid PR number", + url: "https://github.com/owner/repo/pull/abc", + wantErr: true, + }, + { + name: "too short path", + url: "/pull/1", + wantErr: true, + }, + { + name: "empty owner", + url: "https://github.com//repo/pull/1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, number, err := parsePRURL(tt.url) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if owner != tt.wantOwner { + t.Errorf("owner = %q, want %q", owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("repo = %q, want %q", repo, tt.wantRepo) + } + if number != tt.wantNumber { + t.Errorf("number = %d, want %d", number, tt.wantNumber) + } + }) + } +} + +func TestComputeOverallCheckStatus(t *testing.T) { + tests := []struct { + name string + checks []CheckRun + want string + }{ + {"empty", nil, ""}, + { + "all completed success", + []CheckRun{ + {Status: "completed", Conclusion: "success"}, + {Status: "completed", Conclusion: "success"}, + }, + "success", + }, + { + "one failure", + []CheckRun{ + {Status: "completed", Conclusion: "success"}, + {Status: "completed", Conclusion: "failure"}, + }, + "failure", + }, + { + "failure takes priority over pending", + []CheckRun{ + {Status: "in_progress"}, + {Status: "completed", Conclusion: "failure"}, + }, + "failure", + }, + { + "pending when in progress", + []CheckRun{ + {Status: "completed", Conclusion: "success"}, + {Status: "in_progress"}, + }, + "pending", + }, + { + "all in progress", + []CheckRun{ + {Status: "queued"}, + {Status: "in_progress"}, + }, + "pending", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeOverallCheckStatus(tt.checks) + if got != tt.want { + t.Errorf("computeOverallCheckStatus() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestComputeOverallReviewState(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + reviews []PRReview + want string + }{ + {"empty", nil, ""}, + { + "all approved", + []PRReview{ + {Author: "alice", State: "APPROVED", CreatedAt: t1}, + {Author: "bob", State: "APPROVED", CreatedAt: t1}, + }, + "approved", + }, + { + "changes requested", + []PRReview{ + {Author: "alice", State: "APPROVED", CreatedAt: t1}, + {Author: "bob", State: "CHANGES_REQUESTED", CreatedAt: t1}, + }, + "changes_requested", + }, + { + "latest per author wins", + []PRReview{ + {Author: "alice", State: "CHANGES_REQUESTED", CreatedAt: t1}, + {Author: "alice", State: "APPROVED", CreatedAt: t2}, + }, + "approved", + }, + { + "pending when not all approved", + []PRReview{ + {Author: "alice", State: "APPROVED", CreatedAt: t1}, + {Author: "bob", State: "COMMENTED", CreatedAt: t1}, + }, + "pending", + }, + { + "changes requested takes priority over pending", + []PRReview{ + {Author: "alice", State: "COMMENTED", CreatedAt: t1}, + {Author: "bob", State: "CHANGES_REQUESTED", CreatedAt: t1}, + }, + "changes_requested", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeOverallReviewState(tt.reviews) + if got != tt.want { + t.Errorf("computeOverallReviewState() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCountPendingReviews(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + reviews []PRReview + want int + }{ + {"empty", nil, 0}, + { + "no pending", + []PRReview{ + {Author: "alice", State: "APPROVED", CreatedAt: t1}, + }, + 0, + }, + { + "one pending", + []PRReview{ + {Author: "alice", State: "PENDING", CreatedAt: t1}, + }, + 1, + }, + { + "commented counts as pending", + []PRReview{ + {Author: "alice", State: "COMMENTED", CreatedAt: t1}, + }, + 1, + }, + { + "latest per author wins", + []PRReview{ + {Author: "alice", State: "COMMENTED", CreatedAt: t1}, + {Author: "alice", State: "APPROVED", CreatedAt: t2}, + }, + 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countPendingReviews(tt.reviews) + if got != tt.want { + t.Errorf("countPendingReviews() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestFindLatestCommentTime(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC) + t3 := time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + comments []PRComment + want *time.Time + }{ + {"empty", nil, nil}, + { + "single", + []PRComment{{UpdatedAt: t1}}, + &t1, + }, + { + "multiple", + []PRComment{ + {UpdatedAt: t1}, + {UpdatedAt: t2}, + {UpdatedAt: t3}, + }, + &t2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findLatestCommentTime(tt.comments) + if tt.want == nil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil, got nil") + } + if !got.Equal(*tt.want) { + t.Errorf("got %v, want %v", *got, *tt.want) + } + }) + } +} diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go new file mode 100644 index 0000000000..bb0833f622 --- /dev/null +++ b/apps/backend/internal/github/store.go @@ -0,0 +1,469 @@ +package github + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// Store provides SQLite persistence for GitHub integration data. +type Store struct { + db *sqlx.DB // writer + ro *sqlx.DB // reader +} + +// NewStore creates a new GitHub store and initializes the schema. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("github schema init: %w", err) + } + return s, nil +} + +// createTablesSQL holds the DDL for all GitHub integration tables. +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS github_pr_watches ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + branch TEXT NOT NULL, + last_checked_at DATETIME, + last_comment_at DATETIME, + last_check_status TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS github_task_prs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + pr_title TEXT NOT NULL, + head_branch TEXT NOT NULL, + base_branch TEXT NOT NULL, + author_login TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'open', + review_state TEXT NOT NULL DEFAULT '', + checks_state TEXT NOT NULL DEFAULT '', + review_count INTEGER DEFAULT 0, + pending_review_count INTEGER DEFAULT 0, + comment_count INTEGER DEFAULT 0, + additions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + merged_at DATETIME, + closed_at DATETIME, + last_synced_at DATETIME, + updated_at DATETIME NOT NULL, + UNIQUE(task_id, pr_number) + ); + + CREATE TABLE IF NOT EXISTS github_review_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + repos TEXT NOT NULL DEFAULT '[]', + agent_profile_id TEXT NOT NULL, + executor_profile_id TEXT NOT NULL, + prompt TEXT DEFAULT '', + enabled BOOLEAN DEFAULT 1, + poll_interval_seconds INTEGER DEFAULT 300, + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS github_review_pr_tasks ( + id TEXT PRIMARY KEY, + review_watch_id TEXT NOT NULL, + repo_owner TEXT NOT NULL DEFAULT '', + repo_name TEXT NOT NULL DEFAULT '', + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + task_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(review_watch_id, repo_owner, repo_name, pr_number) + ); +` + +func (s *Store) initSchema() error { + _, err := s.db.Exec(createTablesSQL) + return err +} + +// --- PR Watch operations --- + +// CreatePRWatch creates a new PR watch. +func (s *Store) CreatePRWatch(ctx context.Context, w *PRWatch) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + now := time.Now().UTC() + w.CreatedAt = now + w.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO github_pr_watches (id, session_id, task_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.SessionID, w.TaskID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) + return err +} + +// GetPRWatchBySession returns the PR watch for a session. +func (s *Store) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { + var w PRWatch + err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE session_id = ?`, sessionID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &w, err +} + +// ListActivePRWatches returns all active PR watches. +func (s *Store) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, `SELECT * FROM github_pr_watches ORDER BY created_at`) + return watches, err +} + +// UpdatePRWatchTimestamps updates the last checked timestamps. +func (s *Store) UpdatePRWatchTimestamps(ctx context.Context, id string, checkedAt time.Time, commentAt *time.Time, checkStatus string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE github_pr_watches SET last_checked_at = ?, last_comment_at = ?, last_check_status = ?, updated_at = ? + WHERE id = ?`, + checkedAt, commentAt, checkStatus, time.Now().UTC(), id) + return err +} + +// DeletePRWatch deletes a PR watch by ID. +func (s *Store) DeletePRWatch(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM github_pr_watches WHERE id = ?`, id) + return err +} + +// UpdatePRWatchPRNumber updates a PR watch's PR number after discovery. +func (s *Store) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error { + _, err := s.db.ExecContext(ctx, + `UPDATE github_pr_watches SET pr_number = ?, updated_at = ? WHERE id = ?`, + prNumber, time.Now().UTC(), id) + return err +} + +// --- TaskPR operations --- + +// CreateTaskPR associates a PR with a task. +func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { + if tp.ID == "" { + tp.ID = uuid.New().String() + } + now := time.Now().UTC() + tp.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + state, review_state, checks_state, review_count, pending_review_count, comment_count, additions, deletions, + created_at, merged_at, closed_at, last_synced_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + tp.State, tp.ReviewState, tp.ChecksState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, + tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt) + return err +} + +// GetTaskPR returns the PR association for a task. +func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { + var tp TaskPR + err := s.ro.GetContext(ctx, &tp, `SELECT * FROM github_task_prs WHERE task_id = ? LIMIT 1`, taskID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &tp, err +} + +// ListTaskPRsByTaskIDs returns PR associations for multiple tasks. +func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { + if len(taskIDs) == 0 { + return make(map[string]*TaskPR), nil + } + query, args, err := sqlx.In(`SELECT * FROM github_task_prs WHERE task_id IN (?)`, taskIDs) + if err != nil { + return nil, err + } + query = s.ro.Rebind(query) + var prs []TaskPR + if err := s.ro.SelectContext(ctx, &prs, query, args...); err != nil { + return nil, err + } + result := make(map[string]*TaskPR, len(prs)) + for i := range prs { + result[prs[i].TaskID] = &prs[i] + } + return result, nil +} + +// UpdateTaskPR updates a task-PR association. +func (s *Store) UpdateTaskPR(ctx context.Context, tp *TaskPR) error { + tp.UpdatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, ` + UPDATE github_task_prs SET state = ?, review_state = ?, checks_state = ?, + review_count = ?, pending_review_count = ?, comment_count = ?, + additions = ?, deletions = ?, pr_title = ?, + merged_at = ?, closed_at = ?, last_synced_at = ?, updated_at = ? + WHERE id = ?`, + tp.State, tp.ReviewState, tp.ChecksState, + tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, + tp.Additions, tp.Deletions, tp.PRTitle, + tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt, tp.ID) + return err +} + +// --- Review Watch operations --- + +// CreateReviewWatch creates a new review watch configuration. +func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { + if rw.ID == "" { + rw.ID = uuid.New().String() + } + now := time.Now().UTC() + rw.CreatedAt = now + rw.UpdatedAt = now + reposJSON, err := json.Marshal(rw.Repos) + if err != nil { + return fmt.Errorf("marshal repos: %w", err) + } + rw.ReposJSON = string(reposJSON) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO github_review_watches (id, workspace_id, workflow_id, workflow_step_id, repos, + agent_profile_id, executor_profile_id, prompt, enabled, poll_interval_seconds, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + rw.ID, rw.WorkspaceID, rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, + rw.AgentProfileID, rw.ExecutorProfileID, rw.Prompt, rw.Enabled, rw.PollIntervalSeconds, + rw.CreatedAt, rw.UpdatedAt) + return err +} + +// hydrateReviewWatchRepos unmarshals the ReposJSON field into the Repos slice. +func hydrateReviewWatchRepos(rw *ReviewWatch) { + if rw.ReposJSON != "" { + if err := json.Unmarshal([]byte(rw.ReposJSON), &rw.Repos); err != nil { + // Log but don't fail — the watch can still function with no repo filter. + fmt.Printf("WARN: failed to unmarshal repos JSON for review watch %s: %v\n", rw.ID, err) + } + } + if rw.Repos == nil { + rw.Repos = []RepoFilter{} + } +} + +// GetReviewWatch returns a review watch by ID. +func (s *Store) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { + var rw ReviewWatch + err := s.ro.GetContext(ctx, &rw, `SELECT * FROM github_review_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + hydrateReviewWatchRepos(&rw) + return &rw, nil +} + +// ListReviewWatches returns all review watches for a workspace. +func (s *Store) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + var watches []*ReviewWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_review_watches WHERE workspace_id = ? ORDER BY created_at`, workspaceID) + if err != nil { + return nil, err + } + for _, w := range watches { + hydrateReviewWatchRepos(w) + } + return watches, nil +} + +// ListEnabledReviewWatches returns all enabled review watches. +func (s *Store) ListEnabledReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + var watches []*ReviewWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_review_watches WHERE enabled = 1 ORDER BY created_at`) + if err != nil { + return nil, err + } + for _, w := range watches { + hydrateReviewWatchRepos(w) + } + return watches, nil +} + +// UpdateReviewWatch updates a review watch. +func (s *Store) UpdateReviewWatch(ctx context.Context, rw *ReviewWatch) error { + rw.UpdatedAt = time.Now().UTC() + reposJSON, err := json.Marshal(rw.Repos) + if err != nil { + return fmt.Errorf("marshal repos: %w", err) + } + rw.ReposJSON = string(reposJSON) + _, err = s.db.ExecContext(ctx, ` + UPDATE github_review_watches SET workflow_id = ?, workflow_step_id = ?, repos = ?, + agent_profile_id = ?, executor_profile_id = ?, + prompt = ?, enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ? + WHERE id = ?`, + rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, + rw.AgentProfileID, rw.ExecutorProfileID, + rw.Prompt, rw.Enabled, rw.PollIntervalSeconds, rw.LastPolledAt, rw.UpdatedAt, rw.ID) + return err +} + +// DeleteReviewWatch deletes a review watch. +func (s *Store) DeleteReviewWatch(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM github_review_watches WHERE id = ?`, id) + return err +} + +// --- Review PR Task deduplication --- + +// CreateReviewPRTask records that a task was created for a review PR. +func (s *Store) CreateReviewPRTask(ctx context.Context, rpt *ReviewPRTask) error { + if rpt.ID == "" { + rpt.ID = uuid.New().String() + } + rpt.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, ` + INSERT INTO github_review_pr_tasks (id, review_watch_id, repo_owner, repo_name, pr_number, pr_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + rpt.ID, rpt.ReviewWatchID, rpt.RepoOwner, rpt.RepoName, rpt.PRNumber, rpt.PRURL, rpt.TaskID, rpt.CreatedAt) + return err +} + +// HasReviewPRTask checks if a task was already created for a PR in a review watch. +func (s *Store) HasReviewPRTask(ctx context.Context, reviewWatchID, repoOwner, repoName string, prNumber int) (bool, error) { + var count int + err := s.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM github_review_pr_tasks WHERE review_watch_id = ? AND repo_owner = ? AND repo_name = ? AND pr_number = ?`, + reviewWatchID, repoOwner, repoName, prNumber) + return count > 0, err +} + +// --- Stats queries --- + +// prStatsQuery builds parameterised SELECT queries against the github_task_prs table. +type prStatsQuery struct { + from string + where string + args []interface{} +} + +func newPRStatsQuery(req *PRStatsRequest) *prStatsQuery { + q := &prStatsQuery{ + from: "github_task_prs gtp", + where: "1=1", + } + if req.WorkspaceID != "" { + q.from += " INNER JOIN tasks t ON gtp.task_id = t.id" + q.where += " AND t.workspace_id = ?" + q.args = append(q.args, req.WorkspaceID) + } + if req.StartDate != nil { + q.where += " AND gtp.created_at >= ?" + q.args = append(q.args, req.StartDate) + } + if req.EndDate != nil { + q.where += " AND gtp.created_at <= ?" + q.args = append(q.args, req.EndDate) + } + return q +} + +func (q *prStatsQuery) build(sel, extraWhere string) string { + w := q.where + if extraWhere != "" { + w += " AND " + extraWhere + } + return fmt.Sprintf(`SELECT %s FROM %s WHERE %s`, sel, q.from, w) +} + +// GetPRStats returns aggregated PR statistics. +func (s *Store) GetPRStats(ctx context.Context, req *PRStatsRequest) (*PRStats, error) { + return s.runPRStatsQueries(ctx, newPRStatsQuery(req)) +} + +func (s *Store) runPRStatsQueries(ctx context.Context, q *prStatsQuery) (*PRStats, error) { + stats := &PRStats{} + + if err := s.ro.GetContext(ctx, &stats.TotalPRsCreated, q.build("COUNT(*)", ""), q.args...); err != nil { + return nil, err + } + if err := s.ro.GetContext(ctx, &stats.TotalComments, + q.build("COALESCE(SUM(gtp.comment_count), 0)", ""), q.args...); err != nil { + return nil, err + } + if err := s.fetchCIPassRate(ctx, q, stats); err != nil { + return nil, err + } + if err := s.fetchApprovalRate(ctx, q, stats); err != nil { + return nil, err + } + + var avgMerge sql.NullFloat64 + avgQ := q.build("AVG((julianday(gtp.merged_at) - julianday(gtp.created_at)) * 24)", "gtp.merged_at IS NOT NULL") + if err := s.ro.GetContext(ctx, &avgMerge, avgQ, q.args...); err != nil { + return nil, err + } + if avgMerge.Valid { + stats.AvgTimeToMergeHours = avgMerge.Float64 + } + + dailyQ := q.build("date(gtp.created_at) as date, COUNT(*) as count", "") + + " GROUP BY date(gtp.created_at) ORDER BY date" + if err := s.ro.SelectContext(ctx, &stats.PRsByDay, dailyQ, q.args...); err != nil { + return nil, err + } + return stats, nil +} + +func (s *Store) fetchCIPassRate(ctx context.Context, q *prStatsQuery, stats *PRStats) error { + var totalWithChecks, passed int + if err := s.ro.GetContext(ctx, &totalWithChecks, + q.build("COUNT(*)", "gtp.checks_state != ''"), q.args...); err != nil { + return err + } + if err := s.ro.GetContext(ctx, &passed, + q.build("COUNT(*)", "gtp.checks_state = 'success'"), q.args...); err != nil { + return err + } + if totalWithChecks > 0 { + stats.CIPassRate = float64(passed) / float64(totalWithChecks) + } + return nil +} + +func (s *Store) fetchApprovalRate(ctx context.Context, q *prStatsQuery, stats *PRStats) error { + var totalReviewed, approved int + if err := s.ro.GetContext(ctx, &totalReviewed, + q.build("COUNT(*)", "gtp.review_state != ''"), q.args...); err != nil { + return err + } + if err := s.ro.GetContext(ctx, &approved, + q.build("COUNT(*)", "gtp.review_state = 'approved'"), q.args...); err != nil { + return err + } + stats.TotalPRsReviewed = totalReviewed + if totalReviewed > 0 { + stats.ApprovalRate = float64(approved) / float64(totalReviewed) + } + return nil +} diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index 572aa150d6..bc58494620 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -91,6 +91,31 @@ func (s *Service) handleGitStatusUpdate(ctx context.Context, data watcher.GitEve } go s.persistGitSnapshot(data.SessionID, data.TaskID, snapshot) + + // Push detection: when ahead goes from >0 to 0, a push happened + s.trackPushAndAssociatePR(ctx, data) +} + +// trackPushAndAssociatePR detects git pushes by tracking the "ahead" count. +// When ahead transitions from >0 to 0 with a remote branch set, a push occurred. +func (s *Service) trackPushAndAssociatePR(ctx context.Context, data watcher.GitEventData) { + prevAheadVal, loaded := s.pushTracker.Swap(data.SessionID, data.Status.Ahead) + if !loaded { + return // first status update for this session, skip + } + prevAhead, ok := prevAheadVal.(int) + if !ok || prevAhead <= 0 { + return + } + // Push detected: ahead went from >0 to 0 + if data.Status.Ahead == 0 && data.Status.RemoteBranch != "" { + go s.detectPushAndAssociatePR( + context.Background(), + data.SessionID, + data.TaskID, + data.Status.Branch, + ) + } } func (s *Service) persistGitSnapshot(sessionID, taskID string, snapshot *models.GitSnapshot) { diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go new file mode 100644 index 0000000000..bfb6861897 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -0,0 +1,402 @@ +package orchestrator + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// GitHubService is the interface the orchestrator uses for GitHub operations. +type GitHubService interface { + Client() github.Client + CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) + EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*github.PRWatch, error) + GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) + AssociatePRWithTask(ctx context.Context, taskID string, pr *github.PR) (*github.TaskPR, error) + RecordReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL, taskID string) error +} + +// ReviewTaskCreator creates tasks from review watch events. +type ReviewTaskCreator interface { + CreateReviewTask(ctx context.Context, req *ReviewTaskRequest) (*models.Task, error) +} + +// RepositoryResolver resolves a GitHub repo to a local clone + Repository DB record. +type RepositoryResolver interface { + ResolveForReview(ctx context.Context, workspaceID, provider, owner, name, defaultBranch string) (repositoryID, baseBranch string, err error) +} + +// ReviewTaskRequest contains the data for creating a task from a review watch PR. +type ReviewTaskRequest struct { + WorkspaceID string + WorkflowID string + WorkflowStepID string + Title string + Description string + Metadata map[string]interface{} + Repositories []ReviewTaskRepository +} + +// ReviewTaskRepository associates a repository with a review task. +type ReviewTaskRepository struct { + RepositoryID string + BaseBranch string +} + +// SetGitHubService sets the GitHub service for PR auto-detection. +func (s *Service) SetGitHubService(ghSvc GitHubService) { + s.githubService = ghSvc +} + +// SetReviewTaskCreator sets the task creator for review watch auto-task creation. +func (s *Service) SetReviewTaskCreator(tc ReviewTaskCreator) { + s.reviewTaskCreator = tc +} + +// SetRepositoryResolver sets the repository resolver for review task creation. +func (s *Service) SetRepositoryResolver(rr RepositoryResolver) { + s.repositoryResolver = rr +} + +// handlePRFeedback logs PR feedback events. WS broadcasting is handled in main.go. +func (s *Service) handlePRFeedback(_ context.Context, event *bus.Event) error { + feedbackEvt, ok := event.Data.(*github.PRFeedbackEvent) + if !ok { + return nil + } + s.logger.Debug("received PR feedback event", + zap.String("session_id", feedbackEvt.SessionID), + zap.Int("pr_number", feedbackEvt.PRNumber)) + return nil +} + +// handleNewReviewPR creates a task for a new PR needing review. +// Auto-start is determined by the workflow step's on_enter actions. +func (s *Service) handleNewReviewPR(ctx context.Context, event *bus.Event) error { + reviewEvt, ok := event.Data.(*github.NewReviewPREvent) + if !ok { + return nil + } + + pr := reviewEvt.PR + s.logger.Info("new PR to review detected", + zap.String("review_watch_id", reviewEvt.ReviewWatchID), + zap.String("repo", fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName)), + zap.Int("pr_number", pr.Number)) + + if s.reviewTaskCreator == nil { + s.logger.Warn("review task creator not configured, skipping task creation") + return nil + } + + // Use a background context: the parent ctx may be an HTTP request context + // that gets canceled as soon as the response is sent, but the clone + + // task creation must survive beyond the request lifetime. + go s.createReviewTask(context.Background(), reviewEvt) + return nil +} + +func (s *Service) createReviewTask(ctx context.Context, evt *github.NewReviewPREvent) { + pr := evt.PR + repoSlug := fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName) + + title := fmt.Sprintf("PR #%d: %s", pr.Number, pr.Title) + description := interpolateReviewPrompt(evt.Prompt, pr) + + // Resolve repository: clone if needed, find-or-create DB record + repositories := s.resolveReviewRepository(ctx, evt.WorkspaceID, pr) + + task, err := s.reviewTaskCreator.CreateReviewTask(ctx, &ReviewTaskRequest{ + WorkspaceID: evt.WorkspaceID, + WorkflowID: evt.WorkflowID, + WorkflowStepID: evt.WorkflowStepID, + Title: title, + Description: description, + Repositories: repositories, + Metadata: map[string]interface{}{ + "review_watch_id": evt.ReviewWatchID, + "pr_number": pr.Number, + "pr_url": pr.HTMLURL, + "pr_repo": repoSlug, + "pr_author": pr.AuthorLogin, + "pr_branch": pr.HeadBranch, + }, + }) + if err != nil { + s.logger.Error("failed to create review task", + zap.String("review_watch_id", evt.ReviewWatchID), + zap.Int("pr_number", pr.Number), + zap.Error(err)) + return + } + + // Record dedup entry so this PR won't be picked up again + if s.githubService != nil { + if recordErr := s.githubService.RecordReviewPRTask( + ctx, evt.ReviewWatchID, pr.RepoOwner, pr.RepoName, pr.Number, pr.HTMLURL, task.ID, + ); recordErr != nil { + s.logger.Error("failed to record review PR task", + zap.String("task_id", task.ID), + zap.Int("pr_number", pr.Number), + zap.Error(recordErr)) + } + + // Associate PR with task so the frontend can display PR info + if _, assocErr := s.githubService.AssociatePRWithTask(ctx, task.ID, pr); assocErr != nil { + s.logger.Error("failed to associate PR with review task", + zap.String("task_id", task.ID), + zap.Int("pr_number", pr.Number), + zap.Error(assocErr)) + } + } + + s.logger.Info("created review task", + zap.String("task_id", task.ID), + zap.Int("pr_number", pr.Number), + zap.String("repo", repoSlug)) + + // Check if the target workflow step has auto_start_agent on_enter action. + // If so, start the task (which launches the agent and triggers processOnEnter). + // Otherwise, the task sits in the step waiting for user action. + if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) { + return + } + s.autoStartReviewTask(ctx, evt, task) +} + +// shouldAutoStartStep checks if the workflow step has the OnEnterAutoStartAgent action. +func (s *Service) shouldAutoStartStep(ctx context.Context, stepID string) bool { + if s.workflowStepGetter == nil || stepID == "" { + return false + } + step, err := s.workflowStepGetter.GetStep(ctx, stepID) + if err != nil { + s.logger.Warn("failed to get workflow step for auto-start check", + zap.String("step_id", stepID), + zap.Error(err)) + return false + } + return step.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) +} + +func (s *Service) autoStartReviewTask( + ctx context.Context, evt *github.NewReviewPREvent, task *models.Task, +) { + _, err := s.StartTask( + ctx, + task.ID, + evt.AgentProfileID, + "", + evt.ExecutorProfileID, + 0, + task.Description, + evt.WorkflowStepID, + false, + ) + if err != nil { + s.logger.Error("failed to auto-start review task", + zap.String("task_id", task.ID), + zap.Error(err)) + return + } + s.logger.Info("auto-started review task", + zap.String("task_id", task.ID), + zap.Int("pr_number", evt.PR.Number)) +} + +// resolveReviewRepository attempts to resolve and clone the PR's repository. +// Returns a slice with one entry on success, or nil on failure (graceful degradation). +func (s *Service) resolveReviewRepository(ctx context.Context, workspaceID string, pr *github.PR) []ReviewTaskRepository { + if s.repositoryResolver == nil { + return nil + } + repoID, baseBranch, err := s.repositoryResolver.ResolveForReview( + ctx, workspaceID, "github", pr.RepoOwner, pr.RepoName, pr.BaseBranch, + ) + if err != nil { + s.logger.Warn("failed to resolve repository for review task (continuing without repo)", + zap.String("repo", fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName)), + zap.Error(err)) + return nil + } + if repoID == "" { + return nil + } + return []ReviewTaskRepository{{RepositoryID: repoID, BaseBranch: baseBranch}} +} + +// detectPushAndAssociatePR checks if a push happened and looks for a PR on +// that branch. If no PR is found immediately, retries after a delay to handle +// the case where the user creates the PR on GitHub shortly after pushing. +// The pushTracker entry for this session is always removed when the function returns +// to prevent unbounded growth of the tracker map. +func (s *Service) detectPushAndAssociatePR( + ctx context.Context, sessionID, taskID, branch string, +) { + defer s.pushTracker.Delete(sessionID) + + if s.githubService == nil { + return + } + client := s.githubService.Client() + if client == nil { + return + } + + // Check if we already have a watch for this session + existing, err := s.githubService.GetPRWatchBySession(ctx, sessionID) + if err == nil && existing != nil { + return // already watching + } + + owner, repoName := s.resolveSessionRepo(ctx, sessionID) + if owner == "" || repoName == "" { + return + } + + // Try to find a PR immediately, then retry after delays + delays := []time.Duration{0, 30 * time.Second, 60 * time.Second} + for _, delay := range delays { + if delay > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + // Re-check if a watch was created in the meantime (e.g. by CreatePR callback) + if ex, err := s.githubService.GetPRWatchBySession(ctx, sessionID); err == nil && ex != nil { + return + } + } + foundPR, findErr := client.FindPRByBranch(ctx, owner, repoName, branch) + if findErr != nil || foundPR == nil { + s.logger.Debug("no PR found for branch (will retry)", + zap.String("branch", branch), + zap.String("session_id", sessionID), + zap.Duration("delay", delay)) + continue + } + s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, foundPR) + return + } +} + +// resolveSessionRepo looks up the repository owner and name for a session. +func (s *Service) resolveSessionRepo(ctx context.Context, sessionID string) (string, string) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || session.RepositoryID == "" { + return "", "" + } + store, ok := s.repo.(repoStore) + if !ok { + return "", "" + } + repoObj, err := store.GetRepository(ctx, session.RepositoryID) + if err != nil || repoObj == nil { + return "", "" + } + return repoObj.ProviderOwner, repoObj.ProviderName +} + +// ensureSessionPRWatch creates a PRWatch (pr_number=0) for a session's branch +// so the poller will search for a PR on GitHub. Runs as a background goroutine. +func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, branch string) { + if s.githubService == nil || branch == "" { + return + } + + owner, repoName := s.resolveTaskRepo(ctx, taskID) + if owner == "" || repoName == "" { + return + } + + if _, err := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); err != nil { + s.logger.Warn("failed to ensure PR watch for session", + zap.String("session_id", sessionID), + zap.String("branch", branch), + zap.Error(err)) + } +} + +// resolveTaskRepo looks up the GitHub owner and repo name for a task's primary repository. +func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, string) { + store, ok := s.repo.(repoStore) + if !ok { + return "", "" + } + taskRepo, err := store.GetPrimaryTaskRepository(ctx, taskID) + if err != nil || taskRepo == nil { + return "", "" + } + repoObj, err := store.GetRepository(ctx, taskRepo.RepositoryID) + if err != nil || repoObj == nil { + return "", "" + } + if repoObj.Provider != "github" { + return "", "" + } + return repoObj.ProviderOwner, repoObj.ProviderName +} + +// associatePRFromPush creates the PR watch and task-PR association after push detection. +func (s *Service) associatePRFromPush( + ctx context.Context, sessionID, taskID, owner, repoName, branch string, pr *github.PR, +) { + if _, watchErr := s.githubService.CreatePRWatch( + ctx, sessionID, taskID, owner, repoName, pr.Number, branch, + ); watchErr != nil { + s.logger.Error("failed to create PR watch on push detection", + zap.String("session_id", sessionID), zap.Error(watchErr)) + } + + if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + s.logger.Error("failed to associate PR with task on push detection", + zap.String("task_id", taskID), zap.Error(assocErr)) + } + + s.logger.Info("auto-detected PR from push", + zap.String("session_id", sessionID), + zap.Int("pr_number", pr.Number), + zap.String("branch", branch)) +} + +// subscribeGitHubEvents subscribes to GitHub-related events on the event bus. +func (s *Service) subscribeGitHubEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.GitHubPRFeedback, s.handlePRFeedback); err != nil { + s.logger.Error("failed to subscribe to github.pr_feedback events", zap.Error(err)) + } + if _, err := s.eventBus.Subscribe(events.GitHubNewReviewPR, s.handleNewReviewPR); err != nil { + s.logger.Error("failed to subscribe to github.new_pr_to_review events", zap.Error(err)) + } +} + +// interpolateReviewPrompt replaces {{pr.*}} placeholders in the prompt template with actual PR values. +func interpolateReviewPrompt(promptTemplate string, pr *github.PR) string { + if promptTemplate == "" { + return fmt.Sprintf("Pull Request ready for review: %s", pr.HTMLURL) + } + repoSlug := fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName) + replacer := strings.NewReplacer( + "{{pr.link}}", pr.HTMLURL, + "{{pr.number}}", strconv.Itoa(pr.Number), + "{{pr.title}}", pr.Title, + "{{pr.author}}", pr.AuthorLogin, + "{{pr.repo}}", repoSlug, + "{{pr.branch}}", pr.HeadBranch, + "{{pr.base_branch}}", pr.BaseBranch, + ) + return replacer.Replace(promptTemplate) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go new file mode 100644 index 0000000000..d0a282c01e --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -0,0 +1,56 @@ +package orchestrator + +import ( + "testing" + + "github.com/kandev/kandev/internal/github" +) + +func TestInterpolateReviewPrompt(t *testing.T) { + pr := &github.PR{ + Number: 42, + Title: "Add feature X", + HTMLURL: "https://github.com/myorg/myrepo/pull/42", + AuthorLogin: "alice", + RepoOwner: "myorg", + RepoName: "myrepo", + HeadBranch: "feature-x", + BaseBranch: "main", + } + + tests := []struct { + name string + template string + want string + }{ + { + "empty template uses default", + "", + "Pull Request ready for review: https://github.com/myorg/myrepo/pull/42", + }, + { + "all placeholders", + "Review {{pr.link}} (#{{pr.number}}) by {{pr.author}} in {{pr.repo}} on {{pr.branch}} -> {{pr.base_branch}}: {{pr.title}}", + "Review https://github.com/myorg/myrepo/pull/42 (#42) by alice in myorg/myrepo on feature-x -> main: Add feature X", + }, + { + "no placeholders", + "Please review this PR", + "Please review this PR", + }, + { + "partial placeholders", + "Check {{pr.link}}", + "Check https://github.com/myorg/myrepo/pull/42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := interpolateReviewPrompt(tt.template, pr) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 089abf2e7c..5017882283 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -416,6 +416,9 @@ func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID func (m *mockRepository) ListScriptsByRepositoryIDs(_ context.Context, _ []string) (map[string][]*models.RepositoryScript, error) { return make(map[string][]*models.RepositoryScript), nil } +func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _ string) (*models.Repository, error) { + return nil, nil +} // Executor operations func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index e3a5cf1994..a0c7edb467 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -190,6 +190,18 @@ type Service struct { // Worktree recreator for recreating missing worktrees on resume worktreeRecreator WorktreeRecreator + // GitHub service for PR auto-detection on push + githubService GitHubService + + // Review task creator for auto-creating tasks from review watch PRs + reviewTaskCreator ReviewTaskCreator + + // Repository resolver for cloning + finding/creating repos for review tasks + repositoryResolver RepositoryResolver + + // Push tracker: sessionID -> last known ahead count + pushTracker sync.Map + // Active turns map: sessionID -> turnID activeTurns sync.Map @@ -432,6 +444,9 @@ func (s *Service) Start(ctx context.Context) error { return err } + // Subscribe to GitHub integration events + s.subscribeGitHubEvents() + s.logger.Info("orchestrator service started successfully") return nil } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4e6e129915..b69573e24c 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -78,12 +78,14 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr // Launch workspace infrastructure (agentctl) without starting the agent subprocess. // This enables file browsing, editing, etc. while the session is in CREATED state. - if _, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, "", workflowStepID, false); err != nil { + if prepExec, launchErr := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, "", workflowStepID, false); launchErr != nil { s.logger.Warn("failed to launch workspace for prepared session (file browsing may be unavailable)", zap.String("task_id", taskID), zap.String("session_id", sessionID), - zap.Error(err)) + zap.Error(launchErr)) // Non-fatal: session is still usable, workspace will be launched when agent starts + } else if prepExec != nil && prepExec.WorktreeBranch != "" { + go s.ensureSessionPRWatch(context.Background(), taskID, prepExec.SessionID, prepExec.WorktreeBranch) } s.logger.Info("task session prepared", @@ -134,6 +136,9 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi if execution.SessionID != "" { s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive) } + if execution.WorktreeBranch != "" { + go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) + } return execution, nil } @@ -281,6 +286,9 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s if execution.SessionID != "" { s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive) } + if execution.WorktreeBranch != "" { + go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) + } // Note: Task stays in SCHEDULING state until the agent is fully initialized. // The executor will transition to IN_PROGRESS after StartAgentProcess() succeeds. @@ -399,6 +407,10 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin zap.String("task_id", taskID), zap.String("session_id", sessionID)) + if execution.WorktreeBranch != "" { + go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) + } + return execution, nil } diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go new file mode 100644 index 0000000000..b140b95896 --- /dev/null +++ b/apps/backend/internal/repoclone/clone.go @@ -0,0 +1,121 @@ +// Package repoclone handles automatic cloning and fetching of git repositories. +package repoclone + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Config holds configuration for the repository cloner. +type Config struct { + // BasePath is the base directory for cloned repos. + // Supports ~ expansion for home directory. + // Default: ~/.kandev/repos + BasePath string `mapstructure:"basePath"` +} + +// Cloner handles git clone and fetch operations. +type Cloner struct { + config Config + protocol string + logger *logger.Logger + // repoMus is a map of per-repo path → *sync.Mutex to prevent concurrent + // clone or fetch operations on the same repository directory. + repoMus sync.Map +} + +// NewCloner creates a new Cloner with the given config and git protocol. +func NewCloner(cfg Config, protocol string, log *logger.Logger) *Cloner { + if cfg.BasePath == "" { + cfg.BasePath = "~/.kandev/repos" + } + return &Cloner{config: cfg, protocol: protocol, logger: log} +} + +// repoMu returns (or lazily creates) the mutex for a repository path. +func (c *Cloner) repoMu(path string) *sync.Mutex { + mu, _ := c.repoMus.LoadOrStore(path, &sync.Mutex{}) + return mu.(*sync.Mutex) //nolint:forcetypeassert // LoadOrStore always stores *sync.Mutex +} + +// ExpandedBasePath returns the base path with ~ expanded to the user's home directory. +func (c *Cloner) ExpandedBasePath() (string, error) { + path := c.config.BasePath + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home directory: %w", err) + } + path = filepath.Join(home, path[2:]) + } + return path, nil +} + +// RepoPath returns the full local path for a repository. +func (c *Cloner) RepoPath(owner, name string) (string, error) { + basePath, err := c.ExpandedBasePath() + if err != nil { + return "", err + } + return filepath.Join(basePath, owner, name), nil +} + +// EnsureCloned clones the repository if it doesn't exist locally, or fetches if it does. +// The cloneURL is the full git URL (HTTPS or SSH) to clone from. +// Returns the local filesystem path to the repository. +// Concurrent calls for the same repository are serialised to prevent double-clone races. +func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) { + targetPath, err := c.RepoPath(owner, name) + if err != nil { + return "", err + } + + mu := c.repoMu(targetPath) + mu.Lock() + defer mu.Unlock() + + gitDir := filepath.Join(targetPath, ".git") + if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { + c.fetch(ctx, targetPath) + return targetPath, nil + } + + return targetPath, c.clone(ctx, cloneURL, targetPath) +} + +func (c *Cloner) fetch(ctx context.Context, repoPath string) { + c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath)) + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "fetch", "--all", "--prune") + if out, err := cmd.CombinedOutput(); err != nil { + c.logger.Warn("git fetch failed (non-fatal)", + zap.String("path", repoPath), + zap.String("output", string(out)), + zap.Error(err)) + } +} + +func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath string) error { + parentDir := filepath.Dir(targetPath) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + c.logger.Info("cloning repository", + zap.String("url", cloneURL), + zap.String("target", targetPath)) + + cmd := exec.CommandContext(ctx, "git", "clone", cloneURL, targetPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %s: %w", string(out), err) + } + return nil +} diff --git a/apps/backend/internal/repoclone/protocol.go b/apps/backend/internal/repoclone/protocol.go new file mode 100644 index 0000000000..74d0b66c7b --- /dev/null +++ b/apps/backend/internal/repoclone/protocol.go @@ -0,0 +1,52 @@ +package repoclone + +import ( + "fmt" + "strings" +) + +// ProtocolSSH is the SSH git protocol. +const ProtocolSSH = "ssh" + +// ProtocolHTTPS is the HTTPS git protocol. +const ProtocolHTTPS = "https" + +// DetectGitProtocol returns the user's preferred git clone protocol. +// Currently returns "ssh" unconditionally. In the future this may be +// configurable or auto-detected from gh CLI / SSH config. +func DetectGitProtocol() string { + return ProtocolSSH +} + +// CloneURL builds a clone URL for the given provider, owner, name, and protocol. +// For SSH: git@github.com:{owner}/{name}.git +// For HTTPS: https://github.com/{owner}/{name}.git +// Returns an error if the provider is not supported. +func CloneURL(provider, owner, name, protocol string) (string, error) { + host, err := providerHost(provider) + if err != nil { + return "", err + } + if protocol == ProtocolSSH { + return fmt.Sprintf("git@%s:%s/%s.git", host, owner, name), nil + } + return fmt.Sprintf("https://%s/%s/%s.git", host, owner, name), nil +} + +// providerHost maps a provider name to its git host. +// Only "github" (and the empty string) are currently supported. +// "gitlab" and "bitbucket" entries are placeholders for future support. +func providerHost(provider string) (string, error) { + switch strings.ToLower(provider) { + case "github", "": + return "github.com", nil + case "gitlab": + // TODO: GitLab support is not yet implemented + return "gitlab.com", nil + case "bitbucket": + // TODO: Bitbucket support is not yet implemented + return "bitbucket.org", nil + default: + return "", fmt.Errorf("unsupported git provider: %q", provider) + } +} diff --git a/apps/backend/internal/repoclone/protocol_test.go b/apps/backend/internal/repoclone/protocol_test.go new file mode 100644 index 0000000000..a6e75a23ac --- /dev/null +++ b/apps/backend/internal/repoclone/protocol_test.go @@ -0,0 +1,104 @@ +package repoclone + +import "testing" + +func TestCloneURL(t *testing.T) { + tests := []struct { + name string + provider string + owner string + repo string + protocol string + want string + wantErr bool + }{ + { + "github SSH", + "github", "owner", "repo", ProtocolSSH, + "git@github.com:owner/repo.git", false, + }, + { + "github HTTPS", + "github", "owner", "repo", ProtocolHTTPS, + "https://github.com/owner/repo.git", false, + }, + { + "empty provider defaults to github SSH", + "", "owner", "repo", ProtocolSSH, + "git@github.com:owner/repo.git", false, + }, + { + "gitlab SSH", + "gitlab", "owner", "repo", ProtocolSSH, + "git@gitlab.com:owner/repo.git", false, + }, + { + "bitbucket HTTPS", + "bitbucket", "owner", "repo", ProtocolHTTPS, + "https://bitbucket.org/owner/repo.git", false, + }, + { + "unsupported provider", + "unknown", "owner", "repo", ProtocolSSH, + "", true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CloneURL(tt.provider, tt.owner, tt.repo, tt.protocol) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestProviderHost(t *testing.T) { + tests := []struct { + provider string + want string + wantErr bool + }{ + {"github", "github.com", false}, + {"GitHub", "github.com", false}, + {"", "github.com", false}, + {"gitlab", "gitlab.com", false}, + {"bitbucket", "bitbucket.org", false}, + {"unknown", "", true}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + got, err := providerHost(tt.provider) + if tt.wantErr { + if err == nil { + t.Error("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestDetectGitProtocol(t *testing.T) { + got := DetectGitProtocol() + if got != ProtocolSSH { + t.Errorf("expected %q, got %q", ProtocolSSH, got) + } +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 6ac1b0b525..5a9bff7a67 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -269,6 +269,9 @@ func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID func (m *mockRepository) ListScriptsByRepositoryIDs(_ context.Context, _ []string) (map[string][]*models.RepositoryScript, error) { return make(map[string][]*models.RepositoryScript), nil } +func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _ string) (*models.Repository, error) { + return nil, nil +} func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { return nil } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 8012ce2d54..676dff0001 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -143,6 +143,7 @@ type RepositoryEntityRepository interface { DeleteRepositoryScript(ctx context.Context, id string) error ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) ListScriptsByRepositoryIDs(ctx context.Context, repoIDs []string) (map[string][]*models.RepositoryScript, error) + GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) } // ExecutorRepository handles executor CRUD, executor profiles, and running state. diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 6eef4a4365..70b788e196 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -121,6 +121,26 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ( return result, rows.Err() } +// GetRepositoryByProviderInfo finds a repository by workspace, provider, owner, and name. +// Returns nil, nil if not found. +func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) { + repository := &models.Repository{} + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + FROM repositories + WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL + `), workspaceID, provider, owner, name).Scan( + &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + return repository, err +} + // CreateRepositoryScript creates a new repository script func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { if script.ID == "" { diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index a9621398b9..6c359b96d3 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -75,6 +75,16 @@ type UpdateWorkspaceRequest struct { DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` } +// FindOrCreateRepositoryRequest contains the data for finding or creating a repository by provider info. +type FindOrCreateRepositoryRequest struct { + WorkspaceID string `json:"workspace_id"` + Provider string `json:"provider"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + LocalPath string `json:"local_path"` +} + // CreateRepositoryRequest contains the data for creating a new repository type CreateRepositoryRequest struct { WorkspaceID string `json:"workspace_id"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 426a07ca94..a80fdf1c7a 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -242,6 +242,37 @@ func (s *Service) GetRepository(ctx context.Context, id string) (*models.Reposit return s.repoEntities.GetRepository(ctx, id) } +// FindOrCreateRepository looks up a repository by provider info, creating one if not found. +// If the repository exists but has no LocalPath and the request provides one, updates LocalPath. +func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateRepositoryRequest) (*models.Repository, error) { + existing, err := s.repoEntities.GetRepositoryByProviderInfo(ctx, req.WorkspaceID, req.Provider, req.ProviderOwner, req.ProviderName) + if err != nil { + return nil, fmt.Errorf("lookup repository: %w", err) + } + if existing != nil { + if existing.LocalPath == "" && req.LocalPath != "" { + existing.LocalPath = req.LocalPath + if updateErr := s.repoEntities.UpdateRepository(ctx, existing); updateErr != nil { + s.logger.Warn("failed to update repository local path", + zap.String("repository_id", existing.ID), zap.Error(updateErr)) + } + } + return existing, nil + } + + name := fmt.Sprintf("%s/%s", req.ProviderOwner, req.ProviderName) + return s.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Name: name, + SourceType: "provider", + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + }) +} + func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) { repository, err := s.repoEntities.GetRepository(ctx, id) if err != nil { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 6e21ea52a6..eab80fd9ec 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -278,6 +278,26 @@ const ( ActionMCPDeleteTaskPlan = "mcp.delete_task_plan" ) +// GitHub integration actions +const ( + ActionGitHubStatus = "github.status" + ActionGitHubTaskPRsList = "github.task_prs.list" + ActionGitHubTaskPRGet = "github.task_pr.get" + ActionGitHubPRFeedbackGet = "github.pr_feedback.get" + ActionGitHubReviewWatchesList = "github.review_watches.list" + ActionGitHubReviewWatchCreate = "github.review_watches.create" + ActionGitHubReviewWatchUpdate = "github.review_watches.update" + ActionGitHubReviewWatchDelete = "github.review_watches.delete" + ActionGitHubReviewTrigger = "github.review_watches.trigger" + ActionGitHubReviewTriggerAll = "github.review_watches.trigger_all" + ActionGitHubPRWatchesList = "github.pr_watches.list" + ActionGitHubPRWatchDelete = "github.pr_watches.delete" + ActionGitHubTaskPRUpdated = "github.task_pr.updated" // Notification + ActionGitHubPRFeedbackNotify = "github.pr_feedback.notify" // Notification + ActionGitHubNewReviewPRNotify = "github.new_review_pr.notify" // Notification + ActionGitHubStats = "github.stats" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/packages/ui/src/tooltip.tsx b/apps/packages/ui/src/tooltip.tsx index c6f0e8db3e..e6352ef8a7 100644 --- a/apps/packages/ui/src/tooltip.tsx +++ b/apps/packages/ui/src/tooltip.tsx @@ -7,12 +7,14 @@ import { cn } from "./lib/utils"; function TooltipProvider({ delayDuration = 0, + disableHoverableContent = true, ...props }: React.ComponentProps) { return ( ); @@ -38,7 +40,7 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)", + "pointer-events-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)", className, )} {...props} diff --git a/apps/web/app/settings/general/github/page.tsx b/apps/web/app/settings/general/github/page.tsx new file mode 100644 index 0000000000..4a7eb0674b --- /dev/null +++ b/apps/web/app/settings/general/github/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { listWorkspaces } from "@/lib/api"; + +export default async function GeneralGitHubPage() { + const workspaces = await listWorkspaces({ cache: "no-store" }).catch(() => ({ + workspaces: [], + })); + + const firstWorkspace = workspaces.workspaces[0]; + if (firstWorkspace) { + redirect(`/settings/workspace/${firstWorkspace.id}/github`); + } + + redirect("/settings/workspace"); +} diff --git a/apps/web/app/settings/workspace/[id]/github/page.tsx b/apps/web/app/settings/workspace/[id]/github/page.tsx new file mode 100644 index 0000000000..d9d20142f7 --- /dev/null +++ b/apps/web/app/settings/workspace/[id]/github/page.tsx @@ -0,0 +1,36 @@ +import { GitHubSettings } from "@/components/github/github-settings"; +import { StateProvider } from "@/components/state-provider"; +import { fetchGitHubStatus, listReviewWatches } from "@/lib/api/domains/github-api"; + +export default async function WorkspaceGitHubPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let initialState = {}; + + try { + const [status, watches] = await Promise.all([ + fetchGitHubStatus({ cache: "no-store" }).catch(() => null), + listReviewWatches(id, { cache: "no-store" }).catch(() => ({ watches: [] as never[] })), + ]); + + initialState = { + githubStatus: { + status: status ?? null, + loaded: true, + loading: false, + }, + reviewWatches: { + items: watches.watches ?? [], + loaded: true, + loading: false, + }, + }; + } catch { + initialState = {}; + } + + return ( + + + + ); +} diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index 16f1100dcd..5f811aab2d 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -21,6 +21,7 @@ import { CompletedTasksChart, MostProductiveSummary, } from "./stats-charts"; +import { PRStatsPanel } from "@/components/github/pr-stats"; interface StatsPageClientProps { stats: StatsResponse | null; @@ -157,7 +158,7 @@ function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHead ); } -type StatsContentProps = { resolvedStats: StatsResponse; rangeLabel: string }; +type StatsContentProps = { resolvedStats: StatsResponse; rangeLabel: string; workspaceId?: string }; function SectionDivider({ id, label }: { id: string; label: string }) { return ( @@ -168,12 +169,73 @@ function SectionDivider({ id, label }: { id: string; label: string }) { ); } -function StatsContent({ resolvedStats, rangeLabel }: StatsContentProps) { +function TelemetrySection({ + completedActivity, + dailyActivity, + agentUsage, + rangeLabel, +}: { + completedActivity: StatsResponse["completed_activity"]; + dailyActivity: StatsResponse["daily_activity"]; + agentUsage: StatsResponse["agent_usage"]; + rangeLabel: string; +}) { + return ( + <> +
+
+ + + + Completed Tasks Over Time + + + + + + + + + + Most Productive + + + + + + +
+
+
+ + + + Activity ({rangeLabel.toLowerCase()}) + + + + + + + + + Top Agents + + + + + +
+ + ); +} + +function StatsContent({ resolvedStats, rangeLabel, workspaceId }: StatsContentProps) { const { global, task_stats, - daily_activity, completed_activity, + daily_activity, agent_usage, repository_stats, git_stats, @@ -184,52 +246,12 @@ function StatsContent({ resolvedStats, rangeLabel }: StatsContentProps) {
-
-
- - - - Completed Tasks Over Time - - - - - - - - - - Most Productive - - - - - - -
-
-
- - - - Activity ({rangeLabel.toLowerCase()}) - - - - - - - - - - Top Agents - - - - - - -
+ @@ -260,6 +282,8 @@ function StatsContent({ resolvedStats, rangeLabel }: StatsContentProps) { + +
@@ -358,7 +382,11 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat onRangeChange={handleRangeChange} onCopy={handleCopyStats} /> - + ); } diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx new file mode 100644 index 0000000000..3ae57da6f4 --- /dev/null +++ b/apps/web/components/github/github-settings.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { IconBrandGithub, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Separator } from "@kandev/ui/separator"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { GitHubStatusCard } from "./github-status"; +import { ReviewWatchTable } from "./review-watch-table"; +import { ReviewWatchDialog } from "./review-watch-dialog"; +import { PRStatsPanel } from "./pr-stats"; +import { useReviewWatches } from "@/hooks/domains/github/use-review-watches"; +import type { ReviewWatch } from "@/lib/types/github"; + +type GitHubSettingsProps = { + workspaceId: string; +}; + +function useWatchActions(workspaceId: string | null) { + const { items: watches, create, update, remove, trigger } = useReviewWatches(workspaceId); + const { toast } = useToast(); + + const handleDelete = useCallback( + async (id: string) => { + try { + await remove(id); + toast({ description: "Review watch deleted", variant: "success" }); + } catch { + toast({ description: "Failed to delete review watch", variant: "error" }); + } + }, + [remove, toast], + ); + + const handleTrigger = useCallback( + async (id: string) => { + try { + const result = await trigger(id); + const count = result?.new_prs_found ?? 0; + if (count > 0) { + toast({ + description: `Found ${count} new PR${count > 1 ? "s" : ""}`, + variant: "success", + }); + } else { + toast({ description: "No new PRs found" }); + } + } catch { + toast({ description: "Failed to check for PRs", variant: "error" }); + } + }, + [trigger, toast], + ); + + const handleToggleEnabled = useCallback( + async (watch: ReviewWatch) => { + try { + await update(watch.id, { enabled: !watch.enabled }); + toast({ + description: watch.enabled ? "Watch paused" : "Watch enabled", + variant: "success", + }); + } catch { + toast({ description: "Failed to update watch", variant: "error" }); + } + }, + [update, toast], + ); + + return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled }; +} + +export function GitHubSettings({ workspaceId }: GitHubSettingsProps) { + const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } = + useWatchActions(workspaceId); + const { toast } = useToast(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingWatch, setEditingWatch] = useState(null); + + const handleEdit = useCallback((watch: ReviewWatch) => { + setEditingWatch(watch); + setDialogOpen(true); + }, []); + + return ( + +
+
+

+ + GitHub Integration +

+

+ Configure GitHub PR monitoring and review queue automation. +

+
+ + + + + + + + + + + + { + setEditingWatch(null); + setDialogOpen(true); + }} + className="cursor-pointer" + > + + Add Watch + + } + > + + + + + + + + + + + + { + await create(req); + toast({ description: "Review watch created", variant: "success" }); + }} + onUpdate={async (id, req) => { + await update(id, req); + toast({ description: "Review watch updated", variant: "success" }); + }} + /> +
+
+ ); +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx new file mode 100644 index 0000000000..1c94f3bced --- /dev/null +++ b/apps/web/components/github/github-status.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { IconCheck, IconX } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Spinner } from "@kandev/ui/spinner"; +import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; + +export function GitHubStatusCard() { + const { status, loaded, loading } = useGitHubStatus(); + + if (loading || !loaded) { + return ( +
+ + Checking GitHub connection... +
+ ); + } + + if (!status || !status.authenticated) { + return ( +
+ + Not connected to GitHub + + Run gh auth login or add a GITHUB_TOKEN + secret + +
+ ); + } + + return ( +
+ + + Connected as {status.username} + + + {status.auth_method === "gh_cli" ? "gh CLI" : "PAT"} + +
+ ); +} diff --git a/apps/web/components/github/pr-checks-section.tsx b/apps/web/components/github/pr-checks-section.tsx new file mode 100644 index 0000000000..4a9c7dd20f --- /dev/null +++ b/apps/web/components/github/pr-checks-section.tsx @@ -0,0 +1,128 @@ +import { IconCheck, IconX, IconClock } from "@tabler/icons-react"; +import type { CheckRun } from "@/lib/types/github"; +import { CollapsibleSection, AddToContextButton, formatDuration, formatElapsed } from "./pr-shared"; + +function CheckStatusIcon({ check }: { check: CheckRun }) { + const value = check.conclusion || check.status; + if (value === "success") return ; + if (value === "failure" || value === "timed_out") + return ; + if (value === "in_progress" || value === "queued") + return ; + return ; +} + +function conclusionLabel(check: CheckRun): string | null { + const c = check.conclusion; + if (!c || c === "success" || c === "failure") return c || null; + const labels: Record = { + timed_out: "timed out", + cancelled: "cancelled", + action_required: "action required", + skipped: "skipped", + neutral: "neutral", + stale: "stale", + }; + return labels[c] ?? c; +} + +function checkDurationText(check: CheckRun): string | null { + if (check.started_at && check.completed_at) + return formatDuration(check.started_at, check.completed_at); + if (check.started_at && !check.completed_at) return `${formatElapsed(check.started_at)} running`; + return null; +} + +function isFailedCheck(check: CheckRun): boolean { + return check.conclusion === "failure" || check.conclusion === "timed_out"; +} + +function buildCheckMessage(check: CheckRun): string { + const parts = [`CI check **${check.name}** failed (${check.conclusion}).`]; + if (check.output) parts.push(check.output); + if (check.html_url) parts.push(`Check URL: ${check.html_url}`); + parts.push("Please investigate and fix this failing check."); + return parts.join("\n\n"); +} + +function buildAllFailedMessage(checks: CheckRun[]): string { + const failed = checks.filter(isFailedCheck); + const parts = [`### ${failed.length} CI Check${failed.length !== 1 ? "s" : ""} Failed`, ""]; + for (const check of failed) { + parts.push(`**${check.name}** — ${check.conclusion}`); + if (check.output) parts.push(check.output); + if (check.html_url) parts.push(`URL: ${check.html_url}`); + parts.push(""); + } + parts.push("Please investigate and fix these failing checks."); + return parts.join("\n"); +} + +function formatSectionSummary(checks: CheckRun[]): string { + const failed = checks.filter(isFailedCheck).length; + const passed = checks.filter((c) => c.conclusion === "success").length; + const pending = checks.length - failed - passed; + const parts: string[] = []; + if (failed > 0) parts.push(`${failed} failed`); + if (passed > 0) parts.push(`${passed} passed`); + if (pending > 0) parts.push(`${pending} pending`); + return parts.join(", "); +} + +export function ChecksSection({ + checks, + onAddAsContext, +}: { + checks: CheckRun[]; + onAddAsContext: (message: string) => void; +}) { + const summary = checks.length > 0 ? ` \u2014 ${formatSectionSummary(checks)}` : ""; + const hasFailed = checks.some(isFailedCheck); + + return ( + onAddAsContext(buildAllFailedMessage(checks)) : undefined} + addAllLabel="Add all failed checks to chat context" + > + {checks.length === 0 &&

No checks

} + {checks.map((check) => { + const label = conclusionLabel(check); + const duration = checkDurationText(check); + return ( +
+
+ + {check.html_url ? ( + + {check.name} + + ) : ( + {check.name} + )} + {isFailedCheck(check) && ( +
+ onAddAsContext(buildCheckMessage(check))} /> +
+ )} +
+ {(label || duration) && ( +
+ {label && {label}} + {label && duration && ·} + {duration && {duration}} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/components/github/pr-comments-section.tsx b/apps/web/components/github/pr-comments-section.tsx new file mode 100644 index 0000000000..392c34b836 --- /dev/null +++ b/apps/web/components/github/pr-comments-section.tsx @@ -0,0 +1,180 @@ +import { useMemo } from "react"; +import type { PRComment } from "@/lib/types/github"; +import { + CollapsibleSection, + AddToContextButton, + AuthorAvatar, + AuthorLink, + formatTimeAgo, +} from "./pr-shared"; + +function buildCommentMessage(comment: PRComment, prUrl: string): string { + const location = comment.path + ? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\`` + : ""; + const header = location + ? `Comment from **${comment.author}** on ${location}:` + : `Comment from **${comment.author}**:`; + return [header, comment.body, `PR: ${prUrl}`, "Please address this comment."].join("\n\n"); +} + +function buildThreadMessage(thread: CommentThread, prUrl: string): string { + const parts = ["### Comment Thread", ""]; + for (const c of [thread.root, ...thread.replies]) { + const loc = c.path ? ` on \`${c.path}${c.line > 0 ? `:${c.line}` : ""}\`` : ""; + parts.push(`**${c.author}**${loc}:`); + parts.push(c.body); + parts.push(""); + } + parts.push(`PR: ${prUrl}`); + parts.push("Please address this comment thread."); + return parts.join("\n"); +} + +function buildAllCommentsMessage(comments: PRComment[], prUrl: string): string { + const parts = ["### All PR Comments", ""]; + for (const c of comments) { + const loc = c.path ? ` on \`${c.path}${c.line > 0 ? `:${c.line}` : ""}\`` : ""; + parts.push(`**${c.author}**${loc}:`); + parts.push(c.body); + parts.push(""); + } + parts.push(`PR: ${prUrl}`); + parts.push("Please address the comments above."); + return parts.join("\n"); +} + +type CommentThread = { + root: PRComment; + replies: PRComment[]; +}; + +function buildThreads(comments: PRComment[]): CommentThread[] { + const byId = new Map(); + for (const c of comments) byId.set(c.id, c); + + const roots: PRComment[] = []; + const repliesByParent = new Map(); + + for (const c of comments) { + if (c.in_reply_to === null || c.in_reply_to === 0 || !byId.has(c.in_reply_to)) { + roots.push(c); + } else { + const existing = repliesByParent.get(c.in_reply_to) ?? []; + existing.push(c); + repliesByParent.set(c.in_reply_to, existing); + } + } + + return roots.map((root) => ({ + root, + replies: repliesByParent.get(root.id) ?? [], + })); +} + +function CommentItem({ + comment, + prUrl, + onAddAsContext, + isReply, +}: { + comment: PRComment; + prUrl: string; + onAddAsContext: (message: string) => void; + isReply?: boolean; +}) { + return ( +
+
+
+ + + {comment.path && ( + + {comment.path} + {comment.line > 0 && `:${comment.line}`} + + )} + {!comment.path && !isReply && ( + (general) + )} + + {formatTimeAgo(comment.created_at)} + + onAddAsContext(buildCommentMessage(comment, prUrl))} /> +
+

{comment.body}

+
+
+ ); +} + +function ThreadBlock({ + thread, + prUrl, + onAddAsContext, +}: { + thread: CommentThread; + prUrl: string; + onAddAsContext: (message: string) => void; +}) { + const hasReplies = thread.replies.length > 0; + return ( +
+
+
+ +
+ {hasReplies && ( + onAddAsContext(buildThreadMessage(thread, prUrl))} + tooltip="Add whole thread to chat context" + /> + )} +
+ {thread.replies.map((reply) => ( + + ))} +
+ ); +} + +export function CommentsSection({ + comments, + prUrl, + onAddAsContext, +}: { + comments: PRComment[]; + prUrl: string; + onAddAsContext: (message: string) => void; +}) { + const threads = useMemo(() => buildThreads(comments), [comments]); + + return ( + onAddAsContext(buildAllCommentsMessage(comments, prUrl))} + addAllLabel="Add all comments to chat context" + > + {comments.length === 0 && ( +

No comments yet

+ )} + {threads.map((thread) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx new file mode 100644 index 0000000000..f2e6ec6792 --- /dev/null +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import type { IDockviewPanelProps } from "dockview-react"; +import { + IconRefresh, + IconPlus, + IconMinus, + IconAlertTriangle, + IconGitMerge, +} from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Separator } from "@kandev/ui/separator"; +import { ScrollArea } from "@kandev/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { Spinner } from "@kandev/ui/spinner"; +import { useAppStore } from "@/components/state-provider"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { prPanelLabel } from "@/components/github/pr-utils"; +import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; +import { useCommentsStore } from "@/lib/state/slices/comments"; +import type { PRFeedbackComment } from "@/lib/state/slices/comments"; +import { useToast } from "@/components/toast-provider"; +import type { TaskPR, PRFeedback } from "@/lib/types/github"; +import { formatTimeAgo, AuthorLink } from "./pr-shared"; +import { ReviewStateBadge } from "./pr-reviews-section"; +import { ChecksSection } from "./pr-checks-section"; +import { ReviewsSection } from "./pr-reviews-section"; +import { CommentsSection } from "./pr-comments-section"; + +// --- Dockview panel wrapper --- + +export function PRDetailPanelComponent(props: IDockviewPanelProps) { + const pr = useActiveTaskPR(); + const sessionId = useAppStore((s) => s.tasks.activeSessionId); + + useEffect(() => { + const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request"; + if (props.api.title !== title) props.api.setTitle(title); + }, [pr, props.api]); + + if (!pr || !sessionId) { + return ( +
+ No pull request linked to this session. +
+ ); + } + + return ; +} + +// --- Add PR feedback as chat context --- + +function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { + const { toast } = useToast(); + const addComment = useCommentsStore((s) => s.addComment); + + const addAsContext = useCallback( + (feedbackType: PRFeedbackComment["feedbackType"], content: string) => { + const comment: PRFeedbackComment = { + id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + sessionId, + text: content, + createdAt: new Date().toISOString(), + status: "pending", + source: "pr-feedback", + prNumber, + feedbackType, + content, + }; + addComment(comment); + toast({ description: "Added to chat context" }); + }, + [sessionId, prNumber, addComment, toast], + ); + + return { addAsContext }; +} + +// --- Main content --- + +function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) { + const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number); + const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number); + + return ( +
+ + + +
+ {loading && !feedback && ( +
+ +
+ )} + {feedback && ( + <> + addAsContext("review", msg)} + /> + addAsContext("check", msg)} + /> + addAsContext("comment", msg)} + /> + + )} +
+
+ {taskPR.last_synced_at && ( + <> + +
+ Last synced {formatTimeAgo(taskPR.last_synced_at)} +
+ + )} +
+ ); +} + +// --- Header --- + +function StateBadge({ state }: { state: string }) { + const styles: Record = { + open: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + merged: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", + closed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + }; + return ( + + {state} + + ); +} + +function HeaderTitleRow({ + taskPR, + loading, + onRefresh, +}: { + taskPR: TaskPR; + loading: boolean; + onRefresh: () => void; +}) { + return ( +
+ + {taskPR.pr_title} + + + + + + Refresh + +
+ ); +} + +function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) { + return ( +
+ + by + + · + opened {formatTimeAgo(taskPR.created_at)} + {taskPR.merged_at && ( + <> + · + + + merged {formatTimeAgo(taskPR.merged_at)} + + + )} + {taskPR.closed_at && !taskPR.merged_at && ( + <> + · + closed {formatTimeAgo(taskPR.closed_at)} + + )} +
+ ); +} + +function HeaderStatsLine({ taskPR }: { taskPR: TaskPR }) { + return ( +
+ + + {taskPR.additions} + + + + {taskPR.deletions} + + · + + {taskPR.review_count} review{taskPR.review_count !== 1 ? "s" : ""} + {taskPR.pending_review_count > 0 && ( + + {" "} + ({taskPR.pending_review_count} pending) + + )} + + · + + {taskPR.comment_count} comment{taskPR.comment_count !== 1 ? "s" : ""} + + {taskPR.review_state && } +
+ ); +} + +function PRHeader({ + taskPR, + feedback, + loading, + onRefresh, +}: { + taskPR: TaskPR; + feedback: PRFeedback | null; + loading: boolean; + onRefresh: () => void; +}) { + const isDraft = feedback?.pr.draft ?? false; + const isMergeable = feedback?.pr.mergeable ?? true; + const showWarnings = isDraft || (!isMergeable && taskPR.state === "open"); + + return ( +
+ +
+ + #{taskPR.pr_number} + + {taskPR.head_branch} → {taskPR.base_branch} + +
+ {showWarnings && ( +
+ {isDraft && ( + + Draft + + )} + {!isMergeable && taskPR.state === "open" && ( + + + Not mergeable + + )} +
+ )} + + +
+ ); +} diff --git a/apps/web/components/github/pr-reviews-section.tsx b/apps/web/components/github/pr-reviews-section.tsx new file mode 100644 index 0000000000..8ee53e7232 --- /dev/null +++ b/apps/web/components/github/pr-reviews-section.tsx @@ -0,0 +1,144 @@ +import { IconCheck, IconX, IconClock } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import type { PRReview } from "@/lib/types/github"; +import { + CollapsibleSection, + AddToContextButton, + AuthorAvatar, + AuthorLink, + formatTimeAgo, +} from "./pr-shared"; + +function ReviewStateIcon({ state }: { state: string }) { + if (state === "APPROVED") return ; + if (state === "CHANGES_REQUESTED") return ; + return ; +} + +function reviewStateLabel(state: string): string { + const labels: Record = { + APPROVED: "Approved", + CHANGES_REQUESTED: "Changes Requested", + COMMENTED: "Commented", + PENDING: "Pending", + DISMISSED: "Dismissed", + }; + return labels[state] ?? state; +} + +export function ReviewStateBadge({ state }: { state: string }) { + if (!state) return null; + const styles: Record = { + approved: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + changes_requested: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400", + }; + const labelMap: Record = { + approved: "Approved", + changes_requested: "Changes requested", + pending: "Review pending", + }; + return ( + + {labelMap[state] ?? state} + + ); +} + +function buildReviewMessage(review: PRReview, prUrl: string): string { + const parts = [`Review from **${review.author}** (${review.state}):`]; + if (review.body) parts.push(review.body); + parts.push(`PR: ${prUrl}`); + parts.push("Please address this review feedback."); + return parts.join("\n\n"); +} + +function buildAllReviewsMessage(reviews: PRReview[], prUrl: string): string { + const parts = ["### All PR Reviews", ""]; + for (const r of reviews) { + parts.push(`**${r.author}** — ${reviewStateLabel(r.state)}`); + if (r.body) parts.push(r.body); + parts.push(""); + } + parts.push(`PR: ${prUrl}`); + parts.push("Please address the review feedback above."); + return parts.join("\n"); +} + +function formatReviewSummary(reviews: PRReview[]): string { + const approved = reviews.filter((r) => r.state === "APPROVED").length; + const changes = reviews.filter((r) => r.state === "CHANGES_REQUESTED").length; + const parts: string[] = []; + if (approved > 0) parts.push(`${approved} approved`); + if (changes > 0) parts.push(`${changes} changes requested`); + const other = reviews.length - approved - changes; + if (other > 0) parts.push(`${other} other`); + return parts.join(", "); +} + +export function ReviewsSection({ + reviews, + prUrl, + reviewState, + reviewCount, + pendingReviewCount, + onAddAsContext, +}: { + reviews: PRReview[]; + prUrl: string; + reviewState: string; + reviewCount: number; + pendingReviewCount: number; + onAddAsContext: (message: string) => void; +}) { + const summary = reviews.length > 0 ? ` \u2014 ${formatReviewSummary(reviews)}` : ""; + const pendingText = pendingReviewCount > 0 ? ` (${pendingReviewCount} pending)` : ""; + + const subtitle = reviewState ? ( +
+ Overall: + {pendingText && {pendingText}} +
+ ) : null; + + return ( + onAddAsContext(buildAllReviewsMessage(reviews, prUrl))} + addAllLabel="Add all reviews to chat context" + > + {reviews.length === 0 && ( +

No reviews yet

+ )} + {reviews.map((review) => ( +
+
+ + + + + {reviewStateLabel(review.state)} + + + {formatTimeAgo(review.created_at)} + + {(review.state === "CHANGES_REQUESTED" || review.body) && ( + onAddAsContext(buildReviewMessage(review, prUrl))} + /> + )} +
+ {review.body && ( +

{review.body}

+ )} +
+ ))} +
+ ); +} diff --git a/apps/web/components/github/pr-shared.tsx b/apps/web/components/github/pr-shared.tsx new file mode 100644 index 0000000000..7193a93628 --- /dev/null +++ b/apps/web/components/github/pr-shared.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; + +export function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export function formatDuration(startedAt: string, completedAt: string): string { + return formatMs(new Date(completedAt).getTime() - new Date(startedAt).getTime()); +} + +export function formatElapsed(startedAt: string): string { + return formatMs(Date.now() - new Date(startedAt).getTime()); +} + +export function AuthorAvatar({ src, author }: { src: string; author: string }) { + const inner = src ? ( + // eslint-disable-next-line @next/next/no-img-element + {author} + ) : ( +
+ {author[0]?.toUpperCase() ?? "?"} +
+ ); + return ( + + {inner} + + ); +} + +export function AuthorLink({ author }: { author: string }) { + return ( + + {author} + + ); +} + +export function CollapsibleSection({ + title, + count, + defaultOpen, + subtitle, + onAddAll, + addAllLabel, + children, +}: { + title: string; + count: number; + defaultOpen?: boolean; + subtitle?: React.ReactNode; + onAddAll?: () => void; + addAllLabel?: string; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen ?? true); + + return ( +
+
+ + {onAddAll && count > 0 && open && ( + + + + + {addAllLabel ?? "Add all to chat context"} + + )} +
+ {subtitle && open &&
{subtitle}
} + {open &&
{children}
} +
+ ); +} + +export function AddToContextButton({ + onClick, + tooltip, +}: { + onClick: () => void; + tooltip?: string; +}) { + return ( + + + + + {tooltip ?? "Add to chat context"} + + ); +} diff --git a/apps/web/components/github/pr-stats.tsx b/apps/web/components/github/pr-stats.tsx new file mode 100644 index 0000000000..7ed1ec4564 --- /dev/null +++ b/apps/web/components/github/pr-stats.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useReducer } from "react"; +import { + IconGitPullRequest, + IconEye, + IconMessage, + IconCheck, + IconClock, +} from "@tabler/icons-react"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Spinner } from "@kandev/ui/spinner"; +import { fetchGitHubStats } from "@/lib/api/domains/github-api"; +import type { PRStats } from "@/lib/types/github"; + +type StatCardProps = { + icon: React.ReactNode; + label: string; + value: string | number; + subtext?: string; +}; + +function StatCard({ icon, label, value, subtext }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{value}

+

{label}

+ {subtext &&

{subtext}

} +
+
+
+
+ ); +} + +function formatHours(hours: number): string { + if (hours < 1) return "< 1h"; + if (hours < 24) return `${Math.round(hours)}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +function formatPercent(rate: number): string { + return `${Math.round(rate * 100)}%`; +} + +type StatsState = { stats: PRStats | null; loading: boolean }; +type StatsAction = { type: "fetch" } | { type: "done"; stats: PRStats | null }; + +function statsReducer(state: StatsState, action: StatsAction): StatsState { + switch (action.type) { + case "fetch": + return { ...state, loading: true }; + case "done": + return { stats: action.stats, loading: false }; + } +} + +function usePRStats(workspaceId: string | null) { + const [state, dispatch] = useReducer(statsReducer, { stats: null, loading: true }); + + useEffect(() => { + let cancelled = false; + dispatch({ type: "fetch" }); + fetchGitHubStats(workspaceId ? { workspace_id: workspaceId } : {}, { cache: "no-store" }) + .then((data) => { + if (!cancelled) dispatch({ type: "done", stats: data }); + }) + .catch(() => { + if (!cancelled) dispatch({ type: "done", stats: null }); + }); + return () => { + cancelled = true; + }; + }, [workspaceId]); + + return state; +} + +function StatsGrid({ stats }: { stats: PRStats }) { + return ( +
+ } + label="PRs Created" + value={stats.total_prs_created} + /> + } + label="PRs Reviewed" + value={stats.total_prs_reviewed} + /> + } + label="Comments" + value={stats.total_comments} + /> + } + label="CI Pass Rate" + value={formatPercent(stats.ci_pass_rate)} + /> + } + label="Approval Rate" + value={formatPercent(stats.approval_rate)} + /> + } + label="Avg Time to Merge" + value={formatHours(stats.avg_time_to_merge_hours)} + /> +
+ ); +} + +function PRsByDayChart({ prsByDay }: { prsByDay: PRStats["prs_by_day"] }) { + if (!prsByDay || prsByDay.length === 0) return null; + const maxCount = Math.max(...prsByDay.map((d) => d.count), 1); + return ( + + +

PRs by Day

+
+ {prsByDay.map((day) => { + const height = Math.max((day.count / maxCount) * 100, 4); + return ( +
+ ); + })} +
+
+ {prsByDay[0]?.date} + + {prsByDay[prsByDay.length - 1]?.date} + +
+ + + ); +} + +export function PRStatsPanel({ workspaceId }: { workspaceId: string | null }) { + const { stats, loading } = usePRStats(workspaceId); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!stats) { + return ( +

No stats available yet.

+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx new file mode 100644 index 0000000000..4793ddb404 --- /dev/null +++ b/apps/web/components/github/pr-task-icon.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { IconGitPullRequest } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { useAppStore } from "@/components/state-provider"; +import type { TaskPR } from "@/lib/types/github"; + +function getPRStatusColor(pr: TaskPR): string { + if (pr.state === "merged") return "text-purple-500"; + if (pr.state === "closed") return "text-red-500"; + if (pr.review_state === "changes_requested" || pr.checks_state === "failure") { + return "text-red-500"; + } + if (pr.review_state === "approved" && pr.checks_state === "success") { + return "text-green-500"; + } + if (pr.checks_state === "pending" || pr.review_state === "pending") { + return "text-yellow-500"; + } + return "text-muted-foreground"; +} + +function getPRTooltip(pr: TaskPR): string { + const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`]; + if (pr.state !== "open") parts.push(`State: ${pr.state}`); + if (pr.review_state) parts.push(`Review: ${pr.review_state}`); + if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`); + return parts.join(" | "); +} + +export function PRTaskIcon({ taskId }: { taskId: string }) { + const pr = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); + + if (!pr) return null; + + return ( + + + + + + + {getPRTooltip(pr)} + + ); +} diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx new file mode 100644 index 0000000000..0248acb369 --- /dev/null +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { memo } from "react"; +import { IconGitPullRequest, IconCheck, IconX, IconClock } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useAppStore } from "@/components/state-provider"; +import type { TaskPR } from "@/lib/types/github"; + +function PRStatusIcon({ pr }: { pr: TaskPR }) { + if (pr.checks_state === "failure" || pr.review_state === "changes_requested") { + return ; + } + if (pr.checks_state === "success" && pr.review_state === "approved") { + return ; + } + if (pr.checks_state === "pending" || pr.review_state === "pending") { + return ; + } + if (pr.state === "merged") { + return ; + } + if (pr.state === "closed") { + return ; + } + return null; +} + +export const PRTopbarButton = memo(function PRTopbarButton() { + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + // useTaskPR fetches if not in store; useActiveTaskPR just reads + useTaskPR(activeTaskId); + const pr = useActiveTaskPR(); + const addPRPanel = useDockviewStore((s) => s.addPRPanel); + + if (!pr) return null; + + return ( + + + + + {pr.pr_title} + + ); +}); diff --git a/apps/web/components/github/pr-utils.ts b/apps/web/components/github/pr-utils.ts new file mode 100644 index 0000000000..eb70d47391 --- /dev/null +++ b/apps/web/components/github/pr-utils.ts @@ -0,0 +1,4 @@ +/** Shared label for the PR dockview panel tab and dropdown items. */ +export function prPanelLabel(prNumber: number): string { + return `PR #${prNumber}`; +} diff --git a/apps/web/components/github/refresh-reviews-button.tsx b/apps/web/components/github/refresh-reviews-button.tsx new file mode 100644 index 0000000000..2994a4b46b --- /dev/null +++ b/apps/web/components/github/refresh-reviews-button.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useToast } from "@/components/toast-provider"; +import { useAppStore } from "@/components/state-provider"; +import { triggerAllReviewWatches, listReviewWatches } from "@/lib/api/domains/github-api"; + +export function RefreshReviewsButton() { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const hasWatches = useAppStore((state) => state.reviewWatches.items.length > 0); + const watchesLoaded = useAppStore((state) => state.reviewWatches.loaded); + const setReviewWatches = useAppStore((state) => state.setReviewWatches); + const fetchedRef = useRef(false); + + useEffect(() => { + if (!workspaceId || watchesLoaded || fetchedRef.current) return; + fetchedRef.current = true; + listReviewWatches(workspaceId) + .then((r) => setReviewWatches(r?.watches ?? [])) + .catch(() => {}); + }, [workspaceId, watchesLoaded, setReviewWatches]); + + const handleTrigger = useCallback(async () => { + if (!workspaceId || loading) return; + setLoading(true); + try { + const result = await triggerAllReviewWatches(workspaceId); + const count = result?.new_prs_found ?? 0; + if (count > 0) { + toast({ + description: `Found ${count} new PR${count > 1 ? "s" : ""} to review`, + variant: "success", + }); + } else { + toast({ description: "No new PRs to review" }); + } + } catch { + toast({ description: "Failed to check for review PRs", variant: "error" }); + } finally { + setLoading(false); + } + }, [workspaceId, loading, toast]); + + if (!watchesLoaded || !hasWatches) return null; + + return ( + + + + + Check for PRs to review + + ); +} diff --git a/apps/web/components/github/repo-filter-selector.tsx b/apps/web/components/github/repo-filter-selector.tsx new file mode 100644 index 0000000000..746695d5c4 --- /dev/null +++ b/apps/web/components/github/repo-filter-selector.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { IconX, IconLoader2, IconPlus } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandItem, + CommandGroup, +} from "@kandev/ui/command"; +import { Label } from "@kandev/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Switch } from "@kandev/ui/switch"; +import { cn } from "@/lib/utils"; +import { listUserOrgs, searchOrgRepos } from "@/lib/api/domains/github-api"; +import type { RepoFilter, GitHubOrg, GitHubRepoInfo } from "@/lib/types/github"; + +type RepoFilterSelectorProps = { + allRepos: boolean; + selectedRepos: RepoFilter[]; + onAllReposChange: (checked: boolean) => void; + onSelectedReposChange: (repos: RepoFilter[]) => void; +}; + +function useGitHubOrgs() { + const [orgs, setOrgs] = useState([]); + const [loading, setLoading] = useState(true); + const fetchedRef = useRef(false); + + useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; + listUserOrgs() + .then((r) => setOrgs(r.orgs ?? [])) + .catch(() => setOrgs([])) + .finally(() => setLoading(false)); + }, []); + + return { orgs, loading }; +} + +function useRepoSearch(org: string, query: string) { + const [searchState, setSearchState] = useState<{ + results: GitHubRepoInfo[]; + loading: boolean; + org: string; + }>({ results: [], loading: false, org: "" }); + const timerRef = useRef>(undefined); + + useEffect(() => { + clearTimeout(timerRef.current); + if (!org) return; + timerRef.current = setTimeout(() => { + setSearchState((prev) => ({ ...prev, loading: true, org })); + searchOrgRepos(org, query || undefined) + .then((r) => setSearchState({ results: r.repos ?? [], loading: false, org })) + .catch(() => setSearchState({ results: [], loading: false, org })); + }, 300); + return () => clearTimeout(timerRef.current); + }, [org, query]); + + // Clear results when org becomes empty (derived, not in effect) + const results = org ? searchState.results : []; + const loading = org ? searchState.loading : false; + + return { results, loading }; +} + +function SelectedFilters({ + repos, + onRemove, + disabled, +}: { + repos: RepoFilter[]; + onRemove: (r: RepoFilter) => void; + disabled: boolean; +}) { + if (repos.length === 0) return null; + return ( +
+ {repos.map((r) => { + const label = r.name === "" ? `${r.owner}/*` : `${r.owner}/${r.name}`; + return ( + + {label} + + + ); + })} +
+ ); +} + +function OrgBadges({ + orgs, + loading, + selectedRepos, + disabled, + onToggleOrg, +}: { + orgs: GitHubOrg[]; + loading: boolean; + selectedRepos: RepoFilter[]; + disabled: boolean; + onToggleOrg: (login: string) => void; +}) { + if (loading) { + return ( +
+ + Loading organizations... +
+ ); + } + if (orgs.length === 0) return null; + + return ( +
+ {orgs.map((org) => { + const isSelected = selectedRepos.some((r) => r.owner === org.login && r.name === ""); + return ( + onToggleOrg(org.login)} + > + {org.login} + + ); + })} +
+ ); +} + +function RepoSearchCombobox({ + disabled, + onAdd, +}: { + disabled: boolean; + onAdd: (owner: string, name: string) => void; +}) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + + const slashIdx = value.indexOf("/"); + const org = slashIdx > 0 ? value.slice(0, slashIdx) : ""; + const query = slashIdx > 0 ? value.slice(slashIdx + 1) : ""; + const { results, loading: searchLoading } = useRepoSearch(org, query); + const filteredResults = useMemo(() => results.slice(0, 10), [results]); + + const handleSelect = useCallback( + (fullName: string) => { + const [owner, ...rest] = fullName.split("/"); + const name = rest.join("/"); + if (owner && name) { + onAdd(owner, name); + setValue(""); + setOpen(false); + } + }, + [onAdd], + ); + + return ( + + + + + + + + + {searchLoading && ( +
+ + Searching... +
+ )} + {!searchLoading && org && filteredResults.length === 0 && ( + No repos found for "{org}" + )} + {!searchLoading && !org && value.length > 0 && ( + Type owner/repo to search + )} + {filteredResults.length > 0 && ( + + {filteredResults.map((repo) => ( + + {repo.full_name} + {repo.private && ( + private + )} + + ))} + + )} +
+
+
+
+ ); +} + +export function RepoFilterSelector({ + allRepos, + selectedRepos, + onAllReposChange, + onSelectedReposChange, +}: RepoFilterSelectorProps) { + const { orgs, loading: orgsLoading } = useGitHubOrgs(); + + const toggleOrg = useCallback( + (login: string) => { + const exists = selectedRepos.some((r) => r.owner === login && r.name === ""); + if (exists) { + onSelectedReposChange(selectedRepos.filter((r) => !(r.owner === login && r.name === ""))); + } else { + onSelectedReposChange([...selectedRepos, { owner: login, name: "" }]); + } + }, + [selectedRepos, onSelectedReposChange], + ); + + const addRepo = useCallback( + (owner: string, name: string) => { + const exists = selectedRepos.some((r) => r.owner === owner && r.name === name); + if (!exists) { + onSelectedReposChange([...selectedRepos, { owner, name }]); + } + }, + [selectedRepos, onSelectedReposChange], + ); + + const removeFilter = useCallback( + (filter: RepoFilter) => { + onSelectedReposChange( + selectedRepos.filter((r) => !(r.owner === filter.owner && r.name === filter.name)), + ); + }, + [selectedRepos, onSelectedReposChange], + ); + + return ( +
+
+ +

+ Which GitHub repositories to monitor for PRs requesting your review. +

+
+ +
+ + +
+ + {!allRepos && ( + <> +
+ + +
+ + + + + + )} +
+ ); +} diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx new file mode 100644 index 0000000000..4f02cb7ece --- /dev/null +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@kandev/ui/button"; +import { Switch } from "@kandev/ui/switch"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@kandev/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkflows } from "@/hooks/use-workflows"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { ScriptEditor } from "@/components/settings/profile-edit/script-editor"; +import { + REVIEW_WATCH_PLACEHOLDERS, + DEFAULT_REVIEW_WATCH_PROMPT, +} from "@/components/github/review-watch-placeholders"; +import { RepoFilterSelector } from "@/components/github/repo-filter-selector"; +import type { + RepoFilter, + ReviewWatch, + CreateReviewWatchRequest, + UpdateReviewWatchRequest, +} from "@/lib/types/github"; + +type ReviewWatchDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + watch: ReviewWatch | null; + workspaceId: string; + onCreate: (req: CreateReviewWatchRequest) => Promise; + onUpdate: (id: string, req: UpdateReviewWatchRequest) => Promise; +}; + +type FormState = { + selectedRepos: RepoFilter[]; + allRepos: boolean; + workflowId: string; + workflowStepId: string; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollInterval: number; +}; + +type WorkflowStepOption = { id: string; name: string }; + +const defaultFormState: FormState = { + selectedRepos: [], + allRepos: false, + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_REVIEW_WATCH_PROMPT, + enabled: true, + pollInterval: 300, +}; + +function formStateFromWatch(watch: ReviewWatch): FormState { + const hasRepos = watch.repos && watch.repos.length > 0; + return { + selectedRepos: hasRepos ? watch.repos : [], + allRepos: !hasRepos, + workflowId: watch.workflow_id, + workflowStepId: watch.workflow_step_id, + agentProfileId: watch.agent_profile_id, + executorProfileId: watch.executor_profile_id, + prompt: watch.prompt || DEFAULT_REVIEW_WATCH_PROMPT, + enabled: watch.enabled, + pollInterval: watch.poll_interval_seconds, + }; +} + +// --- Generic select field with description --- + +type SelectFieldProps = { + label: string; + description?: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + items: Array<{ id: string; label: string }>; +}; + +function SelectField({ + label, + description, + value, + onChange, + placeholder, + items, +}: SelectFieldProps) { + return ( +
+ + {description &&

{description}

} + +
+ ); +} + +// --- Hooks --- + +function useWorkflowSteps(workflowId: string) { + const [steps, setSteps] = useState([]); + + useEffect(() => { + if (!workflowId) { + void Promise.resolve().then(() => setSteps([])); + return; + } + let cancelled = false; + listWorkflowSteps(workflowId) + .then((response) => { + if (cancelled) return; + const sorted = [...response.steps].sort((a, b) => a.position - b.position); + setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); + }) + .catch(() => { + if (!cancelled) setSteps([]); + }); + return () => { + cancelled = true; + }; + }, [workflowId]); + + return steps; +} + +function useWatchFormData(workspaceId: string) { + useSettingsData(true); + useWorkflows(workspaceId, true); + + const workflows = useAppStore((state) => state.workflows.items); + const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const executors = useAppStore((state) => state.executors.items); + const allExecutorProfiles = useMemo( + () => executors.flatMap((e) => e.profiles ?? []), + [executors], + ); + + return { workflows, agentProfiles, allExecutorProfiles }; +} + +// --- Form field groups --- + +function WatchFormFields({ + form, + setForm, + workspaceId, + onAllReposChange, + onSelectedReposChange, +}: { + form: FormState; + setForm: React.Dispatch>; + workspaceId: string; + onAllReposChange: (checked: boolean) => void; + onSelectedReposChange: (repos: RepoFilter[]) => void; +}) { + const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(workspaceId); + const workflowSteps = useWorkflowSteps(form.workflowId); + + const handleWorkflowChange = useCallback( + (v: string) => { + setForm((prev) => ({ ...prev, workflowId: v, workflowStepId: "" })); + }, + [setForm], + ); + + return ( +
+ + + + + +
+ ); +} + +function WorkflowFields({ + form, + setForm, + workflows, + workflowSteps, + onWorkflowChange, +}: { + form: FormState; + setForm: React.Dispatch>; + workflows: Array<{ id: string; name: string }>; + workflowSteps: WorkflowStepOption[]; + onWorkflowChange: (v: string) => void; +}) { + return ( +
+ ({ id: w.id, label: w.name }))} + /> + setForm((prev) => ({ ...prev, workflowStepId: v }))} + placeholder={form.workflowId ? "Select step" : "Select a workflow first"} + items={workflowSteps.map((s) => ({ id: s.id, label: s.name }))} + /> +
+ ); +} + +function HelpTip({ text }: { text: string }) { + return ( + + + + + + {text} + + + ); +} + +function ProfileFields({ + form, + setForm, + agentProfiles, + executorProfiles, +}: { + form: FormState; + setForm: React.Dispatch>; + agentProfiles: Array<{ id: string; label: string }>; + executorProfiles: Array<{ id: string; name: string }>; +}) { + return ( +
+ setForm((prev) => ({ ...prev, agentProfileId: v }))} + placeholder="Select agent profile" + items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))} + /> +
+
+ + +
+

+ The executor environment where the agent will run. +

+ +
+
+ ); +} + +function PlaceholdersHelp() { + return ( + + + + + + +

Available placeholders:

+
    + {REVIEW_WATCH_PLACEHOLDERS.map((p) => ( +
  • + {`{{${p.key}}}`}{" "} + {p.description} +
  • + ))} +
+
+
+
+ ); +} + +function PromptField({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + return ( +
+
+ + +
+

+ The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders. +

+
+ setForm((prev) => ({ ...prev, prompt: v }))} + language="plaintext" + height="120px" + lineNumbers="off" + placeholders={REVIEW_WATCH_PLACEHOLDERS} + /> +
+

+ The workflow step prompt wraps this prompt. For example, if the step prompt is{" "} + + {"Analyze the task: {{task_prompt}}"} + + , the final prompt becomes{" "} + + {"Analyze the task: Pull Request ready for review: https://..."} + +

+
+ ); +} + +function SettingsFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + return ( + <> +
+ +

+ How often to check for new PRs. Minimum 60s, maximum 3600s. +

+ setForm((prev) => ({ ...prev, pollInterval: Number(e.target.value) }))} + min={60} + max={3600} + /> +
+
+
+ +

Pause or resume polling.

+
+ setForm((prev) => ({ ...prev, enabled: v }))} + className="cursor-pointer" + /> +
+ + ); +} + +function getSaveButtonLabel(saving: boolean, isEditing: boolean): string { + if (saving) return "Saving..."; + return isEditing ? "Update" : "Create"; +} + +export function ReviewWatchDialog({ + open, + onOpenChange, + watch, + workspaceId, + onCreate, + onUpdate, +}: ReviewWatchDialogProps) { + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(defaultFormState); + + useEffect(() => { + setForm(watch ? formStateFromWatch(watch) : defaultFormState); + }, [watch, open]); + + const handleSelectedReposChange = useCallback((repos: RepoFilter[]) => { + setForm((prev) => ({ ...prev, selectedRepos: repos })); + }, []); + + const handleAllReposChange = useCallback((checked: boolean) => { + setForm((prev) => ({ + ...prev, + allRepos: checked, + selectedRepos: checked ? [] : prev.selectedRepos, + })); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const repos = form.allRepos ? [] : form.selectedRepos; + const payload = { + workflow_id: form.workflowId, + workflow_step_id: form.workflowStepId, + repos, + agent_profile_id: form.agentProfileId, + executor_profile_id: form.executorProfileId, + prompt: form.prompt, + enabled: form.enabled, + poll_interval_seconds: form.pollInterval, + }; + if (watch) { + await onUpdate(watch.id, payload); + } else { + await onCreate({ ...payload, workspace_id: workspaceId }); + } + onOpenChange(false); + } catch { + // Error handled by caller + } finally { + setSaving(false); + } + }, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]); + + const hasValidRepos = form.allRepos || form.selectedRepos.length > 0; + const canSave = + hasValidRepos && !!form.workflowId && !!form.workflowStepId && form.prompt.trim().length > 0; + + return ( + + + + {watch ? "Edit Review Watch" : "Create Review Watch"} + + Automatically create tasks when new pull requests need your review. + + + + + + + + + + ); +} diff --git a/apps/web/components/github/review-watch-placeholders.ts b/apps/web/components/github/review-watch-placeholders.ts new file mode 100644 index 0000000000..9da07c2fc7 --- /dev/null +++ b/apps/web/components/github/review-watch-placeholders.ts @@ -0,0 +1,48 @@ +import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; + +export const REVIEW_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ + { + key: "pr.link", + description: "PR URL", + example: "https://github.com/org/repo/pull/123", + executor_types: [], + }, + { + key: "pr.number", + description: "PR number", + example: "123", + executor_types: [], + }, + { + key: "pr.title", + description: "PR title", + example: "Add user authentication", + executor_types: [], + }, + { + key: "pr.author", + description: "PR author username", + example: "octocat", + executor_types: [], + }, + { + key: "pr.repo", + description: "Repository (owner/name)", + example: "org/repo", + executor_types: [], + }, + { + key: "pr.branch", + description: "Source branch", + example: "feature/auth", + executor_types: [], + }, + { + key: "pr.base_branch", + description: "Target branch", + example: "main", + executor_types: [], + }, +]; + +export const DEFAULT_REVIEW_WATCH_PROMPT = "Pull Request ready for review: {{pr.link}}"; diff --git a/apps/web/components/github/review-watch-table.tsx b/apps/web/components/github/review-watch-table.tsx new file mode 100644 index 0000000000..73a79d7920 --- /dev/null +++ b/apps/web/components/github/review-watch-table.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useToast } from "@/components/toast-provider"; +import type { ReviewWatch } from "@/lib/types/github"; + +type ReviewWatchTableProps = { + watches: ReviewWatch[]; + onEdit: (watch: ReviewWatch) => void; + onDelete: (id: string) => void; + onTrigger: (id: string) => void; + onToggleEnabled: (watch: ReviewWatch) => void; +}; + +function formatLastPolled(dateStr: string | null): string { + if (!dateStr) return "Never"; + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function formatRepoNames(watch: ReviewWatch): string { + if (watch.repos.length === 0) return "All repositories"; + return watch.repos + .map((r) => (r.name === "" ? `${r.owner}/*` : `${r.owner}/${r.name}`)) + .join(", "); +} + +type WatchActionsProps = { + watch: ReviewWatch; + onToggleEnabled: (watch: ReviewWatch) => void; + onTrigger: (id: string) => void; + onDelete: (id: string) => void; +}; + +function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActionsProps) { + const { toast } = useToast(); + return ( +
+ + + + + {watch.enabled ? "Pause" : "Enable"} + + + + + + Check now + + + + + + Delete + +
+ ); +} + +export function ReviewWatchTable({ + watches, + onEdit, + onDelete, + onTrigger, + onToggleEnabled, +}: ReviewWatchTableProps) { + if (watches.length === 0) { + return ( +

+ No review watches configured. Create one to start monitoring PRs. +

+ ); + } + + return ( + + + + Repository + Interval + Last Polled + Status + Actions + + + + {watches.map((watch) => ( + onEdit(watch)}> + {formatRepoNames(watch)} + + {Math.round(watch.poll_interval_seconds / 60)}m + + + {formatLastPolled(watch.last_polled_at)} + + + + {watch.enabled ? "Active" : "Paused"} + + + + + + + ))} + +
+ ); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index bbe39d89e7..682b0f34f1 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -21,6 +21,7 @@ import { cn, getRepositoryDisplayName } from "@/lib/utils"; import { getTaskStateIcon } from "@/lib/ui/state-icons"; import { needsAction } from "@/lib/utils/needs-action"; import { useAppStore } from "@/components/state-provider"; +import { PRTaskIcon } from "@/components/github/pr-task-icon"; export interface Task { id: string; @@ -75,7 +76,10 @@ function KanbanCardBody({ {repoName}

)} -

{task.title}

+
+

{task.title}

+ +
{actions} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index c0542831f1..1e0dd941cd 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -15,6 +15,7 @@ import { IconTimeline, } from "@tabler/icons-react"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; +import { RefreshReviewsButton } from "../github/refresh-reviews-button"; import { TaskSearchInput } from "./task-search-input"; import { KanbanHeaderMobile } from "./kanban-header-mobile"; import { MobileMenuSheet } from "./mobile-menu-sheet"; @@ -197,6 +198,7 @@ function DesktopHeader({ Stats +