852 lines
27 KiB
Go
852 lines
27 KiB
Go
package process
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
)
|
|
|
|
var credentialURLPattern = regexp.MustCompile(`(?i)(https?://)[^\s/@]+@`)
|
|
|
|
type prProvider string
|
|
|
|
const (
|
|
prProviderGitHub prProvider = "github"
|
|
prProviderGitLab prProvider = "gitlab"
|
|
prProviderAzureRepos prProvider = "azure_repos"
|
|
prCreateSubcommand = "create"
|
|
repositoryFlagTitle = "--title"
|
|
repositoryFlagBody = "--body"
|
|
repositoryFlagDescription = "--description"
|
|
repositoryFlagHead = "--head"
|
|
redactedLogValue = "[REDACTED]"
|
|
azureDevOpsExtensionName = "azure-devops"
|
|
errAzureCLIMissing = "azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops"
|
|
errAzureDevOpsExtensionMissing = "azure DevOps CLI extension is not installed; run: az extension add --name azure-devops"
|
|
gitLabHostEnv = "KANDEV_GITLAB_HOST"
|
|
gitLabTokenEnv = "GITLAB_TOKEN"
|
|
)
|
|
|
|
const gitLabAPITimeout = 30 * time.Second
|
|
|
|
type azureRepoInfo struct {
|
|
OrganizationURL string
|
|
Project string
|
|
Repository string
|
|
}
|
|
|
|
type azurePRCreateResponse struct {
|
|
PullRequestID int `json:"pullRequestId"`
|
|
Repository struct {
|
|
RemoteURL string `json:"remoteUrl"`
|
|
} `json:"repository"`
|
|
}
|
|
|
|
func detectPRProvider(remoteURL string) prProvider {
|
|
return detectPRProviderWithGitLabHost(remoteURL, os.Getenv(gitLabHostEnv))
|
|
}
|
|
|
|
func detectPRProviderWithGitLabHost(remoteURL, configuredGitLabHost string) prProvider {
|
|
host := remoteHostFromURL(remoteURL)
|
|
if isAzureReposHost(host) {
|
|
return prProviderAzureRepos
|
|
}
|
|
if isGitHubHost(host) {
|
|
return prProviderGitHub
|
|
}
|
|
if isGitLabHost(host, configuredGitLabHost) {
|
|
return prProviderGitLab
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isGitLabHost(host, configuredGitLabHost string) bool {
|
|
if host == "gitlab.com" {
|
|
return true
|
|
}
|
|
configured, err := url.Parse(strings.TrimSpace(configuredGitLabHost))
|
|
return err == nil && configured.Hostname() != "" && strings.EqualFold(host, configured.Hostname())
|
|
}
|
|
|
|
func (g *GitOperator) detectPRProvider(remoteURL string) prProvider {
|
|
return detectPRProviderWithGitLabHost(remoteURL, g.environmentValue(gitLabHostEnv))
|
|
}
|
|
|
|
func isGitHubHost(host string) bool {
|
|
return host == "github.com" || strings.HasSuffix(host, ".github.com")
|
|
}
|
|
|
|
func isAzureReposHost(host string) bool {
|
|
switch host {
|
|
case "dev.azure.com", "ssh.dev.azure.com":
|
|
return true
|
|
default:
|
|
return strings.HasSuffix(host, ".visualstudio.com")
|
|
}
|
|
}
|
|
|
|
// remoteHostFromURL returns the lowercase hostname from an origin remote URL.
|
|
func remoteHostFromURL(remoteURL string) string {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(trimmed, "://") {
|
|
parsed, err := url.Parse(trimmed)
|
|
if err == nil && parsed.Host != "" {
|
|
host := parsed.Hostname()
|
|
return strings.ToLower(host)
|
|
}
|
|
}
|
|
|
|
rest := strings.TrimPrefix(trimmed, "ssh://")
|
|
if _, after, ok := strings.Cut(rest, "@"); ok {
|
|
rest = after
|
|
}
|
|
hostPort, _, ok := strings.Cut(rest, ":")
|
|
if !ok {
|
|
hostPort, _, _ = strings.Cut(rest, "/")
|
|
}
|
|
host := hostPort
|
|
if idx := strings.Index(host, ":"); idx >= 0 {
|
|
host = host[:idx]
|
|
}
|
|
return strings.ToLower(host)
|
|
}
|
|
|
|
func parseAzurePRCreateResponse(stdoutOutput string) (azurePRCreateResponse, error) {
|
|
trimmed := strings.TrimSpace(stdoutOutput)
|
|
var response azurePRCreateResponse
|
|
if err := json.Unmarshal([]byte(trimmed), &response); err == nil {
|
|
return response, nil
|
|
}
|
|
|
|
// az may prefix stdout with status text; decode the first JSON value (incl. pretty-printed).
|
|
if start := strings.Index(trimmed, "{"); start >= 0 {
|
|
dec := json.NewDecoder(strings.NewReader(trimmed[start:]))
|
|
if err := dec.Decode(&response); err == nil {
|
|
return response, nil
|
|
}
|
|
}
|
|
|
|
// Some az versions emit single-line JSON after status lines.
|
|
lines := strings.Split(trimmed, "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := strings.TrimSpace(lines[i])
|
|
if !strings.HasPrefix(line, "{") {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &response); err == nil {
|
|
return response, nil
|
|
}
|
|
}
|
|
return azurePRCreateResponse{}, fmt.Errorf("no JSON object in output")
|
|
}
|
|
|
|
func parseAzureRepoInfo(remoteURL string) (*azureRepoInfo, error) {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
switch {
|
|
case strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://"):
|
|
return parseAzureHTTPRemote(trimmed)
|
|
case strings.HasPrefix(trimmed, "ssh://"):
|
|
return parseAzureSSHRemote(trimmed)
|
|
case strings.Contains(trimmed, "@") && strings.Contains(trimmed, ":"):
|
|
return parseAzureSCPRemote(trimmed)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Azure Repos remote URL: %s", remoteURL)
|
|
}
|
|
}
|
|
|
|
func parseAzureHTTPRemote(remoteURL string) (*azureRepoInfo, error) {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
withoutScheme := strings.TrimPrefix(trimmed, "https://")
|
|
withoutScheme = strings.TrimPrefix(withoutScheme, "http://")
|
|
|
|
hostAndPath := withoutScheme
|
|
if _, after, ok := strings.Cut(hostAndPath, "@"); ok {
|
|
hostAndPath = after
|
|
}
|
|
|
|
host, path, ok := strings.Cut(hostAndPath, "/")
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing repository path in Azure Repos URL: %s", remoteURL)
|
|
}
|
|
|
|
segments := splitRemotePath(path)
|
|
if len(segments) < 3 {
|
|
return nil, fmt.Errorf("invalid Azure Repos URL path: %s", remoteURL)
|
|
}
|
|
|
|
lowerHost := strings.ToLower(host)
|
|
scheme := "https://"
|
|
switch {
|
|
case strings.Contains(lowerHost, "dev.azure.com"):
|
|
if len(segments) < 4 || segments[2] != "_git" {
|
|
return nil, fmt.Errorf("invalid Azure Repos dev.azure.com URL: %s", remoteURL)
|
|
}
|
|
return &azureRepoInfo{
|
|
OrganizationURL: scheme + host + "/" + segments[0],
|
|
Project: segments[1],
|
|
Repository: trimGitSuffix(segments[3]),
|
|
}, nil
|
|
case strings.Contains(lowerHost, "visualstudio.com"):
|
|
if len(segments) < 3 || segments[1] != "_git" {
|
|
return nil, fmt.Errorf("invalid Azure Repos visualstudio.com URL: %s", remoteURL)
|
|
}
|
|
return &azureRepoInfo{
|
|
OrganizationURL: scheme + host,
|
|
Project: segments[0],
|
|
Repository: trimGitSuffix(segments[2]),
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Azure Repos host: %s", host)
|
|
}
|
|
}
|
|
|
|
func parseAzureSSHRemote(remoteURL string) (*azureRepoInfo, error) {
|
|
trimmed := strings.TrimPrefix(strings.TrimSpace(remoteURL), "ssh://")
|
|
if _, after, ok := strings.Cut(trimmed, "@"); ok {
|
|
trimmed = after
|
|
}
|
|
hostPort, path, ok := strings.Cut(trimmed, "/")
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing repository path in Azure Repos SSH URL: %s", remoteURL)
|
|
}
|
|
return parseAzureSSHParts(hostPort, path, remoteURL)
|
|
}
|
|
|
|
func parseAzureSCPRemote(remoteURL string) (*azureRepoInfo, error) {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
if _, after, ok := strings.Cut(trimmed, "@"); ok {
|
|
trimmed = after
|
|
}
|
|
hostPort, path, ok := strings.Cut(trimmed, ":")
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing repository path in Azure Repos SCP URL: %s", remoteURL)
|
|
}
|
|
return parseAzureSSHParts(hostPort, path, remoteURL)
|
|
}
|
|
|
|
func parseAzureSSHParts(hostPort, path, rawURL string) (*azureRepoInfo, error) {
|
|
host := hostPort
|
|
if idx := strings.Index(host, ":"); idx >= 0 {
|
|
host = host[:idx]
|
|
}
|
|
segments := splitRemotePath(path)
|
|
if len(segments) < 4 || segments[0] != "v3" {
|
|
return nil, fmt.Errorf("invalid Azure Repos SSH path: %s", rawURL)
|
|
}
|
|
|
|
org := segments[1]
|
|
project := segments[2]
|
|
repo := trimGitSuffix(segments[3])
|
|
lowerHost := strings.ToLower(host)
|
|
|
|
switch {
|
|
case strings.Contains(lowerHost, "ssh.dev.azure.com"):
|
|
return &azureRepoInfo{
|
|
OrganizationURL: "https://dev.azure.com/" + org,
|
|
Project: project,
|
|
Repository: repo,
|
|
}, nil
|
|
case strings.Contains(lowerHost, "visualstudio.com"):
|
|
return &azureRepoInfo{
|
|
OrganizationURL: "https://" + org + ".visualstudio.com",
|
|
Project: project,
|
|
Repository: repo,
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Azure Repos SSH host: %s", host)
|
|
}
|
|
}
|
|
|
|
func splitRemotePath(path string) []string {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
segments := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
segments = append(segments, part)
|
|
}
|
|
return segments
|
|
}
|
|
|
|
func trimGitSuffix(name string) string {
|
|
return strings.TrimSuffix(name, ".git")
|
|
}
|
|
|
|
func cleanBaseBranch(baseBranch string) string {
|
|
return strings.TrimPrefix(strings.TrimSpace(baseBranch), "origin/")
|
|
}
|
|
|
|
func sanitizeRepositoryArgs(args []string) []string {
|
|
redactedFlags := map[string]struct{}{
|
|
repositoryFlagTitle: {},
|
|
repositoryFlagBody: {},
|
|
repositoryFlagDescription: {},
|
|
}
|
|
|
|
sanitized := make([]string, 0, len(args))
|
|
redactNext := false
|
|
for _, arg := range args {
|
|
if redactNext {
|
|
sanitized = append(sanitized, redactedLogValue)
|
|
redactNext = false
|
|
continue
|
|
}
|
|
|
|
if _, ok := redactedFlags[arg]; ok {
|
|
sanitized = append(sanitized, arg)
|
|
redactNext = true
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(arg, repositoryFlagTitle+"="):
|
|
sanitized = append(sanitized, repositoryFlagTitle+"="+redactedLogValue)
|
|
case strings.HasPrefix(arg, repositoryFlagBody+"="):
|
|
sanitized = append(sanitized, repositoryFlagBody+"="+redactedLogValue)
|
|
case strings.HasPrefix(arg, repositoryFlagDescription+"="):
|
|
sanitized = append(sanitized, repositoryFlagDescription+"="+redactedLogValue)
|
|
default:
|
|
sanitized = append(sanitized, arg)
|
|
}
|
|
}
|
|
|
|
return sanitized
|
|
}
|
|
|
|
func combineCommandOutput(stdout, stderr string) string {
|
|
parts := make([]string, 0, 2)
|
|
if trimmedStdout := strings.TrimSpace(stdout); trimmedStdout != "" {
|
|
parts = append(parts, trimmedStdout)
|
|
}
|
|
if trimmedStderr := strings.TrimSpace(stderr); trimmedStderr != "" {
|
|
parts = append(parts, trimmedStderr)
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func sanitizePRFailure(message string, sensitiveValues ...string) string {
|
|
sanitized := credentialURLPattern.ReplaceAllString(message, `${1}`+redactedLogValue+"@")
|
|
values := append([]string{os.Getenv(gitLabTokenEnv)}, sensitiveValues...)
|
|
for _, value := range values {
|
|
if value != "" {
|
|
sanitized = strings.ReplaceAll(sanitized, value, redactedLogValue)
|
|
}
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func (g *GitOperator) sanitizePRFailure(message string, sensitiveValues ...string) string {
|
|
values := append([]string{g.environmentValue(gitLabTokenEnv)}, sensitiveValues...)
|
|
return sanitizePRFailure(message, values...)
|
|
}
|
|
|
|
func (g *GitOperator) sanitizeGitPushOutput(output string) string {
|
|
return g.sanitizePRFailure(output)
|
|
}
|
|
|
|
func redactRemoteURL(remoteURL string) string {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(trimmed, "://") {
|
|
parsed, err := url.Parse(trimmed)
|
|
if err == nil {
|
|
parsed.User = nil
|
|
return parsed.String()
|
|
}
|
|
}
|
|
|
|
if before, after, ok := strings.Cut(trimmed, "@"); ok && before != "" && strings.Contains(after, ":") {
|
|
return after
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
|
|
func (g *GitOperator) getOriginRemoteURL(ctx context.Context) (string, error) {
|
|
output, err := g.runGitCommand(ctx, "remote", "get-url", "origin")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get origin remote URL: %w", err)
|
|
}
|
|
return strings.TrimSpace(output), nil
|
|
}
|
|
|
|
func (g *GitOperator) runRepositoryCommand(ctx context.Context, name string, args ...string) (string, string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Dir = g.workDir
|
|
cmd.Env = filterGitEnv(g.environmentValues())
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
g.logger.Debug("executing repository command",
|
|
zap.String("command", name),
|
|
zap.Strings("args", sanitizeRepositoryArgs(args)),
|
|
zap.String("workDir", g.workDir))
|
|
|
|
// Route through the matching subproc throttle so PR-creation execs
|
|
// (gh / git) count against the same process-wide cap as the rest of
|
|
// the codebase. Unknown binaries (e.g. az) skip throttling — those
|
|
// aren't part of the fork-storm pattern.
|
|
var err error
|
|
switch name {
|
|
case "gh":
|
|
err = subproc.RunGH(ctx, cmd)
|
|
case "git":
|
|
err = subproc.RunGitClass(ctx, subproc.GitInteractive, cmd)
|
|
default:
|
|
err = cmd.Run()
|
|
}
|
|
stdoutOutput := strings.TrimSpace(stdout.String())
|
|
stderrOutput := strings.TrimSpace(stderr.String())
|
|
if err != nil {
|
|
combined := combineCommandOutput(stdoutOutput, stderrOutput)
|
|
if combined == "" {
|
|
combined = err.Error()
|
|
}
|
|
return stdoutOutput, stderrOutput, fmt.Errorf("%w: %s", err, combined)
|
|
}
|
|
return stdoutOutput, stderrOutput, nil
|
|
}
|
|
|
|
func (g *GitOperator) createGitHubPR(
|
|
ctx context.Context,
|
|
result *PRCreateResult,
|
|
branch, title, body, baseBranch string,
|
|
draft bool,
|
|
) (*PRCreateResult, error) {
|
|
args := []string{"pr", prCreateSubcommand, repositoryFlagTitle, title, repositoryFlagBody, body, repositoryFlagHead, branch}
|
|
if cleanBase := cleanBaseBranch(baseBranch); cleanBase != "" {
|
|
args = append(args, "--base", cleanBase)
|
|
}
|
|
if draft {
|
|
args = append(args, "--draft")
|
|
}
|
|
|
|
stdoutOutput, stderrOutput, err := g.runRepositoryCommand(ctx, "gh", args...)
|
|
result.Output = combineCommandOutput(stdoutOutput, stderrOutput)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
result.PRURL = strings.TrimSpace(stdoutOutput)
|
|
result.Success = true
|
|
g.logger.Info("PR created", zap.String("url", result.PRURL))
|
|
return result, nil
|
|
}
|
|
|
|
func (g *GitOperator) createGitLabPR(
|
|
ctx context.Context,
|
|
result *PRCreateResult,
|
|
info *gitLabRepoInfo,
|
|
branch, title, body, baseBranch string,
|
|
draft bool,
|
|
) (*PRCreateResult, error) {
|
|
targetBranch, err := g.resolveGitLabTargetBranch(ctx, info, baseBranch)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
if _, lookupErr := exec.LookPath("glab"); lookupErr != nil {
|
|
return g.createGitLabPRWithREST(ctx, result, info, branch, title, body, targetBranch, draft)
|
|
}
|
|
if existingURL := g.findExistingGitLabMRWithGLab(ctx, info, branch, targetBranch); existingURL != "" {
|
|
result.PRURL = existingURL
|
|
result.Output = existingURL
|
|
result.Success = true
|
|
return result, nil
|
|
}
|
|
|
|
args := []string{"mr", "create", repositoryFlagTitle, title, repositoryFlagDescription, body, "--source-branch", branch}
|
|
args = append(args, "--target-branch", targetBranch)
|
|
if draft {
|
|
args = append(args, "--draft")
|
|
}
|
|
args = append(args, "--yes")
|
|
|
|
stdoutOutput, stderrOutput, err := g.runRepositoryCommand(ctx, "glab", args...)
|
|
result.Output = g.sanitizePRFailure(combineCommandOutput(stdoutOutput, stderrOutput), title, body)
|
|
if err != nil {
|
|
if g.environmentValue(gitLabTokenEnv) != "" {
|
|
return g.createGitLabPRWithREST(ctx, result, info, branch, title, body, targetBranch, draft)
|
|
}
|
|
result.Error = "GitLab merge request creation failed; authenticate glab for the configured host"
|
|
return result, nil
|
|
}
|
|
result.PRURL = extractGitLabMRURL(stdoutOutput, info)
|
|
result.Success = result.PRURL != ""
|
|
if !result.Success {
|
|
result.Error = "GitLab did not return a merge request URL"
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type gitLabRepoInfo struct {
|
|
Origin string
|
|
APIHost string
|
|
ProjectPath string
|
|
}
|
|
|
|
type gitLabRemoteInfo struct {
|
|
Hostname string
|
|
HTTPOrigin string
|
|
ProjectPath string
|
|
SSH bool
|
|
}
|
|
|
|
func parseGitLabRepoInfo(remoteURL, configuredOrigin string) (*gitLabRepoInfo, error) {
|
|
remote, err := parseGitLabRemote(remoteURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
origin := strings.TrimSpace(configuredOrigin)
|
|
if origin == "" {
|
|
if !strings.EqualFold(remote.Hostname, "gitlab.com") {
|
|
return nil, errors.New("GitLab host is not configured for this remote")
|
|
}
|
|
origin = "https://gitlab.com"
|
|
}
|
|
parsedOrigin, err := url.Parse(origin)
|
|
if err != nil || (parsedOrigin.Scheme != "http" && parsedOrigin.Scheme != "https") || parsedOrigin.Host == "" || parsedOrigin.User != nil || (parsedOrigin.Path != "" && parsedOrigin.Path != "/") {
|
|
return nil, errors.New("configured GitLab host must be an HTTP(S) origin")
|
|
}
|
|
normalizedOrigin := parsedOrigin.Scheme + "://" + parsedOrigin.Host
|
|
if remote.SSH {
|
|
if !strings.EqualFold(parsedOrigin.Hostname(), remote.Hostname) {
|
|
return nil, errors.New("GitLab remote host does not match the configured workspace host")
|
|
}
|
|
} else if !strings.EqualFold(normalizedOrigin, remote.HTTPOrigin) {
|
|
return nil, errors.New("GitLab remote host does not match the configured workspace host")
|
|
}
|
|
return &gitLabRepoInfo{
|
|
Origin: normalizedOrigin,
|
|
APIHost: parsedOrigin.Host,
|
|
ProjectPath: remote.ProjectPath,
|
|
}, nil
|
|
}
|
|
|
|
func parseGitLabRemote(remoteURL string) (*gitLabRemoteInfo, error) {
|
|
trimmed := strings.TrimSpace(remoteURL)
|
|
if strings.Contains(trimmed, "://") {
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.Hostname() == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return nil, errors.New("invalid GitLab remote URL")
|
|
}
|
|
host, projectPath, err := validateGitLabRemoteParts(parsed.Hostname(), parsed.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch parsed.Scheme {
|
|
case "http", "https":
|
|
if parsed.User != nil {
|
|
return nil, errors.New("GitLab HTTP remote URL must not contain credentials")
|
|
}
|
|
return &gitLabRemoteInfo{Hostname: host, HTTPOrigin: parsed.Scheme + "://" + parsed.Host, ProjectPath: projectPath}, nil
|
|
case "ssh":
|
|
return &gitLabRemoteInfo{Hostname: host, ProjectPath: projectPath, SSH: true}, nil
|
|
default:
|
|
return nil, errors.New("unsupported GitLab remote URL scheme")
|
|
}
|
|
}
|
|
withoutUser := trimmed
|
|
if _, after, ok := strings.Cut(withoutUser, "@"); ok {
|
|
withoutUser = after
|
|
}
|
|
host, path, ok := strings.Cut(withoutUser, ":")
|
|
if !ok {
|
|
return nil, errors.New("invalid GitLab SSH remote URL")
|
|
}
|
|
host, projectPath, err := validateGitLabRemoteParts(host, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &gitLabRemoteInfo{Hostname: host, ProjectPath: projectPath, SSH: true}, nil
|
|
}
|
|
|
|
func validateGitLabRemoteParts(host, path string) (string, string, error) {
|
|
projectPath := strings.TrimSuffix(strings.Trim(strings.TrimSpace(path), "/"), ".git")
|
|
if host == "" || projectPath == "" || strings.Contains(projectPath, "..") || strings.Contains(projectPath, "/-/") {
|
|
return "", "", errors.New("invalid GitLab repository path")
|
|
}
|
|
return strings.ToLower(host), projectPath, nil
|
|
}
|
|
|
|
type gitLabMRResponse struct {
|
|
WebURL string `json:"web_url"`
|
|
TargetBranch string `json:"target_branch"`
|
|
}
|
|
|
|
func (g *GitOperator) findExistingGitLabMRWithGLab(ctx context.Context, info *gitLabRepoInfo, branch, targetBranch string) string {
|
|
args := []string{"mr", "list", "--source-branch", branch, "--target-branch", targetBranch, "--state", "opened", "--output", "json"}
|
|
stdoutOutput, _, err := g.runRepositoryCommand(ctx, "glab", args...)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var rows []gitLabMRResponse
|
|
if json.Unmarshal([]byte(stdoutOutput), &rows) != nil {
|
|
return ""
|
|
}
|
|
for _, row := range rows {
|
|
if row.TargetBranch == targetBranch {
|
|
validated, validationErr := validateGitLabMRWebURL(row.WebURL, info)
|
|
if validationErr == nil {
|
|
return validated
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractGitLabMRURL(output string, info *gitLabRepoInfo) string {
|
|
for _, field := range strings.Fields(output) {
|
|
candidate := strings.Trim(field, "()[]{}<>,.;\"'")
|
|
if validated, err := validateGitLabMRWebURL(candidate, info); err == nil {
|
|
return validated
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validateGitLabMRWebURL(raw string, info *gitLabRepoInfo) (string, error) {
|
|
candidate := strings.TrimSpace(raw)
|
|
parsed, err := url.Parse(candidate)
|
|
if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", errors.New("invalid GitLab merge request URL")
|
|
}
|
|
if !strings.EqualFold(parsed.Scheme+"://"+parsed.Host, info.Origin) {
|
|
return "", errors.New("GitLab merge request URL does not match the configured workspace origin")
|
|
}
|
|
prefix := "/" + strings.Trim(info.ProjectPath, "/") + "/-/merge_requests/"
|
|
iid := strings.TrimPrefix(parsed.Path, prefix)
|
|
if !strings.HasPrefix(parsed.Path, prefix) || iid == "" || strings.Contains(iid, "/") {
|
|
return "", errors.New("GitLab merge request URL does not match the repository")
|
|
}
|
|
if _, err := strconv.Atoi(iid); err != nil {
|
|
return "", errors.New("GitLab merge request URL has an invalid IID")
|
|
}
|
|
return candidate, nil
|
|
}
|
|
|
|
func (g *GitOperator) resolveGitLabTargetBranch(ctx context.Context, info *gitLabRepoInfo, baseBranch string) (string, error) {
|
|
if target := cleanBaseBranch(baseBranch); target != "" {
|
|
return target, nil
|
|
}
|
|
projectPath := "projects/" + url.PathEscape(info.ProjectPath)
|
|
if _, err := exec.LookPath("glab"); err == nil {
|
|
stdout, _, runErr := g.runRepositoryCommand(ctx, "glab", "api", projectPath, "--hostname", info.APIHost)
|
|
if runErr == nil {
|
|
var project struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
if json.Unmarshal([]byte(stdout), &project) == nil && strings.TrimSpace(project.DefaultBranch) != "" {
|
|
return strings.TrimSpace(project.DefaultBranch), nil
|
|
}
|
|
}
|
|
}
|
|
token := strings.TrimSpace(g.environmentValue(gitLabTokenEnv))
|
|
if token == "" {
|
|
return "", errors.New("GitLab project default branch is unavailable; authenticate glab for the configured host")
|
|
}
|
|
var project struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
endpoint := info.Origin + "/api/v4/" + projectPath
|
|
if err := gitLabAPIJSON(ctx, &http.Client{Timeout: gitLabAPITimeout}, token, http.MethodGet, endpoint, nil, &project); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(project.DefaultBranch) == "" {
|
|
return "", errors.New("GitLab project did not return a default branch")
|
|
}
|
|
return strings.TrimSpace(project.DefaultBranch), nil
|
|
}
|
|
|
|
func (g *GitOperator) createGitLabPRWithREST(
|
|
ctx context.Context,
|
|
result *PRCreateResult,
|
|
info *gitLabRepoInfo,
|
|
branch, title, body, targetBranch string,
|
|
draft bool,
|
|
) (*PRCreateResult, error) {
|
|
token := strings.TrimSpace(g.environmentValue(gitLabTokenEnv))
|
|
if token == "" {
|
|
result.Error = "GitLab authentication is unavailable; install and authenticate glab or configure a workspace token"
|
|
return result, nil
|
|
}
|
|
client := &http.Client{Timeout: gitLabAPITimeout}
|
|
projectEndpoint := info.Origin + "/api/v4/projects/" + url.PathEscape(info.ProjectPath)
|
|
existingEndpoint := projectEndpoint + "/merge_requests?state=opened&source_branch=" + url.QueryEscape(branch) + "&target_branch=" + url.QueryEscape(targetBranch)
|
|
var existing []gitLabMRResponse
|
|
if err := gitLabAPIJSON(ctx, client, token, http.MethodGet, existingEndpoint, nil, &existing); err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
if len(existing) > 0 && existing[0].WebURL != "" {
|
|
validated, validationErr := validateGitLabMRWebURL(existing[0].WebURL, info)
|
|
if validationErr != nil {
|
|
result.Error = validationErr.Error()
|
|
return result, nil
|
|
}
|
|
result.PRURL = validated
|
|
result.Output = validated
|
|
result.Success = true
|
|
return result, nil
|
|
}
|
|
|
|
if draft && !strings.HasPrefix(strings.ToLower(strings.TrimSpace(title)), "draft:") {
|
|
title = "Draft: " + title
|
|
}
|
|
payload := struct {
|
|
SourceBranch string `json:"source_branch"`
|
|
TargetBranch string `json:"target_branch"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}{SourceBranch: branch, TargetBranch: targetBranch, Title: title, Description: body}
|
|
var created gitLabMRResponse
|
|
if err := gitLabAPIJSON(ctx, client, token, http.MethodPost, projectEndpoint+"/merge_requests", payload, &created); err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
if created.WebURL == "" {
|
|
result.Error = "GitLab did not return a merge request URL"
|
|
return result, nil
|
|
}
|
|
validated, validationErr := validateGitLabMRWebURL(created.WebURL, info)
|
|
if validationErr != nil {
|
|
result.Error = validationErr.Error()
|
|
return result, nil
|
|
}
|
|
result.PRURL = validated
|
|
result.Output = validated
|
|
result.Success = true
|
|
return result, nil
|
|
}
|
|
|
|
func gitLabAPIJSON(ctx context.Context, client *http.Client, token, method, endpoint string, requestBody, responseBody any) error {
|
|
var body io.Reader
|
|
if requestBody != nil {
|
|
encoded, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return errors.New("encode GitLab API request")
|
|
}
|
|
body = bytes.NewReader(encoded)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
|
if err != nil {
|
|
return errors.New("build GitLab API request")
|
|
}
|
|
req.Header.Set("PRIVATE-TOKEN", token)
|
|
if requestBody != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("GitLab API request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
return fmt.Errorf("GitLab API request failed with status %d", resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(responseBody); err != nil {
|
|
return errors.New("decode GitLab API response")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureAzureDevOpsCLI(ctx context.Context) error {
|
|
if _, err := exec.LookPath("az"); err != nil {
|
|
return errors.New(errAzureCLIMissing)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "az", "extension", "show", "--name", azureDevOpsExtensionName)
|
|
cmd.Env = filterGitEnv(os.Environ())
|
|
if err := cmd.Run(); err != nil {
|
|
return errors.New(errAzureDevOpsExtensionMissing)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *GitOperator) createAzureReposPR(
|
|
ctx context.Context,
|
|
result *PRCreateResult,
|
|
remoteURL, branch, title, body, baseBranch string,
|
|
draft bool,
|
|
) (*PRCreateResult, error) {
|
|
if err := ensureAzureDevOpsCLI(ctx); err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
info, err := parseAzureRepoInfo(remoteURL)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
args := []string{
|
|
"repos", "pr", prCreateSubcommand,
|
|
"--organization", info.OrganizationURL,
|
|
"--project", info.Project,
|
|
"--repository", info.Repository,
|
|
"--source-branch", branch,
|
|
}
|
|
if cleanBase := cleanBaseBranch(baseBranch); cleanBase != "" {
|
|
args = append(args, "--target-branch", cleanBase)
|
|
}
|
|
args = append(args,
|
|
repositoryFlagTitle, title,
|
|
repositoryFlagDescription, body,
|
|
)
|
|
if draft {
|
|
args = append(args, "--draft", "true")
|
|
}
|
|
args = append(args, "-o", "json")
|
|
|
|
stdoutOutput, stderrOutput, err := g.runRepositoryCommand(ctx, "az", args...)
|
|
result.Output = combineCommandOutput(stdoutOutput, stderrOutput)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
response, err := parseAzurePRCreateResponse(stdoutOutput)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("failed to parse Azure Repos PR output: %v", err)
|
|
return result, nil
|
|
}
|
|
if response.PullRequestID <= 0 || strings.TrimSpace(response.Repository.RemoteURL) == "" {
|
|
result.Error = "Azure Repos PR output did not include a pull request URL"
|
|
return result, nil
|
|
}
|
|
|
|
result.PRURL = strings.TrimRight(response.Repository.RemoteURL, "/") + "/pullrequest/" + strconv.Itoa(response.PullRequestID)
|
|
result.Success = true
|
|
g.logger.Info("PR created", zap.String("url", result.PRURL))
|
|
return result, nil
|
|
}
|