Files

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
}