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
+ Configure GitHub PR monitoring and review queue automation.
+ No checks {comment.body} No comments yet No reviews yet {review.body} {value} {label} {subtext} No stats available yet.
+ Which GitHub repositories to monitor for PRs requesting your review.
+ {description}
+ The executor environment where the agent will run.
+ Available placeholders:
+ The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders.
+
+ The workflow step prompt wraps this prompt. For example, if the step prompt is{" "}
+
+ How often to check for new PRs. Minimum 60s, maximum 3600s.
+ Pause or resume polling.
+ No review watches configured. Create one to start monitoring PRs.
+
+
+ gh auth login or add a GITHUB_TOKEN
+ secret
+
+
+ ) : (
+
PRs by Day
+
+ {REVIEW_WATCH_PLACEHOLDERS.map((p) => (
+
+ {`{{${p.key}}}`}{" "}
+ {p.description}
+
+ {"Analyze the task: {{task_prompt}}"}
+
+ , the final prompt becomes{" "}
+
+ {"Analyze the task: Pull Request ready for review: https://..."}
+
+
+
+ );
+}
diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx
index bbe39d89e7..682b0f34f1 100644
--- a/apps/web/components/kanban-card.tsx
+++ b/apps/web/components/kanban-card.tsx
@@ -21,6 +21,7 @@ import { cn, getRepositoryDisplayName } from "@/lib/utils";
import { getTaskStateIcon } from "@/lib/ui/state-icons";
import { needsAction } from "@/lib/utils/needs-action";
import { useAppStore } from "@/components/state-provider";
+import { PRTaskIcon } from "@/components/github/pr-task-icon";
export interface Task {
id: string;
@@ -75,7 +76,10 @@ function KanbanCardBody({
{repoName}
{task.title}
+{task.title}
+