541 lines
18 KiB
Go
541 lines
18 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
promptcfg "github.com/kandev/kandev/config/prompts"
|
|
"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"
|
|
"github.com/kandev/kandev/internal/task/service"
|
|
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)
|
|
GetTaskPR(ctx context.Context, taskID string) (*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
|
|
CheckoutBranch 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)
|
|
|
|
s.logger.Debug("creating review task from PR",
|
|
zap.String("repo", repoSlug),
|
|
zap.Int("pr_number", pr.Number),
|
|
zap.String("head_branch", pr.HeadBranch),
|
|
zap.String("base_branch", pr.BaseBranch),
|
|
zap.String("review_watch_id", evt.ReviewWatchID))
|
|
|
|
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,
|
|
"agent_profile_id": evt.AgentProfileID,
|
|
"executor_profile_id": evt.ExecutorProfileID,
|
|
},
|
|
})
|
|
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,
|
|
nil,
|
|
)
|
|
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
|
|
}
|
|
repoSlug := fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName)
|
|
s.logger.Debug("resolving review repository",
|
|
zap.String("repo", repoSlug),
|
|
zap.String("pr_base_branch", pr.BaseBranch),
|
|
zap.String("pr_head_branch", pr.HeadBranch))
|
|
|
|
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", repoSlug),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
if repoID == "" {
|
|
return nil
|
|
}
|
|
s.logger.Debug("resolved review repository",
|
|
zap.String("repo", repoSlug),
|
|
zap.String("repo_id", repoID),
|
|
zap.String("base_branch", baseBranch))
|
|
// BaseBranch = repo default branch (e.g. "main") for worktree creation.
|
|
// CheckoutBranch = PR head branch to fetch and checkout after worktree is created.
|
|
return []ReviewTaskRepository{{RepositoryID: repoID, BaseBranch: baseBranch, CheckoutBranch: pr.HeadBranch}}
|
|
}
|
|
|
|
// 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.
|
|
// If provider info is missing but a local path exists, it detects from the git remote
|
|
// and backfills the DB record for future calls.
|
|
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 "", ""
|
|
}
|
|
if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" {
|
|
if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" {
|
|
repoObj.Provider = p
|
|
repoObj.ProviderOwner = o
|
|
repoObj.ProviderName = n
|
|
go s.backfillRepoProvider(store, repoObj)
|
|
}
|
|
}
|
|
return repoObj.ProviderOwner, repoObj.ProviderName
|
|
}
|
|
|
|
// backfillRepoProvider persists auto-detected provider info to the DB.
|
|
func (s *Service) backfillRepoProvider(store repoStore, repo *models.Repository) {
|
|
if err := store.UpdateRepository(context.Background(), repo); err != nil {
|
|
s.logger.Warn("failed to backfill repository provider info",
|
|
zap.String("repository_id", repo.ID),
|
|
zap.Error(err))
|
|
} else {
|
|
s.logger.Info("backfilled repository provider info from git remote",
|
|
zap.String("repository_id", repo.ID),
|
|
zap.String("provider", repo.Provider),
|
|
zap.String("owner", repo.ProviderOwner),
|
|
zap.String("name", repo.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) {
|
|
branch = s.resolvePRWatchBranch(ctx, taskID, sessionID, branch)
|
|
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))
|
|
}
|
|
}
|
|
|
|
func (s *Service) resolvePRWatchBranch(ctx context.Context, taskID, sessionID, fallback string) string {
|
|
store, ok := s.repo.(repoStore)
|
|
if !ok {
|
|
return fallback
|
|
}
|
|
|
|
taskRepo, err := store.GetPrimaryTaskRepository(ctx, taskID)
|
|
if err == nil && taskRepo != nil && strings.TrimSpace(taskRepo.CheckoutBranch) != "" {
|
|
return strings.TrimSpace(taskRepo.CheckoutBranch)
|
|
}
|
|
|
|
if fallback != "" {
|
|
return fallback
|
|
}
|
|
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil || session == nil {
|
|
return ""
|
|
}
|
|
for _, wt := range session.Worktrees {
|
|
if wt.WorktreeBranch != "" {
|
|
return wt.WorktreeBranch
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveTaskRepo looks up the GitHub owner and repo name for a task's primary repository.
|
|
// If provider info is missing but a local path exists, it detects from the git remote
|
|
// and backfills the DB record for future calls.
|
|
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.ProviderOwner == "" && repoObj.LocalPath != "" {
|
|
if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" {
|
|
repoObj.Provider = p
|
|
repoObj.ProviderOwner = o
|
|
repoObj.ProviderName = n
|
|
go s.backfillRepoProvider(store, repoObj)
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
|
|
// CheckSessionPR checks if a PR exists for a session's branch and associates it
|
|
// if found. This provides an on-demand alternative to the background poller,
|
|
// allowing the frontend to trigger immediate PR detection.
|
|
func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) (bool, error) {
|
|
if s.githubService == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Check if a PR is already associated with this task
|
|
existing, err := s.githubService.GetTaskPR(ctx, taskID)
|
|
if err == nil && existing != nil {
|
|
return true, nil
|
|
}
|
|
|
|
// Resolve the GitHub owner/repo from the task's repository
|
|
owner, repoName := s.resolveTaskRepo(ctx, taskID)
|
|
if owner == "" || repoName == "" {
|
|
return false, nil
|
|
}
|
|
|
|
branch := s.resolvePRWatchBranch(ctx, taskID, sessionID, "")
|
|
if branch == "" {
|
|
return false, nil
|
|
}
|
|
|
|
// Ensure a PR watch exists so the background poller will keep checking
|
|
if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); watchErr != nil {
|
|
s.logger.Warn("failed to ensure PR watch during check",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(watchErr))
|
|
}
|
|
|
|
// Try to find the PR immediately
|
|
client := s.githubService.Client()
|
|
if client == nil {
|
|
return false, nil
|
|
}
|
|
pr, findErr := client.FindPRByBranch(ctx, owner, repoName, branch)
|
|
if findErr != nil || pr == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Found a PR — associate it with the task
|
|
s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, pr)
|
|
return true, nil
|
|
}
|
|
|
|
// 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.
|
|
// When the prompt template is empty (user didn't configure a custom prompt), it uses the
|
|
// embedded default that provides useful PR context to the agent.
|
|
func interpolateReviewPrompt(promptTemplate string, pr *github.PR) string {
|
|
if promptTemplate == "" {
|
|
promptTemplate = promptcfg.Get("pr-review-watch-default")
|
|
}
|
|
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)
|
|
}
|