Files
kandev/apps/backend/internal/agentctl/server/process/git_pr_providers.go
T

483 lines
14 KiB
Go

package process
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/subproc"
)
type prProvider string
const (
prProviderGitHub prProvider = "github"
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"
)
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 {
host := remoteHostFromURL(remoteURL)
if isAzureReposHost(host) {
return prProviderAzureRepos
}
if isGitHubHost(host) {
return prProviderGitHub
}
return ""
}
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 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(os.Environ())
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.RunGit(ctx, 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 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
}