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

219 lines
7.7 KiB
Go

package gitlab
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strings"
)
// ValidateTaskMRScope verifies that the task and optional repository belong to
// the requested workspace. Unknown and cross-workspace resources deliberately
// share one error so callers do not disclose resource existence.
func (s *Store) ValidateTaskMRScope(ctx context.Context, workspaceID, taskID, repositoryID string) error {
var taskExists int
err := s.ro.GetContext(ctx, &taskExists,
`SELECT 1 FROM tasks WHERE id = ? AND workspace_id = ? LIMIT 1`, taskID, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return ErrTaskMRNotFound
}
if err != nil {
return fmt.Errorf("validate task workspace: %w", err)
}
if repositoryID == "" {
return nil
}
var repositoryExists int
err = s.ro.GetContext(ctx, &repositoryExists, `
SELECT 1
FROM task_repositories tr
JOIN repositories r ON r.id = tr.repository_id
WHERE tr.task_id = ? AND tr.repository_id = ? AND r.workspace_id = ?
LIMIT 1`, taskID, repositoryID, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return ErrTaskMRNotFound
}
if err != nil {
return fmt.Errorf("validate task repository: %w", err)
}
return nil
}
// ValidateTaskMRRepositoryIdentity verifies the repository selected by the
// task identifies the same GitLab host and project as the MR. It accepts a
// match against either the repository's durable provider identity or its
// remote_url, since self-hosted GitLab repositories added as local clones
// never get a durable provider identity (only github.com/gitlab.com are
// tagged at discovery time) yet still carry a resolvable remote_url. Rows
// with neither signal resolving to a GitLab identity fail closed.
func (s *Store) ValidateTaskMRRepositoryIdentity(
ctx context.Context,
workspaceID, taskID, repositoryID, configuredHost, projectPath string,
) error {
if repositoryID == "" {
return ErrTaskMRRepositoryMismatch
}
var identity struct {
Provider string `db:"provider"`
Host string `db:"provider_host"`
Owner string `db:"provider_owner"`
Name string `db:"provider_name"`
RemoteURL string `db:"remote_url"`
}
err := s.ro.GetContext(ctx, &identity, `
SELECT COALESCE(r.provider, '') AS provider,
COALESCE(r.provider_host, '') AS provider_host,
COALESCE(r.provider_owner, '') AS provider_owner,
COALESCE(r.provider_name, '') AS provider_name,
COALESCE(r.remote_url, '') AS remote_url
FROM task_repositories tr
JOIN repositories r ON r.id = tr.repository_id
JOIN tasks t ON t.id = tr.task_id
WHERE tr.task_id = ? AND tr.repository_id = ?
AND t.workspace_id = ? AND r.workspace_id = ?
LIMIT 1`, taskID, repositoryID, workspaceID, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return ErrTaskMRNotFound
}
if err != nil {
return fmt.Errorf("load task repository identity: %w", err)
}
wantHost, err := normalizeHostOrigin(configuredHost)
if err != nil {
return ErrTaskMRRepositoryMismatch
}
wantProject := strings.Trim(strings.TrimSpace(projectPath), "/")
type candidate struct{ host, project string }
var candidates []candidate
if strings.EqualFold(identity.Provider, "gitlab") {
if gotHost, err := normalizeHostOrigin(identity.Host); err == nil {
gotProject := strings.Trim(strings.TrimSpace(identity.Owner+"/"+identity.Name), "/")
candidates = append(candidates, candidate{host: gotHost, project: gotProject})
}
}
if remoteURLHost, remoteProject := parseGitLabRemoteURLIdentity(identity.RemoteURL); remoteURLHost != "" {
if gotHost, err := normalizeHostOrigin(remoteURLHost); err == nil {
candidates = append(candidates, candidate{host: gotHost, project: remoteProject})
}
}
for _, c := range candidates {
if sameGitLabHost(c.host, wantHost) && strings.EqualFold(c.project, wantProject) {
return nil
}
}
return ErrTaskMRRepositoryMismatch
}
func sameGitLabHost(left, right string) bool {
leftURL, leftErr := validateHost(strings.TrimSpace(left))
rightURL, rightErr := validateHost(strings.TrimSpace(right))
if leftErr != nil || rightErr != nil {
return false
}
return strings.EqualFold(hostWithoutDefaultPort(leftURL), hostWithoutDefaultPort(rightURL))
}
// sameConfiguredOrigin reports whether candidate and configured identify the
// same GitLab web origin: same scheme, and hosts equal once each side's
// implicit default port is stripped. Used to validate an incoming MR URL's
// origin against the workspace's configured GitLab host, so an explicit
// "https://gitlab.example.com:443" on either side still matches the
// equivalent unported form.
func sameConfiguredOrigin(candidate, configured string) bool {
candidateURL, candidateErr := validateHost(strings.TrimSpace(candidate))
configuredURL, configuredErr := validateHost(strings.TrimSpace(configured))
if candidateErr != nil || configuredErr != nil {
return false
}
return strings.EqualFold(candidateURL.Scheme, configuredURL.Scheme) &&
strings.EqualFold(hostWithoutDefaultPort(candidateURL), hostWithoutDefaultPort(configuredURL))
}
// hostWithoutDefaultPort strips a scheme's implicit default port (":443" for
// https, ":80" for http) so an explicitly-ported origin like
// "https://gitlab.example.com:443" compares equal to the equivalent
// "https://gitlab.example.com" without one.
func hostWithoutDefaultPort(u *url.URL) string {
switch u.Scheme {
case mentionHTTPSScheme:
return strings.TrimSuffix(u.Host, ":443")
case mentionHTTPScheme:
return strings.TrimSuffix(u.Host, ":80")
default:
return u.Host
}
}
// ResolveTaskMRRepository validates an explicit repository or infers the sole
// task repository. Scratch tasks retain an empty repository association;
// multi-repository tasks must make the choice explicit.
func (s *Store) ResolveTaskMRRepository(
ctx context.Context,
workspaceID, taskID, repositoryID string,
) (string, error) {
if repositoryID != "" {
if err := s.ValidateTaskMRScope(ctx, workspaceID, taskID, repositoryID); err != nil {
return "", err
}
return repositoryID, nil
}
if err := s.ValidateTaskMRScope(ctx, workspaceID, taskID, ""); err != nil {
return "", err
}
var repositoryIDs []string
if err := s.ro.SelectContext(ctx, &repositoryIDs, `
SELECT tr.repository_id
FROM task_repositories tr
JOIN repositories r ON r.id = tr.repository_id
WHERE tr.task_id = ? AND r.workspace_id = ?
ORDER BY tr.id`, taskID, workspaceID); err != nil {
return "", fmt.Errorf("list task repositories: %w", err)
}
switch len(repositoryIDs) {
case 0:
return "", nil
case 1:
return repositoryIDs[0], nil
default:
return "", ErrTaskMRRepositoryRequired
}
}
// DeleteTaskMRForWorkspace atomically removes one association and only the
// refresh watch that identifies the same task, repository, project and MR.
func (s *Store) DeleteTaskMRForWorkspace(ctx context.Context, workspaceID, associationID string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var association TaskMR
err = tx.GetContext(ctx, &association, `
SELECT `+taskMRSelectColsQualified+`
FROM gitlab_task_mrs gtm
JOIN tasks t ON t.id = gtm.task_id
WHERE gtm.id = ? AND t.workspace_id = ?
LIMIT 1`, associationID, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return ErrTaskMRNotFound
}
if err != nil {
return fmt.Errorf("find task MR association: %w", err)
}
if _, err = tx.ExecContext(ctx, `
DELETE FROM gitlab_mr_watches
WHERE task_id = ? AND repository_id = ? AND project_path = ? AND mr_iid = ?`,
association.TaskID, association.RepositoryID, association.ProjectPath, association.MRIID,
); err != nil {
return fmt.Errorf("delete task MR refresh watch: %w", err)
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gitlab_task_mrs WHERE id = ?`, association.ID); err != nil {
return fmt.Errorf("delete task MR association: %w", err)
}
return tx.Commit()
}