Files
2026-09-15 23:13:33 +01:00

818 lines
27 KiB
Go

package backendapp
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
)
type taskChangeRequestTaskLookup interface {
GetTask(context.Context, string) (*models.Task, error)
GetRepository(context.Context, string) (*models.Repository, error)
}
type taskChangeRequestProviderResult struct {
Configured bool
Available bool
Status string
ReasonCode string
Settings *mcphandlers.TaskChangeRequestProviderSettings
ChangeRequests []mcphandlers.TaskChangeRequest
Errors []error
}
const taskChangeRequestProviderReadFailedReason = "provider_read_failed"
type taskChangeRequestProviderAdapter interface {
Name() string
ReadTaskChangeRequests(context.Context, *models.Task) taskChangeRequestProviderResult
}
type githubTaskChangeRequestService interface {
ListTaskPRs(context.Context, []string) (map[string][]*github.TaskPR, error)
GetTaskCIOptionsResponse(context.Context, string) (*github.TaskCIOptionsResponse, error)
GetWorkspaceAuthStatus(context.Context, string, string) (*github.WorkspaceAuthStatus, error)
}
type gitlabTaskChangeRequestService interface {
ListTaskMRsByTask(context.Context, string) ([]*gitlab.TaskMR, error)
GetTaskMRAutomationResponse(context.Context, string) (*gitlab.TaskMRAutomationResponse, error)
GetStatusForWorkspace(context.Context, string) (*gitlab.Status, error)
}
type taskChangeRequestReader struct {
tasks taskChangeRequestTaskLookup
providers []taskChangeRequestProviderAdapter
}
func newTaskChangeRequestReader(
tasks *taskservice.Service,
githubProvider githubTaskChangeRequestService,
gitlabProvider gitlabTaskChangeRequestService,
) *taskChangeRequestReader {
providers := []taskChangeRequestProviderAdapter{
newGitHubTaskChangeRequestAdapter(githubProvider),
newGitLabTaskChangeRequestAdapter(tasks, gitlabProvider),
}
return &taskChangeRequestReader{tasks: tasks, providers: providers}
}
func (r *taskChangeRequestReader) GetTaskChangeRequests(
ctx context.Context, taskID string,
) (mcphandlers.TaskChangeRequestReadResponse, error) {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
return mcphandlers.TaskChangeRequestReadResponse{}, fmt.Errorf("task_id is required")
}
if r == nil || r.tasks == nil {
return mcphandlers.TaskChangeRequestReadResponse{}, fmt.Errorf("task change request task reader is unavailable")
}
task, err := r.tasks.GetTask(ctx, taskID)
if err != nil {
return mcphandlers.TaskChangeRequestReadResponse{}, err
}
if task == nil {
return mcphandlers.TaskChangeRequestReadResponse{}, fmt.Errorf("task %q not found", taskID)
}
providers := append([]taskChangeRequestProviderAdapter(nil), r.providers...)
sort.SliceStable(providers, func(i, j int) bool { return providers[i].Name() < providers[j].Name() })
result := mcphandlers.TaskChangeRequestReadResponse{
TaskID: taskID,
Complete: true,
ChangeRequests: []mcphandlers.TaskChangeRequest{},
ProviderSettings: []mcphandlers.TaskChangeRequestProviderSettings{},
ProviderCapabilities: []mcphandlers.TaskChangeRequestProviderCapabilities{},
Errors: []mcphandlers.TaskChangeRequestProviderError{},
}
for _, provider := range providers {
appendTaskChangeRequestProviderRead(ctx, task, provider, &result)
}
return result, nil
}
func appendTaskChangeRequestProviderRead(
ctx context.Context,
task *models.Task,
provider taskChangeRequestProviderAdapter,
result *mcphandlers.TaskChangeRequestReadResponse,
) {
if provider == nil {
return
}
name := provider.Name()
read := provider.ReadTaskChangeRequests(ctx, task)
status := normalizeTaskChangeRequestProviderStatus(read.Status, read.Configured, read.Available, len(read.Errors) > 0)
reasonCode := strings.TrimSpace(read.ReasonCode)
if len(read.Errors) > 0 {
status = mcphandlers.TaskChangeRequestProviderStatusFailed
reasonCode = taskChangeRequestProviderReadFailedReason
}
if reasonCode == "" {
reasonCode = defaultTaskChangeRequestProviderReason(status)
}
settings := read.Settings
if settings == nil {
settings = &mcphandlers.TaskChangeRequestProviderSettings{}
}
settings.Provider = name
settings.Status = status
settings.Configured = read.Configured
settings.Available = read.Available
settings.ReasonCode = reasonCode
if settings.PromptScope == "" {
settings.PromptScope = mcphandlers.TaskChangeRequestPromptScopeTaskProvider
}
result.ProviderSettings = append(result.ProviderSettings, *settings)
result.ProviderCapabilities = append(result.ProviderCapabilities, mcphandlers.TaskChangeRequestProviderCapabilities{
Provider: name,
Status: status,
Configured: read.Configured,
Available: read.Available,
ReasonCode: reasonCode,
Capabilities: taskChangeRequestProviderCapabilities(name),
})
appendTaskChangeRequests(result, name, read.Available, read.ChangeRequests)
appendTaskChangeRequestProviderErrors(result, name, read.Errors)
}
func appendTaskChangeRequests(
result *mcphandlers.TaskChangeRequestReadResponse,
provider string,
available bool,
changes []mcphandlers.TaskChangeRequest,
) {
for _, change := range changes {
if change.Provider == "" {
change.Provider = provider
}
decorateTaskChangeRequestCapabilities(&change, available, provider)
result.ChangeRequests = append(result.ChangeRequests, change)
}
}
func appendTaskChangeRequestProviderErrors(
result *mcphandlers.TaskChangeRequestReadResponse, provider string, providerErrors []error,
) {
for _, providerErr := range providerErrors {
result.Complete = false
code, message := sanitizeTaskChangeRequestReadError(providerErr)
result.Errors = append(result.Errors, mcphandlers.TaskChangeRequestProviderError{
Provider: provider, Code: code, Message: message,
})
}
}
func normalizeTaskChangeRequestProviderStatus(status string, configured, available, failed bool) string {
status = strings.TrimSpace(strings.ToLower(status))
switch status {
case mcphandlers.TaskChangeRequestProviderStatusAvailable,
mcphandlers.TaskChangeRequestProviderStatusUnavailable,
mcphandlers.TaskChangeRequestProviderStatusFailed:
if failed {
return mcphandlers.TaskChangeRequestProviderStatusFailed
}
return status
}
if failed {
return mcphandlers.TaskChangeRequestProviderStatusFailed
}
if available {
return mcphandlers.TaskChangeRequestProviderStatusAvailable
}
if configured {
return mcphandlers.TaskChangeRequestProviderStatusUnavailable
}
return mcphandlers.TaskChangeRequestProviderStatusUnavailable
}
func defaultTaskChangeRequestProviderReason(status string) string {
switch status {
case mcphandlers.TaskChangeRequestProviderStatusAvailable:
return ""
case mcphandlers.TaskChangeRequestProviderStatusFailed:
return taskChangeRequestProviderReadFailedReason
default:
return "provider_not_configured"
}
}
func sanitizeTaskChangeRequestReadError(err error) (string, string) {
if err == nil {
return "", ""
}
// Provider and database details stay in the backend log. The MCP caller
// needs a stable category so it can retry a failed provider read safely.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "provider_read_cancelled", "provider read was cancelled"
}
return taskChangeRequestProviderReadFailedReason, "provider read failed"
}
func finalizeTaskChangeRequestProviderResult(result *taskChangeRequestProviderResult) {
if len(result.Errors) == 0 || result.Status == mcphandlers.TaskChangeRequestProviderStatusUnavailable {
return
}
result.Status = mcphandlers.TaskChangeRequestProviderStatusFailed
result.ReasonCode = taskChangeRequestProviderReadFailedReason
}
var taskChangeRequestCapabilityNames = []string{
"link",
"unlink",
"replace_same_provider",
"auto_fix",
"auto_merge",
"lifecycle_notifications",
"custom_auto_fix_prompt",
"auto_fix_outcome_reporting",
}
func taskChangeRequestProviderCapabilities(provider string) map[string]bool {
capabilities := make(map[string]bool, len(taskChangeRequestCapabilityNames))
for _, name := range taskChangeRequestCapabilityNames {
capabilities[name] = name != "auto_fix_outcome_reporting" || provider == mcphandlers.TaskChangeRequestProviderGitHub
}
return capabilities
}
func decorateTaskChangeRequestCapabilities(
change *mcphandlers.TaskChangeRequest, available bool, provider string,
) {
if change == nil {
return
}
change.Capabilities = taskChangeRequestProviderCapabilities(provider)
if change.CapabilityReasons == nil {
change.CapabilityReasons = map[string]string{}
}
if !available {
for name, supported := range change.Capabilities {
if supported {
change.Capabilities[name] = false
change.CapabilityReasons[name] = "provider_unavailable"
}
}
return
}
if change.IdentityStatus == "" {
if change.RepositoryID == nil {
change.IdentityStatus = mcphandlers.TaskChangeRequestIdentityUnresolved
} else {
change.IdentityStatus = mcphandlers.TaskChangeRequestIdentityResolved
}
}
if change.IdentityStatus == mcphandlers.TaskChangeRequestIdentityResolved {
return
}
reason := "identity_unresolved"
if change.IdentityStatus == mcphandlers.TaskChangeRequestIdentityAmbiguous {
reason = "identity_ambiguous"
}
for name, supported := range change.Capabilities {
if supported {
change.Capabilities[name] = false
change.CapabilityReasons[name] = reason
}
}
}
type githubTaskChangeRequestAdapter struct {
service githubTaskChangeRequestService
}
func newGitHubTaskChangeRequestAdapter(service githubTaskChangeRequestService) taskChangeRequestProviderAdapter {
return githubTaskChangeRequestAdapter{service: service}
}
func (a githubTaskChangeRequestAdapter) Name() string {
return mcphandlers.TaskChangeRequestProviderGitHub
}
func (a githubTaskChangeRequestAdapter) ReadTaskChangeRequests(
ctx context.Context, task *models.Task,
) taskChangeRequestProviderResult {
result := taskChangeRequestProviderResult{
Status: mcphandlers.TaskChangeRequestProviderStatusUnavailable,
ReasonCode: "provider_not_configured",
}
if a.service == nil || task == nil {
return result
}
a.readGitHubTaskChangeRequests(ctx, task, &result)
a.readGitHubStatus(ctx, task, &result)
a.readGitHubAutomationSettings(ctx, task, &result)
finalizeTaskChangeRequestProviderResult(&result)
return result
}
func (a githubTaskChangeRequestAdapter) readGitHubTaskChangeRequests(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
byTask, err := a.service.ListTaskPRs(ctx, []string{task.ID})
if err != nil {
result.Errors = append(result.Errors, err)
return
}
for _, pr := range byTask[task.ID] {
if pr != nil {
result.ChangeRequests = append(result.ChangeRequests, githubTaskChangeRequest(pr))
}
}
}
func (a githubTaskChangeRequestAdapter) readGitHubStatus(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
authStatus, err := a.service.GetWorkspaceAuthStatus(ctx, task.WorkspaceID, "")
if err != nil {
result.Errors = append(result.Errors, err)
return
}
if authStatus == nil {
return
}
result.Configured = authStatus.Automation != nil
result.Available = authStatus.Authenticated
if !result.Configured {
return
}
result.Status = mcphandlers.TaskChangeRequestProviderStatusAvailable
if !result.Available {
result.Status = mcphandlers.TaskChangeRequestProviderStatusUnavailable
result.ReasonCode = "credentials_unavailable"
}
}
func (a githubTaskChangeRequestAdapter) readGitHubAutomationSettings(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
if !result.Configured {
return
}
options, err := a.service.GetTaskCIOptionsResponse(ctx, task.ID)
if err != nil {
result.Errors = append(result.Errors, err)
return
}
if options == nil {
return
}
result.Settings = githubTaskChangeRequestSettings(options)
applyGitHubTaskChangeRequestAutomation(result.ChangeRequests, options)
}
func githubTaskChangeRequest(pr *github.TaskPR) mcphandlers.TaskChangeRequest {
change := mcphandlers.TaskChangeRequest{
Provider: mcphandlers.TaskChangeRequestProviderGitHub,
Number: pr.PRNumber,
URL: pr.PRURL,
Title: pr.PRTitle,
State: normalizeTaskChangeRequestState(pr.State),
ProviderState: pr.State,
Draft: pr.IsDraft,
BaseRef: pr.BaseBranch,
HeadRef: pr.HeadBranch,
HeadSHA: pr.HeadSHA,
MergedAt: pr.MergedAt,
ClosedAt: pr.ClosedAt,
UpdatedAt: taskChangeRequestTimePointer(pr.UpdatedAt),
IdentityStatus: mcphandlers.TaskChangeRequestIdentityResolved,
}
if strings.TrimSpace(pr.RepositoryID) == "" {
change.IdentityStatus = mcphandlers.TaskChangeRequestIdentityUnresolved
} else {
repositoryID := strings.TrimSpace(pr.RepositoryID)
change.RepositoryID = &repositoryID
}
return change
}
func githubTaskChangeRequestSettings(options *github.TaskCIOptionsResponse) *mcphandlers.TaskChangeRequestProviderSettings {
return &mcphandlers.TaskChangeRequestProviderSettings{
Provider: mcphandlers.TaskChangeRequestProviderGitHub,
AutoFixPromptOverride: options.AutoFixPromptOverride,
EffectiveAutoFixPrompt: options.EffectiveAutoFixPrompt,
UsingDefaultPrompt: options.UsingDefaultPrompt,
AutoFixMaxRounds: options.AutoFixMaxRounds,
PromptScope: mcphandlers.TaskChangeRequestPromptScopeTaskProvider,
}
}
func applyGitHubTaskChangeRequestAutomation(
changes []mcphandlers.TaskChangeRequest, options *github.TaskCIOptionsResponse,
) {
optionByIdentity := make(map[string]*github.TaskPRAutomationOptions, len(options.PROptions))
for _, option := range options.PROptions {
if option == nil {
continue
}
optionByIdentity[taskChangeRequestIdentityKey(option.RepositoryID, option.PRNumber)] = option
}
stateByIdentity := make(map[string]*github.TaskCIPRAutomationState, len(options.PRStates))
for _, state := range options.PRStates {
if state == nil {
continue
}
stateByIdentity[taskChangeRequestIdentityKey(state.RepositoryID, state.PRNumber)] = state
}
for i := range changes {
change := &changes[i]
key := taskChangeRequestIdentityKey(pointerString(change.RepositoryID), change.Number)
if option := optionByIdentity[key]; option != nil {
change.Automation = &mcphandlers.TaskChangeRequestAutomation{
AutoFixEnabled: boolPointer(option.AutoFixEnabled),
AutoMergeEnabled: boolPointer(option.AutoMergeEnabled),
PromptOnReviewRequested: boolPointer(option.PromptOnReviewRequested),
PromptOnMerged: boolPointer(option.PromptOnMerged),
PromptOnClosed: boolPointer(option.PromptOnClosed),
}
}
if state := stateByIdentity[key]; state != nil {
rounds := state.AutoFixRoundCount
change.AutomationStatus = &mcphandlers.TaskChangeRequestAutomationStatus{
AutoFixRoundCount: &rounds,
LastError: state.LastError,
StateKnown: true,
}
}
}
}
type gitlabTaskChangeRequestAdapter struct {
tasks taskChangeRequestTaskLookup
service gitlabTaskChangeRequestService
}
func newGitLabTaskChangeRequestAdapter(
tasks taskChangeRequestTaskLookup, service gitlabTaskChangeRequestService,
) taskChangeRequestProviderAdapter {
return gitlabTaskChangeRequestAdapter{tasks: tasks, service: service}
}
func (a gitlabTaskChangeRequestAdapter) Name() string {
return mcphandlers.TaskChangeRequestProviderGitLab
}
func (a gitlabTaskChangeRequestAdapter) ReadTaskChangeRequests(
ctx context.Context, task *models.Task,
) taskChangeRequestProviderResult {
result := taskChangeRequestProviderResult{
Status: mcphandlers.TaskChangeRequestProviderStatusUnavailable,
ReasonCode: "provider_not_configured",
}
if a.service == nil || task == nil {
return result
}
a.readGitLabTaskChangeRequests(ctx, task, &result)
a.readGitLabStatus(ctx, task, &result)
a.readGitLabAutomationSettings(ctx, task, &result)
finalizeTaskChangeRequestProviderResult(&result)
return result
}
func (a gitlabTaskChangeRequestAdapter) readGitLabTaskChangeRequests(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
mrs, err := a.service.ListTaskMRsByTask(ctx, task.ID)
if err != nil {
result.Errors = append(result.Errors, err)
return
}
changes, resolveErr := a.gitLabTaskChangeRequestsWithErrors(ctx, task, mrs)
result.ChangeRequests = changes
if resolveErr != nil {
result.Errors = append(result.Errors, resolveErr)
}
}
func (a gitlabTaskChangeRequestAdapter) readGitLabStatus(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
status, err := a.service.GetStatusForWorkspace(ctx, task.WorkspaceID)
if err != nil {
result.Errors = append(result.Errors, err)
return
}
if status == nil {
return
}
if connectionErr := strings.TrimSpace(status.ConnectionError); connectionErr != "" {
result.Errors = append(result.Errors, errors.New(connectionErr))
}
result.Configured = strings.TrimSpace(status.AuthMethod) != "" && strings.TrimSpace(status.AuthMethod) != "none"
result.Configured = result.Configured || status.TokenConfigured || strings.TrimSpace(status.Username) != ""
result.Available = status.Authenticated
if !result.Configured {
return
}
result.Status = mcphandlers.TaskChangeRequestProviderStatusAvailable
if !result.Available {
result.Status = mcphandlers.TaskChangeRequestProviderStatusUnavailable
result.ReasonCode = "credentials_unavailable"
}
}
func (a gitlabTaskChangeRequestAdapter) readGitLabAutomationSettings(
ctx context.Context, task *models.Task, result *taskChangeRequestProviderResult,
) {
if !result.Configured {
return
}
options, err := a.service.GetTaskMRAutomationResponse(ctx, task.ID)
if err != nil {
result.Errors = append(result.Errors, err)
return
}
if options == nil {
return
}
result.Settings = gitlabTaskChangeRequestSettings(options)
applyGitLabTaskChangeRequestAutomation(result.ChangeRequests, options)
}
func (a gitlabTaskChangeRequestAdapter) gitLabTaskChangeRequests(
ctx context.Context, task *models.Task, mrs []*gitlab.TaskMR,
) []mcphandlers.TaskChangeRequest {
changes, _ := a.gitLabTaskChangeRequestsWithErrors(ctx, task, mrs)
return changes
}
func (a gitlabTaskChangeRequestAdapter) gitLabTaskChangeRequestsWithErrors(
ctx context.Context, task *models.Task, mrs []*gitlab.TaskMR,
) ([]mcphandlers.TaskChangeRequest, error) {
changes := make([]mcphandlers.TaskChangeRequest, 0, len(mrs))
pathsByIdentity := make(map[string]map[string]struct{})
var resolveErrs []error
for _, mr := range mrs {
if mr == nil {
continue
}
change, err := a.gitLabTaskChangeRequestWithError(ctx, task, mr)
if err != nil {
resolveErrs = append(resolveErrs, err)
}
change.ProviderProjectPath = strings.Trim(strings.TrimSpace(mr.ProjectPath), "/")
change.ProviderRepositoryID = strings.TrimSpace(mr.RepositoryID)
changes = append(changes, change)
if change.RepositoryID != nil {
key := taskChangeRequestIdentityKey(pointerString(change.RepositoryID), change.Number)
if pathsByIdentity[key] == nil {
pathsByIdentity[key] = make(map[string]struct{})
}
pathsByIdentity[key][change.ProviderProjectPath] = struct{}{}
}
}
for i := range changes {
change := &changes[i]
if change.RepositoryID == nil {
continue
}
key := taskChangeRequestIdentityKey(pointerString(change.RepositoryID), change.Number)
if len(pathsByIdentity[key]) > 1 {
change.IdentityStatus = mcphandlers.TaskChangeRequestIdentityAmbiguous
}
}
return changes, errors.Join(resolveErrs...)
}
func (a gitlabTaskChangeRequestAdapter) gitLabTaskChangeRequest(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) mcphandlers.TaskChangeRequest {
change, _ := a.gitLabTaskChangeRequestWithError(ctx, task, mr)
return change
}
func (a gitlabTaskChangeRequestAdapter) gitLabTaskChangeRequestWithError(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) (mcphandlers.TaskChangeRequest, error) {
change := mcphandlers.TaskChangeRequest{
Provider: mcphandlers.TaskChangeRequestProviderGitLab,
Number: mr.MRIID,
URL: mr.MRURL,
Title: mr.MRTitle,
State: normalizeTaskChangeRequestState(mr.State),
ProviderState: mr.State,
ProviderProjectPath: strings.Trim(strings.TrimSpace(mr.ProjectPath), "/"),
BaseRef: mr.BaseBranch,
HeadRef: mr.HeadBranch,
HeadSHA: mr.HeadSHA,
Draft: boolPointer(mr.Draft),
MergedAt: mr.MergedAt,
ClosedAt: mr.ClosedAt,
UpdatedAt: taskChangeRequestTimePointer(mr.UpdatedAt),
IdentityStatus: mcphandlers.TaskChangeRequestIdentityUnresolved,
}
if repositoryID, identityStatus, err := a.resolveGitLabRepositoryIdentityWithError(ctx, task, mr); err != nil {
return change, err
} else if repositoryID != "" {
change.RepositoryID = &repositoryID
change.IdentityStatus = identityStatus
} else {
change.IdentityStatus = identityStatus
}
return change, nil
}
func (a gitlabTaskChangeRequestAdapter) resolveGitLabRepositoryIdentity(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) (string, string) {
repositoryID, identityStatus, _ := a.resolveGitLabRepositoryIdentityWithError(ctx, task, mr)
return repositoryID, identityStatus
}
func (a gitlabTaskChangeRequestAdapter) resolveGitLabRepositoryIdentityWithError(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) (string, string, error) {
if repositoryID := strings.TrimSpace(mr.RepositoryID); repositoryID != "" {
return repositoryID, mcphandlers.TaskChangeRequestIdentityResolved, nil
}
if a.tasks == nil || task == nil {
return "", mcphandlers.TaskChangeRequestIdentityUnresolved, nil
}
candidates, err := a.gitLabRepositoryCandidatesWithError(ctx, task, mr)
if err != nil {
return "", mcphandlers.TaskChangeRequestIdentityUnresolved, err
}
switch len(candidates) {
case 0:
return "", mcphandlers.TaskChangeRequestIdentityUnresolved, nil
case 1:
for repositoryID := range candidates {
return repositoryID, mcphandlers.TaskChangeRequestIdentityResolved, nil
}
default:
return "", mcphandlers.TaskChangeRequestIdentityAmbiguous, nil
}
return "", mcphandlers.TaskChangeRequestIdentityUnresolved, nil
}
func (a gitlabTaskChangeRequestAdapter) gitLabRepositoryCandidates(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) map[string]struct{} {
candidates, _ := a.gitLabRepositoryCandidatesWithError(ctx, task, mr)
return candidates
}
func (a gitlabTaskChangeRequestAdapter) gitLabRepositoryCandidatesWithError(
ctx context.Context, task *models.Task, mr *gitlab.TaskMR,
) (map[string]struct{}, error) {
candidates := make(map[string]struct{})
var lookupErrs []error
for _, taskRepository := range task.Repositories {
if taskRepository == nil || strings.TrimSpace(taskRepository.RepositoryID) == "" {
continue
}
repository, err := a.tasks.GetRepository(ctx, taskRepository.RepositoryID)
if err != nil {
lookupErrs = append(lookupErrs, err)
continue
}
if repository == nil || repository.WorkspaceID != task.WorkspaceID {
continue
}
if !strings.EqualFold(strings.TrimSpace(repository.Provider), mcphandlers.TaskChangeRequestProviderGitLab) {
continue
}
if normalizeProviderHost(repository.ProviderHost) != normalizeProviderHost(mr.Host) {
continue
}
if !sameGitLabProjectPath(repository, mr.ProjectPath) {
continue
}
candidates[repository.ID] = struct{}{}
}
return candidates, errors.Join(lookupErrs...)
}
func sameGitLabProjectPath(repository *models.Repository, projectPath string) bool {
projectPath = strings.Trim(strings.TrimSpace(projectPath), "/")
if projectPath == "" {
return false
}
for _, path := range []string{
strings.Trim(strings.TrimSpace(repository.ProviderOwner)+"/"+strings.TrimSpace(repository.ProviderName), "/"),
strings.Trim(strings.TrimSpace(repository.ProviderScope)+"/"+strings.TrimSpace(repository.ProviderName), "/"),
} {
if path != "/" && path == projectPath {
return true
}
}
return false
}
func gitlabTaskChangeRequestSettings(options *gitlab.TaskMRAutomationResponse) *mcphandlers.TaskChangeRequestProviderSettings {
return &mcphandlers.TaskChangeRequestProviderSettings{
Provider: mcphandlers.TaskChangeRequestProviderGitLab,
AutoFixPromptOverride: options.AutoFixPromptOverride,
EffectiveAutoFixPrompt: options.EffectiveAutoFixPrompt,
UsingDefaultPrompt: options.UsingDefaultPrompt,
AutoFixMaxRounds: options.AutoFixMaxRounds,
PromptScope: mcphandlers.TaskChangeRequestPromptScopeTaskProvider,
}
}
func applyGitLabTaskChangeRequestAutomation(
changes []mcphandlers.TaskChangeRequest, options *gitlab.TaskMRAutomationResponse,
) {
for i := range changes {
change := &changes[i]
for _, option := range options.MROptions {
if option == nil || option.MRIID != change.Number {
continue
}
if gitLabAutomationRepositoryIDMatches(option.RepositoryID, change) &&
strings.Trim(strings.TrimSpace(option.ProjectPath), "/") == change.ProviderProjectPath {
change.Automation = &mcphandlers.TaskChangeRequestAutomation{
AutoFixEnabled: boolPointer(option.AutoFixEnabled),
AutoMergeEnabled: boolPointer(option.AutoMergeEnabled),
PromptOnReviewRequested: boolPointer(option.PromptOnReviewRequested),
PromptOnMerged: boolPointer(option.PromptOnMerged),
PromptOnClosed: boolPointer(option.PromptOnClosed),
}
break
}
}
for _, state := range options.MRStates {
if state == nil || state.MRIID != change.Number {
continue
}
if gitLabAutomationRepositoryIDMatches(state.RepositoryID, change) &&
strings.Trim(strings.TrimSpace(state.ProjectPath), "/") == change.ProviderProjectPath {
rounds := state.AutoFixRoundCount
lastError := state.LastError
if lastError == nil {
lastError = state.LastSyncError
}
change.AutomationStatus = &mcphandlers.TaskChangeRequestAutomationStatus{
AutoFixRoundCount: &rounds,
LastError: lastError,
StateKnown: true,
}
break
}
}
}
}
func gitLabAutomationRepositoryIDMatches(repositoryID string, change *mcphandlers.TaskChangeRequest) bool {
if change == nil {
return false
}
repositoryID = strings.TrimSpace(repositoryID)
if repositoryID == strings.TrimSpace(change.ProviderRepositoryID) {
return true
}
return repositoryID == strings.TrimSpace(pointerString(change.RepositoryID))
}
func normalizeTaskChangeRequestState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "open"
case "closed":
return "closed"
case "merged":
return "merged"
default:
return "unknown"
}
}
func normalizeProviderHost(host string) string {
return strings.TrimRight(strings.ToLower(strings.TrimSpace(host)), "/")
}
func taskChangeRequestTimePointer(value time.Time) *time.Time {
if value.IsZero() {
return nil
}
copy := value
return &copy
}
func taskChangeRequestIdentityKey(repositoryID string, number int) string {
return fmt.Sprintf("%s#%d", repositoryID, number)
}
func pointerString(value *string) string {
if value == nil {
return ""
}
return *value
}
func boolPointer(value bool) *bool {
copy := value
return &copy
}