Files
kandev/apps/backend/internal/orchestrator/event_handlers_github.go
T
2026-09-22 23:36:07 +01:00

2013 lines
74 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package orchestrator
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
promptcfg "github.com/kandev/kandev/config/prompts"
"github.com/kandev/kandev/internal/common/securityutil"
"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"
)
func isWatcherCapacityRejection(err error) bool {
return errors.Is(err, wfmodels.ErrWIPLimitExceeded)
}
const (
issueWatchIDKey = "issue_watch_id"
issueNumberKey = "issue_number"
githubPRStateOpen = "open"
githubPRStateClosed = "closed"
githubPRStateMerged = "merged"
ciAutomationDetachedTimeout = 2 * time.Minute
)
// GitHubService is the interface the orchestrator uses for GitHub operations.
// All PR-write methods carry repository_id so multi-repo tasks (where the
// same task spans multiple repos and so multiple PRs) don't collapse to one
// row by colliding on the legacy UNIQUE(session_id) / UNIQUE(task_id, pr_number)
// constraints. Empty repository_id preserves single-repo legacy behavior.
type GitHubService interface {
CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error)
CreatePRWatchForWorkspace(ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error)
EnsurePRWatchForWorkspace(ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error)
FindPRByBranchForWorkspace(ctx context.Context, workspaceID, owner, repo, branch string) (*github.PR, error)
GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*github.PRWatch, error)
GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*github.PRWatch, error)
ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*github.PRWatch, error)
UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error
UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error
UpdatePRWatchRepository(ctx context.Context, id, owner, repo string) error
ResetPRWatch(ctx context.Context, id, branch string) error
AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error)
AssociatePRWithTaskForWorkspace(ctx context.Context, workspaceID, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error)
GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error)
ListTaskPRs(ctx context.Context, taskIDs []string) (map[string][]*github.TaskPR, error)
TriggerPRSyncAll(ctx context.Context, taskID string) ([]*github.TaskPR, error)
GetTaskPRByOwnerRepoNumber(ctx context.Context, taskID, owner, repo string, prNumber int) (*github.TaskPR, error)
GetTaskCIOptionsResponse(ctx context.Context, taskID string) (*github.TaskCIOptionsResponse, error)
GetTaskCIPRState(ctx context.Context, taskID, repositoryID string, prNumber int) (*github.TaskCIPRAutomationState, error)
RecordTaskCIFixAttempt(ctx context.Context, attempt github.TaskCIFixAttempt) error
RefreshTaskCIFixCheckpoint(ctx context.Context, taskID, repositoryID string, prNumber int, signature, checkpointJSON string) error
RecordTaskCIMergeAttempt(ctx context.Context, attempt github.TaskCIMergeAttempt) error
RecordTaskCIMergeAttemptResult(ctx context.Context, taskID, repositoryID string, prNumber int, signature, result, message string) error
RecordTaskCIMergeQueueObservation(ctx context.Context, observation github.TaskCIMergeQueueObservation) error
RecordTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error
RecordTaskCIAutoMergeError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error
MarkTaskCIAutoFixExhausted(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error
ClearTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int) error
GetPRFeedbackForAutomation(ctx context.Context, workspaceID, owner, repo string, number int) (*github.PRFeedback, error)
MergePRForAutomation(ctx context.Context, workspaceID, owner, repo string, number int, mergeMethod, expectedHeadSHA string) error
ListActivePRWatches(ctx context.Context) ([]*github.PRWatch, error)
ReserveReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL string) (bool, error)
AssignReviewPRTaskID(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, taskID string) error
ReleaseReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int) error
// Issue watch dedup operations
ReserveIssueWatchTask(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int, issueURL string) (bool, error)
AssignIssueWatchTaskID(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int, taskID string) error
ReleaseIssueWatchTask(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int) error
// Self-heal operations: invoked from createIssueTask / createReviewTask
// when the watcher's bound agent profile has been soft-deleted. Symmetric
// with the Linear/Jira coordinator-driven path.
DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error
DisableReviewWatchWithError(ctx context.Context, watchID, cause string) error
}
// ReviewTaskCreator creates tasks from review watch events.
type ReviewTaskCreator interface {
CreateReviewTask(ctx context.Context, req *ReviewTaskRequest) (*models.Task, error)
}
// IssueTaskCreator creates tasks from issue watch events.
type IssueTaskCreator interface {
CreateIssueTask(ctx context.Context, req *IssueTaskRequest) (*models.Task, error)
}
// IssueTaskRequest contains the data for creating a task from an issue watch event.
type IssueTaskRequest struct {
WorkspaceID string
WorkflowID string
WorkflowStepID string
Title string
Description string
Metadata map[string]interface{}
Repositories []IssueTaskRepository
}
// IssueTaskRepository associates a repository with an issue task.
type IssueTaskRepository struct {
RepositoryID string
BaseBranch string
}
// 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
// IsEphemeral marks a quick-chat-style task. Automation runs do NOT set
// it: they are hidden from the board by their Origin instead, which is
// what lets them keep their worktree and reach a terminal run status.
IsEphemeral bool
// Origin tags the task with a provenance label (see task/models.TaskOrigin*).
// Defaults to TaskOriginManual when empty.
Origin string
}
// ReviewTaskRepository associates a repository with a review task.
type ReviewTaskRepository struct {
RepositoryID string
BaseBranch string
CheckoutBranch string
PRNumber int // GitHub PR number; carried so worktree creation can use refs/pull/<N>/head for fork PRs.
// Name is the repository's configured Name, populated only by
// resolveExplicitRepositories for use by the webhook repository-selector
// match in resolveAutomationRepository (see S1 in the alert-ingest spec).
// Task creation itself does not read it.
Name 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
}
// SetIssueTaskCreator sets the task creator for issue watch auto-task
// creation. Holds s.mu across both the field write and the coordinator
// (re)init so the watcherCoordinator / issueTaskCreator pair stays
// consistent against concurrent SetProfileLookup or dispatchWatcherEvent
// goroutines — the asymmetric locking surface flagged on PR #1094 review.
func (s *Service) SetIssueTaskCreator(tc IssueTaskCreator) {
s.mu.Lock()
defer s.mu.Unlock()
s.issueTaskCreator = tc
s.initWatcherCoordinatorLocked()
}
// handlePRFeedback logs PR feedback events. WS broadcasting is handled in main.go.
func (s *Service) handlePRFeedback(ctx 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))
if s.githubService != nil {
pr, err := s.githubService.GetTaskPRByOwnerRepoNumber(ctx, feedbackEvt.TaskID, feedbackEvt.Owner, feedbackEvt.Repo, feedbackEvt.PRNumber)
if err != nil {
s.logger.Debug("failed to load task PR for CI automation", zap.String("task_id", feedbackEvt.TaskID), zap.Error(err))
return nil
}
if pr != nil {
s.startTaskPRCIAutomation(ctx, pr)
}
}
return nil
}
func (s *Service) handleTaskPRUpdated(ctx context.Context, event *bus.Event) error {
pr, ok := event.Data.(*github.TaskPR)
if !ok || pr == nil {
return nil
}
s.startTaskPRCIAutomation(ctx, pr)
return nil
}
func (s *Service) handleTaskCIOptionsUpdated(ctx context.Context, event *bus.Event) error {
options, ok := event.Data.(*github.TaskCIOptionsResponse)
if !ok || options == nil || event.Source == ciAutomationStateEventSource ||
!anyTaskPRAutomationEnabled(options) || s.githubService == nil {
return nil
}
detachedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ciAutomationDetachedTimeout)
defer cancel()
prs, err := s.githubService.TriggerPRSyncAll(detachedCtx, options.TaskID)
if err != nil {
s.logger.Warn("failed to sync task PRs for CI automation options update", zap.String("task_id", options.TaskID), zap.Error(err))
s.recordTaskCIOptionsSyncError(detachedCtx, options.TaskID, prs, err)
}
for _, pr := range prs {
s.startTaskPRCIAutomationWithoutRefresh(ctx, pr)
}
return nil
}
// anyTaskPRAutomationEnabled reports whether any linked PR has at least one
// automation switch on. Per-PR scoping means the task-wide aggregate on
// options can be false (mixed configuration across PRs) while individual
// PRs still need this update's PR sync — so this checks the per-PR array
// rather than the aggregated top-level booleans. Falls back to the top-level
// booleans when PROptions is empty (e.g. a caller that built the response by
// hand without per-PR data), matching resolvePRScopedCIOptions's same
// graceful-degradation pattern rather than silently swallowing the event.
func anyTaskPRAutomationEnabled(options *github.TaskCIOptionsResponse) bool {
if options == nil {
return false
}
if len(options.PROptions) == 0 {
return options.AutoFixEnabled || options.AutoMergeEnabled ||
options.PromptOnReviewRequested || options.PromptOnMerged || options.PromptOnClosed
}
for _, opt := range options.PROptions {
if opt == nil {
continue
}
if opt.AutoFixEnabled || opt.AutoMergeEnabled ||
opt.PromptOnReviewRequested || opt.PromptOnMerged || opt.PromptOnClosed {
return true
}
}
return false
}
func (s *Service) recordTaskCIOptionsSyncError(ctx context.Context, taskID string, synced []*github.TaskPR, syncErr error) {
syncedKeys := make(map[ciAutomationTaskPRSyncKey]struct{}, len(synced))
for _, pr := range synced {
if pr == nil {
continue
}
syncedKeys[ciAutomationTaskPRSyncKeyFor(pr)] = struct{}{}
}
prsByTask, err := s.githubService.ListTaskPRs(ctx, []string{taskID})
if err != nil {
s.logger.Warn("failed to load task PRs after CI automation sync failure", zap.String("task_id", taskID), zap.Error(err))
return
}
for _, pr := range prsByTask[taskID] {
if _, ok := syncedKeys[ciAutomationTaskPRSyncKeyFor(pr)]; ok {
continue
}
s.recordCIAutomationError(ctx, pr, fmt.Sprintf("sync PR status: %v", syncErr))
}
}
type ciAutomationTaskPRSyncKey struct {
repositoryID string
owner string
repo string
prNumber int
}
func ciAutomationTaskPRSyncKeyFor(pr *github.TaskPR) ciAutomationTaskPRSyncKey {
if pr == nil {
return ciAutomationTaskPRSyncKey{}
}
return ciAutomationTaskPRSyncKey{
repositoryID: pr.RepositoryID,
owner: strings.ToLower(pr.Owner),
repo: strings.ToLower(pr.Repo),
prNumber: pr.PRNumber,
}
}
func (s *Service) startTaskPRCIAutomation(ctx context.Context, pr *github.TaskPR) {
s.startTaskPRCIAutomationWithRefresh(ctx, pr, true)
}
func (s *Service) startTaskPRCIAutomationWithoutRefresh(ctx context.Context, pr *github.TaskPR) {
s.startTaskPRCIAutomationWithRefresh(ctx, pr, false)
}
func (s *Service) startTaskPRCIAutomationWithRefresh(ctx context.Context, pr *github.TaskPR, refresh bool) {
if pr == nil {
return
}
s.ciAutomationMu.Lock()
if s.ciAutomationStopped {
s.ciAutomationMu.Unlock()
return
}
if s.ciAutomationCtx == nil {
s.ciAutomationCtx, s.ciAutomationCancel = context.WithCancel(context.Background())
}
workerCtx := s.ciAutomationCtx
s.ciAutomationWorkers.Add(1)
s.ciAutomationMu.Unlock()
key := fmt.Sprintf("%s|%s|%d", pr.TaskID, pr.RepositoryID, pr.PRNumber)
request := ciAutomationRequest{ctx: ctx, pr: pr, refresh: refresh}
if !s.ciAutomationInFlight.Enqueue(key, request) {
s.ciAutomationWorkers.Done()
s.logger.Debug("CI automation follow-up coalesced",
zap.String("task_id", pr.TaskID),
zap.String("repository_id", pr.RepositoryID),
zap.Int("pr_number", pr.PRNumber))
return
}
go func() {
defer s.ciAutomationWorkers.Done()
for {
if workerCtx.Err() != nil {
return
}
automationCtx, cancel := context.WithTimeout(workerCtx, ciAutomationDetachedTimeout)
err := s.handleTaskPRCIAutomationWithRefresh(automationCtx, request.pr, request.refresh)
cancel()
if err != nil {
s.logger.Debug("CI automation handling failed", zap.String("task_id", request.pr.TaskID), zap.Error(err))
}
var ok bool
request, ok = s.ciAutomationInFlight.Next(key)
if !ok {
return
}
}
}()
}
type ciAutomationRequest struct {
ctx context.Context
pr *github.TaskPR
refresh bool
}
type ciAutomationWork struct {
pending *ciAutomationRequest
}
// ciAutomationCoordinator owns the per-PR worker lifecycle. Enqueue returns
// true only to the caller that must start the worker. Later requests replace
// the single pending follow-up while preserving the strongest refresh need.
type ciAutomationCoordinator struct {
mu sync.Mutex
works map[string]*ciAutomationWork
}
func (c *ciAutomationCoordinator) Enqueue(key string, request ciAutomationRequest) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.works == nil {
c.works = make(map[string]*ciAutomationWork)
}
if work, ok := c.works[key]; ok {
if work.pending != nil && work.pending.refresh {
request.refresh = true
}
work.pending = &request
return false
}
c.works[key] = &ciAutomationWork{}
return true
}
func (c *ciAutomationCoordinator) Next(key string) (ciAutomationRequest, bool) {
c.mu.Lock()
defer c.mu.Unlock()
work, ok := c.works[key]
if !ok {
return ciAutomationRequest{}, false
}
if work.pending != nil {
request := *work.pending
work.pending = nil
return request, true
}
delete(c.works, key)
return ciAutomationRequest{}, false
}
// Has reports whether a worker is currently active for key. It is intentionally
// read-only so tests can observe coalescing without exposing coordinator state.
func (c *ciAutomationCoordinator) Has(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, exists := c.works[key]
return exists
}
func (s *Service) resetCIAutomationWorkers() {
s.ciAutomationMu.Lock()
defer s.ciAutomationMu.Unlock()
s.ciAutomationStopped = false
s.ciAutomationCtx, s.ciAutomationCancel = context.WithCancel(context.Background())
}
func (s *Service) stopCIAutomationWorkers() {
s.ciAutomationMu.Lock()
s.ciAutomationStopped = true
cancel := s.ciAutomationCancel
s.ciAutomationMu.Unlock()
if cancel != nil {
cancel()
}
done := make(chan struct{})
go func() {
s.ciAutomationWorkers.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(sendNowClaimRecoveryTimeout):
s.logger.Warn("timed out waiting for CI automation workers during shutdown")
}
}
// 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))
if s.preflightDeletedProfileForGitHubReview(ctx, evt) {
return
}
if !s.reserveReviewPR(ctx, evt) {
return
}
repositories := s.resolveReviewRepository(ctx, evt.WorkspaceID, pr)
task, err := s.reviewTaskCreator.CreateReviewTask(ctx, buildReviewTaskRequest(evt, repositories, repoSlug))
if err != nil {
if isWatcherCapacityRejection(err) {
s.logger.Info("review workflow step at capacity; deferring review task",
zap.String("review_watch_id", evt.ReviewWatchID),
zap.Int("pr_number", pr.Number),
zap.String("workflow_step_id", evt.WorkflowStepID),
zap.Error(err))
} else {
s.logger.Error("failed to create review task",
zap.String("review_watch_id", evt.ReviewWatchID),
zap.Int("pr_number", pr.Number),
zap.Error(err))
}
s.releaseReviewPR(ctx, evt)
return
}
s.attachTaskToReservation(ctx, evt, task.ID)
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 task.QueuedForStepID != "" || !s.shouldAutoStartStep(ctx, task.WorkflowStepID) {
return
}
s.autoStartReviewTask(ctx, evt, task)
}
// reserveReviewPR atomically claims the dedup slot before the slow clone +
// task creation. Returns false (caller must bail) if another handler already
// holds the slot or if the reserve call errored. A nil githubService means
// dedup is disabled and we always proceed.
func (s *Service) reserveReviewPR(ctx context.Context, evt *github.NewReviewPREvent) bool {
if s.githubService == nil {
return true
}
pr := evt.PR
reserved, err := s.githubService.ReserveReviewPRTask(
ctx, evt.ReviewWatchID, pr.RepoOwner, pr.RepoName, pr.Number, pr.HTMLURL,
)
if err != nil {
s.logger.Error("failed to reserve review PR slot",
zap.String("review_watch_id", evt.ReviewWatchID),
zap.Int("pr_number", pr.Number),
zap.Error(err))
return false
}
if !reserved {
s.logger.Debug("review PR already reserved by concurrent handler, skipping",
zap.String("review_watch_id", evt.ReviewWatchID),
zap.Int("pr_number", pr.Number))
return false
}
return true
}
// releaseReviewPR removes the reservation when task creation fails so a later
// poll can retry this PR instead of it being blocked by an orphan row.
func (s *Service) releaseReviewPR(ctx context.Context, evt *github.NewReviewPREvent) {
if s.githubService == nil {
return
}
pr := evt.PR
if err := s.githubService.ReleaseReviewPRTask(
ctx, evt.ReviewWatchID, pr.RepoOwner, pr.RepoName, pr.Number,
); err != nil {
s.logger.Warn("failed to release review PR reservation after task-create failure",
zap.Int("pr_number", pr.Number),
zap.Error(err))
}
}
// attachTaskToReservation stamps the created task ID onto the reservation and
// associates the PR with the task so the frontend can display PR info.
func (s *Service) attachTaskToReservation(ctx context.Context, evt *github.NewReviewPREvent, taskID string) {
if s.githubService == nil {
return
}
pr := evt.PR
if err := s.githubService.AssignReviewPRTaskID(
ctx, evt.ReviewWatchID, pr.RepoOwner, pr.RepoName, pr.Number, taskID,
); err != nil {
s.logger.Error("failed to assign task ID to review PR reservation",
zap.String("task_id", taskID),
zap.Int("pr_number", pr.Number),
zap.Error(err))
}
// Review tasks are single-repo (the PR's own repo). Resolve to that
// task_repository's repository_id so the resulting TaskPR row is scoped
// per-repo even though there's only one. Empty falls back to legacy
// "delete all" semantics which is fine for the 1-repo case.
repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID)
if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); err != nil {
s.logger.Error("failed to associate PR with review task",
zap.String("task_id", taskID),
zap.Int("pr_number", pr.Number),
zap.Error(err))
}
}
// buildReviewTaskRequest builds the ReviewTaskRequest payload from an event.
func buildReviewTaskRequest(evt *github.NewReviewPREvent, repositories []ReviewTaskRepository, repoSlug string) *ReviewTaskRequest {
pr := evt.PR
return &ReviewTaskRequest{
WorkspaceID: evt.WorkspaceID,
WorkflowID: evt.WorkflowID,
WorkflowStepID: evt.WorkflowStepID,
Title: service.TruncateTaskTitle(fmt.Sprintf("PR #%d: %s", pr.Number, pr.Title)),
Description: interpolateReviewPrompt(evt.Prompt, pr),
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,
// Permanent guard: signals that the auto-start idempotency protocol
// is active for this task. Both Path A (promotion, async) and Path B
// (watcher, sync) check this marker and then compete for the one-shot
// MetaKeyAutoStartClaimed token before calling StartTask.
models.MetaKeyAutoStartGuard: true,
// One-shot token: both paths atomically remove this key; only the
// first removal wins and proceeds to StartTask. Prevents the
// duplicate-agent bug when CreateTask synchronously promotes the task
// from its feeder into an auto-start destination.
models.MetaKeyAutoStartClaimed: true,
},
}
}
// buildIssueTaskTitle builds the "Issue #<n>: <title>" task title for an issue
// watcher, shortened to the task title limit so an over-long remote issue title
// does not cause CreateIssueTask to reject the whole task.
func buildIssueTaskTitle(number int, title string) string {
return service.TruncateTaskTitle(fmt.Sprintf("Issue #%d: %s", number, title))
}
// 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,
) {
if s.shouldSkipTerminalPRAutoStart(ctx, task) {
return
}
createAutoStartOwned := s.ownsAutoStartOnCreateInFlight(task.ID)
// Compete for the one-shot auto-start token set at task creation.
// The promotion that ran inside CreateTask may have already triggered
// autoStartTaskForStep (Path A) asynchronously; only the first claimer
// launches so exactly one session is created for the task.
if !s.claimAutoStart(ctx, task.ID, "review.auto_start") {
s.logger.Debug("review auto-start claim lost; promotion path will launch",
zap.String("task_id", task.ID),
zap.Int("pr_number", evt.PR.Number))
return
}
_, err := s.StartTask(
ctx,
task.ID,
evt.AgentProfileID,
"",
evt.ExecutorProfileID,
"",
task.Description,
task.WorkflowStepID,
false,
true,
nil,
)
if errors.Is(err, ErrCeilingLaunchDeferred) {
s.logger.Info("review auto-start deferred by session ceiling; will replay once capacity frees up",
zap.String("task_id", task.ID),
zap.Int("pr_number", evt.PR.Number))
s.completeAutoStartOnCreate(ctx, task.ID, "review.auto_start")
return
}
if err != nil {
s.logger.Error("failed to auto-start review task",
zap.String("task_id", task.ID),
zap.Error(err))
s.restoreAutoStartClaim(ctx, task.ID, "review.auto_start")
if createAutoStartOwned || s.ownsAutoStartOnCreateInFlight(task.ID) {
s.restoreAutoStartOnCreate(ctx, task.ID, "review.auto_start")
}
return
}
// The watcher path can win the permanent review guard while the
// create-time path is still preparing its detached launch. A successful
// watcher launch satisfies that intent too, so clear the durable marker
// owned by the other path.
s.completeAutoStartOnCreate(ctx, task.ID, "review.auto_start")
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))
// SECURITY (defense-in-depth): the PR head branch is attacker-controlled for
// fork PRs and flows unescaped-at-source into executor prepare scripts via
// {{worktree.branch}} / {{repository.branch}}. Reject any head branch that
// isn't a plain, safe git ref before it ever reaches CheckoutBranch. We still
// create the review task (just without a checkout) so a malicious ref cannot
// suppress review entirely; the scriptengine now also shell-escapes values.
if !securityutil.IsValidBranchName(pr.HeadBranch) {
s.logger.Warn("PR head branch failed branch-name validation; creating review task without checkout branch",
zap.String("repo", repoSlug),
zap.String("pr_head_branch", pr.HeadBranch),
zap.Int("pr_number", pr.Number))
return []ReviewTaskRepository{{RepositoryID: repoID, BaseBranch: baseBranch, PRNumber: pr.Number}}
}
// 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, PRNumber: pr.Number}}
}
// 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 is intentionally NOT deleted on return: leaving the
// stored ahead=0 in place causes subsequent status events to fall into the
// `prevAhead <= 0` skip in trackPushAndAssociatePR, which prevents repeated
// firing on every status event for a synced branch. The tracker is cleaned
// up at session-deletion time via pushTrackerForget.
//
// Multi-repo: repositoryName scopes the lookup so each repo's push is detected
// and associated independently. Empty repositoryName falls back to the
// session's primary repo (legacy single-repo path).
func (s *Service) detectPushAndAssociatePR(
ctx context.Context, sessionID, taskID, repositoryName, branch string,
) {
if s.githubService == nil {
return
}
identity := s.resolvePushRepositoryIdentity(ctx, sessionID, taskID, repositoryName)
s.detectPushAndAssociatePRWithIdentity(ctx, sessionID, taskID, repositoryName, branch, identity)
}
func (s *Service) detectPushAndAssociatePRWithIdentity(
ctx context.Context, sessionID, taskID, repositoryName, branch string, identity pushRepositoryIdentity,
) {
workspaceID := s.taskWorkspaceID(ctx, taskID)
if workspaceID == "" {
return
}
if identity.owner == "" || identity.name == "" {
return
}
if prDiscoveryContextCanceled(ctx) {
return
}
// Check if we already have a watch for this (session, repo, branch).
// Multi-branch: keying by branch as well means a secondary branch's
// push doesn't get short-circuited by the primary's already-found
// watch (which previously dropped #1218-style PRs on the floor).
// If the watch already has a PR number, the PR was found — nothing to do.
// If the watch has pr_number=0, it's still searching — do an immediate
// search (faster than waiting for the 1-minute poller).
existing, err := s.githubService.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, identity.repositoryID, branch)
if prDiscoveryContextCanceled(ctx) {
return
}
if err == nil && existing != nil {
if existing.PRNumber > 0 {
return // PR already found and being monitored
}
s.searchPRForExistingWatch(ctx, workspaceID, existing, sessionID, taskID, branch)
return
}
foundPR := s.discoverPRAfterPush(ctx, workspaceID, sessionID, taskID, identity, branch)
if foundPR == nil {
return
}
s.logger.Info("PR found after push, associating with task",
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_name", repositoryName),
zap.Int("pr_number", foundPR.Number),
zap.String("branch", branch))
s.associatePRFromPushScoped(ctx, workspaceID, sessionID, taskID, identity.owner, identity.name, identity.repositoryID, branch, foundPR)
}
func (s *Service) discoverPRAfterPush(
ctx context.Context,
workspaceID, sessionID, taskID string,
identity pushRepositoryIdentity,
branch string,
) *github.PR {
const prDiscoveryOutcomeFailed = "failed"
delays := []time.Duration{0, 30 * time.Second, 60 * time.Second}
attemptCount := 0
errorCount := 0
emptyCount := 0
lastOutcome := ""
for attempt, delay := range delays {
if delay > 0 {
if !s.waitForPRDiscoveryRetry(ctx, delay) {
return nil
}
// Re-check if a watch was created in the meantime (e.g. by CreatePR callback).
if ex, err := s.githubService.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, identity.repositoryID, branch); prDiscoveryContextCanceled(ctx) {
return nil
} else if err == nil && ex != nil {
return nil
}
}
if prDiscoveryContextCanceled(ctx) {
return nil
}
attemptCount++
foundPR, findErr := s.githubService.FindPRByBranchForWorkspace(
ctx, workspaceID, identity.owner, identity.name, branch,
)
if prDiscoveryContextCanceled(ctx) {
return nil
}
if findErr != nil {
errorCount++
lastOutcome = prDiscoveryOutcomeFailed
s.logger.Warn("PR discovery lookup failed after push",
zap.String("operation", "post_push_branch_lookup"),
zap.String("workspace_id", workspaceID),
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_id", identity.repositoryID),
zap.String("owner", identity.owner),
zap.String("repo", identity.name),
zap.String("branch", branch),
zap.String("category", string(github.ClassifyPRDiscoveryError(findErr))),
zap.Int("attempt", attempt+1))
continue
}
if foundPR == nil {
emptyCount++
lastOutcome = "empty"
s.logger.Debug("no PR found for branch (will retry)",
zap.String("operation", "post_push_branch_lookup"),
zap.String("workspace_id", workspaceID),
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_id", identity.repositoryID),
zap.String("owner", identity.owner),
zap.String("repo", identity.name),
zap.String("branch", branch),
zap.Int("attempt", attempt+1),
zap.Duration("delay", delay))
continue
}
return foundPR
}
s.logPRDiscoveryExhausted(workspaceID, sessionID, taskID, identity, branch,
attemptCount, errorCount, emptyCount, lastOutcome)
return nil
}
func (s *Service) logPRDiscoveryExhausted(
workspaceID, sessionID, taskID string,
identity pushRepositoryIdentity,
branch string,
attemptCount, errorCount, emptyCount int,
lastOutcome string,
) {
s.logger.Debug("exhausted all retries, no PR found after push",
zap.String("operation", "post_push_branch_lookup"),
zap.String("workspace_id", workspaceID),
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_id", identity.repositoryID),
zap.String("owner", identity.owner),
zap.String("repo", identity.name),
zap.String("branch", branch),
zap.Int("attempt_count", attemptCount),
zap.Int("error_count", errorCount),
zap.Int("empty_count", emptyCount),
zap.String("last_outcome", lastOutcome))
}
func (s *Service) waitForPRDiscoveryRetry(ctx context.Context, delay time.Duration) bool {
if s.prDiscoveryWait != nil {
return s.prDiscoveryWait(ctx, delay)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func prDiscoveryContextCanceled(ctx context.Context) bool {
// Provider clients can use their own timeout context and return a wrapped
// context error while the caller remains active. Only the caller context
// decides whether discovery should stop quietly.
return ctx.Err() != nil
}
// resolvePushRepo returns (owner, name, repository_id) for the per-repo push
// detection.
//
// - Empty repositoryName → session's primary repo (legacy single-repo).
// - Exact match on repoObj.Name → multi-repo path: that repository.
// - Subdir prefix match (`<repo.Name>-<branch-slug>`) → multi-branch path:
// the named subdir belongs to a sibling worktree of the same repo, so
// resolve back to that repository's id. Without this fallback the
// secondary branch's push event arrives with a tracker-tag that no
// `task_repositories` row matches, push detection short-circuits, and
// the secondary PR never registers.
func (s *Service) resolvePushRepo(
ctx context.Context, sessionID, taskID, repositoryName string,
) (owner, repo, repositoryID string) {
if repositoryName == "" {
o, r := s.resolveSessionRepo(ctx, sessionID)
return o, r, s.resolvePrimaryTaskRepositoryID(ctx, taskID)
}
store, ok := s.repo.(repoStore)
if !ok {
return "", "", ""
}
links, err := store.ListTaskRepositories(ctx, taskID)
if err != nil {
return "", "", ""
}
// Two passes: exact name first so a repo whose name is a prefix of a
// sibling (e.g. "backend" vs "backend-admin") doesn't swallow a push
// tagged with the longer name via isMultiBranchSubdir.
if owner, repo, id := matchPushRepo(ctx, s, store, links, repositoryName, true); id != "" {
return owner, repo, id
}
return matchPushRepo(ctx, s, store, links, repositoryName, false)
}
// matchPushRepo walks links once and returns the first matching repo. When
// exactOnly is true only Name == repositoryName matches; otherwise the
// multi-branch subdir prefix is also accepted.
func matchPushRepo(
ctx context.Context,
s *Service,
store repoStore,
links []*models.TaskRepository,
repositoryName string,
exactOnly bool,
) (owner, repo, repositoryID string) {
for _, link := range links {
repoObj, err := store.GetRepository(ctx, link.RepositoryID)
if err != nil || repoObj == nil {
continue
}
matched := repoObj.Name == repositoryName
if !matched && !exactOnly {
matched = isMultiBranchSubdir(repositoryName, repoObj.Name)
}
if !matched {
continue
}
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, link.RepositoryID
}
return "", "", ""
}
// isMultiBranchSubdir reports whether subdir is a multi-branch sibling of
// repoName (formatted as `<repoName>-<slug>`). Used by resolvePushRepo to
// route a tracker event tagged with the subdir name back to the underlying
// repository row.
func isMultiBranchSubdir(subdir, repoName string) bool {
if repoName == "" {
return false
}
prefix := repoName + "-"
return len(subdir) > len(prefix) && subdir[:len(prefix)] == prefix
}
// searchPRForExistingWatch handles the case where a PR watch exists with pr_number=0
// (still searching). It updates the watch branch if the agent pushed from a different
// branch, then does a single immediate search so we don't wait for the 1-minute poller.
func (s *Service) searchPRForExistingWatch(
ctx context.Context, workspaceID string, watch *github.PRWatch,
sessionID, taskID, branch string,
) {
// Update branch if the agent switched branches since the watch was created.
// Use the atomic variant to guard against a concurrent PR association.
if watch.Branch != branch {
if err := s.githubService.UpdatePRWatchBranchIfSearching(ctx, watch.ID, branch); err != nil {
s.logger.Warn("failed to update PR watch branch",
zap.String("watch_id", watch.ID),
zap.String("old_branch", watch.Branch),
zap.String("new_branch", branch),
zap.Error(err))
}
}
// Immediate search — if found, update the existing watch and associate.
// We must not call associatePRFromPush here because it calls CreatePRWatch,
// which would fail with a UNIQUE constraint since the watch already exists.
foundPR, findErr := s.githubService.FindPRByBranchForWorkspace(
ctx, workspaceID, watch.Owner, watch.Repo, branch,
)
if prDiscoveryContextCanceled(ctx) {
return
}
if findErr != nil {
s.logger.Warn("PR discovery lookup failed for existing watch",
zap.String("operation", "existing_watch_branch_lookup"),
zap.String("workspace_id", workspaceID),
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_id", watch.RepositoryID),
zap.String("owner", watch.Owner),
zap.String("repo", watch.Repo),
zap.String("branch", branch),
zap.String("category", string(github.ClassifyPRDiscoveryError(findErr))))
return
}
if foundPR == nil {
s.logger.Debug("no PR found for existing watch",
zap.String("operation", "existing_watch_branch_lookup"),
zap.String("workspace_id", workspaceID),
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.String("repository_id", watch.RepositoryID),
zap.String("owner", watch.Owner),
zap.String("repo", watch.Repo),
zap.String("branch", branch))
return
}
if foundPR.RepoOwner != "" && foundPR.RepoName != "" &&
(!strings.EqualFold(watch.Owner, foundPR.RepoOwner) || !strings.EqualFold(watch.Repo, foundPR.RepoName)) {
if err := s.githubService.UpdatePRWatchRepository(ctx, watch.ID, foundPR.RepoOwner, foundPR.RepoName); err != nil {
s.logger.Warn("failed to rebind PR watch repository",
zap.String("watch_id", watch.ID),
zap.String("owner", foundPR.RepoOwner),
zap.String("repo", foundPR.RepoName),
zap.Error(err))
return
}
watch.Owner = foundPR.RepoOwner
watch.Repo = foundPR.RepoName
}
if err := s.githubService.UpdatePRWatchPRNumber(ctx, watch.ID, foundPR.Number); err != nil {
s.logger.Warn("failed to update PR watch number",
zap.String("watch_id", watch.ID),
zap.Int("pr_number", foundPR.Number),
zap.Error(err))
}
// Use the watch's own repository_id so the association lands on the
// correct per-repo TaskPR row (matters once multi-repo watches exist;
// for legacy single-repo watches this is empty and matches the old
// "delete all" behavior).
if _, err := s.githubService.AssociatePRWithTaskForWorkspace(
ctx, workspaceID, taskID, watch.RepositoryID, foundPR,
); err != nil {
s.logger.Error("failed to associate PR with task",
zap.String("task_id", taskID),
zap.Int("pr_number", foundPR.Number),
zap.Error(err))
}
s.logger.Info("auto-detected PR from push (existing watch)",
zap.String("session_id", sessionID),
zap.Int("pr_number", foundPR.Number),
zap.String("branch", branch))
}
// 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 one PRWatch (pr_number=0) per repository in
// the task so the poller will search GitHub for each repo's PR. Runs as a
// background goroutine.
//
// Multi-repo: each repository gets its own watch row because the table is
// keyed UNIQUE(session_id, repository_id). Without this, only the primary
// repo's PR was ever discovered — secondary repos in a multi-repo task
// silently dropped their associations.
//
// `fallbackBranch` is used only when a task_repository has no checkout_branch
// AND the session worktree for that repo has no branch (rare; ensures
// backwards compatibility with single-repo callers that pass the resolved
// branch directly).
//
// The watch's task_id is redirected via resolveEffectivePushTaskID, same as
// push detection: this runs on every session start, so without the redirect
// a subtask sharing its parent's worktree gets its own member-attributed
// watch here even when push detection itself is fixed — the watch the
// poller then associates the PR under (see poller.go's detectPRForWatch).
func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, fallbackBranch string) {
if s.githubService == nil {
return
}
workspaceID := s.taskWorkspaceID(ctx, taskID)
if workspaceID == "" {
return
}
targets := s.resolveSessionWatchTargets(ctx, taskID, sessionID, fallbackBranch)
for _, t := range targets {
effectiveTaskID := s.resolveEffectivePushTaskIDForSession(ctx, sessionID, taskID, t.RepositoryID)
if _, err := s.githubService.EnsurePRWatchForWorkspace(
ctx, workspaceID, sessionID, effectiveTaskID, t.RepositoryID, t.Owner, t.Repo, t.Branch,
); err != nil {
s.logger.Warn("failed to ensure PR watch for session",
zap.String("session_id", sessionID),
zap.String("repository_id", t.RepositoryID),
zap.String("branch", t.Branch),
zap.Error(err))
}
}
}
func (s *Service) taskWorkspaceID(ctx context.Context, taskID string) string {
task, err := s.repo.GetTask(ctx, taskID)
if err != nil || task == nil {
return ""
}
return strings.TrimSpace(task.WorkspaceID)
}
// sessionWatchTarget describes one (session, repo) pair that should have a
// PR watch. Built by walking the task's repositories and matching each to
// the session's worktree (branch) and the repo (owner/name).
type sessionWatchTarget struct {
RepositoryID string
Owner string
Repo string
Branch string
}
// resolveSessionWatchTargets walks the task's repositories and produces one
// target per repo whose owner/name and branch resolve. Skips repos for which
// any of those fields are empty — they can't be searched on GitHub.
//
// Branch resolution per repo (matches the legacy single-repo
// resolvePRWatchBranch priority so PR-review-style synthetic worktree
// branches don't override the task's real branch):
//
// 1. task_repository.checkout_branch (set at task creation — authoritative)
// 2. caller's fallbackBranch, but only for the primary repo (legacy signature)
// 3. session worktree's branch (last-resort guess from the worktree)
//
// Applying the fallback to non-primary repos would assign every repo the same
// branch and corrupt cross-repo watches.
func (s *Service) resolveSessionWatchTargets(
ctx context.Context, taskID, sessionID, fallbackBranch string,
) []sessionWatchTarget {
store, ok := s.repo.(repoStore)
if !ok {
return nil
}
// Multi-branch: when a session has TWO OR MORE worktrees on the same
// repository, derive targets from the worktree rows directly. Each
// worktree's actual branch (worktree_branch) is the right key because
// task_repositories.checkout_branch may differ when the worktree
// manager suffixed a collision, and the agent pushes the actual name.
// Single-branch tasks (including PR-review tasks with a synthetic
// worktree branch + an authoritative checkout_branch) fall through to
// the legacy task_repositories walk.
if targets := s.targetsFromMultiBranchWorktrees(ctx, store, sessionID); len(targets) > 0 {
return targets
}
taskRepos, err := store.ListTaskRepositories(ctx, taskID)
if err != nil || len(taskRepos) == 0 {
return nil
}
branchByRepo := s.branchByRepoForSession(ctx, sessionID)
var targets []sessionWatchTarget
for _, tr := range taskRepos {
owner, repoName := s.resolveRepoOwnerName(ctx, store, tr.RepositoryID)
if owner == "" || repoName == "" {
continue
}
branch := strings.TrimSpace(tr.CheckoutBranch)
if branch == "" && tr.Position == 0 {
branch = fallbackBranch
}
if branch == "" {
branch = branchByRepo[tr.RepositoryID]
}
if branch == "" {
continue
}
targets = append(targets, sessionWatchTarget{
RepositoryID: tr.RepositoryID,
Owner: owner,
Repo: repoName,
Branch: branch,
})
}
return targets
}
// targetsFromMultiBranchWorktrees emits one target per environment repository
// row, but ONLY when at least one repository has more than one worktree —
// the multi-branch case. The worktree's actual branch (worktree_branch) is
// the authoritative key because the worktree manager may suffix a
// requested checkout_branch on collision, and the agent pushes the
// suffixed name; using task_repositories.checkout_branch would search
// GitHub for a branch name that doesn't exist.
//
// Returns nil for single-branch sessions so callers keep the legacy
// task_repositories walk (which honors PR-review tasks' authoritative
// checkout_branch over their synthetic worktree branch).
func (s *Service) targetsFromMultiBranchWorktrees(ctx context.Context, store repoStore, sessionID string) []sessionWatchTarget {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil || len(session.Worktrees) < 2 {
return nil
}
repoCount := make(map[string]int, len(session.Worktrees))
for _, wt := range session.Worktrees {
if wt.RepositoryID == "" {
continue
}
repoCount[wt.RepositoryID]++
}
multiBranch := false
for _, n := range repoCount {
if n > 1 {
multiBranch = true
break
}
}
if !multiBranch {
return nil
}
var targets []sessionWatchTarget
for _, wt := range session.Worktrees {
branch := strings.TrimSpace(wt.WorktreeBranch)
if branch == "" || wt.RepositoryID == "" {
continue
}
owner, repoName := s.resolveRepoOwnerName(ctx, store, wt.RepositoryID)
if owner == "" || repoName == "" {
continue
}
targets = append(targets, sessionWatchTarget{
RepositoryID: wt.RepositoryID,
Owner: owner,
Repo: repoName,
Branch: branch,
})
}
return targets
}
// branchByRepoForSession returns repository_id → worktree_branch for every
// worktree on the session. Empty map if the session can't be loaded — caller
// falls back to the task_repository's configured branch.
func (s *Service) branchByRepoForSession(ctx context.Context, sessionID string) map[string]string {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil {
return nil
}
out := make(map[string]string, len(session.Worktrees))
for _, wt := range session.Worktrees {
if wt.WorktreeBranch == "" || wt.RepositoryID == "" {
continue
}
out[wt.RepositoryID] = wt.WorktreeBranch
}
return out
}
// resolveRepoOwnerName returns (owner, name) for a repository, backfilling
// provider info from the local git remote when missing. Mirrors what
// resolveTaskRepo does for the primary repo, but works against any repo id.
func (s *Service) resolveRepoOwnerName(
ctx context.Context, store repoStore, repositoryID string,
) (string, string) {
if repositoryID == "" {
return "", ""
}
repoObj, err := store.GetRepository(ctx, 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
}
// resolvePrimaryTaskRepositoryID returns the primary task_repository's
// repository_id for a task, or "" on miss / error. Used to scope PR
// watches and TaskPR rows to the correct per-repo row in multi-repo tasks
// (and to the single repo's row in single-repo tasks). Empty preserves the
// legacy single-repo behavior at the store layer.
func (s *Service) resolvePrimaryTaskRepositoryID(ctx context.Context, taskID string) string {
store, ok := s.repo.(repoStore)
if !ok {
return ""
}
taskRepo, err := store.GetPrimaryTaskRepository(ctx, taskID)
if err != nil || taskRepo == nil {
return ""
}
return taskRepo.RepositoryID
}
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
}
// associatePRFromPushScoped creates the PR watch and task-PR association
// after push detection, scoped to a specific repository_id. Multi-repo callers
// pass the per-repo id resolved from the git event's repository_name; the
// legacy single-repo path passes the primary task_repository id.
func (s *Service) associatePRFromPushScoped(
ctx context.Context, workspaceID, sessionID, taskID, owner, repoName, repositoryID, branch string, pr *github.PR,
) {
watchOwner, watchRepo := owner, repoName
if pr != nil && pr.RepoOwner != "" && pr.RepoName != "" {
watchOwner, watchRepo = pr.RepoOwner, pr.RepoName
}
watch, watchErr := s.githubService.CreatePRWatchForWorkspace(
ctx, workspaceID, sessionID, taskID, repositoryID, watchOwner, watchRepo, pr.Number, branch,
)
if watchErr != nil {
s.logger.Error("failed to create PR watch on push detection",
zap.String("session_id", sessionID),
zap.String("repository_id", repositoryID),
zap.Error(watchErr))
} else if watch != nil && watch.ID != "" &&
(!strings.EqualFold(watch.Owner, watchOwner) || !strings.EqualFold(watch.Repo, watchRepo)) {
if err := s.githubService.UpdatePRWatchRepository(ctx, watch.ID, watchOwner, watchRepo); err != nil {
s.logger.Warn("failed to rebind existing PR watch repository",
zap.String("watch_id", watch.ID), zap.Error(err))
}
}
if _, assocErr := s.githubService.AssociatePRWithTaskForWorkspace(
ctx, workspaceID, taskID, repositoryID, pr,
); assocErr != nil {
s.logger.Error("failed to associate PR with task on push detection",
zap.String("task_id", taskID),
zap.String("repository_id", repositoryID),
zap.Error(assocErr))
}
s.logger.Info("auto-detected PR from push",
zap.String("session_id", sessionID),
zap.String("repository_id", repositoryID),
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) {
// Per-user scoping first, before any early return: this both reveals PR
// association and installs a PR watch, so it must be owner-only. Report
// "no PR" rather than an error so a foreign session leaks nothing about
// its existence.
//
// Both IDs, not just the session: everything below (GetTaskPR,
// resolveTaskRepo, EnsurePRWatch, resolvePrimaryTaskRepositoryID) is keyed
// off taskID, so authorizing only the session would let a caller pair one
// of their own sessions with another user's task.
if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil {
return false, nil
}
if s.githubService == nil {
return false, nil
}
// The watch/association write destination is redirected the same way as
// push detection (resolveEffectivePushTaskID): this is the frontend's
// on-demand alternative to that same detection, so without the redirect a
// user opening a shared-worktree subtask reproduces the multi-binding on
// demand. Resolved first, ahead of resolveTaskRepo/branch below, so the
// already-associated short-circuit only needs a repositoryID lookup, not
// a full owner/repo/branch resolution.
repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID)
effectiveTaskID := s.resolveEffectivePushTaskIDForSession(ctx, sessionID, taskID, repositoryID)
// Check if a PR is already associated with the effective task.
existing, err := s.githubService.GetTaskPR(ctx, effectiveTaskID)
if err == nil && existing != nil {
return true, nil
}
// Resolve the GitHub owner/repo from the task's repository. This, and
// branch resolution below, stay keyed off the observing taskID: the
// physical checkout and task_repositories rows belong to whichever task's
// session the frontend is checking, same as dispatchPushDetection.
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
workspaceID := s.taskWorkspaceID(ctx, taskID)
if workspaceID == "" {
return false, github.ErrGitHubWorkspaceRequired
}
if _, watchErr := s.githubService.EnsurePRWatchForWorkspace(
ctx, workspaceID, sessionID, effectiveTaskID, repositoryID, 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
pr, findErr := s.githubService.FindPRByBranchForWorkspace(
ctx, workspaceID, owner, repoName, branch,
)
if findErr != nil || pr == nil {
return false, nil
}
// Found a PR — associate it with the effective task.
s.associatePRFromPushScoped(ctx, workspaceID, sessionID, effectiveTaskID, owner, repoName, repositoryID, branch, pr)
return true, nil
}
// ListTasksNeedingPRWatch returns task-session pairs that have worktree branches
// but no existing PR watch. This satisfies the github.TaskBranchProvider interface.
func (s *Service) ListTasksNeedingPRWatch(ctx context.Context) ([]github.TaskBranchInfo, error) {
if s.githubService == nil {
return nil, nil
}
store, ok := s.repo.(repoStore)
if !ok {
return nil, nil
}
return s.buildTaskBranchList(ctx, store)
}
// ResolveBranchForRepository returns the current desired branch for a task's
// repository, independent of which session (if any) created the watch.
// Session identity does not participate in canonical PR-watch identity or
// polling (task/repository/branch owns searching watches), so a still-
// searching watch's branch must be re-derived from the task's own
// configuration, not from whichever session happened to create it — a prior
// per-session resolver ignored watch.RepositoryID entirely and always
// resolved against the task's PRIMARY repository, corrupting secondary-repo
// watches on multi-repo tasks. This satisfies the github.TaskBranchProvider
// interface.
func (s *Service) ResolveBranchForRepository(ctx context.Context, taskID, repositoryID string) string {
store, ok := s.repo.(repoStore)
if !ok {
return ""
}
if branch := s.checkoutBranchForRepository(ctx, store, taskID, repositoryID); branch != "" {
return branch
}
// Fallback: any currently-active session's worktree branch for this
// specific repository. Session identity is a best-effort provenance
// candidate only here (checkout_branch is authoritative once set).
return s.activeWorktreeBranchForRepository(ctx, taskID, repositoryID)
}
// ResolveBranchesForRepository returns every authoritative checkout branch
// configured for a task/repository pair. Multi-branch tasks may legitimately
// have several rows for the same repository, so PR-watch reconciliation must
// preserve each matching watch instead of collapsing the repository to the
// first row returned by ListTaskRepositories.
func (s *Service) ResolveBranchesForRepository(ctx context.Context, taskID, repositoryID string) []string {
store, ok := s.repo.(repoStore)
if !ok {
return nil
}
branches := s.checkoutBranchesForRepository(ctx, store, taskID, repositoryID)
if len(branches) > 0 {
return branches
}
if branch := s.activeWorktreeBranchForRepository(ctx, taskID, repositoryID); branch != "" {
return []string{branch}
}
return nil
}
// checkoutBranchForRepository returns the authoritative checkout_branch
// configured for a specific task/repository pair, or "" if unset or missing.
func (s *Service) checkoutBranchForRepository(ctx context.Context, store repoStore, taskID, repositoryID string) string {
branches := s.checkoutBranchesForRepository(ctx, store, taskID, repositoryID)
if len(branches) == 0 {
return ""
}
return branches[0]
}
func (s *Service) checkoutBranchesForRepository(ctx context.Context, store repoStore, taskID, repositoryID string) []string {
taskRepos, err := store.ListTaskRepositories(ctx, taskID)
if err != nil {
return nil
}
seen := make(map[string]bool)
var branches []string
for _, tr := range taskRepos {
if tr.RepositoryID == repositoryID {
branch := strings.TrimSpace(tr.CheckoutBranch)
if branch == "" || seen[branch] {
continue
}
seen[branch] = true
branches = append(branches, branch)
}
}
return branches
}
// activeWorktreeBranchForRepository returns the worktree branch of any
// currently-active session on the task whose worktree targets the given
// repository. Best-effort fallback only.
func (s *Service) activeWorktreeBranchForRepository(ctx context.Context, taskID, repositoryID string) string {
sessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, taskID)
if err != nil {
return ""
}
for _, sess := range sessions {
for _, wt := range sess.Worktrees {
if wt.RepositoryID == repositoryID && strings.TrimSpace(wt.WorktreeBranch) != "" {
return strings.TrimSpace(wt.WorktreeBranch)
}
}
}
return ""
}
// buildTaskBranchList walks sessions × their repositories and emits one
// TaskBranchInfo per (task, repository, branch) that doesn't already have a
// PR watch. Session identity does not own canonical PR-watch identity, so
// multiple sessions resolving to the same (task, repository, branch) target
// collapse to a single emission here — one lookup per canonical target per
// cycle, regardless of how many sessions are active on it.
//
// TaskID is redirected via resolveEffectivePushTaskID (to the workspace-group
// owner task) before the dedup key is computed and before the caller
// (poller.reconcileWatches) creates the watch, same as ensureSessionPRWatch:
// this is the third and last producer that would otherwise write a
// member-attributed watch for a shared-worktree subtask. Deduping on the
// pre-redirect sess.TaskID would let a member and its owner both emit a
// TaskBranchInfo for the same underlying repository/branch, so the redirect
// must run first — canonical identity is (effective task, repository,
// branch), never session or pre-redirect task.
func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]github.TaskBranchInfo, error) {
sessions, err := store.ListSessionsWithBranches(ctx)
if err != nil {
return nil, fmt.Errorf("list sessions with branches: %w", err)
}
if len(sessions) == 0 {
return nil, nil
}
// Batch fetch existing watches to avoid N+1 queries.
watchedKeys := s.buildWatchedTaskRepoBranchSet(ctx)
emitted := make(map[string]bool)
var result []github.TaskBranchInfo
seenSessions := make(map[string]struct{})
for _, sess := range sessions {
if _, seen := seenSessions[sess.SessionID]; seen {
continue
}
seenSessions[sess.SessionID] = struct{}{}
// Inventory rows can carry any repository's branch. Resolve each session
// once from its per-repository targets, as watch refresh does.
targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, "")
for _, t := range targets {
effectiveTaskID := s.resolveEffectivePushTaskIDForSession(ctx, sess.SessionID, sess.TaskID, t.RepositoryID)
key := watchedTaskRepoBranchKey(effectiveTaskID, t.RepositoryID, t.Branch)
if watchedKeys[key] || emitted[key] {
continue
}
emitted[key] = true
result = append(result, github.TaskBranchInfo{
WorkspaceID: sess.WorkspaceID,
TaskID: effectiveTaskID,
SessionID: sess.SessionID,
RepositoryID: t.RepositoryID,
Owner: t.Owner,
Repo: t.Repo,
Branch: t.Branch,
})
}
}
return result, nil
}
// buildWatchedTaskRepoBranchSet returns the set of (task_id, repository_id,
// branch) triples that already have a PR watch row. Canonical searching
// watches are owned by task/repository/branch, not session_id, so dedup here
// must key on the same triple to avoid re-creating a watch that another
// session already established for the same task/repo/branch.
func (s *Service) buildWatchedTaskRepoBranchSet(ctx context.Context) map[string]bool {
watches, err := s.githubService.ListActivePRWatches(ctx)
if err != nil {
s.logger.Debug("failed to list PR watches for reconciliation", zap.Error(err))
return nil
}
set := make(map[string]bool, len(watches))
for _, w := range watches {
set[watchedTaskRepoBranchKey(w.TaskID, w.RepositoryID, w.Branch)] = true
}
return set
}
// watchedTaskRepoBranchKey builds the per-(task, repo, branch) dedup key.
func watchedTaskRepoBranchKey(taskID, repositoryID, branch string) string {
return taskID + "|" + repositoryID + "|" + 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.GitHubTaskPRUpdated, s.handleTaskPRUpdated); err != nil {
s.logger.Error("failed to subscribe to github.task_pr.updated events", zap.Error(err))
}
if _, err := s.eventBus.Subscribe(events.GitHubTaskCIOptionsUpdated, s.handleTaskCIOptionsUpdated); err != nil {
s.logger.Error("failed to subscribe to github.task_ci_options.updated 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))
}
if _, err := s.eventBus.Subscribe(events.GitHubNewIssue, s.handleNewIssue); err != nil {
s.logger.Error("failed to subscribe to github.new_issue events", zap.Error(err))
}
}
// handleNewIssue creates a task for a new GitHub issue matching an issue watch.
func (s *Service) handleNewIssue(ctx context.Context, event *bus.Event) error {
issueEvt, ok := event.Data.(*github.NewIssueEvent)
if !ok {
return nil
}
issue := issueEvt.Issue
s.logger.Info("new issue detected from watch",
zap.String(issueWatchIDKey, issueEvt.IssueWatchID),
zap.String("repo", fmt.Sprintf("%s/%s", issue.RepoOwner, issue.RepoName)),
zap.Int(issueNumberKey, issue.Number))
if s.issueTaskCreator == nil {
s.logger.Warn("issue task creator not configured, skipping task creation")
return nil
}
go s.createIssueTask(context.Background(), issueEvt)
return nil
}
func (s *Service) createIssueTask(ctx context.Context, evt *github.NewIssueEvent) {
issue := evt.Issue
repoSlug := fmt.Sprintf("%s/%s", issue.RepoOwner, issue.RepoName)
if s.preflightDeletedProfileForGitHubIssue(ctx, evt) {
return
}
if !s.reserveIssueWatch(ctx, evt) {
return
}
repositories := s.resolveIssueRepository(ctx, evt.WorkspaceID, issue)
req := &IssueTaskRequest{
WorkspaceID: evt.WorkspaceID,
WorkflowID: evt.WorkflowID,
WorkflowStepID: evt.WorkflowStepID,
Title: buildIssueTaskTitle(issue.Number, issue.Title),
Description: interpolateIssuePrompt(evt.Prompt, issue),
Metadata: map[string]interface{}{
issueWatchIDKey: evt.IssueWatchID,
issueNumberKey: issue.Number,
"issue_url": issue.HTMLURL,
"issue_repo": repoSlug,
"issue_author": issue.AuthorLogin,
"agent_profile_id": evt.AgentProfileID,
"executor_profile_id": evt.ExecutorProfileID,
},
Repositories: repositories,
}
task, err := s.issueTaskCreator.CreateIssueTask(ctx, req)
if err != nil {
if isWatcherCapacityRejection(err) {
s.logger.Info("issue workflow step at capacity; deferring issue task",
zap.String(issueWatchIDKey, evt.IssueWatchID),
zap.Int(issueNumberKey, issue.Number),
zap.String("workflow_step_id", evt.WorkflowStepID),
zap.Error(err))
} else {
s.logger.Error("failed to create issue task",
zap.String(issueWatchIDKey, evt.IssueWatchID),
zap.Int(issueNumberKey, issue.Number),
zap.Error(err))
}
s.releaseIssueWatch(ctx, evt)
return
}
s.attachIssueTaskToReservation(ctx, evt, task.ID)
s.logger.Info("created issue task",
zap.String("task_id", task.ID),
zap.Int(issueNumberKey, issue.Number),
zap.String("repo", repoSlug))
if task.QueuedForStepID != "" || !s.shouldAutoStartStep(ctx, task.WorkflowStepID) {
return
}
s.autoStartIssueTask(ctx, evt, task)
}
func (s *Service) reserveIssueWatch(ctx context.Context, evt *github.NewIssueEvent) bool {
if s.githubService == nil {
return true
}
issue := evt.Issue
reserved, err := s.githubService.ReserveIssueWatchTask(
ctx, evt.IssueWatchID, issue.RepoOwner, issue.RepoName, issue.Number, issue.HTMLURL,
)
if err != nil {
s.logger.Error("failed to reserve issue watch slot",
zap.String(issueWatchIDKey, evt.IssueWatchID),
zap.Int(issueNumberKey, issue.Number),
zap.Error(err))
return false
}
if !reserved {
s.logger.Debug("issue already reserved by concurrent handler, skipping",
zap.String(issueWatchIDKey, evt.IssueWatchID),
zap.Int(issueNumberKey, issue.Number))
return false
}
return true
}
func (s *Service) releaseIssueWatch(ctx context.Context, evt *github.NewIssueEvent) {
if s.githubService == nil {
return
}
issue := evt.Issue
if err := s.githubService.ReleaseIssueWatchTask(
ctx, evt.IssueWatchID, issue.RepoOwner, issue.RepoName, issue.Number,
); err != nil {
s.logger.Warn("failed to release issue watch reservation after task-create failure",
zap.Int(issueNumberKey, issue.Number),
zap.Error(err))
}
}
func (s *Service) attachIssueTaskToReservation(ctx context.Context, evt *github.NewIssueEvent, taskID string) {
if s.githubService == nil {
return
}
issue := evt.Issue
if err := s.githubService.AssignIssueWatchTaskID(
ctx, evt.IssueWatchID, issue.RepoOwner, issue.RepoName, issue.Number, taskID,
); err != nil {
s.logger.Error("failed to assign task ID to issue watch reservation",
zap.String("task_id", taskID),
zap.Int(issueNumberKey, issue.Number),
zap.Error(err))
}
}
// resolveIssueRepository attempts to resolve and clone the issue's repository.
// Returns a slice with one entry on success, or nil on failure (graceful degradation).
func (s *Service) resolveIssueRepository(ctx context.Context, workspaceID string, issue *github.Issue) []IssueTaskRepository {
if s.repositoryResolver == nil {
return nil
}
repoSlug := fmt.Sprintf("%s/%s", issue.RepoOwner, issue.RepoName)
s.logger.Debug("resolving issue repository", zap.String("repo", repoSlug))
repoID, baseBranch, err := s.repositoryResolver.ResolveForReview(
ctx, workspaceID, "github", issue.RepoOwner, issue.RepoName, "",
)
if err != nil {
s.logger.Warn("failed to resolve repository for issue task (continuing without repo)",
zap.String("repo", repoSlug),
zap.Error(err))
return nil
}
if repoID == "" {
return nil
}
s.logger.Debug("resolved issue repository",
zap.String("repo", repoSlug),
zap.String("repo_id", repoID),
zap.String("base_branch", baseBranch))
return []IssueTaskRepository{{RepositoryID: repoID, BaseBranch: baseBranch}}
}
func (s *Service) autoStartIssueTask(
ctx context.Context, evt *github.NewIssueEvent, task *models.Task,
) {
_, err := s.StartTask(
ctx,
task.ID,
evt.AgentProfileID,
"",
evt.ExecutorProfileID,
"",
task.Description,
task.WorkflowStepID,
false,
true,
nil,
)
if errors.Is(err, ErrCeilingLaunchDeferred) {
s.logger.Info("issue auto-start deferred by session ceiling; will replay once capacity frees up",
zap.String("task_id", task.ID),
zap.Int(issueNumberKey, evt.Issue.Number))
return
}
if err != nil {
s.logger.Error("failed to auto-start issue task",
zap.String("task_id", task.ID),
zap.Error(err))
return
}
s.logger.Info("auto-started issue task",
zap.String("task_id", task.ID),
zap.Int(issueNumberKey, evt.Issue.Number))
}
// 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)
}
// interpolateIssuePrompt replaces {{issue.*}} placeholders in the prompt template with actual issue values.
func interpolateIssuePrompt(promptTemplate string, issue *github.Issue) string {
if promptTemplate == "" {
promptTemplate = promptcfg.Get("issue-watch-default")
}
repoSlug := fmt.Sprintf("%s/%s", issue.RepoOwner, issue.RepoName)
labelsStr := strings.Join(issue.Labels, ", ")
replacer := strings.NewReplacer(
"{{issue.link}}", issue.HTMLURL,
"{{issue.number}}", strconv.Itoa(issue.Number),
"{{issue.title}}", issue.Title,
"{{issue.author}}", issue.AuthorLogin,
"{{issue.repo}}", repoSlug,
"{{issue.labels}}", labelsStr,
"{{issue.body}}", issue.Body,
)
return replacer.Replace(promptTemplate)
}