package github import ( "context" "errors" "fmt" "strings" "sync" "time" "go.uber.org/zap" ) // SubmitReview submits a review on a pull request. For APPROVE events it // first checks that the authenticated user is not the PR author, returning // ErrSelfApprove instead of letting the request fail at GitHub with an opaque // 422. Lookup failures here are non-fatal — we fall through to GitHub so a // transient API hiccup doesn't block legitimate approvals. 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 submitReview(ctx, s.client, owner, repo, number, event, body) } // SubmitReviewForWorkspace submits a user-triggered review with an explicitly // resolved workspace principal. GitHub App fallback is allowed by the resolver // and remains attributable to the App rather than impersonating the user. func (s *Service) SubmitReviewForWorkspace( ctx context.Context, workspaceID string, userID string, owner string, repo string, number int, event string, body string, ) (AuthPrincipal, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return AuthPrincipal{}, err } resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) if err != nil { return AuthPrincipal{}, err } if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { return AuthPrincipal{}, err } return resolved.Principal, submitReview(ctx, resolved.Client, owner, repo, number, event, body) } func submitReview(ctx context.Context, client Client, owner, repo string, number int, event, body string) error { if event == reviewEventApprove { user, userErr := client.GetAuthenticatedUser(ctx) if userErr == nil && user != "" { pr, prErr := client.GetPR(ctx, owner, repo, number) if prErr == nil && pr != nil && strings.EqualFold(strings.TrimSpace(pr.AuthorLogin), strings.TrimSpace(user)) { return ErrSelfApprove } } } return client.SubmitReview(ctx, owner, repo, number, event, body) } // RequestReviewers requests reviews and evicts the affected PR caches so the // next feedback/status fetch reflects GitHub's new requested reviewer state. func (s *Service) RequestReviewers(ctx context.Context, owner, repo string, number int, reviewers []string) error { if s.client == nil { return ErrNoClient } return s.requestReviewersWithClient(ctx, s.client, "legacy", owner, repo, number, reviewers) } // RequestReviewersForWorkspace requests a review using the workspace's // personal-write routing rules and returns the identity GitHub will attribute. func (s *Service) RequestReviewersForWorkspace( ctx context.Context, workspaceID string, userID string, owner string, repo string, number int, reviewers []string, ) (AuthPrincipal, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return AuthPrincipal{}, err } resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) if err != nil { return AuthPrincipal{}, err } if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { return AuthPrincipal{}, err } return resolved.Principal, s.requestReviewersWithClient( ctx, resolved.Client, resolved.cacheScopeForPurpose(CredentialPurposePersonalRead), owner, repo, number, reviewers, ) } func (s *Service) requestReviewersWithClient( ctx context.Context, client Client, cacheScope string, owner string, repo string, number int, reviewers []string, ) error { if err := client.RequestReviewers(ctx, owner, repo, number, reviewers); err != nil { return err } // The legacy API still stores its entries in the explicit legacy cache // namespace. Keep invalidation in that same generation namespace so a // successful request cannot leave stale feedback or status behind. key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) s.prFeedbackCache.invalidateKey(key) s.prStatusCache.invalidateKey(key) return nil } // MergePR merges a pull request. mergeMethod is one of "merge", "squash", // "rebase"; an empty string asks the service to pick the first method the // repo allows. The caller is expected to refresh PR feedback after a // successful merge — the background poller will catch the merged state on // its next pass. func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) { if s.client == nil { return "", ErrNoClient } return s.mergePRWithClient(ctx, s.client, "legacy", owner, repo, number, MergePRRequest{MergeMethod: mergeMethod}) } // MergePRForWorkspace performs a user-triggered merge using the workspace's // personal-write routing rules and returns the actual attributed principal. func (s *Service) MergePRForWorkspace( ctx context.Context, workspaceID string, userID string, owner string, repo string, number int, mergeMethod string, ) (AuthPrincipal, MergeOutcome, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return AuthPrincipal{}, "", err } resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) if err != nil { return AuthPrincipal{}, "", err } if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { return AuthPrincipal{}, "", err } outcome, err := s.mergePRWithClient( ctx, resolved.Client, resolved.CacheScope, owner, repo, number, MergePRRequest{MergeMethod: mergeMethod}, ) return resolved.Principal, outcome, err } func (s *Service) mergePRWithClient( ctx context.Context, client Client, cacheScope string, owner string, repo string, number int, request MergePRRequest, ) (MergeOutcome, error) { if request.MergeMethod == "" { // Resolve to an allowed method up-front so we don't rely on GitHub's // "default to merge" behavior, which 405s on repos that disallow // merge commits (squash-only / rebase-only). Best-effort: if the // lookup fails (or — degenerate config — reports no method allowed), // fall back to GitHub's default and surface its error rather than // blocking the merge attempt. if methods, err := s.getRepoMergeMethods(ctx, client, cacheScope, owner, repo); err == nil { if pick := pickDefaultMergeMethod(methods); pick != "" { request.MergeMethod = pick } } } return client.MergePR(ctx, owner, repo, number, request) } // GetRepoMergeMethods returns the merge methods a repo allows, cached for // a few minutes since repo settings rarely change. func (s *Service) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) { if s.client == nil { return RepoMergeMethods{}, ErrNoClient } return s.getRepoMergeMethods(ctx, s.client, "legacy", owner, repo) } func (s *Service) GetRepoMergeMethodsForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, ) (RepoMergeMethods, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return RepoMergeMethods{}, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return RepoMergeMethods{}, err } return s.getRepoMergeMethods(ctx, resolved.Client, resolved.CacheScope, owner, repo) } func (s *Service) getRepoMergeMethods( ctx context.Context, client Client, cacheScope string, owner string, repo string, ) (RepoMergeMethods, error) { key := scopedCacheKey(cacheScope, owner+"/"+repo) v, err := s.mergeMethodsCache.doOrFetch(key, func() (any, error) { return client.GetRepoMergeMethods(ctx, owner, repo) }) if err != nil { return RepoMergeMethods{}, err } return v.(RepoMergeMethods), nil } // Merge method identifiers accepted by GitHub's pulls/{number}/merge endpoint // and used throughout the merge resolution paths. const ( mergeMethodMerge = "merge" mergeMethodSquash = "squash" mergeMethodRebase = "rebase" ) // pickDefaultMergeMethod picks the merge method to use when the caller // didn't pin one. Prefers squash (matches the convention most repos in // this codebase follow) and falls back to merge, then rebase. func pickDefaultMergeMethod(m RepoMergeMethods) string { switch { case m.Squash: return mergeMethodSquash case m.Merge: return mergeMethodMerge case m.Rebase: return mergeMethodRebase default: return "" } } // --- 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 { pr, err := s.client.GetPR(ctx, owner, repo, number) if !errors.Is(err, ErrNoClient) { return pr, err } } return getPRAnonymous(ctx, owner, repo, number) } func (s *Service) GetPRForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) (*PR, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { if errors.Is(err, ErrGitHubNotConfigured) { return getPRAnonymous(ctx, owner, repo, number) } return nil, err } return resolved.Client.GetPR(ctx, owner, repo, number) } // GetPRForAutomation fetches pull-request details through the workspace-owned // automation credential. It is used by background launch paths that have a // repository workspace but no interactive user identity. func (s *Service) GetPRForAutomation( ctx context.Context, workspaceID, owner, repo string, number int, ) (*PR, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) if err != nil { return nil, err } return resolved.Client.GetPR(ctx, owner, repo, number) } // GetIssue fetches basic issue details from GitHub. The create-task dialog is // currently the only caller and dedupes requests per URL on the frontend. func (s *Service) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) { if s.client != nil { issue, err := s.client.GetIssue(ctx, owner, repo, number) if !errors.Is(err, ErrNoClient) { return issue, err } } return getIssueAnonymous(ctx, owner, repo, number) } func (s *Service) GetIssueForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) (*Issue, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return nil, err } return resolved.Client.GetIssue(ctx, owner, repo, number) } // GetPRFeedback fetches live PR feedback from GitHub. Cached briefly with // singleflight coalescing: the task page fires this for the same PR many times // in quick succession (chat-bar CI chip, detail panel, hover popover — each on // its own render/mount triggers), and each miss is 4 sequential GitHub REST // calls. Without this, a burst fanned out into dozens of slow, rate-limited // duplicate requests. The returned pointer is shared — callers must not mutate it. 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") } // No workspace to scope a persist to; this legacy entry point reads only. return s.getPRFeedback(ctx, s.client, "legacy", "", owner, repo, number) } func (s *Service) GetPRFeedbackForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) (*PRFeedback, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return nil, err } return s.getPRFeedback(ctx, resolved.Client, resolved.CacheScope, workspaceID, owner, repo, number) } func (s *Service) getPRFeedback( ctx context.Context, client Client, cacheScope, workspaceID, owner, repo string, number int, ) (*PRFeedback, error) { // Detach the upstream call's context from the singleflight leader: a // cancelled leader (user closes the tab) would otherwise return its // context error to all co-waiters, cascading a single client disconnect // into a burst of transient failures across concurrent callers. // context.WithoutCancel strips the deadline too, so re-attach the // parent deadline explicitly — without this, a still-active caller's // timeout wouldn't bound the shared fetch and quota could keep being // consumed past the deadline. fetchCtx, cancelFetch := derivedFetchContext(ctx) defer cancelFetch() key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) v, err := s.prFeedbackCache.doOrFetch(key, func() (any, error) { feedback, fetchErr := client.GetPRFeedback(fetchCtx, owner, repo, number) if fetchErr != nil { return nil, fetchErr } // Persist inside the fetch closure, not around doOrFetch: this runs // once per real upstream call (TTL miss, singleflight leader) rather // than on every panel render served from cache, and it inherits // fetchCtx so a client disconnecting mid-flight can't abort the write // that its own fetch just earned. s.persistPRFeedbackState(fetchCtx, workspaceID, feedback) return feedback, nil }) if err != nil { return nil, err } return v.(*PRFeedback), nil } // 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") } return s.getPRStatus(ctx, s.client, "legacy", owner, repo, number) } func (s *Service) GetPRStatusForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) (*PRStatus, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return nil, err } return s.getPRStatus(ctx, resolved.Client, resolved.CacheScope, owner, repo, number) } func (s *Service) getPRStatus( ctx context.Context, client Client, cacheScope, owner, repo string, number int, ) (*PRStatus, error) { // Detach the upstream call from the singleflight leader's context; see // GetPRFeedback for the cascading-cancel + deadline-preserve rationale. fetchCtx, cancelFetch := derivedFetchContext(ctx) defer cancelFetch() key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) v, err := s.prStatusCache.doOrFetch(key, func() (any, error) { return client.GetPRStatus(fetchCtx, owner, repo, number) }) if err != nil { return nil, err } return v.(*PRStatus), nil } // derivedFetchContext builds the upstream-fetch context used by the // singleflight-cached GetPRFeedback / GetPRStatus paths. It strips the // caller's cancellation via context.WithoutCancel so a single client // disconnect doesn't cascade the same error to all co-waiters, but // preserves the caller's deadline (when present) so the fetch can't // outlive the request budget and keep consuming GitHub quota past it. // Returns the derived context and a cancel func the caller must defer // to release resources promptly. func derivedFetchContext(ctx context.Context) (context.Context, context.CancelFunc) { fetchCtx := context.WithoutCancel(ctx) if deadline, ok := ctx.Deadline(); ok { return context.WithDeadline(fetchCtx, deadline) } return fetchCtx, func() {} } // 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") } return s.getPRStatusesBatchWithClient(ctx, s.client, "legacy", refs) } func (s *Service) GetPRStatusesBatchForWorkspace( ctx context.Context, workspaceID, userID string, refs []PRRef, ) (map[string]*PRStatus, error) { resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") if err != nil { return nil, err } settings, err := s.GetWorkspaceSettings(ctx, workspaceID) if err != nil { return nil, err } filtered := make([]PRRef, 0, len(refs)) for _, ref := range refs { if repositoryInWorkspaceScope(settings, ref.Owner, ref.Repo) { filtered = append(filtered, ref) } } visibilityInput := make([]RepoFilter, 0, len(filtered)) for _, ref := range filtered { visibilityInput = append(visibilityInput, RepoFilter{Owner: ref.Owner, Name: ref.Repo}) } visible, err := s.automationRepositoryVisibility(ctx, workspaceID, visibilityInput) if err != nil { return nil, err } allowed := filtered[:0] for _, ref := range filtered { if visible[repositoryIdentityKey(ref.Owner, ref.Repo)] { allowed = append(allowed, ref) } } return s.getPRStatusesBatchWithClient(ctx, resolved.Client, resolved.CacheScope, allowed) } func (s *Service) getPRStatusesBatchWithClient( ctx context.Context, client Client, cacheScope string, refs []PRRef, ) (map[string]*PRStatus, error) { 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, client, cacheScope, 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 } // --- 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) } func (s *Service) GetPRFilesForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) ([]PRFile, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return nil, err } return resolved.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) } // GitHub's pull-request commit endpoint returns at most 250 commits, even // when pagination has been requested. Treat a response at that limit as // incomplete because the missing older ancestry can change a safe relation // into an apparent divergence. const githubPRCommitHistoryLimit = 250 func completePRCommitHistory(headSHA string, commits []PRCommitInfo) bool { if headSHA == "" || len(commits) == 0 || len(commits) >= githubPRCommitHistoryLimit { return false } for _, commit := range commits { if commit.SHA == "" { return false } } return commits[len(commits)-1].SHA == headSHA } func (s *Service) GetPRCommitsForWorkspace( ctx context.Context, workspaceID, userID, owner, repo string, number int, ) (PRCommitsResult, error) { if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return PRCommitsResult{}, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return PRCommitsResult{}, err } pr, err := resolved.Client.GetPR(ctx, owner, repo, number) if err != nil { return PRCommitsResult{}, fmt.Errorf("get PR #%d for commit history: %w", number, err) } if pr == nil { return PRCommitsResult{}, fmt.Errorf("get PR #%d for commit history: empty response", number) } commits, err := resolved.Client.ListPRCommits(ctx, owner, repo, number) if err != nil { return PRCommitsResult{}, fmt.Errorf("list PR #%d commits: %w", number, err) } return PRCommitsResult{ Commits: commits, HeadSHA: pr.HeadSHA, Complete: completePRCommitHistory(pr.HeadSHA, commits), }, nil } // GetPRCommitDetailForWorkspace fetches one commit through the workspace's // authorized personal read client. It never consults a task worktree. func (s *Service) GetPRCommitDetailForWorkspace( ctx context.Context, workspaceID, userID, owner, repo, sha string, ) (PRCommitDetail, error) { if err := validateGitHubCommitSHA(sha); err != nil { return PRCommitDetail{}, err } if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return PRCommitDetail{}, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return PRCommitDetail{}, err } return resolved.Client.GetPRCommitDetail(ctx, owner, repo, sha) } // 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) } // intPtrEqual compares two nullable int pointers for equality. func intPtrEqual(a, b *int) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } // boolPtrEqual compares two nullable bool pointers for equality. func boolPtrEqual(a, b *bool) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } // stringPtrEqual compares two nullable string pointers for equality. func stringPtrEqual(a, b *string) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b }