Files

861 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) {
head := branch
args := []string{"pr", prCreateSubcommand, repositoryFlagTitle, title, repositoryFlagBody, body}
if g.contributionDestination != nil {
parts := strings.Split(g.contributionDestination.TargetRepository.Path, "/")
if len(parts) == 2 {
head = parts[0] + ":" + branch
args = append(args, "--repo", g.contributionDestination.SourceRepository.Path)
}
}
args = append(args, repositoryFlagHead, head)
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
}