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

476 lines
17 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
const (
contributionForkPreparationTimeout = 30 * time.Second
)
var contributionForkPollInterval = 200 * time.Millisecond
// ContributionForkStatus reports the workspace automation capability without
// exposing credentials or relying on an ambient executor identity.
type ContributionForkStatus string
const (
ContributionForkStatusDirectWrite ContributionForkStatus = "direct"
ContributionForkStatusReady ContributionForkStatus = "ready"
ContributionForkStatusCreatable ContributionForkStatus = "creatable"
ContributionForkStatusBlocked ContributionForkStatus = "blocked"
)
// ContributionForkResolution contains transient provider information. Only
// Destination is suitable for persistence after Validate succeeds.
type ContributionForkResolution struct {
Status ContributionForkStatus
ActorLogin string
Repository *GitHubRepository
Destination *taskmodels.ContributionDestination
}
var (
ErrContributionForkConflict = errors.New("GitHub contribution fork has the wrong parent")
ErrContributionForkAppUnsupported = errors.New("GitHub App cannot prepare a contribution fork")
ErrContributionForkNotWritable = errors.New("GitHub contribution fork is not writable")
ErrContributionForkNotReady = errors.New("GitHub contribution fork is not ready")
ErrContributionForkUnsupported = errors.New("GitHub client cannot prepare a contribution fork")
)
type contributionForkClient interface {
GetAuthenticatedUser(context.Context) (string, error)
GetRepository(context.Context, string, string) (*GitHubRepository, error)
ListRepositoryForks(context.Context, string, string) ([]*GitHubRepository, error)
CreateFork(context.Context, string, string) (*GitHubRepository, error)
}
// ResolveContributionForkForWorkspace checks or creates the exact managed
// publication destination for one canonical repository. Set create to false
// for a read-only bootstrap capability probe.
func (s *Service) ResolveContributionForkForWorkspace(
ctx context.Context,
workspaceID, owner, repo string,
create bool,
) (ContributionForkResolution, error) {
if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil {
return ContributionForkResolution{}, err
}
resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo)
if err != nil {
return ContributionForkResolution{}, err
}
client, ok := resolved.Client.(contributionForkClient)
if !ok {
return ContributionForkResolution{}, ErrContributionForkUnsupported
}
prepareCtx, cancel := context.WithTimeout(ctx, contributionForkPreparationTimeout)
defer cancel()
result, err := resolveContributionFork(prepareCtx, client, resolved.Principal, owner, repo, create)
if err != nil || result.Destination == nil {
return result, err
}
if err := bindContributionDestinationCredential(result.Destination, resolved); err != nil {
return ContributionForkResolution{}, err
}
return result, nil
}
// ProbeContributionForkCapabilityForWorkspace returns direct, ready, or
// creatable capability without mutating GitHub. Configuration failures are
// returned with a blocked status so callers can preserve the issue-only path.
func (s *Service) ProbeContributionForkCapabilityForWorkspace(
ctx context.Context,
workspaceID, owner, repo string,
) (ContributionForkResolution, error) {
result, err := s.ResolveContributionForkForWorkspace(ctx, workspaceID, owner, repo, false)
if err == nil {
return result, nil
}
result.Status = ContributionForkStatusBlocked
return result, err
}
// ResolveContributionDestinationForWorkspace prepares and returns the
// server-authored destination. Direct target write returns a nil binding.
func (s *Service) ResolveContributionDestinationForWorkspace(
ctx context.Context,
workspaceID, owner, repo string,
) (*taskmodels.ContributionDestination, error) {
result, err := s.ResolveContributionForkForWorkspace(ctx, workspaceID, owner, repo, true)
if err != nil {
return nil, err
}
return result.Destination, nil
}
// VerifyContributionDestinationForWorkspace re-reads the target repository
// through the current workspace automation connection. It is used at broker
// issuance and redemption so a deleted-and-recreated path cannot inherit an
// old destination lease.
func (s *Service) VerifyContributionDestinationForWorkspace(
ctx context.Context,
workspaceID, sourceOwner, sourceRepo, sourceProviderID, targetOwner, targetRepo, targetProviderID string,
) error {
if strings.TrimSpace(sourceProviderID) == "" || strings.TrimSpace(targetProviderID) == "" {
return errors.New("contribution destination provider IDs are required")
}
resolved, err := s.resolveAutomationClient(ctx, workspaceID, targetOwner, targetRepo)
if err != nil {
return err
}
client, ok := resolved.Client.(interface {
GetRepository(context.Context, string, string) (*GitHubRepository, error)
})
if !ok {
return errors.New("GitHub automation client cannot verify contribution destinations")
}
target, err := client.GetRepository(ctx, targetOwner, targetRepo)
if err != nil {
return fmt.Errorf("verify contribution destination %s/%s: %w", targetOwner, targetRepo, err)
}
parsedTargetID, parseErr := strconv.ParseInt(strings.TrimSpace(targetProviderID), 10, 64)
if parseErr != nil || target == nil || target.ID != parsedTargetID || !target.Fork || target.ParentID <= 0 {
return errors.New("contribution destination target provider identity does not match")
}
parsedSourceID, parseErr := strconv.ParseInt(strings.TrimSpace(sourceProviderID), 10, 64)
if parseErr != nil || target.ParentID != parsedSourceID ||
!strings.EqualFold(target.ParentFullName, strings.TrimSpace(sourceOwner)+"/"+strings.TrimSpace(sourceRepo)) {
return errors.New("contribution destination parent provider identity does not match")
}
return nil
}
func resolveContributionFork(
ctx context.Context,
client contributionForkClient,
principal AuthPrincipal,
owner, repo string,
create bool,
) (ContributionForkResolution, error) {
if client == nil {
return ContributionForkResolution{}, ErrContributionForkUnsupported
}
canonical, err := client.GetRepository(ctx, owner, repo)
if err != nil {
return ContributionForkResolution{}, fmt.Errorf("verify canonical repository %s/%s: %w", owner, repo, err)
}
if err := validateCanonicalRepository(canonical, owner, repo); err != nil {
return ContributionForkResolution{}, err
}
fail := func(err error) (ContributionForkResolution, error) {
return ContributionForkResolution{Repository: canonical}, err
}
if repositoryWritable(canonical) {
login, err := contributionActorLogin(ctx, client, principal)
if err != nil {
return fail(err)
}
return ContributionForkResolution{
Status: ContributionForkStatusDirectWrite,
ActorLogin: login,
Repository: canonical,
}, nil
}
if principal.Kind == AuthPrincipalApp {
return fail(fmt.Errorf("%w: direct write to %s/%s is required", ErrContributionForkAppUnsupported, owner, repo))
}
login, err := contributionActorLogin(ctx, client, principal)
if err != nil {
return fail(err)
}
result, err := findContributionFork(ctx, client, canonical, login, owner, repo)
if err == nil {
return result, nil
}
if !errors.Is(err, errContributionForkMissing) {
return fail(err)
}
if !create {
return ContributionForkResolution{Status: ContributionForkStatusCreatable, ActorLogin: login, Repository: canonical}, nil
}
created, err := client.CreateFork(ctx, owner, repo)
if err != nil {
return fail(fmt.Errorf("create contribution fork for %s/%s: %w", owner, repo, err))
}
forkOwner, forkName, err := repositoryOwnerAndName(created)
if err != nil {
return fail(fmt.Errorf("create contribution fork for %s/%s: %w", owner, repo, err))
}
fork, err := waitForContributionFork(ctx, client, canonical, forkOwner, forkName)
if err != nil {
return fail(err)
}
result, err = buildContributionForkResolution(canonical, fork, login, forkOwner, forkName)
if err != nil {
return fail(err)
}
return result, nil
}
var errContributionForkMissing = errors.New("contribution fork is missing")
func findContributionFork(
ctx context.Context,
client contributionForkClient,
canonical *GitHubRepository,
login, owner, repo string,
) (ContributionForkResolution, error) {
fork, err := client.GetRepository(ctx, login, repo)
if err == nil {
return buildContributionForkResolution(canonical, fork, login, login, repo)
}
if !isContributionRepositoryNotFound(err) {
return ContributionForkResolution{}, fmt.Errorf("verify contribution fork %s/%s: %w", login, repo, err)
}
return findContributionForkInNetwork(ctx, client, canonical, login, owner, repo)
}
func findContributionForkInNetwork(
ctx context.Context,
client contributionForkClient,
canonical *GitHubRepository,
login, owner, repo string,
) (ContributionForkResolution, error) {
forks, err := client.ListRepositoryForks(ctx, owner, repo)
if err != nil {
return ContributionForkResolution{}, fmt.Errorf("list contribution forks for %s/%s: %w", owner, repo, err)
}
var candidateErr error
for _, candidate := range forks {
candidateOwner, candidateName, nameErr := repositoryOwnerAndName(candidate)
if nameErr != nil || !strings.EqualFold(candidateOwner, login) {
continue
}
verified, verifyErr := client.GetRepository(ctx, candidateOwner, candidateName)
if verifyErr != nil {
if isContributionRepositoryNotFound(verifyErr) {
candidateErr = fmt.Errorf("%w: verify fork %s/%s", ErrContributionForkConflict, candidateOwner, candidateName)
continue
}
return ContributionForkResolution{}, fmt.Errorf("verify contribution fork %s/%s: %w", candidateOwner, candidateName, verifyErr)
}
result, validationErr := buildContributionForkResolution(canonical, verified, login, login, "")
if validationErr == nil {
return result, nil
}
candidateErr = validationErr
}
if candidateErr != nil {
return ContributionForkResolution{}, candidateErr
}
return ContributionForkResolution{}, errContributionForkMissing
}
func buildContributionForkResolution(
canonical, fork *GitHubRepository,
actorLogin, expectedOwner, expectedRepo string,
) (ContributionForkResolution, error) {
if err := validateContributionFork(canonical, fork, expectedOwner, expectedRepo); err != nil {
return ContributionForkResolution{}, err
}
destination, err := makeContributionDestination(canonical, fork)
if err != nil {
return ContributionForkResolution{}, err
}
return ContributionForkResolution{
Status: ContributionForkStatusReady,
ActorLogin: actorLogin,
Repository: fork,
Destination: destination,
}, nil
}
func validateCanonicalRepository(repository *GitHubRepository, owner, repo string) error {
if repository == nil || !sameRepositoryName(repository, owner, repo) || repository.Fork {
return fmt.Errorf("verify canonical repository %s/%s: provider identity does not match", owner, repo)
}
if repository.ID <= 0 {
return fmt.Errorf("verify canonical repository %s/%s: provider ID is missing", owner, repo)
}
return nil
}
func validateContributionFork(canonical, fork *GitHubRepository, login, repo string) error {
if fork == nil || !fork.Fork ||
!strings.EqualFold(repositoryOwner(fork), login) ||
(repo != "" && !sameRepositoryName(fork, login, repo)) ||
fork.ParentID != canonical.ID || !strings.EqualFold(fork.ParentFullName, canonical.FullName) {
label := login
if repo != "" {
label += "/" + repo
}
return fmt.Errorf("%w: expected %s to fork %s", ErrContributionForkConflict, label, canonical.FullName)
}
if !repositoryWritable(fork) {
return fmt.Errorf("%w: %s/%s", ErrContributionForkNotWritable, login, repo)
}
return nil
}
func repositoryOwner(repository *GitHubRepository) string {
if repository == nil {
return ""
}
if strings.TrimSpace(repository.Owner) != "" {
return strings.TrimSpace(repository.Owner)
}
owner, _, err := repositoryOwnerAndName(repository)
if err != nil {
return ""
}
return owner
}
func bindContributionDestinationCredential(
destination *taskmodels.ContributionDestination,
resolved *resolvedServiceClient,
) error {
if destination == nil || resolved == nil || resolved.credential == nil {
return errors.New("managed contribution destination credential binding is unavailable")
}
credential := resolved.credential
binding := &taskmodels.ContributionDestinationCredentialBinding{
Source: string(credential.Principal.Source),
Login: strings.TrimSpace(credential.Principal.Login),
CredentialGeneration: credential.CredentialGeneration,
AppRegistrationID: credential.AppRegistrationID,
AppCredentialGeneration: credential.AppCredentialGeneration,
}
if credential.Principal.InstallationID > 0 {
binding.InstallationID = credential.Principal.InstallationID
}
if err := binding.Validate(); err != nil {
return fmt.Errorf("validate managed contribution destination credential binding: %w", err)
}
destination.CredentialBinding = binding
return nil
}
func makeContributionDestination(canonical, fork *GitHubRepository) (*taskmodels.ContributionDestination, error) {
source, err := repositoryBinding(canonical)
if err != nil {
return nil, fmt.Errorf("build contribution source: %w", err)
}
target, err := repositoryBinding(fork)
if err != nil {
return nil, fmt.Errorf("build contribution target: %w", err)
}
destination := &taskmodels.ContributionDestination{
Version: taskmodels.ContributionDestinationVersion,
Provider: taskmodels.ContributionDestinationProviderGitHub,
SourceRepository: source,
TargetRepository: target,
}
if err := destination.Validate(); err != nil {
return nil, err
}
return destination, nil
}
func repositoryBinding(repository *GitHubRepository) (taskmodels.RemoteContributionRepository, error) {
if repository == nil || repository.ID <= 0 {
return taskmodels.RemoteContributionRepository{}, errors.New("provider repository identity is missing")
}
fullName := repositoryFullName(repository)
parts := strings.Split(fullName, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return taskmodels.RemoteContributionRepository{}, errors.New("provider repository full name is invalid")
}
remoteURL := strings.TrimSpace(repository.CloneURL)
if remoteURL == "" {
remoteURL = "https://github.com/" + fullName + ".git"
}
return taskmodels.RemoteContributionRepository{
Host: "github.com",
Path: fullName,
ProviderID: strconv.FormatInt(repository.ID, 10),
RemoteURL: remoteURL,
}, nil
}
func repositoryFullName(repository *GitHubRepository) string {
if repository == nil {
return ""
}
if strings.TrimSpace(repository.FullName) != "" {
return strings.TrimSpace(repository.FullName)
}
return strings.TrimSpace(repository.Owner) + "/" + strings.TrimSpace(repository.Name)
}
func repositoryOwnerAndName(repository *GitHubRepository) (string, string, error) {
parts := strings.Split(repositoryFullName(repository), "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", errors.New("provider repository full name is invalid")
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
}
func sameRepositoryName(repository *GitHubRepository, owner, repo string) bool {
return strings.EqualFold(repositoryFullName(repository), strings.TrimSpace(owner)+"/"+strings.TrimSpace(repo))
}
func repositoryWritable(repository *GitHubRepository) bool {
return repository != nil && (repository.PushAccess || repository.AdminAccess)
}
func contributionActorLogin(ctx context.Context, client contributionForkClient, principal AuthPrincipal) (string, error) {
if login := strings.TrimSpace(principal.Login); login != "" {
return login, nil
}
login, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return "", fmt.Errorf("resolve GitHub automation login: %w", err)
}
if strings.TrimSpace(login) == "" {
return "", errors.New("GitHub automation connection did not report a login")
}
return strings.TrimSpace(login), nil
}
func waitForContributionFork(
ctx context.Context,
client contributionForkClient,
canonical *GitHubRepository,
owner, repo string,
) (*GitHubRepository, error) {
for {
fork, err := client.GetRepository(ctx, owner, repo)
if err == nil {
if validationErr := validateContributionFork(canonical, fork, owner, repo); validationErr == nil {
return fork, nil
} else if !errors.Is(validationErr, ErrContributionForkNotWritable) {
return nil, validationErr
}
} else if !isContributionRepositoryNotFound(err) {
return nil, fmt.Errorf("verify created contribution fork %s/%s: %w", owner, repo, err)
}
timer := time.NewTimer(contributionForkPollInterval)
select {
case <-ctx.Done():
timer.Stop()
return nil, fmt.Errorf("%w: %s/%s: %v", ErrContributionForkNotReady, owner, repo, ctx.Err())
case <-timer.C:
}
}
}
func isContributionRepositoryNotFound(err error) bool {
if err == nil {
return false
}
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == 404
}
return false
}