Files
kandev/apps/backend/internal/gitlab/service_task_mr_autolink.go
T

100 lines
4.2 KiB
Go

package gitlab
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
)
// AutoLinkMRForBranch is the push-detection counterpart to
// AssociateExistingMRByURL: instead of a user-supplied MR URL, it searches
// the branch for an open MR and links whatever it finds. Returns (nil, nil)
// when no open MR exists on the branch yet — that is the normal "pushed but
// no MR opened" case, not an error.
//
// Order matters: ResolveTaskMRRepository and ValidateTaskMRRepositoryIdentity
// run before any network call, so a task whose repository doesn't match the
// requested project fails closed without leaking an API call to a merge
// request the caller was never entitled to link.
//
// Idempotent: UpsertTaskMR and EnsureMRWatch are both get-or-create/update
// keyed on their respective unique constraints, so re-running this for an
// already-linked (task, repository, project, iid) updates the existing rows
// in place rather than duplicating them.
func (s *Service) AutoLinkMRForBranch(
ctx context.Context,
workspaceID, sessionID, taskID, repositoryID, projectPath, branch string,
) (*TaskMR, error) {
// A blank branch must never reach FindMRByBranch: PATClient interpolates
// it into `?source_branch=&state=opened&per_page=1`, a query with no
// effective source-branch filter, so GitLab would answer with an
// arbitrary open MR of the project and the wrong MR would get linked.
// Git refs cannot contain whitespace, so a whitespace-only value is
// equally invalid. Guarded here — not just at orchestrator call sites —
// so every caller of this service method gets the same safety guarantee.
branch = strings.TrimSpace(branch)
if branch == "" {
return nil, nil
}
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
repositoryID, err := store.ResolveTaskMRRepository(ctx, workspaceID, taskID, repositoryID)
if err != nil {
return nil, err
}
client, err := s.ClientForWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
if err := store.ValidateTaskMRRepositoryIdentity(
ctx, workspaceID, taskID, repositoryID, client.Host(), projectPath,
); err != nil {
return nil, err
}
mr, err := client.FindMRByBranch(ctx, projectPath, branch)
if err != nil {
return nil, fmt.Errorf("find merge request by branch: %w", err)
}
if mr == nil {
// No MR is open on this branch yet — the normal "pushed but nothing
// opened" result, not an error. Still leave a placeholder (iid=0)
// watch behind so the poller's own iid<=0 resolution (CheckMRWatch)
// can discover an MR opened later — e.g. from the GitLab web UI —
// well after push detection's own retry window ([0, 30s, 60s])
// closes. Best-effort: a watch failure here must not turn "no MR
// yet" into a hard auto-link error.
if _, err := s.EnsureMRWatch(ctx, sessionID, taskID, repositoryID, projectPath, 0, branch); err != nil {
s.logger.Warn("failed to ensure placeholder MR watch while no MR is open on branch",
zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err))
}
return nil, nil
}
status, err := client.GetMRStatus(ctx, projectPath, mr.IID)
if err != nil {
return nil, fmt.Errorf("fetch merge request: %w", err)
}
if err := validateReturnedMRIdentity(status, client.Host(), projectPath, mr.IID); err != nil {
return nil, ErrTaskMRNotFound
}
association := taskMRFromStatus(taskID, repositoryID, client.Host(), projectPath, status)
if err := store.UpsertTaskMR(ctx, association); err != nil {
return nil, fmt.Errorf("upsert task MR: %w", err)
}
// Detach from ctx's cancellation, bounded by the same timeout as the
// URL-link flow's equivalent post-commit call: the association above
// already committed, so a caller's context canceling right after must
// not skip watch creation and silently leave this MR unpolled.
watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), watchDeleteTimeout)
defer cancel()
if _, err := s.EnsureMRWatch(watchCtx, sessionID, taskID, repositoryID, projectPath, mr.IID, branch); err != nil {
s.logger.Warn("failed to ensure MR watch after auto-link",
zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err))
}
s.publishTaskMRUpdated(ctx, workspaceID, association)
return association, nil
}