From dff0b49c5166f1cf1817c18fa1013e1542acc528 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Mon, 23 Feb 2026 00:16:37 +0000
Subject: [PATCH] docs: add github integration (pr watcher) (#262)
---
apps/backend/cmd/kandev/gateway.go | 7 +
apps/backend/cmd/kandev/helpers.go | 20 +
apps/backend/cmd/kandev/main.go | 40 +-
apps/backend/cmd/kandev/orchestrator.go | 83 ++
apps/backend/cmd/kandev/services.go | 38 +-
apps/backend/cmd/kandev/types.go | 2 +
.../internal/agent/handlers/git_handlers.go | 27 +
.../server/process/workspace_files.go | 54 +-
.../server/process/workspace_tracker_test.go | 174 ++++
apps/backend/internal/common/config/config.go | 9 +
apps/backend/internal/events/types.go | 9 +
apps/backend/internal/github/client.go | 47 ++
.../backend/internal/github/client_helpers.go | 132 ++++
.../internal/github/client_helpers_test.go | 208 +++++
apps/backend/internal/github/constants.go | 30 +
apps/backend/internal/github/controller.go | 260 ++++++
apps/backend/internal/github/factory.go | 70 ++
apps/backend/internal/github/gh_client.go | 411 ++++++++++
.../backend/internal/github/gh_client_test.go | 168 ++++
apps/backend/internal/github/handlers.go | 264 +++++++
apps/backend/internal/github/models.go | 245 ++++++
apps/backend/internal/github/pat_client.go | 316 ++++++++
.../internal/github/pat_client_test.go | 100 +++
apps/backend/internal/github/poller.go | 243 ++++++
apps/backend/internal/github/provider.go | 36 +
apps/backend/internal/github/service.go | 683 ++++++++++++++++
apps/backend/internal/github/service_test.go | 322 ++++++++
apps/backend/internal/github/store.go | 469 +++++++++++
.../orchestrator/event_handlers_git.go | 25 +
.../orchestrator/event_handlers_github.go | 402 ++++++++++
.../event_handlers_github_test.go | 56 ++
.../orchestrator/executor/executor_test.go | 3 +
apps/backend/internal/orchestrator/service.go | 15 +
.../internal/orchestrator/task_operations.go | 16 +-
apps/backend/internal/repoclone/clone.go | 121 +++
apps/backend/internal/repoclone/protocol.go | 52 ++
.../internal/repoclone/protocol_test.go | 104 +++
.../task/handlers/process_handlers_test.go | 3 +
.../internal/task/repository/interface.go | 1 +
.../repository/sqlite/repository_entity.go | 20 +
.../internal/task/service/service_requests.go | 10 +
.../task/service/service_resources.go | 31 +
apps/backend/pkg/websocket/actions.go | 20 +
apps/packages/ui/src/tooltip.tsx | 4 +-
apps/web/app/settings/general/github/page.tsx | 15 +
.../settings/workspace/[id]/github/page.tsx | 36 +
apps/web/app/stats/stats-page-client.tsx | 128 +--
.../web/components/github/github-settings.tsx | 166 ++++
apps/web/components/github/github-status.tsx | 44 ++
.../components/github/pr-checks-section.tsx | 128 +++
.../components/github/pr-comments-section.tsx | 180 +++++
.../web/components/github/pr-detail-panel.tsx | 290 +++++++
.../components/github/pr-reviews-section.tsx | 144 ++++
apps/web/components/github/pr-shared.tsx | 152 ++++
apps/web/components/github/pr-stats.tsx | 175 ++++
apps/web/components/github/pr-task-icon.tsx | 47 ++
.../components/github/pr-topbar-button.tsx | 57 ++
apps/web/components/github/pr-utils.ts | 4 +
.../github/refresh-reviews-button.tsx | 67 ++
.../github/repo-filter-selector.tsx | 317 ++++++++
.../components/github/review-watch-dialog.tsx | 507 ++++++++++++
.../github/review-watch-placeholders.ts | 48 ++
.../components/github/review-watch-table.tsx | 159 ++++
apps/web/components/kanban-card.tsx | 6 +-
apps/web/components/kanban/kanban-header.tsx | 2 +
.../settings/profile-edit/script-editor.tsx | 14 +-
.../settings/settings-app-sidebar.tsx | 10 +
.../web/components/task/chat-context-items.ts | 22 +-
.../components/task/chat/chat-input-area.tsx | 14 +-
.../task/chat/context-items/context-chip.tsx | 3 +
.../context-items/context-item-renderer.tsx | 3 +
.../task/chat/context-items/index.ts | 1 +
.../chat/context-items/pr-feedback-item.tsx | 25 +
.../task/chat/use-chat-panel-state.ts | 34 +-
.../task/dockview-desktop-layout.tsx | 2 +
.../task/dockview-header-actions.tsx | 11 +
apps/web/components/task/task-top-bar.tsx | 2 +
.../domains/comments/use-pending-comments.ts | 28 +-
.../hooks/domains/github/use-github-status.ts | 30 +
.../hooks/domains/github/use-pr-feedback.ts | 63 ++
apps/web/hooks/domains/github/use-pr-watch.ts | 46 ++
.../domains/github/use-review-watches.ts | 85 ++
apps/web/hooks/domains/github/use-task-pr.ts | 64 ++
apps/web/lib/api/domains/github-api.ts | 136 ++++
apps/web/lib/api/index.ts | 1 +
apps/web/lib/state/dockview-store.ts | 11 +
apps/web/lib/state/hydration/hydrator.ts | 9 +
apps/web/lib/state/slices/comments/format.ts | 25 +-
apps/web/lib/state/slices/comments/index.ts | 3 +
apps/web/lib/state/slices/comments/types.ts | 14 +-
.../lib/state/slices/github/github-slice.ts | 123 +++
apps/web/lib/state/slices/github/types.ts | 51 ++
apps/web/lib/state/slices/index.ts | 8 +
apps/web/lib/state/store.ts | 51 ++
apps/web/lib/types/backend.ts | 5 +-
apps/web/lib/types/context.ts | 11 +-
apps/web/lib/types/github.ts | 195 +++++
apps/web/lib/ws/handlers/github.ts | 15 +
apps/web/lib/ws/router.ts | 2 +
github-plan.md | 744 ++++++++++++++++++
100 files changed, 9802 insertions(+), 81 deletions(-)
create mode 100644 apps/backend/internal/github/client.go
create mode 100644 apps/backend/internal/github/client_helpers.go
create mode 100644 apps/backend/internal/github/client_helpers_test.go
create mode 100644 apps/backend/internal/github/constants.go
create mode 100644 apps/backend/internal/github/controller.go
create mode 100644 apps/backend/internal/github/factory.go
create mode 100644 apps/backend/internal/github/gh_client.go
create mode 100644 apps/backend/internal/github/gh_client_test.go
create mode 100644 apps/backend/internal/github/handlers.go
create mode 100644 apps/backend/internal/github/models.go
create mode 100644 apps/backend/internal/github/pat_client.go
create mode 100644 apps/backend/internal/github/pat_client_test.go
create mode 100644 apps/backend/internal/github/poller.go
create mode 100644 apps/backend/internal/github/provider.go
create mode 100644 apps/backend/internal/github/service.go
create mode 100644 apps/backend/internal/github/service_test.go
create mode 100644 apps/backend/internal/github/store.go
create mode 100644 apps/backend/internal/orchestrator/event_handlers_github.go
create mode 100644 apps/backend/internal/orchestrator/event_handlers_github_test.go
create mode 100644 apps/backend/internal/repoclone/clone.go
create mode 100644 apps/backend/internal/repoclone/protocol.go
create mode 100644 apps/backend/internal/repoclone/protocol_test.go
create mode 100644 apps/web/app/settings/general/github/page.tsx
create mode 100644 apps/web/app/settings/workspace/[id]/github/page.tsx
create mode 100644 apps/web/components/github/github-settings.tsx
create mode 100644 apps/web/components/github/github-status.tsx
create mode 100644 apps/web/components/github/pr-checks-section.tsx
create mode 100644 apps/web/components/github/pr-comments-section.tsx
create mode 100644 apps/web/components/github/pr-detail-panel.tsx
create mode 100644 apps/web/components/github/pr-reviews-section.tsx
create mode 100644 apps/web/components/github/pr-shared.tsx
create mode 100644 apps/web/components/github/pr-stats.tsx
create mode 100644 apps/web/components/github/pr-task-icon.tsx
create mode 100644 apps/web/components/github/pr-topbar-button.tsx
create mode 100644 apps/web/components/github/pr-utils.ts
create mode 100644 apps/web/components/github/refresh-reviews-button.tsx
create mode 100644 apps/web/components/github/repo-filter-selector.tsx
create mode 100644 apps/web/components/github/review-watch-dialog.tsx
create mode 100644 apps/web/components/github/review-watch-placeholders.ts
create mode 100644 apps/web/components/github/review-watch-table.tsx
create mode 100644 apps/web/components/task/chat/context-items/pr-feedback-item.tsx
create mode 100644 apps/web/hooks/domains/github/use-github-status.ts
create mode 100644 apps/web/hooks/domains/github/use-pr-feedback.ts
create mode 100644 apps/web/hooks/domains/github/use-pr-watch.ts
create mode 100644 apps/web/hooks/domains/github/use-review-watches.ts
create mode 100644 apps/web/hooks/domains/github/use-task-pr.ts
create mode 100644 apps/web/lib/api/domains/github-api.ts
create mode 100644 apps/web/lib/state/slices/github/github-slice.ts
create mode 100644 apps/web/lib/state/slices/github/types.ts
create mode 100644 apps/web/lib/types/github.ts
create mode 100644 apps/web/lib/ws/handlers/github.ts
create mode 100644 github-plan.md
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 (
+
+ );
+}
+
+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[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 (
+
+
+ setOpen(!open)}
+ >
+ {open ? (
+
+ ) : (
+
+ )}
+ {title}
+ ({count})
+
+ {onAddAll && count > 0 && open && (
+
+
+
+
+ Add all
+
+
+ {addAllLabel ?? "Add all to chat context"}
+
+ )}
+
+ {subtitle && open &&
{subtitle}
}
+ {open &&
{children}
}
+
+ );
+}
+
+export function AddToContextButton({
+ onClick,
+ tooltip,
+}: {
+ onClick: () => void;
+ tooltip?: string;
+}) {
+ return (
+
+
+ {
+ e.stopPropagation();
+ onClick();
+ }}
+ >
+
+
+
+ {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_number}
+
+
+
+ {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}
+ onRemove(r)}
+ disabled={disabled}
+ >
+
+
+
+ );
+ })}
+
+ );
+}
+
+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 (
+
+
+
+
+ Add repository
+
+
+
+
+
+
+ {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 (
+
+
+
Repositories
+
+ Which GitHub repositories to monitor for PRs requesting your review.
+
+
+
+
+
+
+ All repositories (no filtering)
+
+
+
+ {!allRepos && (
+ <>
+
+ Organizations
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
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 (
+
+
{label}
+ {description &&
{description}
}
+
+
+
+
+
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ );
+}
+
+// --- 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 }))}
+ />
+
+
+ Executor Profile
+
+
+
+ The executor environment where the agent will run.
+
+
setForm((prev) => ({ ...prev, executorProfileId: v }))}
+ >
+
+
+
+
+ {executorProfiles.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+
+ );
+}
+
+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 (
+ <>
+
+
Poll Interval (seconds)
+
+ How often to check for new PRs. Minimum 60s, maximum 3600s.
+
+
setForm((prev) => ({ ...prev, pollInterval: Number(e.target.value) }))}
+ min={60}
+ max={3600}
+ />
+
+
+
+
Enabled
+
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.
+
+
+
+
+ onOpenChange(false)} className="cursor-pointer">
+ Cancel
+
+
+ {getSaveButtonLabel(saving, !!watch)}
+
+
+
+
+ );
+}
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 (
+
+
+
+ {
+ e.stopPropagation();
+ onToggleEnabled(watch);
+ }}
+ >
+ {watch.enabled ? (
+
+ ) : (
+
+ )}
+
+
+ {watch.enabled ? "Pause" : "Enable"}
+
+
+
+ {
+ e.stopPropagation();
+ onTrigger(watch.id);
+ toast({ description: "Checking for new PRs..." });
+ }}
+ >
+
+
+
+ Check now
+
+
+
+ {
+ e.stopPropagation();
+ onDelete(watch.id);
+ }}
+ >
+
+
+
+ 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}
+
{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
+
diff --git a/apps/web/components/settings/profile-edit/script-editor.tsx b/apps/web/components/settings/profile-edit/script-editor.tsx
index baa528b656..77b933c581 100644
--- a/apps/web/components/settings/profile-edit/script-editor.tsx
+++ b/apps/web/components/settings/profile-edit/script-editor.tsx
@@ -2,7 +2,8 @@
import { useEffect, useRef, useCallback } from "react";
import dynamic from "next/dynamic";
-import type { OnMount } from "@monaco-editor/react";
+import type { BeforeMount, OnMount } from "@monaco-editor/react";
+import { KANDEV_MONACO_DARK } from "@/lib/theme/editor-theme";
import type { ScriptPlaceholder } from "./script-editor-completions";
const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), {
@@ -22,6 +23,7 @@ type ScriptEditorProps = {
placeholders?: ScriptPlaceholder[];
executorType?: string;
readOnly?: boolean;
+ lineNumbers?: "on" | "off";
};
export function ScriptEditor({
@@ -32,6 +34,7 @@ export function ScriptEditor({
placeholders,
executorType,
readOnly = false,
+ lineNumbers = "on",
}: ScriptEditorProps) {
const disposableRef = useRef<{ dispose: () => void } | null>(null);
@@ -42,6 +45,10 @@ export function ScriptEditor({
};
}, []);
+ const handleBeforeMount: BeforeMount = useCallback((monaco) => {
+ monaco.editor.defineTheme("kandev-dark", KANDEV_MONACO_DARK);
+ }, []);
+
const handleMount: OnMount = useCallback(
(editor, monaco) => {
// Register placeholder completions if provided
@@ -66,11 +73,12 @@ export function ScriptEditor({
language={language}
value={value}
onChange={(v) => onChange(v ?? "")}
+ beforeMount={handleBeforeMount}
onMount={handleMount}
- theme="vs-dark"
+ theme="kandev-dark"
options={{
minimap: { enabled: false },
- lineNumbers: "on",
+ lineNumbers,
wordWrap: "on",
fontSize: 13,
scrollBeyondLastLine: false,
diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx
index e078d87b43..5afa28e31a 100644
--- a/apps/web/components/settings/settings-app-sidebar.tsx
+++ b/apps/web/components/settings/settings-app-sidebar.tsx
@@ -12,6 +12,7 @@ import {
IconCpu,
IconKey,
IconMessageCircle,
+ IconBrandGithub,
} from "@tabler/icons-react";
import {
Sidebar,
@@ -99,6 +100,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
const workspacePath = `/settings/workspace/${workspace.id}`;
const workflowsPath = `${workspacePath}/workflows`;
const repositoriesPath = `${workspacePath}/repositories`;
+ const githubPath = `${workspacePath}/github`;
return (
@@ -126,6 +128,14 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
+
+
+
+
+ GitHub
+
+
+
);
diff --git a/apps/web/components/task/chat-context-items.ts b/apps/web/components/task/chat-context-items.ts
index b5e373e0bf..5e2d887827 100644
--- a/apps/web/components/task/chat-context-items.ts
+++ b/apps/web/components/task/chat-context-items.ts
@@ -2,7 +2,7 @@ import type { ContextFile } from "@/lib/state/context-files-store";
import { getFileName } from "@/lib/utils/file-path";
import type { ContextItem } from "@/lib/types/context";
import type { DiffComment } from "@/lib/diff/types";
-import type { PlanComment } from "@/lib/state/slices/comments";
+import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments";
const PLAN_CONTEXT_PATH = "plan:context";
@@ -21,6 +21,9 @@ export type BuildContextItemsParams = {
onOpenFileAtLine?: (filePath: string) => void;
planComments: PlanComment[];
handleClearPlanComments: () => void;
+ pendingPRFeedback: PRFeedbackComment[];
+ handleRemovePRFeedback: (commentId: string) => void;
+ handleClearPRFeedback: () => void;
taskId: string | null;
};
@@ -151,6 +154,21 @@ function buildCommentItems(params: BuildContextItemsParams): ContextItem[] {
return items;
}
+function buildPRFeedbackItems(params: BuildContextItemsParams): ContextItem[] {
+ const { pendingPRFeedback, handleRemovePRFeedback, handleClearPRFeedback } = params;
+ if (!pendingPRFeedback || pendingPRFeedback.length === 0) return [];
+ return [
+ {
+ kind: "pr-feedback" as const,
+ id: "pr-feedback",
+ label: `${pendingPRFeedback.length} PR feedback`,
+ comments: pendingPRFeedback,
+ onRemove: handleClearPRFeedback,
+ onRemoveComment: (id: string) => handleRemovePRFeedback(id),
+ },
+ ];
+}
+
/** Sort: pinned first, then by kind order, then by label */
const KIND_ORDER: Record = {
plan: 0,
@@ -159,6 +177,7 @@ const KIND_ORDER: Record = {
comment: 3,
"plan-comment": 4,
image: 5,
+ "pr-feedback": 6,
};
export function contextItemSortFn(a: ContextItem, b: ContextItem): number {
@@ -177,6 +196,7 @@ export function buildContextItems(params: BuildContextItemsParams): ContextItem[
if (planItem) items.push(planItem);
items.push(...buildFileAndPromptItems(params));
items.push(...buildCommentItems(params));
+ items.push(...buildPRFeedbackItems(params));
if (params.planComments.length > 0) {
items.push({
diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx
index 3deac4c7e6..a34f38f740 100644
--- a/apps/web/components/task/chat/chat-input-area.tsx
+++ b/apps/web/components/task/chat/chat-input-area.tsx
@@ -12,7 +12,10 @@ import {
type MessageAttachment,
} from "@/components/task/chat/chat-input-container";
import { type QueuedMessageIndicatorHandle } from "@/components/task/chat/queued-message-indicator";
-import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
+import {
+ formatReviewCommentsAsMarkdown,
+ formatPRFeedbackAsMarkdown,
+} from "@/lib/state/slices/comments/format";
import type { DiffComment } from "@/lib/diff/types";
import type { useChatPanelState } from "./use-chat-panel-state";
@@ -40,10 +43,12 @@ export function useSubmitHandler(
isAgentBusy,
activeDocument,
planComments,
+ pendingPRFeedback,
contextFiles,
prompts,
markCommentsSent,
clearSessionPlanComments,
+ handleClearPRFeedback,
clearEphemeral,
} = panelState;
const { handleSendMessage } = useMessageHandler({
@@ -74,6 +79,10 @@ export function useSubmitHandler(
const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments);
finalMessage = reviewMarkdown + (message ? message : "");
}
+ if (pendingPRFeedback.length > 0) {
+ const prMarkdown = formatPRFeedbackAsMarkdown(pendingPRFeedback);
+ finalMessage = prMarkdown + finalMessage;
+ }
const hasReviewComments = !!(reviewComments && reviewComments.length > 0);
if (onSend) {
await onSend(finalMessage);
@@ -82,6 +91,7 @@ export function useSubmitHandler(
}
if (reviewComments && reviewComments.length > 0)
markCommentsSent(reviewComments.map((c) => c.id));
+ if (pendingPRFeedback.length > 0) handleClearPRFeedback();
if (planComments.length > 0) clearSessionPlanComments();
if (resolvedSessionId) clearEphemeral(resolvedSessionId);
} finally {
@@ -95,6 +105,8 @@ export function useSubmitHandler(
markCommentsSent,
planComments.length,
clearSessionPlanComments,
+ pendingPRFeedback,
+ handleClearPRFeedback,
resolvedSessionId,
clearEphemeral,
],
diff --git a/apps/web/components/task/chat/context-items/context-chip.tsx b/apps/web/components/task/chat/context-items/context-chip.tsx
index 18ed2a0455..29694a7897 100644
--- a/apps/web/components/task/chat/context-items/context-chip.tsx
+++ b/apps/web/components/task/chat/context-items/context-chip.tsx
@@ -7,6 +7,7 @@ import {
IconMessageDots,
IconPhoto,
IconAt,
+ IconGitPullRequest,
IconX,
IconPinFilled,
} from "@tabler/icons-react";
@@ -21,6 +22,7 @@ const ICON_BY_KIND: Record = {
"plan-comment": IconMessageDots,
image: IconPhoto,
prompt: IconAt,
+ "pr-feedback": IconGitPullRequest,
};
type ContextChipProps = {
@@ -102,6 +104,7 @@ function ControlledHoverChip({ preview, children }: { preview: ReactNode; childr
setOpen(next);
}}
openDelay={300}
+ closeDelay={0}
>
;
case "image":
return ;
+ case "pr-feedback":
+ return ;
}
}
diff --git a/apps/web/components/task/chat/context-items/index.ts b/apps/web/components/task/chat/context-items/index.ts
index 98d0e8ed0b..76259b7ed4 100644
--- a/apps/web/components/task/chat/context-items/index.ts
+++ b/apps/web/components/task/chat/context-items/index.ts
@@ -6,5 +6,6 @@ export { FileItem } from "./file-item";
export { PromptItem } from "./prompt-item";
export { CommentItem } from "./comment-item";
export { PlanCommentItem } from "./plan-comment-item";
+export { PRFeedbackItem } from "./pr-feedback-item";
export { ImageItem } from "./image-item";
export { LazyFilePreview } from "./lazy-file-preview";
diff --git a/apps/web/components/task/chat/context-items/pr-feedback-item.tsx b/apps/web/components/task/chat/context-items/pr-feedback-item.tsx
new file mode 100644
index 0000000000..c54d936be7
--- /dev/null
+++ b/apps/web/components/task/chat/context-items/pr-feedback-item.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { memo } from "react";
+import type { PRFeedbackContextItem } from "@/lib/types/context";
+import { ContextChip } from "./context-chip";
+
+export const PRFeedbackItem = memo(function PRFeedbackItem({
+ item,
+}: {
+ item: PRFeedbackContextItem;
+}) {
+ const preview = (
+
+ {item.comments.map((c) => (
+
+ ))}
+
+ );
+
+ return (
+
+ );
+});
diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts
index 712427d6c7..fa4cef2cc5 100644
--- a/apps/web/components/task/chat/use-chat-panel-state.ts
+++ b/apps/web/components/task/chat/use-chat-panel-state.ts
@@ -12,13 +12,16 @@ import { useProcessedMessages } from "@/hooks/use-processed-messages";
import { useSessionModel } from "@/hooks/domains/session/use-session-model";
import { useQueue } from "@/hooks/domains/session/use-queue";
import { useContextFilesStore, type ContextFile } from "@/lib/state/context-files-store";
-import { useCommentsStore, isPlanComment } from "@/lib/state/slices/comments";
+import { useCommentsStore, isPlanComment, isPRFeedbackComment } from "@/lib/state/slices/comments";
import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments";
-import { usePendingPlanComments } from "@/hooks/domains/comments/use-pending-comments";
+import {
+ usePendingPlanComments,
+ usePendingPRFeedback,
+} from "@/hooks/domains/comments/use-pending-comments";
import { buildContextItems } from "../chat-context-items";
import type { ContextItem } from "@/lib/types/context";
import type { DiffComment } from "@/lib/diff/types";
-import type { PlanComment } from "@/lib/state/slices/comments";
+import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments";
const EMPTY_CONTEXT_FILES: ContextFile[] = [];
const PLAN_CONTEXT_PATH = "plan:context";
@@ -26,9 +29,12 @@ const PLAN_CONTEXT_PATH = "plan:context";
export type CommentsState = {
planComments: PlanComment[];
pendingCommentsByFile: Record;
+ pendingPRFeedback: PRFeedbackComment[];
markCommentsSent: (ids: string[]) => void;
handleRemoveCommentFile: (filePath: string) => void;
handleRemoveComment: (commentId: string) => void;
+ handleRemovePRFeedback: (commentId: string) => void;
+ handleClearPRFeedback: () => void;
clearSessionPlanComments: () => void;
};
@@ -137,6 +143,7 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat
}, [resolvedSessionId, hydrateComments]);
const planComments = usePendingPlanComments();
const pendingCommentsByFile = usePendingDiffCommentsByFile();
+ const pendingPRFeedback = usePendingPRFeedback();
const markCommentsSent = useCommentsStore((state) => state.markCommentsSent);
const removeComment = useCommentsStore((state) => state.removeComment);
const clearSessionPlanComments = useCallback(() => {
@@ -159,12 +166,27 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat
(commentId: string) => removeComment(commentId),
[removeComment],
);
+ const handleRemovePRFeedback = useCallback(
+ (commentId: string) => removeComment(commentId),
+ [removeComment],
+ );
+ const handleClearPRFeedback = useCallback(() => {
+ const state = useCommentsStore.getState();
+ const allPending = [...state.pendingForChat];
+ for (const id of allPending) {
+ const c = state.byId[id];
+ if (c && isPRFeedbackComment(c)) state.removeComment(id);
+ }
+ }, []);
return {
planComments,
pendingCommentsByFile,
+ pendingPRFeedback,
markCommentsSent,
handleRemoveCommentFile,
handleRemoveComment,
+ handleRemovePRFeedback,
+ handleClearPRFeedback,
clearSessionPlanComments,
};
}
@@ -219,6 +241,9 @@ function useChatContextItems(opts: ChatContextItemsOptions) {
onOpenFileAtLine,
planComments: comments.planComments,
handleClearPlanComments: comments.clearSessionPlanComments,
+ pendingPRFeedback: comments.pendingPRFeedback,
+ handleRemovePRFeedback: comments.handleRemovePRFeedback,
+ handleClearPRFeedback: comments.handleClearPRFeedback,
taskId,
}),
[
@@ -236,6 +261,9 @@ function useChatContextItems(opts: ChatContextItemsOptions) {
onOpenFileAtLine,
comments.planComments,
comments.clearSessionPlanComments,
+ comments.pendingPRFeedback,
+ comments.handleRemovePRFeedback,
+ comments.handleClearPRFeedback,
taskId,
],
);
diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx
index 59cadd6d99..2f203dcbce 100644
--- a/apps/web/components/task/dockview-desktop-layout.tsx
+++ b/apps/web/components/task/dockview-desktop-layout.tsx
@@ -36,6 +36,7 @@ import { TerminalPanel } from "./terminal-panel";
import { BrowserPanel } from "./browser-panel";
import { VscodePanel } from "./vscode-panel";
import { CommitDetailPanel } from "./commit-detail-panel";
+import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel";
import { PreviewController } from "./preview/preview-controller";
import { ReviewDialog } from "@/components/review/review-dialog";
import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
@@ -233,6 +234,7 @@ const components: Record> =
browser: BrowserPanel,
vscode: VscodePanel,
plan: PlanPanelComponent,
+ "pr-detail": PRDetailPanelComponent,
// Backwards compat aliases for saved layouts
"diff-files": ChangesPanelWrapper,
"all-files": FilesPanelWrapper,
diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx
index 7877a4729f..3196cbce9a 100644
--- a/apps/web/components/task/dockview-header-actions.tsx
+++ b/apps/web/components/task/dockview-header-actions.tsx
@@ -9,6 +9,7 @@ import {
IconFileText,
IconFolder,
IconGitBranch,
+ IconGitPullRequest,
IconPlayerPlay,
IconLayoutSidebarRightCollapse,
IconLayoutColumns,
@@ -26,6 +27,8 @@ import {
} from "@kandev/ui/dropdown-menu";
import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
+import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
+import { prPanelLabel } from "@/components/github/pr-utils";
import { startProcess } from "@/lib/api";
import { createUserShell } from "@/lib/api/domains/user-shell-api";
import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts";
@@ -66,6 +69,8 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
const addFilesPanel = useDockviewStore((s) => s.addFilesPanel);
const addChangesPanel = useDockviewStore((s) => s.addChangesPanel);
+ const addPRPanel = useDockviewStore((s) => s.addPRPanel);
+ const pr = useActiveTaskPR();
const hasChanges = Boolean(
containerApi.getPanel("changes") ?? containerApi.getPanel("diff-files"),
@@ -137,6 +142,12 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
Files
)}
+ {pr && (
+ addPRPanel()} className="cursor-pointer text-xs">
+
+ {prPanelLabel(pr.pr_number)}
+
+ )}
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx
index 3e6d6ecbef..e719363d2e 100644
--- a/apps/web/components/task/task-top-bar.tsx
+++ b/apps/web/components/task/task-top-bar.tsx
@@ -29,6 +29,7 @@ import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { VcsSplitButton } from "@/components/vcs-split-button";
+import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { DEBUG_UI } from "@/lib/config";
@@ -323,6 +324,7 @@ function TopBarRight({
<>
+
>
)}
diff --git a/apps/web/hooks/domains/comments/use-pending-comments.ts b/apps/web/hooks/domains/comments/use-pending-comments.ts
index 7fc69614aa..925cc5e061 100644
--- a/apps/web/hooks/domains/comments/use-pending-comments.ts
+++ b/apps/web/hooks/domains/comments/use-pending-comments.ts
@@ -1,11 +1,17 @@
import { useMemo } from "react";
import { useCommentsStore } from "@/lib/state/slices/comments";
-import type { Comment, DiffComment, PlanComment } from "@/lib/state/slices/comments";
-import { isDiffComment, isPlanComment } from "@/lib/state/slices/comments";
+import type {
+ Comment,
+ DiffComment,
+ PlanComment,
+ PRFeedbackComment,
+} from "@/lib/state/slices/comments";
+import { isDiffComment, isPlanComment, isPRFeedbackComment } from "@/lib/state/slices/comments";
const EMPTY_COMMENTS: Comment[] = [];
const EMPTY_DIFF_COMMENTS: DiffComment[] = [];
const EMPTY_PLAN_COMMENTS: PlanComment[] = [];
+const EMPTY_PR_FEEDBACK_COMMENTS: PRFeedbackComment[] = [];
/**
* Get all pending comments (any source).
@@ -60,3 +66,21 @@ export function usePendingPlanComments(): PlanComment[] {
return pending.length === 0 ? EMPTY_PLAN_COMMENTS : pending;
}, [byId, pendingForChat]);
}
+
+/**
+ * Get all pending PR feedback comments.
+ */
+export function usePendingPRFeedback(): PRFeedbackComment[] {
+ const byId = useCommentsStore((state) => state.byId);
+ const pendingForChat = useCommentsStore((state) => state.pendingForChat);
+
+ return useMemo(() => {
+ if (pendingForChat.length === 0) return EMPTY_PR_FEEDBACK_COMMENTS;
+ const pending: PRFeedbackComment[] = [];
+ for (const id of pendingForChat) {
+ const comment = byId[id];
+ if (comment && isPRFeedbackComment(comment)) pending.push(comment);
+ }
+ return pending.length === 0 ? EMPTY_PR_FEEDBACK_COMMENTS : pending;
+ }, [byId, pendingForChat]);
+}
diff --git a/apps/web/hooks/domains/github/use-github-status.ts b/apps/web/hooks/domains/github/use-github-status.ts
new file mode 100644
index 0000000000..c353d3bd44
--- /dev/null
+++ b/apps/web/hooks/domains/github/use-github-status.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect } from "react";
+import { fetchGitHubStatus } from "@/lib/api/domains/github-api";
+import { useAppStore } from "@/components/state-provider";
+
+export function useGitHubStatus() {
+ const status = useAppStore((state) => state.githubStatus.status);
+ const loaded = useAppStore((state) => state.githubStatus.loaded);
+ const loading = useAppStore((state) => state.githubStatus.loading);
+ const setGitHubStatus = useAppStore((state) => state.setGitHubStatus);
+ const setGitHubStatusLoading = useAppStore((state) => state.setGitHubStatusLoading);
+
+ useEffect(() => {
+ if (loaded || loading) return;
+ setGitHubStatusLoading(true);
+ fetchGitHubStatus({ cache: "no-store" })
+ .then((response) => {
+ setGitHubStatus(response ?? null);
+ })
+ .catch(() => {
+ setGitHubStatus(null);
+ })
+ .finally(() => {
+ setGitHubStatusLoading(false);
+ });
+ }, [loaded, loading, setGitHubStatus, setGitHubStatusLoading]);
+
+ return { status, loaded, loading };
+}
diff --git a/apps/web/hooks/domains/github/use-pr-feedback.ts b/apps/web/hooks/domains/github/use-pr-feedback.ts
new file mode 100644
index 0000000000..558c5dbed8
--- /dev/null
+++ b/apps/web/hooks/domains/github/use-pr-feedback.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import { useState, useEffect, useCallback, useReducer } from "react";
+import { getPRFeedback } from "@/lib/api/domains/github-api";
+import type { PRFeedback } from "@/lib/types/github";
+
+type State = {
+ feedback: PRFeedback | null;
+ loading: boolean;
+ error: string | null;
+};
+
+type Action =
+ | { type: "fetch" }
+ | { type: "success"; feedback: PRFeedback }
+ | { type: "error"; message: string };
+
+function reducer(state: State, action: Action): State {
+ switch (action.type) {
+ case "fetch":
+ return { ...state, loading: true, error: null };
+ case "success":
+ return { feedback: action.feedback, loading: false, error: null };
+ case "error":
+ return { ...state, loading: false, error: action.message };
+ }
+}
+
+/**
+ * Fetch live PR feedback (reviews, comments, checks) from GitHub.
+ * This is not stored in the global store since it's session-scoped
+ * and fetched on demand.
+ */
+export function usePRFeedback(owner: string | null, repo: string | null, prNumber: number | null) {
+ const [state, dispatch] = useReducer(reducer, { feedback: null, loading: false, error: null });
+ const [fetchCount, setFetchCount] = useState(0);
+
+ const refresh = useCallback(() => {
+ setFetchCount((c) => c + 1);
+ }, []);
+
+ useEffect(() => {
+ if (!owner || !repo || !prNumber) return;
+ let cancelled = false;
+ dispatch({ type: "fetch" });
+ getPRFeedback(owner, repo, prNumber, { cache: "no-store" })
+ .then((response) => {
+ if (!cancelled) dispatch({ type: "success", feedback: response });
+ })
+ .catch((err) => {
+ if (!cancelled)
+ dispatch({
+ type: "error",
+ message: err instanceof Error ? err.message : "Failed to fetch PR feedback",
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [owner, repo, prNumber, fetchCount]);
+
+ return { feedback: state.feedback, loading: state.loading, error: state.error, refresh };
+}
diff --git a/apps/web/hooks/domains/github/use-pr-watch.ts b/apps/web/hooks/domains/github/use-pr-watch.ts
new file mode 100644
index 0000000000..4af27f2fa1
--- /dev/null
+++ b/apps/web/hooks/domains/github/use-pr-watch.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useCallback } from "react";
+import { listPRWatches, deletePRWatch } from "@/lib/api/domains/github-api";
+import { useAppStore } from "@/components/state-provider";
+
+export function usePRWatches() {
+ const items = useAppStore((state) => state.prWatches.items);
+ const loaded = useAppStore((state) => state.prWatches.loaded);
+ const loading = useAppStore((state) => state.prWatches.loading);
+ const setPRWatches = useAppStore((state) => state.setPRWatches);
+ const setPRWatchesLoading = useAppStore((state) => state.setPRWatchesLoading);
+ const removePRWatch = useAppStore((state) => state.removePRWatch);
+
+ useEffect(() => {
+ if (loaded || loading) return;
+ setPRWatchesLoading(true);
+ listPRWatches({ cache: "no-store" })
+ .then((response) => {
+ setPRWatches(response?.watches ?? []);
+ })
+ .catch(() => {
+ setPRWatches([]);
+ })
+ .finally(() => {
+ setPRWatchesLoading(false);
+ });
+ }, [loaded, loading, setPRWatches, setPRWatchesLoading]);
+
+ const remove = useCallback(
+ async (id: string) => {
+ await deletePRWatch(id);
+ removePRWatch(id);
+ },
+ [removePRWatch],
+ );
+
+ return { items, loaded, loading, remove };
+}
+
+/** Get the PR watch for a specific session. */
+export function usePRWatchForSession(sessionId: string | null) {
+ const items = useAppStore((state) => state.prWatches.items);
+ const watch = sessionId ? (items.find((w) => w.session_id === sessionId) ?? null) : null;
+ return watch;
+}
diff --git a/apps/web/hooks/domains/github/use-review-watches.ts b/apps/web/hooks/domains/github/use-review-watches.ts
new file mode 100644
index 0000000000..209c58adfb
--- /dev/null
+++ b/apps/web/hooks/domains/github/use-review-watches.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useCallback } from "react";
+import {
+ listReviewWatches,
+ createReviewWatch,
+ updateReviewWatch,
+ deleteReviewWatch,
+ triggerReviewWatch,
+ triggerAllReviewWatches,
+} from "@/lib/api/domains/github-api";
+import { useAppStore } from "@/components/state-provider";
+import type { CreateReviewWatchRequest, UpdateReviewWatchRequest } from "@/lib/types/github";
+
+export function useReviewWatches(workspaceId: string | null) {
+ const items = useAppStore((state) => state.reviewWatches.items);
+ const loaded = useAppStore((state) => state.reviewWatches.loaded);
+ const loading = useAppStore((state) => state.reviewWatches.loading);
+ const setReviewWatches = useAppStore((state) => state.setReviewWatches);
+ const setReviewWatchesLoading = useAppStore((state) => state.setReviewWatchesLoading);
+ const addWatch = useAppStore((state) => state.addReviewWatch);
+ const updateWatch = useAppStore((state) => state.updateReviewWatch);
+ const removeWatch = useAppStore((state) => state.removeReviewWatch);
+
+ useEffect(() => {
+ if (!workspaceId || loaded || loading) return;
+ setReviewWatchesLoading(true);
+ listReviewWatches(workspaceId, { cache: "no-store" })
+ .then((response) => {
+ setReviewWatches(response?.watches ?? []);
+ })
+ .catch(() => {
+ setReviewWatches([]);
+ })
+ .finally(() => {
+ setReviewWatchesLoading(false);
+ });
+ }, [workspaceId, loaded, loading, setReviewWatches, setReviewWatchesLoading]);
+
+ const create = useCallback(
+ async (req: CreateReviewWatchRequest) => {
+ const watch = await createReviewWatch(req);
+ addWatch(watch);
+ return watch;
+ },
+ [addWatch],
+ );
+
+ const update = useCallback(
+ async (id: string, req: UpdateReviewWatchRequest) => {
+ const watch = await updateReviewWatch(id, req);
+ updateWatch(watch);
+ return watch;
+ },
+ [updateWatch],
+ );
+
+ const remove = useCallback(
+ async (id: string) => {
+ await deleteReviewWatch(id);
+ removeWatch(id);
+ },
+ [removeWatch],
+ );
+
+ const trigger = useCallback(async (id: string) => {
+ return triggerReviewWatch(id);
+ }, []);
+
+ const triggerAll = useCallback(async () => {
+ if (!workspaceId) return null;
+ return triggerAllReviewWatches(workspaceId);
+ }, [workspaceId]);
+
+ return {
+ items,
+ loaded,
+ loading,
+ create,
+ update,
+ remove,
+ trigger,
+ triggerAll,
+ };
+}
diff --git a/apps/web/hooks/domains/github/use-task-pr.ts b/apps/web/hooks/domains/github/use-task-pr.ts
new file mode 100644
index 0000000000..a67802dfd9
--- /dev/null
+++ b/apps/web/hooks/domains/github/use-task-pr.ts
@@ -0,0 +1,64 @@
+"use client";
+
+import { useEffect, useCallback } from "react";
+import { listTaskPRs, getTaskPR } from "@/lib/api/domains/github-api";
+import { useAppStore } from "@/components/state-provider";
+import type { TaskPR } from "@/lib/types/github";
+
+/** Fetch and cache PR associations for a batch of task IDs. */
+export function useTaskPRs(taskIds: string[]) {
+ const byTaskId = useAppStore((state) => state.taskPRs.byTaskId);
+ const loading = useAppStore((state) => state.taskPRs.loading);
+ const setTaskPRs = useAppStore((state) => state.setTaskPRs);
+ const setTaskPRsLoading = useAppStore((state) => state.setTaskPRsLoading);
+
+ useEffect(() => {
+ if (taskIds.length === 0 || loading) return;
+ setTaskPRsLoading(true);
+ listTaskPRs(taskIds, { cache: "no-store" })
+ .then((response) => {
+ setTaskPRs(response?.task_prs ?? {});
+ })
+ .catch(() => {
+ // Keep existing data on error
+ })
+ .finally(() => {
+ setTaskPRsLoading(false);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [taskIds.join(",")]);
+
+ return { byTaskId, loading };
+}
+
+/** Fetch a single task's PR association. */
+export function useTaskPR(taskId: string | null) {
+ const pr = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null));
+ const setTaskPR = useAppStore((state) => state.setTaskPR);
+
+ const refresh = useCallback(() => {
+ if (!taskId) return;
+ getTaskPR(taskId, { cache: "no-store" })
+ .then((response) => {
+ if (response) setTaskPR(taskId, response);
+ })
+ .catch(() => {
+ // Ignore - PR may not exist for this task
+ });
+ }, [taskId, setTaskPR]);
+
+ useEffect(() => {
+ if (!taskId || pr) return;
+ refresh();
+ }, [taskId, pr, refresh]);
+
+ return { pr, refresh } as { pr: TaskPR | null; refresh: () => void };
+}
+
+/** Read the active task's PR from the store (no fetching). */
+export function useActiveTaskPR(): TaskPR | null {
+ return useAppStore((s) => {
+ const taskId = s.tasks.activeTaskId;
+ return taskId ? (s.taskPRs.byTaskId[taskId] ?? null) : null;
+ });
+}
diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts
new file mode 100644
index 0000000000..a4dc0b0210
--- /dev/null
+++ b/apps/web/lib/api/domains/github-api.ts
@@ -0,0 +1,136 @@
+import { fetchJson, type ApiRequestOptions } from "../client";
+import type {
+ GitHubStatusResponse,
+ GitHubOrg,
+ GitHubRepoInfo,
+ TaskPRsResponse,
+ TaskPR,
+ PRFeedback,
+ PRWatchesResponse,
+ ReviewWatch,
+ ReviewWatchesResponse,
+ CreateReviewWatchRequest,
+ UpdateReviewWatchRequest,
+ TriggerReviewResponse,
+ PRStatsResponse,
+} from "@/lib/types/github";
+
+// Status
+export async function fetchGitHubStatus(options?: ApiRequestOptions) {
+ return fetchJson("/api/v1/github/status", options);
+}
+
+// Task PR associations
+export async function listTaskPRs(taskIds: string[], options?: ApiRequestOptions) {
+ const query = new URLSearchParams();
+ query.set("task_ids", taskIds.join(","));
+ return fetchJson(`/api/v1/github/task-prs?${query.toString()}`, options);
+}
+
+export async function getTaskPR(taskId: string, options?: ApiRequestOptions) {
+ return fetchJson(`/api/v1/github/task-prs/${taskId}`, options);
+}
+
+// PR feedback (live from GitHub)
+export async function getPRFeedback(
+ owner: string,
+ repo: string,
+ number: number,
+ options?: ApiRequestOptions,
+) {
+ return fetchJson(`/api/v1/github/prs/${owner}/${repo}/${number}`, options);
+}
+
+// PR watches
+export async function listPRWatches(options?: ApiRequestOptions) {
+ return fetchJson("/api/v1/github/watches/pr", options);
+}
+
+export async function deletePRWatch(id: string, options?: ApiRequestOptions) {
+ return fetchJson<{ success: boolean }>(`/api/v1/github/watches/pr/${id}`, {
+ ...options,
+ init: { method: "DELETE", ...(options?.init ?? {}) },
+ });
+}
+
+// Review watches
+export async function listReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
+ const query = new URLSearchParams({ workspace_id: workspaceId });
+ return fetchJson(
+ `/api/v1/github/watches/review?${query.toString()}`,
+ options,
+ );
+}
+
+export async function createReviewWatch(
+ payload: CreateReviewWatchRequest,
+ options?: ApiRequestOptions,
+) {
+ return fetchJson("/api/v1/github/watches/review", {
+ ...options,
+ init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
+ });
+}
+
+export async function updateReviewWatch(
+ id: string,
+ payload: UpdateReviewWatchRequest,
+ options?: ApiRequestOptions,
+) {
+ return fetchJson(`/api/v1/github/watches/review/${id}`, {
+ ...options,
+ init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) },
+ });
+}
+
+export async function deleteReviewWatch(id: string, options?: ApiRequestOptions) {
+ return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}`, {
+ ...options,
+ init: { method: "DELETE", ...(options?.init ?? {}) },
+ });
+}
+
+export async function triggerReviewWatch(id: string, options?: ApiRequestOptions) {
+ return fetchJson(`/api/v1/github/watches/review/${id}/trigger`, {
+ ...options,
+ init: { method: "POST", ...(options?.init ?? {}) },
+ });
+}
+
+export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
+ const query = new URLSearchParams({ workspace_id: workspaceId });
+ return fetchJson(
+ `/api/v1/github/watches/review/trigger-all?${query.toString()}`,
+ {
+ ...options,
+ init: { method: "POST", ...(options?.init ?? {}) },
+ },
+ );
+}
+
+// Orgs & repo search
+export async function listUserOrgs(options?: ApiRequestOptions) {
+ return fetchJson<{ orgs: GitHubOrg[] }>("/api/v1/github/orgs", options);
+}
+
+export async function searchOrgRepos(org: string, query?: string, options?: ApiRequestOptions) {
+ const params = new URLSearchParams({ org });
+ if (query) params.set("q", query);
+ return fetchJson<{ repos: GitHubRepoInfo[] }>(
+ `/api/v1/github/repos/search?${params.toString()}`,
+ options,
+ );
+}
+
+// Stats
+export async function fetchGitHubStats(
+ params?: { workspace_id?: string; start_date?: string; end_date?: string },
+ options?: ApiRequestOptions,
+) {
+ const query = new URLSearchParams();
+ if (params?.workspace_id) query.set("workspace_id", params.workspace_id);
+ if (params?.start_date) query.set("start_date", params.start_date);
+ if (params?.end_date) query.set("end_date", params.end_date);
+ const suffix = query.toString();
+ return fetchJson(`/api/v1/github/stats${suffix ? `?${suffix}` : ""}`, options);
+}
diff --git a/apps/web/lib/api/index.ts b/apps/web/lib/api/index.ts
index 9860d56054..63f4c8e829 100644
--- a/apps/web/lib/api/index.ts
+++ b/apps/web/lib/api/index.ts
@@ -8,3 +8,4 @@ export * from "./domains/workspace-api";
export * from "./domains/settings-api";
export * from "./domains/process-api";
export * from "./domains/workflow-api";
+export * from "./domains/github-api";
diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts
index 63f997c692..8a2941283e 100644
--- a/apps/web/lib/state/dockview-store.ts
+++ b/apps/web/lib/state/dockview-store.ts
@@ -83,6 +83,7 @@ type DockviewStore = {
addVscodePanel: () => void;
openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void;
addPlanPanel: (groupId?: string) => void;
+ addPRPanel: () => void;
addTerminalPanel: (terminalId?: string, groupId?: string) => void;
selectedDiff: { path: string; content?: string } | null;
setSelectedDiff: (diff: { path: string; content?: string } | null) => void;
@@ -510,6 +511,16 @@ function buildExtraPanelActions(_set: StoreSet, get: StoreGet) {
: { referencePanel: "chat" as const, direction: "right" as const };
focusOrAddPanel(api, { id: "plan", component: "plan", title: "Plan", position });
},
+ addPRPanel: () => {
+ const { api, centerGroupId } = get();
+ if (!api) return;
+ focusOrAddPanel(api, {
+ id: "pr-detail",
+ component: "pr-detail",
+ title: "Pull Request",
+ position: { referenceGroup: centerGroupId },
+ });
+ },
addTerminalPanel: (terminalId?: string, groupId?: string) => {
const { api, rightBottomGroupId } = get();
if (!api) return;
diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts
index d65c3adcf7..47915b1bc4 100644
--- a/apps/web/lib/state/hydration/hydrator.ts
+++ b/apps/web/lib/state/hydration/hydrator.ts
@@ -189,5 +189,14 @@ export function hydrateState(
hydrateSessionRuntime(draft, state, activeSessionId, forceMergeSessionId);
}
+ hydrateGitHub(draft, state);
hydrateUI(draft, state);
}
+
+/** Hydrate GitHub slices, preserving loading states. */
+function hydrateGitHub(draft: Draft, state: Partial): void {
+ if (state.githubStatus) mergeWithLoading(draft.githubStatus, state.githubStatus);
+ if (state.taskPRs) mergeWithLoading(draft.taskPRs, state.taskPRs);
+ if (state.prWatches) mergeWithLoading(draft.prWatches, state.prWatches);
+ if (state.reviewWatches) mergeWithLoading(draft.reviewWatches, state.reviewWatches);
+}
diff --git a/apps/web/lib/state/slices/comments/format.ts b/apps/web/lib/state/slices/comments/format.ts
index 659abdf81b..078f0e686b 100644
--- a/apps/web/lib/state/slices/comments/format.ts
+++ b/apps/web/lib/state/slices/comments/format.ts
@@ -1,5 +1,5 @@
-import type { Comment, DiffComment, PlanComment } from "./types";
-import { isDiffComment, isPlanComment } from "./types";
+import type { Comment, DiffComment, PlanComment, PRFeedbackComment } from "./types";
+import { isDiffComment, isPlanComment, isPRFeedbackComment } from "./types";
/**
* Format diff review comments as human-readable markdown for sending to agent.
@@ -53,20 +53,39 @@ export function formatPlanCommentsAsMarkdown(comments: PlanComment[]): string {
return lines.join("\n");
}
+/**
+ * Format PR feedback comments as markdown for sending to agent.
+ */
+export function formatPRFeedbackAsMarkdown(comments: PRFeedbackComment[]): string {
+ if (!comments || comments.length === 0) return "";
+
+ const lines: string[] = ["### PR Feedback", ""];
+ for (const c of comments) {
+ lines.push(c.content);
+ lines.push("");
+ }
+ lines.push("---");
+ lines.push("");
+ return lines.join("\n");
+}
+
/**
* Format all pending comments for inclusion in a chat message.
*/
export function formatCommentsForMessage(comments: Comment[]): {
diffComments: DiffComment[];
planComments: PlanComment[];
+ prFeedbackComments: PRFeedbackComment[];
} {
const diffComments: DiffComment[] = [];
const planComments: PlanComment[] = [];
+ const prFeedbackComments: PRFeedbackComment[] = [];
for (const c of comments) {
if (isDiffComment(c)) diffComments.push(c);
else if (isPlanComment(c)) planComments.push(c);
+ else if (isPRFeedbackComment(c)) prFeedbackComments.push(c);
}
- return { diffComments, planComments };
+ return { diffComments, planComments, prFeedbackComments };
}
diff --git a/apps/web/lib/state/slices/comments/index.ts b/apps/web/lib/state/slices/comments/index.ts
index e4772c67b4..5f624717cc 100644
--- a/apps/web/lib/state/slices/comments/index.ts
+++ b/apps/web/lib/state/slices/comments/index.ts
@@ -4,6 +4,7 @@ export {
type DiffComment,
type PlanComment,
type FileEditorComment,
+ type PRFeedbackComment,
type AnnotationSide,
type CommentsState,
type CommentsActions,
@@ -11,10 +12,12 @@ export {
isDiffComment,
isPlanComment,
isFileEditorComment,
+ isPRFeedbackComment,
} from "./types";
export {
formatReviewCommentsAsMarkdown,
formatPlanCommentsAsMarkdown,
+ formatPRFeedbackAsMarkdown,
formatCommentsForMessage,
} from "./format";
export {
diff --git a/apps/web/lib/state/slices/comments/types.ts b/apps/web/lib/state/slices/comments/types.ts
index 74b44b2c7b..f29f3f82a0 100644
--- a/apps/web/lib/state/slices/comments/types.ts
+++ b/apps/web/lib/state/slices/comments/types.ts
@@ -43,7 +43,15 @@ export type FileEditorComment = CommentBase & {
endLine?: number;
};
-export type Comment = DiffComment | PlanComment | FileEditorComment;
+export type PRFeedbackComment = CommentBase & {
+ source: "pr-feedback";
+ prNumber: number;
+ feedbackType: "check" | "review" | "comment";
+ /** Pre-formatted markdown text for display and sending */
+ content: string;
+};
+
+export type Comment = DiffComment | PlanComment | FileEditorComment | PRFeedbackComment;
// ---------------------------------------------------------------------------
// Type guards
@@ -61,6 +69,10 @@ export function isFileEditorComment(c: Comment): c is FileEditorComment {
return c.source === "file-editor";
}
+export function isPRFeedbackComment(c: Comment): c is PRFeedbackComment {
+ return c.source === "pr-feedback";
+}
+
// ---------------------------------------------------------------------------
// Store state & actions
// ---------------------------------------------------------------------------
diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts
new file mode 100644
index 0000000000..f75837d404
--- /dev/null
+++ b/apps/web/lib/state/slices/github/github-slice.ts
@@ -0,0 +1,123 @@
+import type { StateCreator } from "zustand";
+import type { GitHubSlice, GitHubSliceState } from "./types";
+
+export const defaultGitHubState: GitHubSliceState = {
+ githubStatus: { status: null, loaded: false, loading: false },
+ taskPRs: { byTaskId: {}, loaded: false, loading: false },
+ prWatches: { items: [], loaded: false, loading: false },
+ reviewWatches: { items: [], loaded: false, loading: false },
+};
+
+type ImmerSet = Parameters<
+ StateCreator
+>[0];
+
+function createGitHubStatusActions(
+ set: ImmerSet,
+): Pick {
+ return {
+ setGitHubStatus: (status) =>
+ set((draft) => {
+ draft.githubStatus.status = status;
+ draft.githubStatus.loaded = true;
+ }),
+ setGitHubStatusLoading: (loading) =>
+ set((draft) => {
+ draft.githubStatus.loading = loading;
+ }),
+ };
+}
+
+function createTaskPRActions(
+ set: ImmerSet,
+): Pick {
+ return {
+ setTaskPRs: (prs) =>
+ set((draft) => {
+ draft.taskPRs.byTaskId = prs;
+ draft.taskPRs.loaded = true;
+ }),
+ setTaskPR: (taskId, pr) =>
+ set((draft) => {
+ draft.taskPRs.byTaskId[taskId] = pr;
+ }),
+ removeTaskPR: (taskId) =>
+ set((draft) => {
+ delete draft.taskPRs.byTaskId[taskId];
+ }),
+ setTaskPRsLoading: (loading) =>
+ set((draft) => {
+ draft.taskPRs.loading = loading;
+ }),
+ };
+}
+
+function createWatchActions(
+ set: ImmerSet,
+): Pick<
+ GitHubSlice,
+ | "setPRWatches"
+ | "setPRWatchesLoading"
+ | "removePRWatch"
+ | "setReviewWatches"
+ | "setReviewWatchesLoading"
+ | "addReviewWatch"
+ | "updateReviewWatch"
+ | "removeReviewWatch"
+> {
+ return {
+ setPRWatches: (watches) =>
+ set((draft) => {
+ draft.prWatches.items = watches;
+ draft.prWatches.loaded = true;
+ }),
+ setPRWatchesLoading: (loading) =>
+ set((draft) => {
+ draft.prWatches.loading = loading;
+ }),
+ removePRWatch: (id) =>
+ set((draft) => {
+ draft.prWatches.items = draft.prWatches.items.filter((w) => w.id !== id);
+ }),
+ setReviewWatches: (watches) =>
+ set((draft) => {
+ draft.reviewWatches.items = watches;
+ draft.reviewWatches.loaded = true;
+ }),
+ setReviewWatchesLoading: (loading) =>
+ set((draft) => {
+ draft.reviewWatches.loading = loading;
+ }),
+ addReviewWatch: (watch) =>
+ set((draft) => {
+ draft.reviewWatches.items = [
+ ...draft.reviewWatches.items.filter((w) => w.id !== watch.id),
+ watch,
+ ];
+ draft.reviewWatches.loaded = true;
+ }),
+ updateReviewWatch: (watch) =>
+ set((draft) => {
+ const idx = draft.reviewWatches.items.findIndex((w) => w.id === watch.id);
+ if (idx >= 0) {
+ draft.reviewWatches.items[idx] = watch;
+ }
+ }),
+ removeReviewWatch: (id) =>
+ set((draft) => {
+ draft.reviewWatches.items = draft.reviewWatches.items.filter((w) => w.id !== id);
+ }),
+ };
+}
+
+export const createGitHubSlice: StateCreator<
+ GitHubSlice,
+ [["zustand/immer", never]],
+ [],
+ GitHubSlice
+> = (set) => ({
+ ...defaultGitHubState,
+ ...createGitHubStatusActions(set),
+ ...createTaskPRActions(set),
+ ...createWatchActions(set),
+});
diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts
new file mode 100644
index 0000000000..54751fee38
--- /dev/null
+++ b/apps/web/lib/state/slices/github/types.ts
@@ -0,0 +1,51 @@
+import type { GitHubStatus, TaskPR, PRWatch, ReviewWatch } from "@/lib/types/github";
+
+export type GitHubStatusState = {
+ status: GitHubStatus | null;
+ loaded: boolean;
+ loading: boolean;
+};
+
+export type TaskPRsState = {
+ byTaskId: Record;
+ loaded: boolean;
+ loading: boolean;
+};
+
+export type PRWatchesState = {
+ items: PRWatch[];
+ loaded: boolean;
+ loading: boolean;
+};
+
+export type ReviewWatchesState = {
+ items: ReviewWatch[];
+ loaded: boolean;
+ loading: boolean;
+};
+
+export type GitHubSliceState = {
+ githubStatus: GitHubStatusState;
+ taskPRs: TaskPRsState;
+ prWatches: PRWatchesState;
+ reviewWatches: ReviewWatchesState;
+};
+
+export type GitHubSliceActions = {
+ setGitHubStatus: (status: GitHubStatus | null) => void;
+ setGitHubStatusLoading: (loading: boolean) => void;
+ setTaskPRs: (prs: Record) => void;
+ setTaskPR: (taskId: string, pr: TaskPR) => void;
+ removeTaskPR: (taskId: string) => void;
+ setTaskPRsLoading: (loading: boolean) => void;
+ setPRWatches: (watches: PRWatch[]) => void;
+ setPRWatchesLoading: (loading: boolean) => void;
+ removePRWatch: (id: string) => void;
+ setReviewWatches: (watches: ReviewWatch[]) => void;
+ setReviewWatchesLoading: (loading: boolean) => void;
+ addReviewWatch: (watch: ReviewWatch) => void;
+ updateReviewWatch: (watch: ReviewWatch) => void;
+ removeReviewWatch: (id: string) => void;
+};
+
+export type GitHubSlice = GitHubSliceState & GitHubSliceActions;
diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts
index a8c5310485..227a10e00c 100644
--- a/apps/web/lib/state/slices/index.ts
+++ b/apps/web/lib/state/slices/index.ts
@@ -8,6 +8,7 @@ export {
defaultSessionRuntimeState,
} from "./session-runtime/session-runtime-slice";
export { createUISlice, defaultUIState } from "./ui/ui-slice";
+export { createGitHubSlice, defaultGitHubState } from "./github/github-slice";
// Export types
export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types";
@@ -20,6 +21,7 @@ export type {
SessionRuntimeSliceActions,
} from "./session-runtime/types";
export type { UISlice, UISliceState, UISliceActions } from "./ui/types";
+export type { GitHubSlice, GitHubSliceState, GitHubSliceActions } from "./github/types";
// Re-export commonly used types from each domain
export type {
@@ -105,4 +107,10 @@ export type {
ActiveDocument,
DocumentPanelState,
} from "./ui/types";
+export type {
+ GitHubStatusState,
+ TaskPRsState,
+ PRWatchesState,
+ ReviewWatchesState,
+} from "./github/types";
export type { Repository, Branch } from "@/lib/types/http";
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index 69c4d88b56..d5e592a757 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -9,6 +9,12 @@ import type {
Turn,
TaskSession,
} from "@/lib/types/http";
+import type {
+ GitHubStatus,
+ TaskPR,
+ PRWatch,
+ ReviewWatch as GitHubReviewWatch,
+} from "@/lib/types/github";
import {
createKanbanSlice,
createWorkspaceSlice,
@@ -16,12 +22,14 @@ import {
createSessionSlice,
createSessionRuntimeSlice,
createUISlice,
+ createGitHubSlice,
defaultKanbanState,
defaultWorkspaceState,
defaultSettingsState,
defaultSessionState,
defaultSessionRuntimeState,
defaultUIState,
+ defaultGitHubState,
type WorkspaceState,
type WorkflowsState,
type ExecutorsState,
@@ -102,6 +110,13 @@ export type {
DiffState,
ConnectionState,
MobileKanbanState,
+ GitHubSlice,
+ GitHubSliceState,
+ GitHubSliceActions,
+ GitHubStatusState,
+ TaskPRsState,
+ PRWatchesState,
+ ReviewWatchesState,
} from "./slices";
// Combined AppState type
@@ -159,6 +174,12 @@ export type AppState = {
userShells: (typeof defaultSessionRuntimeState)["userShells"];
prepareProgress: (typeof defaultSessionRuntimeState)["prepareProgress"];
+ // GitHub slice
+ githubStatus: (typeof defaultGitHubState)["githubStatus"];
+ taskPRs: (typeof defaultGitHubState)["taskPRs"];
+ prWatches: (typeof defaultGitHubState)["prWatches"];
+ reviewWatches: (typeof defaultGitHubState)["reviewWatches"];
+
// UI slice
previewPanel: (typeof defaultUIState)["previewPanel"];
rightPanel: (typeof defaultUIState)["rightPanel"];
@@ -169,6 +190,22 @@ export type AppState = {
chatInput: (typeof defaultUIState)["chatInput"];
documentPanel: (typeof defaultUIState)["documentPanel"];
+ // GitHub actions
+ setGitHubStatus: (status: GitHubStatus | null) => void;
+ setGitHubStatusLoading: (loading: boolean) => void;
+ setTaskPRs: (prs: Record) => void;
+ setTaskPR: (taskId: string, pr: TaskPR) => void;
+ removeTaskPR: (taskId: string) => void;
+ setTaskPRsLoading: (loading: boolean) => void;
+ setPRWatches: (watches: PRWatch[]) => void;
+ setPRWatchesLoading: (loading: boolean) => void;
+ removePRWatch: (id: string) => void;
+ setReviewWatches: (watches: GitHubReviewWatch[]) => void;
+ setReviewWatchesLoading: (loading: boolean) => void;
+ addReviewWatch: (watch: GitHubReviewWatch) => void;
+ updateReviewWatch: (watch: GitHubReviewWatch) => void;
+ removeReviewWatch: (id: string) => void;
+
// Actions from all slices
hydrate: (state: Partial, options?: HydrationOptions) => void;
setActiveWorkspace: (workspaceId: string | null) => void;
@@ -367,6 +404,10 @@ const defaultState = {
sessionMode: defaultSessionRuntimeState.sessionMode,
userShells: defaultSessionRuntimeState.userShells,
prepareProgress: defaultSessionRuntimeState.prepareProgress,
+ githubStatus: defaultGitHubState.githubStatus,
+ taskPRs: defaultGitHubState.taskPRs,
+ prWatches: defaultGitHubState.prWatches,
+ reviewWatches: defaultGitHubState.reviewWatches,
previewPanel: defaultUIState.previewPanel,
rightPanel: defaultUIState.rightPanel,
diffs: defaultUIState.diffs,
@@ -430,6 +471,10 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat
sessionMode: { ...defaultState.sessionMode, ...initialState.sessionMode },
userShells: { ...defaultState.userShells, ...initialState.userShells },
prepareProgress: { ...defaultState.prepareProgress, ...initialState.prepareProgress },
+ githubStatus: { ...defaultState.githubStatus, ...initialState.githubStatus },
+ taskPRs: { ...defaultState.taskPRs, ...initialState.taskPRs },
+ prWatches: { ...defaultState.prWatches, ...initialState.prWatches },
+ reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches },
previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel },
rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel },
diffs: { ...defaultState.diffs, ...initialState.diffs },
@@ -458,6 +503,8 @@ export function createAppStore(initialState?: Partial) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createSessionRuntimeSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...createGitHubSlice(set as any, get as any, api as any),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
...createUISlice(set as any, get as any, api as any),
// Override state with merged initial state
kanban: merged.kanban,
@@ -498,6 +545,10 @@ export function createAppStore(initialState?: Partial) {
sessionMode: merged.sessionMode,
userShells: merged.userShells,
prepareProgress: merged.prepareProgress,
+ githubStatus: merged.githubStatus,
+ taskPRs: merged.taskPRs,
+ prWatches: merged.prWatches,
+ reviewWatches: merged.reviewWatches,
previewPanel: merged.previewPanel,
rightPanel: merged.rightPanel,
diffs: merged.diffs,
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index e682a3e1c0..9387e868de 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -55,7 +55,8 @@ export type BackendMessageType =
| "secrets.created"
| "secrets.updated"
| "secrets.deleted"
- | "message.queue.status_changed";
+ | "message.queue.status_changed"
+ | "github.task_pr.updated";
export type BackendMessage = {
id?: string;
@@ -68,6 +69,7 @@ export type BackendMessage = {
import type { AvailableAgent, SavedLayout, StepEvents, TaskState } from "@/lib/types/http";
import type { SecretListItem } from "@/lib/types/http-secrets";
import type { GitEventPayload } from "@/lib/types/git-events";
+import type { TaskPR } from "@/lib/types/github";
export type KanbanUpdatePayload = {
workflowId: string;
@@ -495,6 +497,7 @@ export type BackendMessageMap = {
"message.queue.status_changed",
QueueStatusChangedPayload
>;
+ "github.task_pr.updated": BackendMessage<"github.task_pr.updated", TaskPR>;
};
// Workspace file types
diff --git a/apps/web/lib/types/context.ts b/apps/web/lib/types/context.ts
index 565c343102..d706d22761 100644
--- a/apps/web/lib/types/context.ts
+++ b/apps/web/lib/types/context.ts
@@ -1,4 +1,4 @@
-import type { DiffComment, PlanComment } from "@/lib/state/slices/comments";
+import type { DiffComment, PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments";
import type { ImageAttachment } from "@/components/task/chat/image-attachment-preview";
type ContextItemBase = {
@@ -46,12 +46,19 @@ export type ImageContextItem = ContextItemBase & {
attachment: ImageAttachment;
};
+export type PRFeedbackContextItem = ContextItemBase & {
+ kind: "pr-feedback";
+ comments: PRFeedbackComment[];
+ onRemoveComment: (id: string) => void;
+};
+
export type ContextItem =
| PlanContextItem
| FileContextItem
| PromptContextItem
| CommentContextItem
| PlanCommentContextItem
- | ImageContextItem;
+ | ImageContextItem
+ | PRFeedbackContextItem;
export type ContextItemKind = ContextItem["kind"];
diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts
new file mode 100644
index 0000000000..e4893071e2
--- /dev/null
+++ b/apps/web/lib/types/github.ts
@@ -0,0 +1,195 @@
+// GitHub integration types
+
+export type GitHubAuthMethod = "gh_cli" | "pat" | "none";
+
+export type GitHubStatus = {
+ authenticated: boolean;
+ username: string;
+ auth_method: GitHubAuthMethod;
+};
+
+export type GitHubPR = {
+ number: number;
+ title: string;
+ url: string;
+ html_url: string;
+ state: "open" | "closed" | "merged";
+ head_branch: string;
+ base_branch: string;
+ author_login: string;
+ repo_owner: string;
+ repo_name: string;
+ draft: boolean;
+ mergeable: boolean;
+ additions: number;
+ deletions: number;
+ created_at: string;
+ updated_at: string;
+ merged_at: string | null;
+ closed_at: string | null;
+};
+
+export type PRReview = {
+ id: number;
+ author: string;
+ author_avatar: string;
+ state: string;
+ body: string;
+ created_at: string;
+};
+
+export type PRComment = {
+ id: number;
+ author: string;
+ author_avatar: string;
+ body: string;
+ path: string;
+ line: number;
+ side: string;
+ created_at: string;
+ updated_at: string;
+ in_reply_to: number | null;
+};
+
+export type CheckRun = {
+ name: string;
+ status: string;
+ conclusion: string;
+ html_url: string;
+ output: string;
+ started_at: string | null;
+ completed_at: string | null;
+};
+
+export type PRFeedback = {
+ pr: GitHubPR;
+ reviews: PRReview[];
+ comments: PRComment[];
+ checks: CheckRun[];
+ has_issues: boolean;
+};
+
+export type TaskPR = {
+ id: string;
+ task_id: string;
+ owner: string;
+ repo: string;
+ pr_number: number;
+ pr_url: string;
+ pr_title: string;
+ head_branch: string;
+ base_branch: string;
+ author_login: string;
+ state: "open" | "closed" | "merged";
+ review_state: "approved" | "changes_requested" | "pending" | "";
+ checks_state: "success" | "failure" | "pending" | "";
+ review_count: number;
+ pending_review_count: number;
+ comment_count: number;
+ additions: number;
+ deletions: number;
+ created_at: string;
+ merged_at: string | null;
+ closed_at: string | null;
+ last_synced_at: string | null;
+ updated_at: string;
+};
+
+export type PRWatch = {
+ id: string;
+ session_id: string;
+ task_id: string;
+ owner: string;
+ repo: string;
+ pr_number: number;
+ branch: string;
+ last_checked_at: string | null;
+ last_comment_at: string | null;
+ last_check_status: string;
+ created_at: string;
+ updated_at: string;
+};
+
+export type RepoFilter = {
+ owner: string;
+ name: string;
+};
+
+export type GitHubOrg = {
+ login: string;
+ avatar_url: string;
+};
+
+export type GitHubRepoInfo = {
+ full_name: string;
+ owner: string;
+ name: string;
+ private: boolean;
+};
+
+export type ReviewWatch = {
+ id: string;
+ workspace_id: string;
+ workflow_id: string;
+ workflow_step_id: string;
+ repos: RepoFilter[];
+ agent_profile_id: string;
+ executor_profile_id: string;
+ prompt: string;
+ enabled: boolean;
+ poll_interval_seconds: number;
+ last_polled_at: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type DailyCount = {
+ date: string;
+ count: number;
+};
+
+export type PRStats = {
+ total_prs_created: number;
+ total_prs_reviewed: number;
+ total_comments: number;
+ ci_pass_rate: number;
+ approval_rate: number;
+ avg_time_to_merge_hours: number;
+ prs_by_day: DailyCount[];
+};
+
+// Response types
+export type GitHubStatusResponse = GitHubStatus;
+
+export type TaskPRsResponse = {
+ task_prs: Record;
+};
+
+export type PRWatchesResponse = {
+ watches: PRWatch[];
+};
+
+export type ReviewWatchesResponse = {
+ watches: ReviewWatch[];
+};
+
+export type TriggerReviewResponse = {
+ new_prs_found: number;
+};
+
+export type PRStatsResponse = PRStats;
+
+// Request types
+export type CreateReviewWatchRequest = {
+ workspace_id: string;
+ workflow_id: string;
+ workflow_step_id: string;
+ repos: RepoFilter[];
+ agent_profile_id: string;
+ executor_profile_id: string;
+ prompt?: string;
+ enabled?: boolean;
+ poll_interval_seconds?: number;
+};
+
+export type UpdateReviewWatchRequest = Partial>;
diff --git a/apps/web/lib/ws/handlers/github.ts b/apps/web/lib/ws/handlers/github.ts
new file mode 100644
index 0000000000..3260f11938
--- /dev/null
+++ b/apps/web/lib/ws/handlers/github.ts
@@ -0,0 +1,15 @@
+import type { StoreApi } from "zustand";
+import type { AppState } from "@/lib/state/store";
+import type { WsHandlers } from "@/lib/ws/handlers/types";
+import type { TaskPR } from "@/lib/types/github";
+
+export function registerGitHubHandlers(store: StoreApi): WsHandlers {
+ return {
+ "github.task_pr.updated": (message) => {
+ const pr = message.payload as TaskPR;
+ if (pr.task_id) {
+ store.getState().setTaskPR(pr.task_id, pr);
+ }
+ },
+ };
+}
diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts
index 384da9e9cb..823d8ef8e7 100644
--- a/apps/web/lib/ws/router.ts
+++ b/apps/web/lib/ws/router.ts
@@ -22,6 +22,7 @@ import { registerTurnsHandlers } from "@/lib/ws/handlers/turns";
import { registerSecretsHandlers } from "@/lib/ws/handlers/secrets";
import { registerUsersHandlers } from "@/lib/ws/handlers/users";
import { registerWorkspacesHandlers } from "@/lib/ws/handlers/workspaces";
+import { registerGitHubHandlers } from "@/lib/ws/handlers/github";
export function registerWsHandlers(store: StoreApi) {
return {
@@ -47,5 +48,6 @@ export function registerWsHandlers(store: StoreApi) {
...registerGitStatusHandlers(store),
...registerSystemEventsHandlers(store),
...registerTurnsHandlers(store),
+ ...registerGitHubHandlers(store),
};
}
diff --git a/github-plan.md b/github-plan.md
new file mode 100644
index 0000000000..7115bdaf08
--- /dev/null
+++ b/github-plan.md
@@ -0,0 +1,744 @@
+# GitHub Integration Plan
+
+## Context
+
+Kandev needs GitHub integration to support two automation workflows:
+
+1. **PR Monitor** - When an agent creates a PR during a session, poll GitHub for CI failures and review comments. The user manually reviews this data in the session UI and can choose to send specific feedback to the agent for fixing.
+2. **PR Review Queue** - A background service that periodically polls GitHub for PRs awaiting the user's review, creates tasks in a workflow for each, and optionally auto-starts an agent to review them.
+
+**Additional requirements**:
+- PRs must be associated with tasks in the DB (show PR icon on task list)
+- Session detail UI: show PR, comments, CI checks, approval status, pending review count, time-pending-review
+- PR analytics/stats: PRs created, reviewed, comments, CI results
+
+**Design decisions** (confirmed with user):
+- Auth: `gh` CLI primary (existing `gh auth login`), PAT in secrets as fallback
+- PR detection: Auto-detect from git push events (no manual workflow config)
+- PR monitor: User manually reviews feedback, NOT auto-sent to agent
+- Review queue: Standalone background service with a manual "pull now" topbar button
+- Scope: Full stack (backend + frontend)
+
+---
+
+## Phase 1: GitHub Client Package
+
+**New package**: `apps/backend/internal/github/`
+
+### 1.1 `models.go` - Data models
+
+```go
+// PR represents a GitHub Pull Request
+type PR struct {
+ Number int
+ Title string
+ URL string
+ HTMLURL string
+ State string // open, closed, merged
+ HeadBranch string
+ BaseBranch string
+ AuthorLogin string
+ RepoOwner string
+ RepoName string
+ Draft bool
+ Mergeable bool
+ Additions int
+ Deletions int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ MergedAt *time.Time
+ ClosedAt *time.Time
+}
+
+// PRReview represents a review on a PR
+type PRReview struct {
+ ID int64
+ Author string
+ State string // APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED
+ Body string
+ CreatedAt time.Time
+}
+
+// PRComment represents a review comment on specific code
+type PRComment struct {
+ ID int64
+ Author string
+ Body string
+ Path string
+ Line int
+ Side string // LEFT, RIGHT
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ InReplyTo *int64
+}
+
+// CheckRun represents a CI check result
+type CheckRun struct {
+ Name string
+ Status string // queued, in_progress, completed
+ Conclusion string // success, failure, neutral, cancelled, timed_out, action_required, skipped
+ HTMLURL string
+ Output string // summary text
+ StartedAt *time.Time
+ CompletedAt *time.Time
+}
+
+// PRFeedback aggregates all feedback for a PR (fetched live)
+type PRFeedback struct {
+ PR *PR
+ Reviews []PRReview
+ Comments []PRComment
+ Checks []CheckRun
+ HasIssues bool // true if failing checks or changes_requested
+}
+
+// PRWatch tracks active PR monitoring (session → PR)
+type PRWatch struct {
+ ID string
+ SessionID string
+ TaskID string
+ Owner string
+ Repo string
+ PRNumber int
+ Branch string
+ LastCheckedAt *time.Time
+ LastCommentAt *time.Time
+ LastCheckStatus string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// ReviewWatch configures periodic polling for PRs needing user's review
+type ReviewWatch struct {
+ ID string
+ WorkspaceID string
+ WorkflowID string
+ WorkflowStepID string
+ RepoOwner string
+ RepoName string
+ AgentProfileID string
+ ExecutorProfileID string
+ Prompt string
+ AutoStart bool
+ Enabled bool
+ PollIntervalSeconds int
+ LastPolledAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// GitHubStatus represents GitHub connection status
+type GitHubStatus struct {
+ Authenticated bool
+ Username string
+ AuthMethod string // "gh_cli", "pat", "none"
+}
+```
+
+### 1.2 `client.go` - Interface
+
+```go
+type Client interface {
+ // Auth
+ IsAuthenticated(ctx context.Context) (bool, error)
+ GetAuthenticatedUser(ctx context.Context) (string, error)
+
+ // PR queries
+ GetPR(ctx context.Context, owner, repo string, number int) (*PR, error)
+ FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error)
+ ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error)
+ ListReviewRequestedPRs(ctx context.Context, username string) ([]*PR, error)
+
+ // PR details
+ ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error)
+ ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error)
+ ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error)
+
+ // Aggregated
+ GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error)
+}
+```
+
+### 1.3 `gh_client.go` - Primary (gh CLI)
+
+Executes `gh` commands:
+- `gh pr list --json number,title,url,headRefName,baseRefName,author,state,createdAt,updatedAt,additions,deletions,isDraft,mergeable`
+- `gh pr view --json reviews,comments`
+- `gh api repos/{owner}/{repo}/commits/{ref}/check-runs`
+- `gh api search/issues?q=type:pr+review-requested:@me+state:open`
+
+Detect availability via `gh --version`. Parse JSON stdout.
+
+### 1.4 `pat_client.go` - Fallback (PAT via net/http)
+
+Uses `GITHUB_TOKEN` secret from secrets store. `Authorization: token `. Rate limit header awareness.
+
+### 1.5 `factory.go` - Client factory
+
+Try `gh` CLI first, fall back to PAT from secrets store.
+
+### 1.6 `provider.go` - DI provider
+
+Standard `Provide(cfg, log, secretsSvc, db) (*Service, cleanup, error)` pattern.
+
+---
+
+## Phase 2: Service + Persistence
+
+### 2.1 `service.go` - Business logic
+
+```go
+type Service struct {
+ client Client
+ store *Store
+ eventBus bus.EventBus
+ logger *logger.Logger
+}
+
+// PR Watch operations (for PR monitoring)
+func (s *Service) CreatePRWatch(ctx, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error)
+func (s *Service) DeletePRWatch(ctx, id string) error
+func (s *Service) GetPRWatchBySession(ctx, sessionID string) (*PRWatch, error)
+func (s *Service) ListActivePRWatches(ctx) ([]*PRWatch, error)
+
+// Task-PR association
+func (s *Service) AssociatePRWithTask(ctx, taskID, owner, repo string, prNumber int, prURL, prTitle, authorLogin string) (*TaskPR, error)
+func (s *Service) GetTaskPR(ctx, taskID string) (*TaskPR, error)
+func (s *Service) ListTaskPRs(ctx, taskIDs []string) (map[string]*TaskPR, error)
+func (s *Service) UpdateTaskPR(ctx, id string, state, mergeState string, ...) error
+
+// PR feedback (fetched live, not stored)
+func (s *Service) GetPRFeedback(ctx, owner, repo string, number int) (*PRFeedback, error)
+
+// Review Watch operations (for review queue)
+func (s *Service) CreateReviewWatch(ctx, req *CreateReviewWatchRequest) (*ReviewWatch, error)
+func (s *Service) UpdateReviewWatch(ctx, id string, req *UpdateReviewWatchRequest) error
+func (s *Service) DeleteReviewWatch(ctx, id string) error
+func (s *Service) ListReviewWatches(ctx, workspaceID string) ([]*ReviewWatch, error)
+
+// Polling
+func (s *Service) CheckPRWatch(ctx, watch *PRWatch) (*PRFeedback, bool, error) // returns hasNewFeedback
+func (s *Service) CheckReviewWatch(ctx, watch *ReviewWatch) ([]*PR, error) // returns new PRs
+
+// Manual fetch
+func (s *Service) TriggerReviewCheck(ctx, watchID string) ([]*PR, error)
+func (s *Service) TriggerAllReviewChecks(ctx, workspaceID string) (int, error) // returns count of new PRs found
+
+// Status
+func (s *Service) GetStatus(ctx) (*GitHubStatus, error)
+```
+
+### 2.2 `store.go` - SQLite persistence
+
+Four tables (owned by github package):
+
+```sql
+-- Tracks active PR monitoring (session → PR)
+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
+);
+
+-- Associates a PR with a task (1:1 for now, could be 1:many later)
+-- Used for: PR icon on task list, session detail PR view, stats
+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', -- open, closed, merged
+ review_state TEXT NOT NULL DEFAULT '', -- approved, changes_requested, pending, ''
+ checks_state TEXT NOT NULL DEFAULT '', -- success, failure, pending, ''
+ 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, -- PR creation time on GitHub
+ merged_at DATETIME,
+ closed_at DATETIME,
+ last_synced_at DATETIME,
+ updated_at DATETIME NOT NULL,
+ UNIQUE(task_id, pr_number)
+);
+
+-- Review watch configurations (workspace → repo polling)
+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,
+ repo_owner TEXT NOT NULL,
+ repo_name TEXT NOT NULL,
+ agent_profile_id TEXT NOT NULL,
+ executor_profile_id TEXT NOT NULL,
+ prompt TEXT DEFAULT '',
+ auto_start BOOLEAN DEFAULT 0,
+ enabled BOOLEAN DEFAULT 1,
+ poll_interval_seconds INTEGER DEFAULT 300,
+ last_polled_at DATETIME,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+);
+
+-- Deduplication: which PRs we already created tasks for
+CREATE TABLE IF NOT EXISTS github_review_pr_tasks (
+ id TEXT PRIMARY KEY,
+ review_watch_id TEXT NOT NULL,
+ pr_number INTEGER NOT NULL,
+ pr_url TEXT NOT NULL,
+ task_id TEXT NOT NULL,
+ created_at DATETIME NOT NULL,
+ UNIQUE(review_watch_id, pr_number)
+);
+```
+
+---
+
+## Phase 3: Background Poller
+
+### 3.1 `poller.go`
+
+Two goroutine loops:
+
+**PR Monitor Loop** (every 2 min):
+1. List all active `github_pr_watches`
+2. For each watch, fetch PR feedback (comments + checks) via client
+3. Compare with last known state (last_comment_at, last_check_status)
+4. If new comments or check state changes:
+ - Update the `github_task_prs` record (review_state, checks_state, comment_count, etc.)
+ - Publish `github.pr_feedback` event to event bus (for UI notification, NOT auto-prompt)
+ - Update watch timestamps
+5. Also sync PR state (merged, closed) to `github_task_prs`
+
+**Review Queue Loop** (every 5 min, per-watch configurable):
+1. List all enabled `github_review_watches`
+2. For each watch, call `ListReviewRequestedPRs` filtered to the watch's repo
+3. For each PR not already in `github_review_pr_tasks`:
+ - Publish `github.new_pr_to_review` event
+ - Orchestrator handler creates the task + associates the PR
+
+Methods: `Start(ctx)`, `Stop()`, `TriggerReviewCheck(ctx, watchID)`, `TriggerAllReviewChecks(ctx, workspaceID)`.
+
+---
+
+## Phase 4: Event Bus + Orchestrator Integration
+
+### 4.1 New event types
+
+**File**: `apps/backend/internal/events/types.go`
+
+```go
+// 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
+ GitHubWatchEvent = "github.watch.event" // Watch created/deleted
+ GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh)
+)
+```
+
+### 4.2 PR auto-detection from git events
+
+**File**: `apps/backend/internal/orchestrator/event_handlers_git.go`
+
+In `handleGitStatusUpdate()`, detect push completion:
+- Track per-session state: when `Ahead` goes from `>0` to `0` with a `RemoteBranch` set, a push happened
+- After detecting push, call `githubService.Client().FindPRByBranch()` to check if a PR exists
+- If PR found and no watch exists for this session:
+ 1. Auto-create `PRWatch` via `githubService.CreatePRWatch()`
+ 2. Auto-create `TaskPR` association via `githubService.AssociatePRWithTask()`
+ 3. Publish `github.task_pr.updated` event (so task list shows PR icon)
+
+**Lookup chain**:
+- `repoStore.GetTaskSession(sessionID)` → `RepositoryID`
+- `repoStore.GetRepository(repositoryID)` → `ProviderOwner`, `ProviderName`
+- `FindPRByBranch(providerOwner, providerName, branch)`
+
+### 4.3 Orchestrator GitHub event handlers
+
+**New file**: `apps/backend/internal/orchestrator/event_handlers_github.go`
+
+```go
+// handlePRFeedback forwards PR feedback events to the frontend via WS.
+// The user manually decides whether to send feedback to the agent.
+// This does NOT auto-prompt the agent.
+func (s *Service) handlePRFeedback(ctx context.Context, event *bus.Event) {
+ // Extract sessionID, feedback summary from event
+ // Forward to WS for session UI notification
+ // Frontend shows updated PR data in session detail panel
+}
+
+// handleNewReviewPR creates a task for a PR needing review.
+func (s *Service) handleNewReviewPR(ctx context.Context, event *bus.Event) {
+ // Extract PR data, ReviewWatch config
+ // Create task in the watch's workflow at configured step
+ // Associate PR with task via githubService.AssociatePRWithTask()
+ // Record in github_review_pr_tasks for dedup
+ // If auto_start, enqueue the task for agent execution
+}
+```
+
+### 4.4 "Send to Agent" action
+
+The session detail UI will have a "Send to Agent" button that lets the user compose a message to the agent including selected PR comments and/or CI failures. This uses the existing `PromptTask()` flow — the frontend sends a user message with the selected feedback context embedded.
+
+### 4.5 Orchestrator wiring
+
+**File**: `apps/backend/internal/orchestrator/service.go`
+- Add `githubService` field + `SetGitHubService()` setter
+- Add `pushTracker sync.Map` for per-session ahead-count tracking
+- Subscribe to `github.pr_feedback` and `github.new_pr_to_review` events in `Start()`
+
+**File**: `apps/backend/cmd/kandev/services.go`
+- Add GitHub service to `Services` struct, initialize via `github.Provide()`
+- Wire into orchestrator, start poller
+
+**File**: `apps/backend/cmd/kandev/main.go`
+- Register GitHub HTTP routes
+
+---
+
+## Phase 5: HTTP API
+
+### 5.1 `controller.go`
+
+**Route group**: `/api/v1/github/`
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/status` | GET | Auth status (username, auth method) |
+| `/prs/authored` | GET | User's open PRs (query: owner, repo) |
+| `/prs/review-requested` | GET | PRs awaiting user's review |
+| `/prs/{owner}/{repo}/{number}` | GET | PR details with full feedback |
+| `/prs/{owner}/{repo}/{number}/checks` | GET | CI check status only |
+| `/prs/{owner}/{repo}/{number}/comments` | GET | PR comments only |
+| `/task-prs` | GET | List task-PR associations (query: task_ids) |
+| `/task-prs/{taskId}` | GET | Get PR association for a task |
+| `/watches/pr` | GET | List active PR watches |
+| `/watches/pr/{id}` | DELETE | Remove a PR watch |
+| `/watches/review` | GET | List review watches (query: workspace_id) |
+| `/watches/review` | POST | Create review watch |
+| `/watches/review/{id}` | PUT | Update review watch |
+| `/watches/review/{id}` | DELETE | Delete review watch |
+| `/watches/review/{id}/trigger` | POST | Manual "pull now" for one watch |
+| `/watches/review/trigger-all` | POST | Trigger all for workspace |
+| `/stats` | GET | PR stats (query: workspace_id, date range) |
+
+### 5.2 `handlers.go` - WS handlers
+
+| Action | Description |
+|--------|-------------|
+| `github.status` | Auth status |
+| `github.task_prs.list` | Get task-PR associations (batch) |
+| `github.pr_feedback.get` | Get live PR feedback for session |
+| `github.review_watches.list` | List review watches |
+| `github.review_watches.create` | Create review watch |
+| `github.review_watches.update` | Update review watch |
+| `github.review_watches.delete` | Delete review watch |
+| `github.review_watches.trigger` | Trigger single check |
+| `github.review_watches.trigger_all` | Trigger all for workspace |
+
+---
+
+## Phase 6: Frontend
+
+### 6.1 Types: `apps/web/lib/types/github.ts`
+
+```typescript
+interface GitHubStatus {
+ authenticated: boolean;
+ username: string;
+ authMethod: 'gh_cli' | 'pat' | 'none';
+}
+
+interface GitHubPR {
+ number: number; title: string; url: string; htmlUrl: string;
+ state: 'open' | 'closed' | 'merged';
+ headBranch: string; baseBranch: string;
+ authorLogin: string; repoOwner: string; repoName: string;
+ draft: boolean; additions: number; deletions: number;
+ createdAt: string; updatedAt: string;
+ mergedAt: string | null; closedAt: string | null;
+}
+
+interface TaskPR {
+ id: string; taskId: string;
+ owner: string; repo: string; prNumber: number;
+ prUrl: string; prTitle: string;
+ headBranch: string; baseBranch: string; authorLogin: string;
+ state: 'open' | 'closed' | 'merged';
+ reviewState: 'approved' | 'changes_requested' | 'pending' | '';
+ checksState: 'success' | 'failure' | 'pending' | '';
+ reviewCount: number; pendingReviewCount: number; commentCount: number;
+ additions: number; deletions: number;
+ createdAt: string; mergedAt: string | null; closedAt: string | null;
+ lastSyncedAt: string | null;
+}
+
+interface PRReview { id: number; author: string; state: string; body: string; createdAt: string; }
+interface PRComment { id: number; author: string; body: string; path: string; line: number; side: string; createdAt: string; inReplyTo: number | null; }
+interface CheckRun { name: string; status: string; conclusion: string; htmlUrl: string; output: string; }
+interface PRFeedback { pr: GitHubPR; reviews: PRReview[]; comments: PRComment[]; checks: CheckRun[]; hasIssues: boolean; }
+
+interface PRWatch { id: string; sessionId: string; taskId: string; owner: string; repo: string; prNumber: number; branch: string; lastCheckedAt: string | null; }
+interface ReviewWatch { id: string; workspaceId: string; workflowId: string; workflowStepId: string; repoOwner: string; repoName: string; agentProfileId: string; executorProfileId: string; prompt: string; autoStart: boolean; enabled: boolean; pollIntervalSeconds: number; lastPolledAt: string | null; }
+```
+
+### 6.2 API Client: `apps/web/lib/api/domains/github-api.ts`
+
+Methods mirroring all HTTP endpoints.
+
+### 6.3 Store: `apps/web/lib/state/slices/github/github-slice.ts`
+
+Zustand slice:
+- `github.status` - auth status
+- `github.taskPRs.byTaskId` - PR associations per task (for task list PR icon)
+- `github.prWatches.bySessionId` - active PR watches per session
+- `github.reviewWatches.byWorkspaceId` - review watch configs
+
+### 6.4 Hooks: `apps/web/hooks/domains/github/`
+
+- `use-github-status.ts` - auth status
+- `use-task-pr.ts` - PR association for a task (or batch for task list)
+- `use-pr-feedback.ts` - live PR feedback for session detail
+- `use-review-watches.ts` - CRUD for review watches
+- `use-pr-watch.ts` - PR monitoring status per session
+
+### 6.5 Task List: PR Icon
+
+**File**: Modify task card/row components to show a PR icon (e.g., git-pull-request icon from lucide) when `taskPR` exists for that task. Color-coded: green (approved/passing), red (changes requested/failing), yellow (pending).
+
+### 6.6 Settings Page: `apps/web/app/settings/general/github/page.tsx`
+
+- **Connection Status card**: Auth method, username, `gh` CLI availability
+- **Review Watches table**: List watches with repo, workflow, status, last polled
+- **Create/Edit Review Watch dialog**: Repo selector (from workspace repos with `provider: "github"`), workflow+step selector, agent profile, executor profile, custom prompt, auto-start toggle, poll interval, enable/disable
+
+### 6.7 Session Detail: PR Info in Topbar + PR Panel
+
+**Session topbar** (`apps/web/components/github/pr-session-topbar.tsx`):
+In the session detail topbar (not the kanban topbar), show a compact PR summary when a PR is associated:
+- PR number + link (e.g., `#42`)
+- State badge (open/merged/closed)
+- CI status indicator (green check / red X / yellow spinner)
+- Review status (e.g., "2/3 approved", "changes requested")
+- Comment count badge
+- Clicking opens the full PR detail panel
+
+**Full PR detail panel** (`apps/web/components/github/pr-detail-panel.tsx`):
+Expandable/drawer panel accessible from the session topbar:
+- **PR header**: Title, number, link, state badge, draft indicator
+- **Stats bar**: +additions/-deletions, time since created, time pending review
+- **Reviews section**: List of reviews with state (approved/changes_requested), author, body
+- **Pending reviewers**: Who hasn't reviewed yet, how long pending
+- **CI Checks section**: List of check runs with status badge (pass/fail/pending), link to details
+- **Comments section**: Threaded review comments with file path, line number, author
+- **"Send to Agent" button**: Opens a dialog to compose a message to the agent, with checkboxes to include specific comments/CI failures as context. Sends via normal `PromptTask` flow.
+
+### 6.8 Kanban Homepage Topbar: Manual Pull Button
+
+**Component**: `apps/web/components/github/refresh-reviews-button.tsx`
+
+Button in the **kanban/homepage topbar** (NOT the session detail topbar):
+- Triggers `POST /watches/review/trigger-all` for active workspace
+- Shows toast with results ("Found 3 new PRs to review")
+- Only visible when review watches exist for the workspace
+
+### 6.9 Stats
+
+**Route**: `/api/v1/github/stats` (backend) + stats section in settings or dedicated page
+
+**Backend** (`internal/github/stats.go`):
+
+```go
+type PRStats struct {
+ // Counts
+ TotalPRsCreated int // PRs authored by user (from github_task_prs)
+ TotalPRsReviewed int // PRs reviewed (tasks from review watches)
+ TotalComments int // Total review comments across all PRs
+ TotalChecksRun int // Total CI check runs tracked
+
+ // Rates
+ CIPassRate float64 // % of PRs where all checks passed
+ ApprovalRate float64 // % of PRs approved on first review cycle
+
+ // Timing (averages)
+ AvgTimeToFirstReview time.Duration // Time from PR creation to first review
+ AvgTimeToMerge time.Duration // Time from PR creation to merge
+ AvgTimePendingReview time.Duration // Time PRs spent waiting for review
+
+ // By period (for charts)
+ PRsByDay []DailyCount // PRs created per day
+ ReviewsByDay []DailyCount // PRs reviewed per day
+ CommentsByDay []DailyCount // Comments per day
+}
+
+type DailyCount struct {
+ Date string `json:"date"` // "2026-02-22"
+ Count int `json:"count"`
+}
+```
+
+Data sourced from `github_task_prs` table aggregations with date range filtering.
+
+**Frontend** (`apps/web/components/github/pr-stats.tsx`):
+- Stats cards: PRs created, reviewed, avg time to merge, CI pass rate
+- Line charts: PRs over time, comments over time
+- Uses `@kandev/ui` Card, Badge for layout
+- Could be embedded in GitHub settings page or as a section in a workspace dashboard
+
+---
+
+## Files to Create
+
+```
+apps/backend/internal/github/
+ models.go # Data models (PR, Review, Comment, Check, Watch, TaskPR)
+ client.go # Client interface
+ gh_client.go # gh CLI implementation
+ pat_client.go # PAT HTTP implementation
+ factory.go # Client factory (gh CLI → PAT fallback)
+ store.go # SQLite persistence (watches, task_prs, dedup)
+ service.go # Business logic
+ poller.go # Background polling loops
+ controller.go # HTTP endpoints
+ handlers.go # WS handlers
+ provider.go # DI provider
+ stats.go # PR analytics queries
+
+apps/web/
+ lib/types/github.ts
+ lib/api/domains/github-api.ts
+ lib/state/slices/github/
+ index.ts
+ github-slice.ts
+ hooks/domains/github/
+ use-github-status.ts
+ use-task-pr.ts
+ use-pr-feedback.ts
+ use-review-watches.ts
+ use-pr-watch.ts
+ app/settings/general/github/
+ page.tsx
+ components/github/
+ github-status.tsx
+ review-watch-table.tsx
+ review-watch-dialog.tsx
+ pr-detail-panel.tsx # Session detail PR view (expandable panel)
+ pr-session-topbar.tsx # Compact PR info in session topbar
+ pr-task-icon.tsx # PR icon for task list
+ refresh-reviews-button.tsx # Topbar manual pull button
+ send-to-agent-dialog.tsx # Compose feedback to send to agent
+ pr-stats.tsx # PR analytics cards + charts
+```
+
+## Files to Modify
+
+```
+apps/backend/internal/events/types.go # Add GitHub event constants
+apps/backend/internal/orchestrator/service.go # Add githubService field + setter + push tracker
+apps/backend/internal/orchestrator/event_handlers_git.go # PR auto-detection on push
+apps/backend/cmd/kandev/services.go # Wire GitHub service
+apps/backend/cmd/kandev/types.go # Add GitHub to Services struct
+apps/backend/cmd/kandev/main.go # Register GitHub routes
+
+apps/web/lib/state/store.ts # Add github slice
+apps/web/lib/state/hydration/merge-strategies.ts # Add github hydration
+apps/web/app/settings/general/ (layout or nav) # Add GitHub settings link
+apps/web/components/ (task card/row) # Add PR icon to task list
+apps/web/components/ (session detail panel) # Add PR detail panel
+```
+
+## New file: `apps/backend/internal/orchestrator/event_handlers_github.go`
+
+Orchestrator event handlers for GitHub events (handlePRFeedback, handleNewReviewPR).
+
+## Key Patterns to Reuse
+
+- **Provider pattern**: `Provide(cfg, log, ...) (*Service, cleanup, error)` — see `apps/backend/internal/secrets/provider.go`
+- **Event bus**: `bus.EventBus` publish/subscribe — see `apps/backend/internal/events/types.go`
+- **Setter injection**: `SetGitHubService()` — see `orchestrator/service.go` `SetWorkflowStepGetter()`
+- **Repository sub-interfaces**: Split interfaces in `apps/backend/internal/task/repository/interface.go`
+- **Task service Repos struct**: `taskservice.Repos{}` — see `apps/backend/cmd/kandev/services.go:38`
+- **Git event handling**: `handleGitStatusUpdate()` in `apps/backend/internal/orchestrator/event_handlers_git.go`
+- **Repository model provider fields**: `Provider`, `ProviderOwner`, `ProviderName` in `apps/backend/internal/task/models/models.go:285`
+- **Secrets store for PAT**: `apps/backend/internal/secrets/service.go` (Reveal method)
+- **Credential env provider**: `GITHUB_TOKEN` already listed in `apps/backend/internal/agent/credentials/env_provider.go:25`
+- **WS handler pattern**: See existing handlers in `apps/backend/internal/task/handlers/`
+- **Frontend store slices**: Domain pattern in `apps/web/lib/state/slices/`
+- **Frontend hooks**: Domain pattern in `apps/web/hooks/domains/`
+- **Settings page layout**: Existing pages at `apps/web/app/settings/general/{editors,secrets,notifications}/`
+- **Shadcn components**: Import from `@kandev/ui` — Table, Dialog, Button, Badge, etc.
+
+## Implementation Order
+
+### Milestone 1: GitHub Client (Backend)
+1. `models.go` - data models
+2. `client.go` - interface
+3. `gh_client.go` - gh CLI implementation
+4. `pat_client.go` - PAT fallback
+5. `factory.go` - client factory
+6. Unit tests for client JSON parsing
+
+### Milestone 2: Persistence + Service (Backend)
+7. `store.go` - SQLite tables + CRUD
+8. `service.go` - business logic
+9. `provider.go` - DI provider
+10. Wire into `cmd/kandev/services.go`
+
+### Milestone 3: API + Handlers (Backend)
+11. `controller.go` - HTTP endpoints
+12. `handlers.go` - WS handlers
+13. Add event types to `events/types.go`
+14. Wire routes in `cmd/kandev/main.go`
+
+### Milestone 4: Poller + Orchestrator (Backend)
+15. `poller.go` - background polling
+16. Push detection in `event_handlers_git.go`
+17. `event_handlers_github.go` - orchestrator GitHub event handlers
+18. Wire GitHub service into orchestrator
+19. Integration tests
+
+### Milestone 5: Frontend Core
+20. TypeScript types
+21. API client
+22. Store slice
+23. Domain hooks
+
+### Milestone 6: Frontend UI
+24. Settings page (GitHub config + review watches)
+25. Task list PR icon
+26. Session detail PR panel (PR, comments, CI, approvals, "Send to Agent")
+27. Topbar refresh button
+
+### Milestone 7: Stats
+28. `stats.go` - PR analytics queries (aggregations on `github_task_prs`)
+29. Stats HTTP endpoint (`/api/v1/github/stats`)
+30. Frontend stats component (`pr-stats.tsx`) with cards + charts
+31. Integrate stats into GitHub settings page or dashboard
+
+## Verification
+
+1. **Backend unit tests**: Client JSON parsing, store CRUD, poller logic
+2. **Integration**: `make -C apps/backend test`
+3. **Lint/typecheck**: `make -C apps/backend lint` + `cd apps && pnpm --filter @kandev/web typecheck`
+4. **Manual**: Check `/api/v1/github/status`, create review watch, trigger manual pull
+5. **E2E**: Agent pushes → PR auto-detected → PR watch created → task shows PR icon → session detail shows PR panel → user reviews and sends feedback to agent