Files
kandev/apps/backend/internal/github/service.go
T
João Salavisa 75f1732421 feat: add GitHub token injection for remote executors and Docker session resume (#654)
* feat: add GitHub token injection for remote executors and Docker session resume

- Add global GitHub access token configuration (backend + frontend)
- Inject GitHub token into Docker and Sprites executors via env vars and Git credential helper
- Transition Docker executor from host mounting to repository cloning
- Implement Docker session resume (reconnect to existing containers after backend restart)
- Add shell validation using exec.LookPath in agentctl
- Add scriptengine providers for agent install and agentctl placeholders

* fix: format github-status.tsx with prettier

* fix: resolve lint issues (goconst, errcheck, file-length)

* fix: address code review findings

- Fix resolveGHCLIToken to check both GITHUB_TOKEN and GH_TOKEN guards
- Fix credential helper to use \\ fallback
- Change /bin/bash to /bin/sh in credential helper for Alpine compat
- Strip embedded token from .git/config after clone in prepare scripts
- Extract buildLaunchAgentRequest helpers to stay under 80-line limit
- Add tests for GitHubAuthProvider, injectTokenIntoURL, reconnect validation
- Add GH_TOKEN-only test case for resolveGHCLIToken

* refactor(executor): extract GitHub credential resolution to separate file

Move injectGitHubToken, injectGitHubTokenFromCLI, resolveRemoteCredentials,
resolveAuthSecrets, methodIDToEnvVar, resolveGHCLIToken, and detectGHToken
out of executor_execute.go (was 803 logical lines, over the 800-line limit
after merging main) into a new executor_credentials.go file. Also adds the
nil-guard on secretStore in resolveAuthSecrets that was flagged by review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 22:15:50 +01:00

1903 lines
61 KiB
Go

package github
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// Auth method constants.
const (
AuthMethodNone = "none"
AuthMethodPAT = "pat"
)
// TaskDeleter deletes tasks by ID. Used for cleaning up merged PR tasks.
type TaskDeleter interface {
DeleteTask(ctx context.Context, taskID string) error
}
// TaskSessionChecker checks if a task has any sessions (user interacted with it).
type TaskSessionChecker interface {
HasTaskSessions(ctx context.Context, taskID string) (bool, error)
}
// SecretManager handles secret creation, update, and deletion.
type SecretManager interface {
Create(ctx context.Context, name, value string) (id string, err error)
Update(ctx context.Context, id, value string) error
Delete(ctx context.Context, id string) error
}
// prSyncFreshnessWindow is how long PR data is considered fresh (skip GitHub API).
const prSyncFreshnessWindow = 30 * time.Second
// Service coordinates GitHub integration operations.
type Service struct {
mu sync.Mutex
client Client
authMethod string
secrets SecretProvider
secretManager SecretManager
store *Store
eventBus bus.EventBus
logger *logger.Logger
taskDeleter TaskDeleter
taskSessionChecker TaskSessionChecker
syncGroup singleflight.Group
taskEventSubs []bus.Subscription
searchCache *ttlCache
prStatusCache *ttlCache
}
// NewService creates a new GitHub service.
func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service {
return &Service{
client: client,
authMethod: authMethod,
secrets: secrets,
store: store,
eventBus: eventBus,
logger: log,
searchCache: newTTLCache(),
prStatusCache: newTTLCache(),
}
}
// SetTaskDeleter sets the task deletion dependency for cleanup operations.
func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d }
// SetTaskSessionChecker sets the session checker for cleanup operations.
func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChecker = c }
// SetSecretManager sets the secret manager for token configuration operations.
func (s *Service) SetSecretManager(m SecretManager) { s.secretManager = m }
// Client returns the underlying GitHub client (may be nil if not authenticated).
func (s *Service) Client() Client {
return s.client
}
// TestStore returns the store for test/mock use only.
func (s *Service) TestStore() *Store {
return s.store
}
// TestEventBus returns the event bus for test/mock use only.
func (s *Service) TestEventBus() bus.EventBus {
return s.eventBus
}
// IsAuthenticated returns whether the service has a working GitHub client.
// Returns false when using the NoopClient fallback (authMethod == "none").
func (s *Service) IsAuthenticated() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.client != nil && s.authMethod != AuthMethodNone
}
// AuthMethod returns the authentication method ("gh_cli", "pat", or "none").
func (s *Service) AuthMethod() string {
return s.authMethod
}
// RequiredGitHubScopes lists the GitHub token scopes needed for full functionality.
var RequiredGitHubScopes = []string{"repo", "read:org"}
// GetStatus returns the current GitHub connection status.
// If not authenticated, it retries client creation to pick up auth changes
// (e.g. GITHUB_TOKEN secret added after startup).
func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) {
if !s.IsAuthenticated() {
s.retryClientCreation(ctx)
}
s.mu.Lock()
client := s.client
authMethod := s.authMethod
s.mu.Unlock()
status := &GitHubStatus{
AuthMethod: authMethod,
RequiredScopes: RequiredGitHubScopes,
}
// Check if a GITHUB_TOKEN secret is configured
tokenSecretID, hasToken := s.findGitHubTokenSecret(ctx)
status.TokenConfigured = hasToken
status.TokenSecretID = tokenSecretID
if client == nil {
status.Diagnostics = runGHDiagnostics(ctx)
return status, nil
}
ok, err := client.IsAuthenticated(ctx)
if err != nil {
return status, nil
}
status.Authenticated = ok
if ok {
user, err := client.GetAuthenticatedUser(ctx)
if err == nil {
status.Username = user
}
} else {
status.Diagnostics = runGHDiagnostics(ctx)
}
return status, nil
}
// findGitHubTokenSecret checks if a GITHUB_TOKEN secret exists in the secrets store.
// Returns the secret ID and whether a token is configured.
func (s *Service) findGitHubTokenSecret(ctx context.Context) (string, bool) {
if s.secrets == nil {
return "", false
}
items, err := s.secrets.List(ctx)
if err != nil {
return "", false
}
for _, item := range items {
if !item.HasValue {
continue
}
if item.Name == "GITHUB_TOKEN" || item.Name == "github_token" {
return item.ID, true
}
}
return "", false
}
// retryClientCreation attempts to create a GitHub client when not authenticated.
// This picks up auth changes made after startup (secrets added, env vars set).
func (s *Service) retryClientCreation(ctx context.Context) {
s.mu.Lock()
defer s.mu.Unlock()
if s.authMethod != AuthMethodNone {
return // already authenticated
}
client, authMethod, err := NewClient(ctx, s.secrets, s.logger)
if err != nil {
s.logger.Debug("GitHub client retry failed", zap.Error(err))
return
}
s.client = client
s.authMethod = authMethod
s.logger.Info("GitHub client recovered after retry",
zap.String("auth_method", authMethod))
}
// runGHDiagnostics runs gh auth status if the gh CLI is available.
func runGHDiagnostics(ctx context.Context) *AuthDiagnostics {
if !GHAvailable() {
return &AuthDiagnostics{
Command: "gh auth status",
Output: "gh CLI is not installed. Install it from https://cli.github.com",
ExitCode: -1,
}
}
return NewGHClient().RunAuthDiagnostics(ctx)
}
// ConfigureToken saves or updates a GitHub Personal Access Token in the secrets store.
// It validates the token by making a test API call before saving.
func (s *Service) ConfigureToken(ctx context.Context, token string) error {
if s.secretManager == nil {
return fmt.Errorf("secret manager not configured")
}
// Validate the token by testing authentication
testClient := NewPATClient(token)
user, err := testClient.GetAuthenticatedUser(ctx)
if err != nil {
return fmt.Errorf("invalid token: %w", err)
}
s.logger.Info("validated GitHub token", zap.String("user", user))
// Check if a GITHUB_TOKEN secret already exists
existingID, exists := s.findGitHubTokenSecret(ctx)
if exists {
// Update existing secret
if err := s.secretManager.Update(ctx, existingID, token); err != nil {
return fmt.Errorf("update token: %w", err)
}
s.logger.Info("updated GitHub token secret", zap.String("id", existingID))
} else {
// Create new secret
newID, err := s.secretManager.Create(ctx, "GITHUB_TOKEN", token)
if err != nil {
return fmt.Errorf("create token: %w", err)
}
s.logger.Info("created GitHub token secret", zap.String("id", newID))
}
// Refresh the client to use the new token
s.mu.Lock()
s.client = testClient
s.authMethod = AuthMethodPAT
s.mu.Unlock()
return nil
}
// ClearToken removes the configured GitHub token from the secrets store.
func (s *Service) ClearToken(ctx context.Context) error {
if s.secretManager == nil {
return fmt.Errorf("secret manager not configured")
}
existingID, exists := s.findGitHubTokenSecret(ctx)
if !exists {
return nil // No token to clear
}
if err := s.secretManager.Delete(ctx, existingID); err != nil {
return fmt.Errorf("delete token: %w", err)
}
s.logger.Info("cleared GitHub token secret", zap.String("id", existingID))
// Reset to try gh CLI or other methods
s.mu.Lock()
s.client = nil
s.authMethod = AuthMethodNone
s.mu.Unlock()
// Try to re-establish connection via other methods
s.retryClientCreation(ctx)
return nil
}
// SubmitReview submits a review on a pull request.
func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
if s.client == nil {
return fmt.Errorf("github client not configured")
}
return s.client.SubmitReview(ctx, owner, repo, number, event, body)
}
// --- PR Watch operations ---
// CreatePRWatch creates a new PR watch for a session.
func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error) {
existing, err := s.store.GetPRWatchBySession(ctx, sessionID)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil // already watching
}
w := &PRWatch{
SessionID: sessionID,
TaskID: taskID,
Owner: owner,
Repo: repo,
PRNumber: prNumber,
Branch: branch,
}
if err := s.store.CreatePRWatch(ctx, w); err != nil {
return nil, fmt.Errorf("create PR watch: %w", err)
}
s.logger.Info("created PR watch",
zap.String("session_id", sessionID),
zap.Int("pr_number", prNumber))
return w, nil
}
// GetPRWatchBySession returns the PR watch for a session.
func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) {
return s.store.GetPRWatchBySession(ctx, sessionID)
}
// ListActivePRWatches returns all active PR watches.
func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) {
return s.store.ListActivePRWatches(ctx)
}
// DeletePRWatch deletes a PR watch by ID.
func (s *Service) DeletePRWatch(ctx context.Context, id string) error {
return s.store.DeletePRWatch(ctx, id)
}
// UpdatePRWatchBranchIfSearching atomically updates branch only when pr_number = 0.
func (s *Service) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error {
return s.store.UpdatePRWatchBranchIfSearching(ctx, id, branch)
}
// UpdatePRWatchPRNumber updates a PR watch's PR number after discovery.
func (s *Service) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error {
return s.store.UpdatePRWatchPRNumber(ctx, id, prNumber)
}
// ResetPRWatch atomically resets a watch's branch and clears its pr_number so
// the poller re-searches for a PR on the new branch. See Store.ResetPRWatch.
func (s *Service) ResetPRWatch(ctx context.Context, id, branch string) error {
return s.store.ResetPRWatch(ctx, id, branch)
}
// CheckPRWatch fetches lightweight PR status for a watch and determines if there are changes.
func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, bool, error) {
if s.client == nil {
return nil, false, fmt.Errorf("github client not available")
}
status, err := s.client.GetPRStatus(ctx, watch.Owner, watch.Repo, watch.PRNumber)
if err != nil {
return nil, false, err
}
hasNew := false
// Check for check status or review state changes
if status.ChecksState != watch.LastCheckStatus {
hasNew = true
}
if status.ReviewState != watch.LastReviewState {
hasNew = true
}
// Update watch timestamps
now := time.Now().UTC()
if err := s.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, status.ChecksState, status.ReviewState); err != nil {
s.logger.Error("failed to update PR watch timestamps", zap.String("id", watch.ID), zap.Error(err))
}
return status, hasNew, nil
}
// EnsurePRWatch creates a PRWatch with pr_number=0 for a session if one doesn't already exist.
// The poller will detect the PR by searching for the branch on GitHub.
func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*PRWatch, error) {
existing, err := s.store.GetPRWatchBySession(ctx, sessionID)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
w := &PRWatch{
SessionID: sessionID,
TaskID: taskID,
Owner: owner,
Repo: repo,
PRNumber: 0,
Branch: branch,
}
if err := s.store.CreatePRWatch(ctx, w); err != nil {
return nil, fmt.Errorf("ensure PR watch: %w", err)
}
s.logger.Info("created PR watch for session (will search for PR)",
zap.String("session_id", sessionID),
zap.String("branch", branch))
return w, nil
}
// --- Task-PR association ---
// AssociatePRWithTask creates a task-PR association.
func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) {
existing, err := s.store.GetTaskPR(ctx, taskID)
if err != nil {
return nil, err
}
if existing != nil && existing.PRNumber == pr.Number {
return existing, nil
}
tp := &TaskPR{
TaskID: taskID,
Owner: pr.RepoOwner,
Repo: pr.RepoName,
PRNumber: pr.Number,
PRURL: pr.HTMLURL,
PRTitle: pr.Title,
HeadBranch: pr.HeadBranch,
BaseBranch: pr.BaseBranch,
AuthorLogin: pr.AuthorLogin,
State: pr.State,
Additions: pr.Additions,
Deletions: pr.Deletions,
CreatedAt: pr.CreatedAt,
MergedAt: pr.MergedAt,
ClosedAt: pr.ClosedAt,
}
// ReplaceTaskPR atomically deletes any existing association for the task
// and inserts the new row inside a single transaction. This preserves the
// effective 1:1 task→PR mapping and prevents a window where the task has
// no associated PR or concurrent calls produce duplicate rows.
if err := s.store.ReplaceTaskPR(ctx, tp); err != nil {
return nil, fmt.Errorf("replace task PR: %w", err)
}
if existing != nil {
s.logger.Info("replaced stale task PR association",
zap.String("task_id", taskID),
zap.Int("old_pr_number", existing.PRNumber),
zap.Int("new_pr_number", pr.Number))
}
// Publish event for UI
if s.eventBus != nil {
event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp)
if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil {
s.logger.Debug("failed to publish task PR updated event", zap.Error(err))
}
}
s.logger.Info("associated PR with task",
zap.String("task_id", taskID),
zap.Int("pr_number", pr.Number))
return tp, nil
}
// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR watch,
// and associates it with the given task. Called after user creates a PR from the UI.
func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL, branch string) {
if s.client == nil {
return
}
owner, repo, prNumber, err := parsePRURL(prURL)
if err != nil {
s.logger.Error("failed to parse PR URL", zap.String("url", prURL), zap.Error(err))
return
}
pr, err := s.client.GetPR(ctx, owner, repo, prNumber)
if err != nil {
s.logger.Error("failed to fetch PR after creation",
zap.String("url", prURL), zap.Error(err))
return
}
// Create PR watch for ongoing monitoring
if branch == "" {
branch = pr.HeadBranch
}
if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, owner, repo, prNumber, branch); watchErr != nil {
s.logger.Error("failed to create PR watch after PR creation",
zap.String("session_id", sessionID), zap.Error(watchErr))
}
// Associate PR with task (persists + publishes WS event)
if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil {
s.logger.Error("failed to associate PR with task after creation",
zap.String("task_id", taskID), zap.Error(assocErr))
}
}
// parsePRURL extracts owner, repo, and PR number from a GitHub PR URL.
// Expected format: https://github.com/{owner}/{repo}/pull/{number}
// Handles trailing slashes, query parameters, and URL fragments.
func parsePRURL(prURL string) (owner, repo string, number int, err error) {
// Strip trailing whitespace/newlines
prURL = strings.TrimSpace(prURL)
// Find the /pull/ segment
idx := strings.Index(prURL, "/pull/")
if idx < 0 {
return "", "", 0, fmt.Errorf("URL does not contain /pull/: %s", prURL)
}
// Parse PR number after /pull/, stripping query params, fragments, and trailing slashes
numStr := prURL[idx+len("/pull/"):]
if i := strings.IndexAny(numStr, "?#"); i >= 0 {
numStr = numStr[:i]
}
numStr = strings.TrimRight(numStr, "/")
number, err = strconv.Atoi(numStr)
if err != nil {
return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", prURL, err)
}
// Parse owner/repo from path before /pull/
pathBefore := prURL[:idx]
// Remove scheme+host prefix (find last two path segments)
parts := strings.Split(strings.TrimRight(pathBefore, "/"), "/")
if len(parts) < 2 {
return "", "", 0, fmt.Errorf("cannot extract owner/repo from URL: %s", prURL)
}
repo = parts[len(parts)-1]
owner = parts[len(parts)-2]
if owner == "" || repo == "" {
return "", "", 0, fmt.Errorf("empty owner or repo in URL: %s", prURL)
}
return owner, repo, number, nil
}
// GetTaskPR returns the PR association for a task.
func (s *Service) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) {
return s.store.GetTaskPR(ctx, taskID)
}
// ListTaskPRs returns PR associations for multiple tasks.
func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) {
return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs)
}
// ListWorkspaceTaskPRs returns all PR associations for a workspace.
// It returns cached data immediately and triggers background refresh for stale entries.
func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) {
result, err := s.store.ListTaskPRsByWorkspaceID(ctx, workspaceID)
if err != nil {
return nil, err
}
// Collect stale task IDs for background refresh
var staleTaskIDs []string
for _, tp := range result {
if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow {
staleTaskIDs = append(staleTaskIDs, tp.TaskID)
}
}
// Background refresh with bounded concurrency
if len(staleTaskIDs) > 0 {
go func() {
sem := make(chan struct{}, 5)
for _, taskID := range staleTaskIDs {
sem <- struct{}{}
go func(id string) {
defer func() { <-sem }()
syncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, syncErr := s.TriggerPRSync(syncCtx, id); syncErr != nil {
s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr))
}
}(taskID)
}
}()
}
return result, nil
}
// SyncTaskPR updates a TaskPR record with the latest PR status.
// It only publishes a github.task_pr.updated event when data actually changed,
// preventing feedback loops with frontend sync handlers.
func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatus) error {
if status == nil || status.PR == nil {
return fmt.Errorf("sync task PR: missing PR data for task %s", taskID)
}
tp, err := s.store.GetTaskPR(ctx, taskID)
if err != nil || tp == nil {
return err
}
changed := tp.State != status.PR.State ||
tp.PRTitle != status.PR.Title ||
tp.Additions != status.PR.Additions ||
tp.Deletions != status.PR.Deletions ||
tp.ReviewState != status.ReviewState ||
tp.ChecksState != status.ChecksState ||
tp.MergeableState != status.MergeableState ||
tp.ReviewCount != status.ReviewCount ||
tp.PendingReviewCount != status.PendingReviewCount ||
!timeEqual(tp.MergedAt, status.PR.MergedAt) ||
!timeEqual(tp.ClosedAt, status.PR.ClosedAt)
tp.State = status.PR.State
tp.PRTitle = status.PR.Title
tp.Additions = status.PR.Additions
tp.Deletions = status.PR.Deletions
tp.MergedAt = status.PR.MergedAt
tp.ClosedAt = status.PR.ClosedAt
tp.ReviewState = status.ReviewState
tp.ChecksState = status.ChecksState
tp.MergeableState = status.MergeableState
tp.ReviewCount = status.ReviewCount
tp.PendingReviewCount = status.PendingReviewCount
// CommentCount is no longer updated from polling -- only refreshed on-demand
now := time.Now().UTC()
tp.LastSyncedAt = &now
if err := s.store.UpdateTaskPR(ctx, tp); err != nil {
return fmt.Errorf("update task PR: %w", err)
}
if changed && s.eventBus != nil {
event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp)
if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil {
s.logger.Debug("failed to publish task PR updated event", zap.Error(err))
}
}
return nil
}
// timeEqual compares two nullable time pointers for equality.
func timeEqual(a, b *time.Time) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Equal(*b)
}
// --- PR info and feedback (live) ---
// GetPR fetches basic PR details from GitHub.
func (s *Service) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.GetPR(ctx, owner, repo, number)
}
// GetPRFeedback fetches live PR feedback from GitHub.
func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.GetPRFeedback(ctx, owner, repo, number)
}
// GetPRStatus fetches lightweight PR status (review + checks + mergeable).
// Cached briefly so repeat loads of the same list (pagination, re-render,
// back-navigation) don't refetch. The returned pointer is shared — callers
// must not mutate it.
func (s *Service) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
key := prStatusCacheKey(owner, repo, number)
v, err := s.prStatusCache.doOrFetch(key, func() (any, error) {
return s.client.GetPRStatus(ctx, owner, repo, number)
})
if err != nil {
return nil, err
}
return v.(*PRStatus), nil
}
// PRRef identifies a pull request by owner/repo/number.
type PRRef struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
// prStatusBatchConcurrency bounds how many upstream GetPRStatus calls run in
// parallel. GitHub has per-hour quotas and per-endpoint concurrency limits;
// this is well under both while still collapsing a 25-PR page into a single
// short wait on the client.
const prStatusBatchConcurrency = 8
// GetPRStatusesBatch fetches statuses for multiple PRs concurrently, honoring
// the per-PR cache. The returned map is keyed by prStatusCacheKey; PRs that
// fail to fetch are logged and omitted from the result so one bad repo
// doesn't poison the page.
func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[string]*PRStatus, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
result := make(map[string]*PRStatus, len(refs))
var mu sync.Mutex
sem := make(chan struct{}, prStatusBatchConcurrency)
var wg sync.WaitGroup
for _, ref := range refs {
if ref.Owner == "" || ref.Repo == "" || ref.Number <= 0 {
continue
}
wg.Add(1)
go func(r PRRef) {
defer wg.Done()
// Release queued goroutines early when the caller disconnects —
// otherwise up to 200 refs queue up serially behind the semaphore
// and each still runs its full upstream fetch.
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
status, err := s.GetPRStatus(ctx, r.Owner, r.Repo, r.Number)
if err != nil {
s.logger.Debug("batch PR status fetch failed",
zap.String("owner", r.Owner),
zap.String("repo", r.Repo),
zap.Int("number", r.Number),
zap.Error(err))
return
}
mu.Lock()
result[prStatusCacheKey(r.Owner, r.Repo, r.Number)] = status
mu.Unlock()
}(ref)
}
wg.Wait()
return result, nil
}
// TriggerPRSync performs an immediate PR status sync for a task.
// If the watch has a PR number, it fetches the latest status from GitHub
// and syncs it to the TaskPR record. If still searching (pr_number=0),
// it attempts to find the PR by branch.
func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, error) {
watch, err := s.store.GetPRWatchByTask(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("get PR watch: %w", err)
}
if watch == nil {
// No watch — just return existing TaskPR if any
return s.store.GetTaskPR(ctx, taskID)
}
if watch.PRNumber == 0 {
return s.triggerPRDetection(ctx, watch, taskID)
}
return s.triggerPRStatusSync(ctx, watch, taskID)
}
func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) {
if s.client == nil {
return nil, nil
}
pr, err := s.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch)
if err != nil || pr == nil {
return nil, err
}
if err := s.store.UpdatePRWatchPRNumber(ctx, watch.ID, pr.Number); err != nil {
s.logger.Error("failed to update PR watch number during sync",
zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err))
return nil, fmt.Errorf("update PR watch: %w", err)
}
if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil {
s.logger.Error("failed to associate PR with task during sync",
zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), zap.Error(assocErr))
return nil, fmt.Errorf("associate PR: %w", assocErr)
}
// Also fetch status so the first response includes review/check state
watch.PRNumber = pr.Number
return s.triggerPRStatusSync(ctx, watch, taskID)
}
func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) {
// Freshness check: skip GitHub API if recently synced
if tp, _ := s.store.GetTaskPR(ctx, taskID); tp != nil && tp.LastSyncedAt != nil {
if time.Since(*tp.LastSyncedAt) < prSyncFreshnessWindow {
return tp, nil
}
}
// Coalesce concurrent syncs for the same PR
key := fmt.Sprintf("%s/%s/%d", watch.Owner, watch.Repo, watch.PRNumber)
v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) {
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
status, _, checkErr := s.CheckPRWatch(bgCtx, watch)
if checkErr != nil {
return nil, checkErr
}
if status == nil {
return s.store.GetTaskPR(bgCtx, taskID)
}
if syncErr := s.SyncTaskPR(bgCtx, taskID, status); syncErr != nil {
return nil, syncErr
}
return s.store.GetTaskPR(bgCtx, taskID)
})
if err != nil {
return nil, err
}
if v == nil {
return nil, nil
}
return v.(*TaskPR), nil
}
// --- PR files and commits (live) ---
// GetPRFiles fetches files changed in a PR from GitHub.
func (s *Service) GetPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.ListPRFiles(ctx, owner, repo, number)
}
// GetPRCommits fetches commits in a PR from GitHub.
func (s *Service) GetPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.ListPRCommits(ctx, owner, repo, number)
}
// --- Review Watch operations ---
// CreateReviewWatch creates a new review watch and triggers an initial poll.
func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchRequest) (*ReviewWatch, error) {
if req.PollIntervalSeconds <= 0 {
req.PollIntervalSeconds = defaultWatchPollIntervalSec
}
if req.PollIntervalSeconds < minWatchPollIntervalSec {
req.PollIntervalSeconds = minWatchPollIntervalSec
}
repos := req.Repos
if repos == nil {
repos = []RepoFilter{}
}
reviewScope := req.ReviewScope
if reviewScope == "" {
reviewScope = ReviewScopeUserAndTeams
}
rw := &ReviewWatch{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Repos: repos,
AgentProfileID: req.AgentProfileID,
ExecutorProfileID: req.ExecutorProfileID,
Prompt: req.Prompt,
ReviewScope: reviewScope,
CustomQuery: req.CustomQuery,
Enabled: true,
PollIntervalSeconds: req.PollIntervalSeconds,
}
if err := s.store.CreateReviewWatch(ctx, rw); err != nil {
return nil, fmt.Errorf("create review watch: %w", err)
}
// Trigger initial poll in background so the watch starts working immediately
go s.initialReviewCheck(context.Background(), rw)
return rw, nil
}
// initialReviewCheck runs a single poll for a newly created review watch.
func (s *Service) initialReviewCheck(ctx context.Context, watch *ReviewWatch) {
newPRs, err := s.CheckReviewWatch(ctx, watch)
if err != nil {
s.logger.Debug("initial review check failed",
zap.String("watch_id", watch.ID), zap.Error(err))
return
}
for _, pr := range newPRs {
s.publishNewReviewPREvent(ctx, watch, pr)
}
if len(newPRs) > 0 {
s.logger.Info("initial review check found PRs",
zap.String("watch_id", watch.ID),
zap.Int("new_prs", len(newPRs)))
}
}
// GetReviewWatch returns a review watch by ID.
func (s *Service) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) {
return s.store.GetReviewWatch(ctx, id)
}
// ListReviewWatches returns all review watches for a workspace.
func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) {
return s.store.ListReviewWatches(ctx, workspaceID)
}
// UpdateReviewWatch updates a review watch.
func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateReviewWatchRequest) error {
rw, err := s.store.GetReviewWatch(ctx, id)
if err != nil {
return err
}
if rw == nil {
return fmt.Errorf("review watch not found: %s", id)
}
if req.WorkflowID != nil {
rw.WorkflowID = *req.WorkflowID
}
if req.WorkflowStepID != nil {
rw.WorkflowStepID = *req.WorkflowStepID
}
if req.Repos != nil {
rw.Repos = *req.Repos
}
if req.AgentProfileID != nil {
rw.AgentProfileID = *req.AgentProfileID
}
if req.ExecutorProfileID != nil {
rw.ExecutorProfileID = *req.ExecutorProfileID
}
if req.Prompt != nil {
rw.Prompt = *req.Prompt
}
if req.ReviewScope != nil {
rw.ReviewScope = *req.ReviewScope
}
if req.CustomQuery != nil {
rw.CustomQuery = *req.CustomQuery
}
if req.Enabled != nil {
rw.Enabled = *req.Enabled
}
if req.PollIntervalSeconds != nil {
rw.PollIntervalSeconds = *req.PollIntervalSeconds
}
return s.store.UpdateReviewWatch(ctx, rw)
}
// DeleteReviewWatch deletes a review watch.
func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error {
return s.store.DeleteReviewWatch(ctx, id)
}
// CheckReviewWatch checks for new PRs needing review and returns ones not yet tracked.
// If watch.Repos is empty, all repos are queried. Otherwise, each repo is queried individually.
func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]*PR, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
s.logger.Debug("checking review watch for pending PRs",
zap.String("watch_id", watch.ID),
zap.Int("repo_filters", len(watch.Repos)),
zap.String("custom_query", watch.CustomQuery),
zap.String("review_scope", watch.ReviewScope),
zap.Bool("enabled", watch.Enabled))
prs, err := s.fetchReviewPRs(ctx, watch)
if err != nil {
return nil, err
}
s.logger.Debug("fetched review-requested PRs",
zap.String("watch_id", watch.ID),
zap.Int("total_prs", len(prs)))
// Pre-filter PRs that are already tracked. This is a best-effort check
// that avoids publishing events for PRs that clearly have tasks; it does
// NOT need to be race-free, because the orchestrator's createReviewTask
// atomically reserves the dedup slot before doing any task-creation work
// (see ReserveReviewPRTask). So a race here at most causes an extra event
// that the reservation step will drop.
var newPRs []*PR
for _, pr := range prs {
exists, err := s.store.HasReviewPRTask(ctx, watch.ID, pr.RepoOwner, pr.RepoName, pr.Number)
if err != nil {
s.logger.Error("failed to check review PR task", zap.Error(err))
continue
}
if exists {
s.logger.Debug("skipping already-tracked PR",
zap.String("watch_id", watch.ID),
zap.String("repo", pr.RepoOwner+"/"+pr.RepoName),
zap.Int("pr_number", pr.Number))
} else {
newPRs = append(newPRs, pr)
}
}
// Enrich new PRs with full details (branch info) from the PR API,
// since the search API does not return head/base branch.
s.enrichPRDetails(ctx, newPRs)
s.logger.Debug("review watch check complete",
zap.String("watch_id", watch.ID),
zap.Int("total_fetched", len(prs)),
zap.Int("new_prs", len(newPRs)),
zap.Int("already_tracked", len(prs)-len(newPRs)))
// Update last polled
now := time.Now().UTC()
watch.LastPolledAt = &now
_ = s.store.UpdateReviewWatch(ctx, watch)
return newPRs, nil
}
// fetchReviewPRs fetches PRs needing review based on the watch configuration.
// When repo filters are set, they are always applied — even when a custom query is present
// (the filter qualifier is appended to the query for each repo).
func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR, error) {
hasRepos := len(watch.Repos) > 0
s.logger.Debug("fetchReviewPRs: starting",
zap.String("watch_id", watch.ID),
zap.String("custom_query", watch.CustomQuery),
zap.String("scope", watch.ReviewScope),
zap.Int("repo_count", len(watch.Repos)),
zap.Bool("has_repos", hasRepos))
// No repo filters: use query verbatim (custom or scope-based)
if !hasRepos {
if watch.CustomQuery != "" {
s.logger.Debug("fetchReviewPRs: using custom query (all repos)",
zap.String("query", watch.CustomQuery))
return s.client.ListReviewRequestedPRs(ctx, "", "", watch.CustomQuery)
}
s.logger.Debug("fetchReviewPRs: using scope (all repos)",
zap.String("scope", watch.ReviewScope))
return s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, "", "")
}
// Has repo filters: iterate repos, appending filter to customQuery or scope
prs := s.fetchReviewPRsWithFilter(ctx, watch)
return prs, nil
}
// fetchReviewPRsWithFilter queries each repo filter individually and deduplicates results.
// When customQuery is set, the repo qualifier is appended to it; otherwise scope+filter is used.
func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, watch *ReviewWatch) []*PR {
var allPRs []*PR
seen := make(map[string]bool)
for _, repo := range watch.Repos {
qualifier := repoFilterToQualifier(repo)
var prs []*PR
var err error
if watch.CustomQuery != "" {
query := watch.CustomQuery + " " + qualifier
s.logger.Debug("fetchReviewPRs: querying with custom query + filter",
zap.String("watch_id", watch.ID),
zap.String("query", query))
prs, err = s.client.ListReviewRequestedPRs(ctx, "", "", query)
} else {
s.logger.Debug("fetchReviewPRs: querying with scope + filter",
zap.String("watch_id", watch.ID),
zap.String("scope", watch.ReviewScope),
zap.String("filter", qualifier))
prs, err = s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, qualifier, "")
}
if err != nil {
if isConnectivityError(err) {
s.logger.Warn("failed to list review PRs (connectivity)",
zap.String("filter", qualifier), zap.Error(err))
} else {
s.logger.Error("failed to list review PRs",
zap.String("filter", qualifier), zap.Error(err))
}
continue
}
s.logger.Debug("fetchReviewPRs: got results for filter",
zap.String("filter", qualifier),
zap.Int("count", len(prs)))
for _, pr := range prs {
key := fmt.Sprintf("%s/%s#%d", pr.RepoOwner, pr.RepoName, pr.Number)
if !seen[key] {
seen[key] = true
allPRs = append(allPRs, pr)
}
}
}
return allPRs
}
// repoFilterToQualifier converts a RepoFilter to a GitHub search qualifier string.
func repoFilterToQualifier(repo RepoFilter) string {
if repo.Name == "" {
return "org:" + repo.Owner
}
return fmt.Sprintf("repo:%s/%s", repo.Owner, repo.Name)
}
// enrichPRDetails fetches full PR details for PRs missing branch info (from the search API).
func (s *Service) enrichPRDetails(ctx context.Context, prs []*PR) {
for _, pr := range prs {
if pr.HeadBranch != "" && pr.BaseBranch != "" {
continue
}
s.logger.Debug("enriching PR with full details (missing branch info)",
zap.String("repo", pr.RepoOwner+"/"+pr.RepoName),
zap.Int("pr_number", pr.Number))
full, err := s.client.GetPR(ctx, pr.RepoOwner, pr.RepoName, pr.Number)
if err != nil {
s.logger.Warn("failed to fetch full PR details, branch info will be empty",
zap.String("repo", pr.RepoOwner+"/"+pr.RepoName),
zap.Int("pr_number", pr.Number),
zap.Error(err))
continue
}
pr.HeadBranch = full.HeadBranch
pr.HeadSHA = full.HeadSHA
pr.BaseBranch = full.BaseBranch
pr.Additions = full.Additions
pr.Deletions = full.Deletions
pr.Mergeable = full.Mergeable
}
}
// ListUserOrgs returns the authenticated user's orgs, prepending their own username.
func (s *Service) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
orgs, err := s.client.ListUserOrgs(ctx)
if err != nil {
return nil, err
}
// Prepend the authenticated user as a pseudo-org (for personal repos).
user, userErr := s.client.GetAuthenticatedUser(ctx)
if userErr == nil && user != "" {
orgs = append([]GitHubOrg{{Login: user}}, orgs...)
}
return orgs, nil
}
// SearchOrgRepos searches repos in an org for autocomplete.
func (s *Service) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.SearchOrgRepos(ctx, org, query, limit)
}
// ListRepoBranches lists branches for a repository.
func (s *Service) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.ListRepoBranches(ctx, owner, repo)
}
// SearchUserPRs searches for PRs using a filter or custom query. Unless the
// caller already pins a type qualifier, `type:pr` is injected into the
// composed query.
func (s *Service) SearchUserPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.SearchPRs(ctx, filter, customQuery)
}
// SearchUserIssues searches for issues using a filter or custom query. Unless
// the caller already pins a type qualifier, `type:issue` is injected into the
// composed query.
func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
return s.client.ListIssues(ctx, filter, customQuery)
}
// SearchUserPRsPaged is the paginated variant of SearchUserPRs. Results are
// cached for a short window (see searchCacheTTL) — callers must not mutate
// the returned page, since it is shared across concurrent requests.
func (s *Service) SearchUserPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
// Clamp before composing the cache key so perPage=150 and perPage=100
// share the same cached page instead of creating two entries for
// identical results.
page, perPage = clampSearchPage(page, perPage)
key := searchCacheKey("pr", filter, customQuery, page, perPage)
v, err := s.searchCache.doOrFetch(key, func() (any, error) {
result, err := s.client.SearchPRsPaged(ctx, filter, customQuery, page, perPage)
if err != nil {
return nil, err
}
if result != nil && result.PRs == nil {
result.PRs = []*PR{}
}
return result, nil
})
if err != nil {
return nil, err
}
return v.(*PRSearchPage), nil
}
// SearchUserIssuesPaged is the paginated variant of SearchUserIssues. See
// SearchUserPRsPaged for caching semantics.
func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
page, perPage = clampSearchPage(page, perPage)
key := searchCacheKey("issue", filter, customQuery, page, perPage)
v, err := s.searchCache.doOrFetch(key, func() (any, error) {
result, err := s.client.ListIssuesPaged(ctx, filter, customQuery, page, perPage)
if err != nil {
return nil, err
}
if result != nil && result.Issues == nil {
result.Issues = []*Issue{}
}
return result, nil
})
if err != nil {
return nil, err
}
return v.(*IssueSearchPage), nil
}
// ReserveReviewPRTask atomically claims the dedup slot for a (watch, repo, PR)
// tuple before task creation begins. Returns true if this caller won and
// should proceed to create the task, false if another caller already holds
// the slot (duplicate, skip). This closes the race window that existed when
// the dedup row was only written AFTER the slow clone + task-creation work,
// which could produce duplicate tasks when two pollers or events raced.
func (s *Service) ReserveReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL string) (bool, error) {
return s.store.ReserveReviewPRTask(ctx, watchID, repoOwner, repoName, prNumber, prURL)
}
// AssignReviewPRTaskID attaches a task ID to a previously reserved slot so
// downstream cleanup (CleanupMergedReviewTasks) can locate and delete the
// task when its PR is merged or closed.
func (s *Service) AssignReviewPRTaskID(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, taskID string) error {
return s.store.AssignReviewPRTaskID(ctx, watchID, repoOwner, repoName, prNumber, taskID)
}
// ReleaseReviewPRTask removes a 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) ReleaseReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int) error {
return s.store.ReleaseReviewPRTask(ctx, watchID, repoOwner, repoName, prNumber)
}
// CleanupMergedReviewTasks checks PRs tracked by a review watch and deletes
// tasks whose PRs are merged/closed. Returns the number of tasks deleted.
func (s *Service) CleanupMergedReviewTasks(ctx context.Context, watch *ReviewWatch) (int, error) {
if s.client == nil || s.taskDeleter == nil {
return 0, nil
}
prTasks, err := s.store.ListReviewPRTasksByWatch(ctx, watch.ID)
if err != nil {
return 0, fmt.Errorf("list review PR tasks: %w", err)
}
deleted := 0
for _, rpt := range prTasks {
// Orphan reservation: process was killed after ReserveReviewPRTask
// succeeded but before AssignReviewPRTaskID ran, so task_id is empty
// and there is no task to delete. Clean up the dedup row once the PR
// reaches a terminal state, same gating as the normal path.
if rpt.TaskID == "" {
if should, _ := s.shouldDeleteReviewTask(ctx, rpt); should {
_ = s.store.DeleteReviewPRTask(ctx, rpt.ID)
deleted++
}
continue
}
shouldDelete, reason := s.shouldDeleteReviewTask(ctx, rpt)
if !shouldDelete {
continue
}
if err := s.taskDeleter.DeleteTask(ctx, rpt.TaskID); err != nil {
if strings.Contains(err.Error(), "not found") {
// Task already deleted; clean up the orphaned dedup record.
_ = s.store.DeleteReviewPRTask(ctx, rpt.ID)
deleted++
continue
}
s.logger.Warn("failed to delete review PR task",
zap.String("task_id", rpt.TaskID), zap.Error(err))
continue
}
_ = s.store.DeleteReviewPRTask(ctx, rpt.ID)
s.logger.Info("deleted review task",
zap.String("task_id", rpt.TaskID),
zap.String("reason", reason),
zap.Int("pr_number", rpt.PRNumber),
zap.String("repo", rpt.RepoOwner+"/"+rpt.RepoName))
deleted++
}
return deleted, nil
}
// shouldDeleteReviewTask checks if a review PR task should be cleaned up.
// Returns true + reason if the PR is done (merged/closed/approved) AND the user
// hasn't interacted with the task yet (no sessions). Tasks with sessions are
// preserved so the user can see the PR merged banner and their work history.
func (s *Service) shouldDeleteReviewTask(ctx context.Context, rpt *ReviewPRTask) (bool, string) {
feedback, err := s.client.GetPRFeedback(ctx, rpt.RepoOwner, rpt.RepoName, rpt.PRNumber)
if err != nil {
s.logger.Debug("failed to fetch PR feedback for cleanup",
zap.Int("pr_number", rpt.PRNumber), zap.Error(err))
return false, ""
}
if feedback.PR == nil {
return false, ""
}
var reason string
if feedback.PR.State == prStateMerged || feedback.PR.State == prStateClosed {
reason = "pr_merged_or_closed"
} else {
// Check if the authenticated user already approved the PR on GitHub.
user, _ := s.client.GetAuthenticatedUser(ctx)
for _, review := range feedback.Reviews {
if review.State == "APPROVED" && review.Author == user {
reason = "pr_approved_by_user"
break
}
}
}
if reason == "" {
return false, ""
}
// Don't delete tasks the user has interacted with (has sessions).
// Those show the PR merged banner instead.
if s.taskSessionChecker != nil {
hasSessions, err := s.taskSessionChecker.HasTaskSessions(ctx, rpt.TaskID)
if err != nil {
s.logger.Debug("failed to check task sessions",
zap.String("task_id", rpt.TaskID), zap.Error(err))
return false, ""
}
if hasSessions {
return false, ""
}
}
return true, reason
}
// TriggerAllReviewChecks triggers all review watches for a workspace.
func (s *Service) TriggerAllReviewChecks(ctx context.Context, workspaceID string) (int, error) {
watches, err := s.store.ListReviewWatches(ctx, workspaceID)
if err != nil {
return 0, err
}
enabled := 0
for _, w := range watches {
if w.Enabled {
enabled++
}
}
s.logger.Info("triggering review checks",
zap.String("workspace_id", workspaceID),
zap.Int("total_watches", len(watches)),
zap.Int("enabled_watches", enabled))
totalNew := 0
for _, watch := range watches {
if !watch.Enabled {
continue
}
newPRs, err := s.CheckReviewWatch(ctx, watch)
if err != nil {
s.logger.Error("failed to check review watch",
zap.String("id", watch.ID), zap.Error(err))
continue
}
for _, pr := range newPRs {
s.publishNewReviewPREvent(ctx, watch, pr)
}
totalNew += len(newPRs)
}
s.logger.Info("review checks completed",
zap.String("workspace_id", workspaceID),
zap.Int("new_prs_found", totalNew))
return totalNew, nil
}
// GetPRStats returns PR statistics.
func (s *Service) GetPRStats(ctx context.Context, req *PRStatsRequest) (*PRStats, error) {
return s.store.GetPRStats(ctx, req)
}
func (s *Service) publishNewReviewPREvent(ctx context.Context, watch *ReviewWatch, pr *PR) {
if s.eventBus == nil {
return
}
event := bus.NewEvent(events.GitHubNewReviewPR, "github", &NewReviewPREvent{
ReviewWatchID: watch.ID,
WorkspaceID: watch.WorkspaceID,
WorkflowID: watch.WorkflowID,
WorkflowStepID: watch.WorkflowStepID,
AgentProfileID: watch.AgentProfileID,
ExecutorProfileID: watch.ExecutorProfileID,
Prompt: watch.Prompt,
PR: pr,
})
if err := s.eventBus.Publish(ctx, events.GitHubNewReviewPR, event); err != nil {
s.logger.Debug("failed to publish new review PR event", zap.Error(err))
}
}
// --- Issue Watch service methods ---
// CreateIssueWatch creates a new issue watch and triggers an initial poll.
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
if req.PollIntervalSeconds <= 0 {
req.PollIntervalSeconds = defaultWatchPollIntervalSec
}
if req.PollIntervalSeconds < minWatchPollIntervalSec {
req.PollIntervalSeconds = minWatchPollIntervalSec
}
repos := req.Repos
if repos == nil {
repos = []RepoFilter{}
}
labels := req.Labels
if labels == nil {
labels = []string{}
}
iw := &IssueWatch{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Repos: repos,
AgentProfileID: req.AgentProfileID,
ExecutorProfileID: req.ExecutorProfileID,
Prompt: req.Prompt,
Labels: labels,
CustomQuery: req.CustomQuery,
Enabled: true,
PollIntervalSeconds: req.PollIntervalSeconds,
}
if err := s.store.CreateIssueWatch(ctx, iw); err != nil {
return nil, fmt.Errorf("create issue watch: %w", err)
}
go s.initialIssueCheck(context.Background(), iw)
return iw, nil
}
// initialIssueCheck runs a single poll for a newly created issue watch.
func (s *Service) initialIssueCheck(ctx context.Context, watch *IssueWatch) {
newIssues, err := s.CheckIssueWatch(ctx, watch)
if err != nil {
s.logger.Debug("initial issue check failed",
zap.String("watch_id", watch.ID), zap.Error(err))
return
}
for _, issue := range newIssues {
s.publishNewIssueEvent(ctx, watch, issue)
}
if len(newIssues) > 0 {
s.logger.Info("initial issue check found issues",
zap.String("watch_id", watch.ID),
zap.Int("new_issues", len(newIssues)))
}
}
// GetIssueWatch returns a single issue watch by ID.
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
return s.store.GetIssueWatch(ctx, id)
}
// ListIssueWatches returns all issue watches for a workspace.
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
return s.store.ListIssueWatches(ctx, workspaceID)
}
// UpdateIssueWatch updates an issue watch.
//
//nolint:dupl // mirrors UpdateReviewWatch — different types, same structure
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) error {
iw, err := s.store.GetIssueWatch(ctx, id)
if err != nil {
return err
}
if iw == nil {
return fmt.Errorf("issue watch not found: %s", id)
}
if req.WorkflowID != nil {
iw.WorkflowID = *req.WorkflowID
}
if req.WorkflowStepID != nil {
iw.WorkflowStepID = *req.WorkflowStepID
}
if req.Repos != nil {
iw.Repos = *req.Repos
}
if req.AgentProfileID != nil {
iw.AgentProfileID = *req.AgentProfileID
}
if req.ExecutorProfileID != nil {
iw.ExecutorProfileID = *req.ExecutorProfileID
}
if req.Prompt != nil {
iw.Prompt = *req.Prompt
}
if req.Labels != nil {
iw.Labels = *req.Labels
}
if req.CustomQuery != nil {
iw.CustomQuery = *req.CustomQuery
}
if req.Enabled != nil {
iw.Enabled = *req.Enabled
}
if req.PollIntervalSeconds != nil {
v := *req.PollIntervalSeconds
if v <= 0 {
v = defaultWatchPollIntervalSec
}
if v < minWatchPollIntervalSec {
v = minWatchPollIntervalSec
}
iw.PollIntervalSeconds = v
}
return s.store.UpdateIssueWatch(ctx, iw)
}
// DeleteIssueWatch deletes an issue watch.
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
return s.store.DeleteIssueWatch(ctx, id)
}
// CheckIssueWatch checks for new issues matching the watch and returns ones not yet tracked.
func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Issue, error) {
if s.client == nil {
return nil, fmt.Errorf("github client not available")
}
s.logger.Debug("checking issue watch for new issues",
zap.String("watch_id", watch.ID),
zap.Int("repo_filters", len(watch.Repos)),
zap.String("custom_query", watch.CustomQuery),
zap.Bool("enabled", watch.Enabled))
issues, err := s.fetchIssues(ctx, watch)
if err != nil {
return nil, err
}
var newIssues []*Issue
for _, issue := range issues {
exists, checkErr := s.store.HasIssueWatchTask(ctx, watch.ID, issue.RepoOwner, issue.RepoName, issue.Number)
if checkErr != nil {
s.logger.Error("failed to check issue watch task", zap.Error(checkErr))
continue
}
if !exists {
newIssues = append(newIssues, issue)
}
}
now := time.Now().UTC()
watch.LastPolledAt = &now
_ = s.store.UpdateIssueWatch(ctx, watch)
return newIssues, nil
}
// fetchIssues fetches issues based on the watch configuration.
func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) {
hasRepos := len(watch.Repos) > 0
if !hasRepos {
filter := s.buildIssueFilter(watch)
return s.client.ListIssues(ctx, filter, watch.CustomQuery)
}
return s.fetchIssuesWithRepoFilter(ctx, watch), nil
}
// buildIssueFilter builds the filter qualifier from watch labels. `state:open`
// is included because the watcher is only interested in active issues —
// buildIssueSearchQuery no longer injects it (the /github page presets
// supply their own state qualifiers), so we add it here instead.
func (s *Service) buildIssueFilter(watch *IssueWatch) string {
parts := []string{"state:open"}
for _, label := range watch.Labels {
if strings.ContainsRune(label, ' ') {
parts = append(parts, `label:"`+label+`"`)
} else {
parts = append(parts, "label:"+label)
}
}
return strings.Join(parts, " ")
}
// fetchIssuesWithRepoFilter queries each repo individually and deduplicates.
func (s *Service) fetchIssuesWithRepoFilter(ctx context.Context, watch *IssueWatch) []*Issue {
var allIssues []*Issue
seen := make(map[string]bool)
labelFilter := s.buildIssueFilter(watch)
for _, repo := range watch.Repos {
qualifier := repoFilterToQualifier(repo)
filter := qualifier
if labelFilter != "" {
filter += " " + labelFilter
}
var issues []*Issue
var err error
if watch.CustomQuery != "" {
query := watch.CustomQuery + " " + qualifier
issues, err = s.client.ListIssues(ctx, "", query)
} else {
issues, err = s.client.ListIssues(ctx, filter, "")
}
if err != nil {
if isConnectivityError(err) {
s.logger.Warn("failed to list issues (connectivity)",
zap.String("filter", qualifier), zap.Error(err))
} else {
s.logger.Error("failed to list issues",
zap.String("filter", qualifier), zap.Error(err))
}
continue
}
for _, issue := range issues {
key := fmt.Sprintf("%s/%s#%d", issue.RepoOwner, issue.RepoName, issue.Number)
if !seen[key] {
seen[key] = true
allIssues = append(allIssues, issue)
}
}
}
return allIssues
}
// ReserveIssueWatchTask atomically claims a dedup slot.
func (s *Service) ReserveIssueWatchTask(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int, issueURL string) (bool, error) {
return s.store.ReserveIssueWatchTask(ctx, watchID, repoOwner, repoName, issueNumber, issueURL)
}
// AssignIssueWatchTaskID attaches a task ID to a previously reserved slot.
func (s *Service) AssignIssueWatchTaskID(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int, taskID string) error {
return s.store.AssignIssueWatchTaskID(ctx, watchID, repoOwner, repoName, issueNumber, taskID)
}
// ReleaseIssueWatchTask removes a reservation when task creation fails.
func (s *Service) ReleaseIssueWatchTask(ctx context.Context, watchID, repoOwner, repoName string, issueNumber int) error {
return s.store.ReleaseIssueWatchTask(ctx, watchID, repoOwner, repoName, issueNumber)
}
// TriggerAllIssueChecks triggers all issue watches for a workspace.
//
//nolint:dupl // mirrors TriggerAllReviewChecks — different types, same structure
func (s *Service) TriggerAllIssueChecks(ctx context.Context, workspaceID string) (int, error) {
watches, err := s.store.ListIssueWatches(ctx, workspaceID)
if err != nil {
return 0, err
}
enabled := 0
for _, w := range watches {
if w.Enabled {
enabled++
}
}
s.logger.Info("triggering issue checks",
zap.String("workspace_id", workspaceID),
zap.Int("total_watches", len(watches)),
zap.Int("enabled_watches", enabled))
totalNew := 0
for _, watch := range watches {
if !watch.Enabled {
continue
}
newIssues, checkErr := s.CheckIssueWatch(ctx, watch)
if checkErr != nil {
s.logger.Error("failed to check issue watch",
zap.String("watch_id", watch.ID), zap.Error(checkErr))
continue
}
for _, issue := range newIssues {
s.publishNewIssueEvent(ctx, watch, issue)
}
totalNew += len(newIssues)
if _, cleanErr := s.CleanupClosedIssueTasks(ctx, watch); cleanErr != nil {
s.logger.Warn("cleanup closed issue tasks failed",
zap.String("watch_id", watch.ID), zap.Error(cleanErr))
}
}
s.logger.Info("issue checks completed",
zap.String("workspace_id", workspaceID),
zap.Int("new_issues_found", totalNew))
return totalNew, nil
}
// CleanupClosedIssueTasks checks issues tracked by a watch and deletes
// tasks whose issues are closed and the user hasn't interacted with.
//
//nolint:dupl // mirrors CleanupMergedReviewTasks — different types, same structure
func (s *Service) CleanupClosedIssueTasks(ctx context.Context, watch *IssueWatch) (int, error) {
if s.client == nil || s.taskDeleter == nil || s.taskSessionChecker == nil {
return 0, nil
}
issueTasks, err := s.store.ListIssueWatchTasksByWatch(ctx, watch.ID)
if err != nil {
return 0, fmt.Errorf("list issue watch tasks: %w", err)
}
deleted := 0
for _, it := range issueTasks {
// Orphan reservation: task_id is empty because the process was
// killed between Reserve and Assign. Clean up once the issue is closed.
if it.TaskID == "" {
if should, _ := s.shouldDeleteIssueTask(ctx, it); should {
_ = s.store.DeleteIssueWatchTask(ctx, it.ID)
deleted++
}
continue
}
shouldDelete, reason := s.shouldDeleteIssueTask(ctx, it)
if !shouldDelete {
continue
}
if err := s.taskDeleter.DeleteTask(ctx, it.TaskID); err != nil {
if strings.Contains(err.Error(), "not found") {
_ = s.store.DeleteIssueWatchTask(ctx, it.ID)
deleted++
continue
}
s.logger.Warn("failed to delete issue task",
zap.String("task_id", it.TaskID), zap.Error(err))
continue
}
_ = s.store.DeleteIssueWatchTask(ctx, it.ID)
s.logger.Info("deleted issue task",
zap.String("task_id", it.TaskID),
zap.String("reason", reason),
zap.Int("issue_number", it.IssueNumber),
zap.String("repo", it.RepoOwner+"/"+it.RepoName))
deleted++
}
return deleted, nil
}
// shouldDeleteIssueTask checks if an issue task should be cleaned up.
// Returns true + reason if the issue is closed AND the user hasn't interacted
// with the task yet (no sessions).
func (s *Service) shouldDeleteIssueTask(ctx context.Context, it *IssueWatchTask) (bool, string) {
state, err := s.client.GetIssueState(ctx, it.RepoOwner, it.RepoName, it.IssueNumber)
if err != nil {
s.logger.Debug("failed to fetch issue state for cleanup",
zap.Int("issue_number", it.IssueNumber), zap.Error(err))
return false, ""
}
if state != "closed" {
return false, ""
}
reason := "issue_closed"
// Don't delete tasks the user has interacted with (has sessions).
if it.TaskID != "" {
hasSessions, err := s.taskSessionChecker.HasTaskSessions(ctx, it.TaskID)
if err != nil {
s.logger.Debug("failed to check task sessions",
zap.String("task_id", it.TaskID), zap.Error(err))
return false, ""
}
if hasSessions {
return false, ""
}
}
return true, reason
}
func (s *Service) publishNewIssueEvent(ctx context.Context, watch *IssueWatch, issue *Issue) {
if s.eventBus == nil {
return
}
event := bus.NewEvent(events.GitHubNewIssue, "github", &NewIssueEvent{
IssueWatchID: watch.ID,
WorkspaceID: watch.WorkspaceID,
WorkflowID: watch.WorkflowID,
WorkflowStepID: watch.WorkflowStepID,
AgentProfileID: watch.AgentProfileID,
ExecutorProfileID: watch.ExecutorProfileID,
Prompt: watch.Prompt,
Issue: issue,
})
if err := s.eventBus.Publish(ctx, events.GitHubNewIssue, event); err != nil {
s.logger.Debug("failed to publish new issue event", zap.Error(err))
}
}
func findLatestCommentTime(comments []PRComment) *time.Time {
var latest *time.Time
for _, c := range comments {
t := c.UpdatedAt
if latest == nil || t.After(*latest) {
latest = &t
}
}
return latest
}
// computeOverallCheckStatus reduces per-check runs to a single PR-level status.
// Mirrors GitHub's own UI: skipped/neutral conclusions are ignored; any failing
// terminal state (failure, timed_out, cancelled, action_required) makes the PR
// failed; non-completed checks keep the PR pending.
func computeOverallCheckStatus(checks []CheckRun) string {
if len(checks) == 0 {
return ""
}
hasPending := false
hasPassing := false
for _, c := range checks {
if c.Status != checkStatusCompleted {
hasPending = true
continue
}
switch c.Conclusion {
case checkConclusionFail, checkConclusionTimedOut,
checkConclusionCancelled, checkConclusionActionRequired:
return checkConclusionFail
case checkConclusionSkipped, checkConclusionNeutral:
// ignore — GitHub's UI does
default:
// Treat success and any future unknown terminal conclusion as passing.
// Being permissive preserves the success signal if GitHub introduces
// a new conclusion we haven't mapped yet.
hasPassing = true
}
}
if hasPending {
return checkStatusPending
}
if hasPassing {
return checkStatusSuccess
}
return ""
}
func computeOverallReviewState(reviews []PRReview) string {
if len(reviews) == 0 {
return ""
}
latest := latestReviewByAuthor(reviews)
changesReq := false
allApproved := true
for _, r := range latest {
if r.State == reviewStateChangesRequested {
changesReq = true
}
if r.State != reviewStateApproved {
allApproved = false
}
}
if changesReq {
return computedReviewStateChangesRequested
}
if allApproved {
return computedReviewStateApproved
}
return computedReviewStatePending
}
func countPendingReviews(reviews []PRReview) int {
latest := latestReviewByAuthor(reviews)
count := 0
for _, r := range latest {
if r.State == reviewStatePending || r.State == reviewStateCommented {
count++
}
}
return count
}
func countPendingRequestedReviewers(pr *PR) int {
if pr == nil {
return 0
}
return len(pr.RequestedReviewers)
}
func deriveReviewSyncState(pr *PR, reviews []PRReview) (string, int) {
pendingReviewCount := countPendingRequestedReviewers(pr)
if pendingReviewCount == 0 {
pendingReviewCount = countPendingReviews(reviews)
}
reviewState := computeOverallReviewState(reviews)
if reviewState == "" && pendingReviewCount > 0 {
reviewState = computedReviewStatePending
}
return reviewState, pendingReviewCount
}