Files
kandev/apps/backend/internal/github/service_ci_automation.go
T

335 lines
11 KiB
Go

package github
import (
"context"
"fmt"
"strings"
promptcfg "github.com/kandev/kandev/config/prompts"
)
// GetTaskCIOptionsResponse returns task CI automation options plus effective prompt text.
func (s *Service) GetTaskCIOptionsResponse(ctx context.Context, taskID string) (*TaskCIOptionsResponse, error) {
if s.store == nil {
return nil, errStoreUnavailable
}
opts, err := s.store.GetTaskCIOptions(ctx, taskID)
if err != nil {
return nil, err
}
return s.buildTaskCIOptionsResponse(ctx, opts)
}
// UpdateTaskCIOptions updates task CI automation options and returns the response shape.
func (s *Service) UpdateTaskCIOptions(ctx context.Context, taskID string, patch TaskCIOptionsPatch) (*TaskCIOptionsResponse, error) {
if s.store == nil {
return nil, errStoreUnavailable
}
if err := s.populateReviewReviewer(ctx, taskID, &patch); err != nil {
return nil, err
}
opts, err := s.store.UpdateTaskCIOptions(ctx, taskID, patch)
if err != nil {
return nil, err
}
return s.buildTaskCIOptionsResponse(ctx, opts)
}
func (s *Service) populateReviewReviewer(
ctx context.Context,
taskID string,
patch *TaskCIOptionsPatch,
) error {
if patch.PromptOnReviewRequested == nil {
return nil
}
if !*patch.PromptOnReviewRequested {
empty := ""
patch.ReviewReviewerLogin = &empty
return nil
}
resolved, err := s.resolveTaskPRAutomationClient(ctx, taskID)
if err != nil {
return err
}
login, err := resolved.Client.GetAuthenticatedUser(ctx)
if err != nil {
return fmt.Errorf("resolve authenticated reviewer: %w", err)
}
patch.ReviewReviewerLogin = &login
return nil
}
// GetTaskCIPRState returns per-PR CI automation state, or nil.
func (s *Service) GetTaskCIPRState(ctx context.Context, taskID, repositoryID string, prNumber int) (*TaskCIPRAutomationState, error) {
if s.store == nil {
return nil, errStoreUnavailable
}
return s.store.GetTaskCIPRState(ctx, taskID, repositoryID, prNumber)
}
// RecordTaskCIFixAttempt records an auto-fix attempt.
func (s *Service) RecordTaskCIFixAttempt(ctx context.Context, attempt TaskCIFixAttempt) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.RecordTaskCIFixAttempt(ctx, attempt)
}
// RefreshTaskCIFixCheckpoint records the current CI checkpoint without recording a prompt dispatch.
func (s *Service) RefreshTaskCIFixCheckpoint(ctx context.Context, taskID, repositoryID string, prNumber int, signature, checkpointJSON string) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.RefreshTaskCIFixCheckpoint(ctx, taskID, repositoryID, prNumber, signature, checkpointJSON)
}
// RecordTaskCIMergeAttempt records an auto-merge attempt.
func (s *Service) RecordTaskCIMergeAttempt(ctx context.Context, attempt TaskCIMergeAttempt) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.RecordTaskCIMergeAttempt(ctx, attempt)
}
// RecordTaskCIError records a CI automation error.
func (s *Service) RecordTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.RecordTaskCIError(ctx, taskID, repositoryID, prNumber, message)
}
// MarkTaskCIAutoFixExhausted records that auto-fix reached its per-PR round cap.
func (s *Service) MarkTaskCIAutoFixExhausted(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.MarkTaskCIAutoFixExhausted(ctx, taskID, repositoryID, prNumber, message)
}
// ClearTaskCIError clears a CI automation error.
func (s *Service) ClearTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int) error {
if s.store == nil {
return errStoreUnavailable
}
return s.store.ClearTaskCIError(ctx, taskID, repositoryID, prNumber)
}
func (s *Service) IsReviewRequestedForLogin(
ctx context.Context, workspaceID, owner, repo string, prNumber int, login string,
) (bool, error) {
resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo)
if err != nil {
return false, err
}
pr, err := resolved.Client.GetPR(ctx, owner, repo, prNumber)
if err != nil {
return false, err
}
for _, reviewer := range pr.RequestedReviewers {
if reviewer.Type == reviewerTypeUser && strings.EqualFold(reviewer.Login, login) {
return true, nil
}
}
return false, nil
}
func (s *Service) HasEnabledTaskPRAgentPrompts(ctx context.Context, taskID string) (bool, error) {
opts, err := s.store.GetTaskCIOptions(ctx, taskID)
if err != nil {
return false, err
}
return opts.PromptOnReviewRequested || opts.PromptOnMerged || opts.PromptOnClosed, nil
}
func (s *Service) SetTaskPRReviewRequestState(
ctx context.Context, taskID, repositoryID string, prNumber int, requested bool,
) error {
return s.store.SetTaskPRReviewRequestState(ctx, taskID, repositoryID, prNumber, requested)
}
// RebindTaskPRReviewer resolves the current GitHub login and atomically resets
// the task's review-request baselines if the connected account changed.
func (s *Service) RebindTaskPRReviewer(ctx context.Context, taskID string) (string, bool, error) {
resolved, err := s.resolveTaskPRAutomationClient(ctx, taskID)
if err != nil {
return "", false, err
}
login, err := resolved.Client.GetAuthenticatedUser(ctx)
if err != nil {
return "", false, fmt.Errorf("resolve authenticated reviewer: %w", err)
}
changed, err := s.store.RebindTaskPRReviewer(ctx, taskID, login)
if err != nil {
return "", false, err
}
return login, changed, nil
}
func (s *Service) resolveTaskPRAutomationClient(
ctx context.Context,
taskID string,
) (*resolvedServiceClient, error) {
prs, err := s.store.ListTaskPRsByTask(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("load linked PR for authenticated reviewer: %w", err)
}
if len(prs) == 0 {
return nil, fmt.Errorf("resolve authenticated reviewer: task has no linked pull request")
}
pr := prs[0]
for _, candidate := range prs {
if strings.TrimSpace(candidate.WorkspaceID) != "" {
pr = candidate
break
}
}
workspaceID := strings.TrimSpace(pr.WorkspaceID)
if workspaceID == "" {
workspaceID, err = s.resolveTaskWatchWorkspace(ctx, taskID)
if err != nil {
return nil, err
}
}
resolved, err := s.resolveAutomationClient(ctx, workspaceID, pr.Owner, pr.Repo)
if err != nil {
return nil, fmt.Errorf("resolve authenticated reviewer: %w", err)
}
return resolved, nil
}
// resolveTaskWatchWorkspace recovers the workspace for linked PR rows written by
// the workspace-less association path. The watch belongs to the same task, so
// reviewer identity still resolves inside that task's own workspace and never
// falls back to an ambient client.
func (s *Service) resolveTaskWatchWorkspace(ctx context.Context, taskID string) (string, error) {
watch, err := s.store.GetPRWatchByTask(ctx, taskID)
if err != nil {
return "", fmt.Errorf("resolve authenticated reviewer workspace: %w", err)
}
if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" {
return "", fmt.Errorf("resolve authenticated reviewer: %w", ErrGitHubWorkspaceRequired)
}
return strings.TrimSpace(watch.WorkspaceID), nil
}
func (s *Service) SetTaskPRObservedState(
ctx context.Context, taskID, repositoryID string, prNumber int, state string,
) error {
return s.store.SetTaskPRObservedState(ctx, taskID, repositoryID, prNumber, state)
}
func (s *Service) RecordTaskPRLifecyclePrompt(ctx context.Context, prompt TaskPRLifecyclePrompt) error {
return s.store.RecordTaskPRLifecyclePrompt(ctx, prompt)
}
// ShouldHoldTerminalPRWatch keeps a terminal PR attached until its subscribed
// lifecycle prompt has been accepted or durably queued.
func (s *Service) ShouldHoldTerminalPRWatch(
ctx context.Context, taskID, repositoryID string, prNumber int, state string,
) (bool, error) {
opts, err := s.store.GetTaskCIOptions(ctx, taskID)
if err != nil {
return false, err
}
subscribed := (state == prStateMerged && opts.PromptOnMerged) ||
(state == prStateClosed && opts.PromptOnClosed)
if !subscribed {
return false, nil
}
checkpoint, err := s.store.GetTaskCIPRState(ctx, taskID, repositoryID, prNumber)
if err != nil {
return false, err
}
return checkpoint == nil || checkpoint.LastObservedPRState != state, nil
}
func (s *Service) buildTaskCIOptionsResponse(ctx context.Context, opts *TaskCIOptions) (*TaskCIOptionsResponse, error) {
prStates, err := s.taskCIPRStates(ctx, opts.TaskID)
if err != nil {
return nil, err
}
effectivePrompt, usingDefault := s.effectiveCIAutoFixPrompt(ctx, opts)
reviewPrompt := effectiveTaskPRPrompt("pr-review-requested")
mergedPrompt := effectiveTaskPRPrompt("pr-merged-final")
closedPrompt := effectiveTaskPRPrompt("pr-closed-final")
return &TaskCIOptionsResponse{
TaskID: opts.TaskID,
AutoFixEnabled: opts.AutoFixEnabled,
AutoMergeEnabled: opts.AutoMergeEnabled,
AutoFixPromptOverride: opts.AutoFixPromptOverride,
AutoFixMaxRounds: TaskCIAutoFixMaxRounds,
EffectiveAutoFixPrompt: effectivePrompt,
UsingDefaultPrompt: usingDefault,
PromptOnReviewRequested: opts.PromptOnReviewRequested,
PromptOnMerged: opts.PromptOnMerged,
PromptOnClosed: opts.PromptOnClosed,
ReviewReviewerLogin: opts.ReviewReviewerLogin,
EffectiveReviewPrompt: reviewPrompt,
EffectiveMergedPrompt: mergedPrompt,
EffectiveClosedPrompt: closedPrompt,
UpdatedAt: opts.UpdatedAt,
PRStates: prStates,
}, nil
}
func effectiveTaskPRPrompt(name string) string {
return promptcfg.Get(name)
}
func (s *Service) effectiveCIAutoFixPrompt(ctx context.Context, opts *TaskCIOptions) (string, bool) {
if opts.AutoFixPromptOverride != nil {
if override := strings.TrimSpace(*opts.AutoFixPromptOverride); override != "" {
return override, false
}
}
fallback := promptcfg.Get(defaultCIAutoFixPromptName)
resolver := s.getPromptResolver()
if resolver == nil {
return fallback, true
}
return resolver.ResolvePromptContent(ctx, defaultCIAutoFixPromptName, fallback), true
}
func (s *Service) taskCIPRStates(ctx context.Context, taskID string) ([]*TaskCIPRAutomationState, error) {
stored, err := s.store.ListTaskCIPRStates(ctx, taskID)
if err != nil {
return nil, err
}
byKey := make(map[string]*TaskCIPRAutomationState, len(stored))
for _, state := range stored {
byKey[taskCIPRStateKey(state.RepositoryID, state.PRNumber)] = state
}
prs, err := s.store.ListTaskPRsByTask(ctx, taskID)
if err != nil {
return nil, err
}
out := make([]*TaskCIPRAutomationState, 0, max(len(prs), len(stored)))
seen := make(map[string]struct{}, len(prs))
for _, pr := range prs {
key := taskCIPRStateKey(pr.RepositoryID, pr.PRNumber)
if state, ok := byKey[key]; ok {
out = append(out, state)
} else {
out = append(out, &TaskCIPRAutomationState{
TaskID: taskID,
RepositoryID: pr.RepositoryID,
PRNumber: pr.PRNumber,
})
}
seen[key] = struct{}{}
}
for _, state := range stored {
key := taskCIPRStateKey(state.RepositoryID, state.PRNumber)
if _, ok := seen[key]; !ok {
out = append(out, state)
}
}
return out, nil
}
func taskCIPRStateKey(repositoryID string, prNumber int) string {
return fmt.Sprintf("%s#%d", repositoryID, prNumber)
}