Files
kandev/apps/backend/internal/github/service_remote_contribution.go
T
2026-08-06 12:07:13 +01:00

158 lines
5.7 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/kandev/kandev/internal/common/securityutil"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
var ErrInvalidRemoteContributionURL = errors.New("invalid GitHub pull request URL")
// ResolveRemoteContributionForWorkspace resolves an existing pull request
// using the workspace's provider credentials and returns only the immutable
// repository/ref identity needed by task creation.
func (s *Service) ResolveRemoteContributionForWorkspace(
ctx context.Context, workspaceID, userID, rawURL string,
) (*taskmodels.RemoteContributionResolution, error) {
owner, repo, number, err := parseGitHubContributionURL(rawURL)
if err != nil {
return nil, err
}
pr, err := s.GetPRForWorkspace(ctx, workspaceID, userID, owner, repo, number)
if err != nil {
return nil, fmt.Errorf("resolve GitHub pull request: %w", err)
}
return buildGitHubRemoteContribution(rawURL, owner, repo, number, pr)
}
func parseGitHubContributionURL(rawURL string) (string, string, int, error) {
if strings.TrimSpace(rawURL) != rawURL || rawURL == "" {
return "", "", 0, ErrInvalidRemoteContributionURL
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", "", 0, ErrInvalidRemoteContributionURL
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) != 4 || parts[2] != "pull" || !validGitHubPathPart(parts[0]) || !validGitHubPathPart(parts[1]) {
return "", "", 0, ErrInvalidRemoteContributionURL
}
number, err := strconv.Atoi(parts[3])
if err != nil || number <= 0 {
return "", "", 0, ErrInvalidRemoteContributionURL
}
return parts[0], parts[1], number, nil
}
func validGitHubPathPart(value string) bool {
if value == "" || strings.ContainsAny(value, "/\\:@?#") {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '-' || r == '_' || r == '.' {
continue
}
return false
}
return !strings.Contains(value, "..")
}
func buildGitHubRemoteContribution(rawURL, owner, repo string, number int, pr *PR) (*taskmodels.RemoteContributionResolution, error) {
if err := validateGitHubContributionPR(owner, repo, number, pr); err != nil {
return nil, err
}
sourceOwner, sourceName, sameRepository, err := githubContributionSource(pr, owner, repo)
if err != nil {
return nil, err
}
canonicalURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, number)
sourceRepository := taskmodels.RemoteContributionRepository{
Host: "github.com",
Path: sourceOwner + "/" + sourceName,
RemoteURL: fmt.Sprintf("https://github.com/%s/%s.git", sourceOwner, sourceName),
ProviderID: githubHeadProviderID(pr),
}
binding := taskmodels.RemoteContribution{
Version: taskmodels.RemoteContributionVersion,
Provider: taskmodels.RemoteContributionProviderGitHub,
Kind: taskmodels.RemoteContributionKindPullRequest,
CanonicalURL: canonicalURL,
Number: number,
State: taskmodels.RemoteContributionStateOpen,
BaseBranch: pr.BaseBranch,
HeadBranch: pr.HeadBranch,
HeadSHA: strings.ToLower(pr.HeadSHA),
SourceRepository: sourceRepository,
CollaborationAllowed: sameRepository || pr.MaintainerCanModify,
}
if err := binding.Validate(); err != nil {
return nil, err
}
return &taskmodels.RemoteContributionResolution{
Binding: binding,
TargetProvider: taskmodels.RemoteContributionProviderGitHub,
TargetHost: "https://github.com",
TargetPath: owner + "/" + repo,
TargetProviderID: positiveID(pr.BaseRepoID),
TargetRemoteURL: fmt.Sprintf("https://github.com/%s/%s.git", owner, repo),
TargetDefaultBranch: pr.BaseDefaultBranch,
}, nil
}
func validateGitHubContributionPR(owner, repo string, number int, pr *PR) error {
if pr == nil {
return errors.New("GitHub pull request response is empty")
}
if pr.Number != number || (pr.RepoOwner != "" && !strings.EqualFold(pr.RepoOwner, owner)) ||
(pr.RepoName != "" && !strings.EqualFold(pr.RepoName, repo)) ||
!strings.EqualFold(pr.BaseRepoOwner, owner) || !strings.EqualFold(pr.BaseRepoName, repo) {
return errors.New("GitHub pull request identity changed during resolution")
}
if strings.ToLower(strings.TrimSpace(pr.State)) != taskmodels.RemoteContributionStateOpen {
return fmt.Errorf("GitHub pull request #%d is not open", number)
}
if !securityutil.IsValidBranchName(pr.HeadBranch) || !securityutil.IsValidBaseBranchRef(pr.BaseBranch) ||
!securityutil.LooksLikeCommitSHA(pr.HeadSHA) {
return errors.New("GitHub pull request has unsafe or incomplete refs")
}
return nil
}
func githubContributionSource(pr *PR, owner, repo string) (string, string, bool, error) {
sourceOwner := pr.HeadRepoOwner
sourceName := pr.HeadRepoName
if !validGitHubPathPart(sourceOwner) || !validGitHubPathPart(sourceName) {
return "", "", false, errors.New("GitHub pull request source repository identity is invalid")
}
sameRepository := strings.EqualFold(sourceOwner, owner) && strings.EqualFold(sourceName, repo)
if !sameRepository && !pr.MaintainerCanModify {
return "", "", false, errors.New("GitHub pull request does not allow maintainers to modify the source branch")
}
return sourceOwner, sourceName, sameRepository, nil
}
func positiveID(id int64) string {
if id <= 0 {
return ""
}
return strconv.FormatInt(id, 10)
}
func githubHeadProviderID(pr *PR) string {
if pr == nil {
return ""
}
if nodeID := strings.TrimSpace(pr.HeadRepoNodeID); nodeID != "" {
return nodeID
}
return positiveID(pr.HeadRepoID)
}