281 lines
9.9 KiB
Go
281 lines
9.9 KiB
Go
package gitbase
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
const (
|
|
ErrorInvalidTarget = "invalid_target"
|
|
ErrorRemoteCollision = "remote_collision"
|
|
ErrorRemoteSetup = "remote_setup_failed"
|
|
ErrorFetch = "fetch_failed"
|
|
ErrorRefUnavailable = "ref_unavailable"
|
|
ErrorOIDMismatch = "oid_mismatch"
|
|
gitSSHUser = "git"
|
|
)
|
|
|
|
// GitRunner executes one Git command in the caller's repository and credential
|
|
// scope. Callers provide the repository directory through their runner.
|
|
type GitRunner func(context.Context, ...string) (string, error)
|
|
|
|
type commandExitCoder interface {
|
|
ExitCode() int
|
|
}
|
|
|
|
// Materialization identifies the exact remote-tracking commit fetched for a
|
|
// qualified PR base.
|
|
type Materialization struct {
|
|
RemoteName string
|
|
Ref string
|
|
OID string
|
|
}
|
|
|
|
// PullRequestHead identifies the immutable commit observed after fetching a
|
|
// GitHub pull-request head into its comparison-only ref.
|
|
type PullRequestHead struct {
|
|
Ref string
|
|
OID string
|
|
}
|
|
|
|
// Error retains a bounded failure category while preserving cancellation and
|
|
// the underlying command error for the owning subsystem.
|
|
type Error struct {
|
|
Code string
|
|
err error
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
if e == nil || e.err == nil {
|
|
return e.Code
|
|
}
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e *Error) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
func ErrorCode(err error) string {
|
|
var targetErr *Error
|
|
if errors.As(err, &targetErr) {
|
|
return targetErr.Code
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func failure(code string, err error) error {
|
|
return &Error{Code: code, err: err}
|
|
}
|
|
|
|
// Materialize fetches only the validated target branch into its deterministic
|
|
// comparison ref. It never updates origin or a user branch. An observed OID is
|
|
// checked against the fetched commit before the ref is returned.
|
|
func Materialize(ctx context.Context, run GitRunner, base models.PRBase) (Materialization, error) {
|
|
if run == nil {
|
|
return Materialization{}, failure(ErrorInvalidTarget, errors.New("git runner is required"))
|
|
}
|
|
if err := base.Validate(); err != nil {
|
|
return Materialization{}, failure(ErrorInvalidTarget, fmt.Errorf("qualified PR base is invalid: %w", err))
|
|
}
|
|
target := base.Target
|
|
remoteURL, err := comparisonRemoteURLForCheckoutTransport(ctx, run, target)
|
|
if err != nil {
|
|
return Materialization{}, failure(ErrorRemoteSetup, fmt.Errorf("comparison remote transport could not be resolved: %w", err))
|
|
}
|
|
remoteName, err := ensureComparisonRemote(ctx, run, target, remoteURL)
|
|
if err != nil {
|
|
return Materialization{}, err
|
|
}
|
|
ref := target.ComparisonRef()
|
|
refspec := "+refs/heads/" + target.TargetBranch + ":" + ref
|
|
if _, err := run(ctx, "fetch", "--no-tags", remoteName, refspec); err != nil {
|
|
return Materialization{}, failure(ErrorFetch, fmt.Errorf("comparison target fetch failed: %w", err))
|
|
}
|
|
output, err := run(ctx, "rev-parse", "--verify", ref+"^{commit}")
|
|
if err != nil {
|
|
return Materialization{}, failure(ErrorRefUnavailable, fmt.Errorf("comparison target ref unavailable: %w", err))
|
|
}
|
|
actualOID := strings.TrimSpace(output)
|
|
if actualOID == "" {
|
|
return Materialization{}, failure(ErrorRefUnavailable, errors.New("comparison target ref resolved to an empty object ID"))
|
|
}
|
|
if base.OID != "" && !strings.EqualFold(actualOID, base.OID) {
|
|
return Materialization{}, failure(ErrorOIDMismatch, fmt.Errorf("comparison target OID changed from %s to %s", base.OID, actualOID))
|
|
}
|
|
return Materialization{RemoteName: remoteName, Ref: ref, OID: actualOID}, nil
|
|
}
|
|
|
|
func ensureComparisonRemote(
|
|
ctx context.Context, run GitRunner, target models.ComparisonTarget, remoteURL string,
|
|
) (string, error) {
|
|
remoteName := target.ComparisonRemoteName()
|
|
configuredURL, remoteErr := run(ctx, "config", "--get", "remote."+remoteName+".url")
|
|
if err := configureComparisonRemote(ctx, run, target, remoteName, remoteURL, configuredURL, remoteErr); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := run(ctx, "config", "remote."+remoteName+".pushurl", "DISABLED"); err != nil {
|
|
return "", failure(ErrorRemoteSetup, fmt.Errorf("comparison remote push protection failed: %w", err))
|
|
}
|
|
return remoteName, nil
|
|
}
|
|
|
|
func configureComparisonRemote(
|
|
ctx context.Context, run GitRunner, target models.ComparisonTarget,
|
|
remoteName, remoteURL, configuredURL string, readErr error,
|
|
) error {
|
|
if readErr != nil {
|
|
var exitCoder commandExitCoder
|
|
if !errors.As(readErr, &exitCoder) || exitCoder.ExitCode() != 1 {
|
|
return failure(ErrorRemoteSetup, fmt.Errorf("comparison remote configuration could not be read: %w", readErr))
|
|
}
|
|
if _, err := run(ctx, "remote", "add", "--no-tags", remoteName, remoteURL); err != nil {
|
|
return failure(ErrorRemoteSetup, fmt.Errorf("comparison remote setup failed: %w", err))
|
|
}
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(configuredURL) == remoteURL {
|
|
return nil
|
|
}
|
|
if !sameGitHubComparisonRepository(configuredURL, target.TargetRepository.Host, target.TargetRepository.Path) {
|
|
return failure(ErrorRemoteCollision, fmt.Errorf("comparison remote collision for %s", remoteName))
|
|
}
|
|
if _, err := run(ctx, "remote", "set-url", remoteName, remoteURL); err != nil {
|
|
return failure(ErrorRemoteSetup, fmt.Errorf("comparison remote transport update failed: %w", err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FetchPullRequestHead fetches GitHub's pull-request head snapshot from the
|
|
// validated base repository. The returned OID remains stable if another fetch
|
|
// later updates the comparison ref.
|
|
func FetchPullRequestHead(ctx context.Context, run GitRunner, target models.ComparisonTarget) (PullRequestHead, error) {
|
|
if run == nil {
|
|
return PullRequestHead{}, failure(ErrorInvalidTarget, errors.New("git runner is required"))
|
|
}
|
|
if err := target.Validate(); err != nil {
|
|
return PullRequestHead{}, failure(ErrorInvalidTarget, fmt.Errorf("comparison target is invalid: %w", err))
|
|
}
|
|
if target.Provider != models.ComparisonTargetProviderGitHub || target.Kind != models.ComparisonTargetKindPullRequest {
|
|
return PullRequestHead{}, failure(ErrorInvalidTarget, errors.New("target is not a GitHub pull request"))
|
|
}
|
|
remoteName := target.ComparisonRemoteName()
|
|
ref := fmt.Sprintf("refs/remotes/%s/pull/%d/head", remoteName, target.Number)
|
|
refspec := fmt.Sprintf("+refs/pull/%d/head:%s", target.Number, ref)
|
|
if _, err := run(ctx, "fetch", "--no-tags", remoteName, refspec); err != nil {
|
|
return PullRequestHead{}, failure(ErrorFetch, fmt.Errorf("pull request head fetch failed: %w", err))
|
|
}
|
|
output, err := run(ctx, "rev-parse", "--verify", ref+"^{commit}")
|
|
if err != nil {
|
|
return PullRequestHead{}, failure(ErrorRefUnavailable, fmt.Errorf("pull request head ref unavailable: %w", err))
|
|
}
|
|
oid := strings.TrimSpace(output)
|
|
if oid == "" {
|
|
return PullRequestHead{}, failure(ErrorRefUnavailable, errors.New("pull request head ref resolved to an empty object ID"))
|
|
}
|
|
return PullRequestHead{Ref: ref, OID: oid}, nil
|
|
}
|
|
|
|
func comparisonRemoteURLForCheckoutTransport(
|
|
ctx context.Context, run GitRunner, target models.ComparisonTarget,
|
|
) (string, error) {
|
|
originURL, err := run(ctx, "config", "--get", "remote.origin.url")
|
|
if err != nil {
|
|
var exitCoder commandExitCoder
|
|
if errors.As(err, &exitCoder) && exitCoder.ExitCode() == 1 {
|
|
return target.TargetRepository.RemoteURL, nil
|
|
}
|
|
return "", err
|
|
}
|
|
if sshURL := githubSSHComparisonURL(originURL, target.TargetRepository.Host, target.TargetRepository.Path); sshURL != "" {
|
|
return sshURL, nil
|
|
}
|
|
return target.TargetRepository.RemoteURL, nil
|
|
}
|
|
|
|
func githubSSHComparisonURL(originURL, providerHost, repositoryPath string) string {
|
|
if !strings.EqualFold(strings.TrimSpace(providerHost), "github.com") || repositoryPath == "" {
|
|
return ""
|
|
}
|
|
if parsed, ok := parseGitHubSSHURL(originURL); ok {
|
|
parsed.Path = "/" + strings.Trim(repositoryPath, "/") + ".git"
|
|
parsed.RawPath = ""
|
|
return parsed.String()
|
|
}
|
|
host, _, ok := parseGitHubSCPURL(originURL)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return gitSSHUser + "@" + host + ":" + strings.Trim(repositoryPath, "/") + ".git"
|
|
}
|
|
|
|
func sameGitHubComparisonRepository(raw, providerHost, repositoryPath string) bool {
|
|
if !strings.EqualFold(strings.TrimSpace(providerHost), "github.com") || repositoryPath == "" {
|
|
return false
|
|
}
|
|
host, path, ok := githubComparisonRemoteIdentity(raw)
|
|
if !ok {
|
|
return false
|
|
}
|
|
path = strings.Trim(strings.TrimSuffix(path, ".git"), "/")
|
|
return strings.EqualFold(host, "github.com") && strings.EqualFold(path, strings.Trim(repositoryPath, "/"))
|
|
}
|
|
|
|
func githubComparisonRemoteIdentity(raw string) (string, string, bool) {
|
|
value := strings.TrimSpace(raw)
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(value), "ssh://"):
|
|
parsed, ok := parseGitHubSSHURL(value)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
return parsed.Hostname(), parsed.Path, true
|
|
case strings.HasPrefix(strings.ToLower(value), "https://"):
|
|
return parseGitHubHTTPSURL(value)
|
|
default:
|
|
return parseGitHubSCPURL(value)
|
|
}
|
|
}
|
|
|
|
func parseGitHubSSHURL(value string) (*url.URL, bool) {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || !strings.EqualFold(parsed.Scheme, "ssh") ||
|
|
!strings.EqualFold(parsed.Hostname(), "github.com") || parsed.User == nil ||
|
|
parsed.User.Username() != gitSSHUser || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return nil, false
|
|
}
|
|
if _, hasPassword := parsed.User.Password(); hasPassword {
|
|
return nil, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
func parseGitHubHTTPSURL(value string) (string, string, bool) {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil ||
|
|
parsed.RawQuery != "" || parsed.Fragment != "" || !strings.EqualFold(parsed.Host, "github.com") {
|
|
return "", "", false
|
|
}
|
|
return parsed.Host, parsed.Path, true
|
|
}
|
|
|
|
func parseGitHubSCPURL(value string) (string, string, bool) {
|
|
user, hostAndPath, found := strings.Cut(value, "@")
|
|
if !found || user != gitSSHUser {
|
|
return "", "", false
|
|
}
|
|
host, path, found := strings.Cut(hostAndPath, ":")
|
|
if !found || !strings.EqualFold(host, "github.com") || strings.TrimSpace(path) == "" {
|
|
return "", "", false
|
|
}
|
|
return host, path, true
|
|
}
|