476 lines
17 KiB
Go
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
|
|
}
|