Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 1 addition & 23 deletions gitcmds/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ import (
)

// CreateDevBranch creates dev branch and pushes it to origin
// Parameters:
// branch - branch name
// notes - notes for branch
// checkRemoteBranchExistence - if true, checks if a branch already exists in remote
func CreateDevBranch(wd, branchName, mainBranch string, notes []string, checkRemoteBranchExistence bool) error {
func CreateDevBranch(wd, branchName, mainBranch string, notes []string) error {
branchName = normalizeBranchName(branchName)
if branchName == "" {
return errors.New("branch name is empty after normalization")
Expand All @@ -45,24 +41,6 @@ func CreateDevBranch(wd, branchName, mainBranch string, notes []string, checkRem
return err
}

if checkRemoteBranchExistence {
// check if a branch already exists in remote
stdout, stderr, err := new(exec.PipedExec).
Command(git, "ls-remote", "--heads", "origin", branchName).
WorkingDir(wd).
RunToStrings()
if err != nil {
logger.Verbose(stderr)

return err
}
logger.Verbose(stdout)

if len(stdout) > 0 {
return fmt.Errorf("branch %s already exists in origin remote", branchName)
}
}

// Create new branch from main
err = new(exec.PipedExec).
Command(git, "checkout", "-B", branchName).
Expand Down
110 changes: 29 additions & 81 deletions gitcmds/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ import (
"github.com/untillpro/qs/utils"
)

// CreateGithubLinkToIssue create a link between an upstream GitHub issue and the dev branch
func CreateGithubLinkToIssue(wd, parentRepo, githubIssueURL string, issueNumber int, args ...string) (branch string, notes []string, err error) {
// LinkBranchToGithubIssue links an existing remote branch to a GitHub issue and prepares notes.
// The branch must already exist on the remote before calling this function.
func LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL string, issueNumber int, branchName string, args ...string) (notes []string, err error) {
repo, org, err := GetRepoAndOrgName(wd)
if err != nil {
return "", nil, fmt.Errorf("GetRepoAndOrgName failed: %w", err)
return nil, fmt.Errorf("GetRepoAndOrgName failed: %w", err)
}

if len(repo) == 0 {
return "", nil, errors.New(repoNotFound)
return nil, errors.New(repoNotFound)
}

strIssueNum := strconv.Itoa(issueNumber)
Expand All @@ -49,40 +50,16 @@ func CreateGithubLinkToIssue(wd, parentRepo, githubIssueURL string, issueNumber
logger.Verbose(stderr)

if len(stderr) > 0 {
return "", nil, errors.New(stderr)
return nil, errors.New(stderr)
}

return "", nil, fmt.Errorf("failed to set default repo: %w", err)
return nil, fmt.Errorf("failed to set default repo: %w", err)
}
printLn(stdout)

branchName, err := buildDevBranchName(githubIssueURL)
if err != nil {
return "", nil, err
}

// check if a branch already exists in remote
stdout, stderr, err = new(exec.PipedExec).
Command(git, "ls-remote", "--heads", origin, branch).
WorkingDir(wd).
RunToStrings()
if err != nil {
logger.Verbose(stderr)

if len(stderr) > 0 {
return "", nil, errors.New(stderr)
}

return "", nil, fmt.Errorf("failed to check if branch exists in origin remote: %w", err)
}

if len(stdout) > 0 {
return "", nil, fmt.Errorf("branch %s already exists in origin remote", branch)
}

mainBranch, err := GetMainBranch(wd)
if err != nil {
return "", nil, fmt.Errorf(errMsgFailedToGetMainBranch, err)
return nil, fmt.Errorf(errMsgFailedToGetMainBranch, err)
}

stdout, stderr, err = new(exec.PipedExec).
Expand All @@ -93,40 +70,16 @@ func CreateGithubLinkToIssue(wd, parentRepo, githubIssueURL string, issueNumber
logger.Verbose(stderr)

if len(stderr) > 0 {
return "", nil, errors.New(stderr)
return nil, errors.New(stderr)
}

return "", nil, fmt.Errorf("failed to create development branch for issue: %w", err)
} // delay to ensure branch is created
return nil, fmt.Errorf("failed to link branch to issue: %w", err)
}
logger.Verbose(stdout)

utils.DelayIfTest()

branch = strings.TrimSpace(stdout)
segments := strings.Split(branch, slash)
branch = segments[len(segments)-1]

if len(branch) == 0 {
return "", nil, errors.New("can not create branch for issue")
}
// old-style notes
issueName, err := GetGithubIssueNameByNumber(strIssueNum, parentRepo)
if err != nil {
return "", nil, err
}

comment := IssuePRTtilePrefix + " '" + issueName + "' "
body := ""
if len(issueName) > 0 {
body = IssueSign + strIssueNum + oneSpace + issueName
}
// Prepare new notes with issue name as description
notesObj, err := notesPkg.Serialize(githubIssueURL, "", notesPkg.BranchTypeDev, issueName)
if err != nil {
return "", nil, err
}

return branch, []string{comment, body, notesObj}, nil
return nil, nil
}

func GetGithubIssueRepoFromURL(url string) (repoName string) {
Expand All @@ -147,67 +100,62 @@ func GetGithubIssueRepoFromURL(url string) (repoName string) {
return
}

func buildDevBranchName(issueURL string) (string, error) {
// Extract issue number from URL
func BuildDevBranchName(issueURL string) (string, []string, error) {
parts := strings.Split(issueURL, slash)
if len(parts) < 2 {
return "", fmt.Errorf("invalid issue URL format: %s", issueURL)
return "", nil, fmt.Errorf("invalid issue URL format: %s", issueURL)
}
issueNumber := parts[len(parts)-1]

// Extract owner and repo from URL
repoURL := strings.Split(issueURL, "/issues/")[0]
urlParts := strings.Split(repoURL, slash)
if len(urlParts) < 5 { //nolint:revive
return "", fmt.Errorf("invalid GitHub URL format: %s", repoURL)
return "", nil, fmt.Errorf("invalid GitHub URL format: %s", repoURL)
}
owner := urlParts[3] //nolint:revive
repo := urlParts[4] //nolint:revive

// Use gh CLI to get issue title
stdout, stderr, err := new(exec.PipedExec).
Command("gh", "issue", "view", issueNumber, "--repo", fmt.Sprintf("%s/%s", owner, repo), "--json", "title").
RunToStrings()

if err != nil {
logger.Verbose(stderr)

if len(stderr) > 0 {
return "", errors.New(stderr)
return "", nil, errors.New(stderr)
}

return "", fmt.Errorf("failed to get issue title: %w", err)
return "", nil, fmt.Errorf("failed to get issue title: %w", err)
}
logger.Verbose(stdout)

// Parse JSON response
var issueData struct {
Title string `json:"title"`
}

if err := json.Unmarshal([]byte(stdout), &issueData); err != nil {
return "", fmt.Errorf("failed to parse issue data: %w", err)
return "", nil, fmt.Errorf("failed to parse issue data: %w", err)
}

// Create kebab-case version of the title
kebabTitle := strings.ToLower(issueData.Title)
// Replace spaces and special characters with dashes
kebabTitle = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(kebabTitle, "-")
// Remove leading and trailing dashes
kebabTitle = strings.Trim(kebabTitle, "-")

// Construct branch name: {issue-number}-{kebab-case-title}
branchName := fmt.Sprintf("%s-%s", issueNumber, kebabTitle)

// Ensure branch name doesn't exceed git's limit (usually around 250 chars)
if len(branchName) > maximumBranchNameLength {
branchName = branchName[:maximumBranchNameLength]
}
branchName = utils.CleanArgFromSpecSymbols(branchName)
// Add suffix "-dev" for a dev branch
branchName += "-dev"

return branchName, nil
comment := IssuePRTtilePrefix + " '" + issueData.Title + "' "
body := ""
if len(issueData.Title) > 0 {
body = IssueSign + issueNumber + oneSpace + issueData.Title
}
notesObj, err := notesPkg.Serialize(issueURL, "", notesPkg.BranchTypeDev, issueData.Title)
if err != nil {
return "", nil, err
}

return branchName, []string{comment, body, notesObj}, nil
}

func GetGithubIssueNameByNumber(issueNum string, parentrepo string) (string, error) {
Expand Down
109 changes: 44 additions & 65 deletions internal/commands/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"github.com/atotto/clipboard"
"github.com/fatih/color"
"github.com/go-git/go-git/v5/plumbing"
"github.com/spf13/cobra"
"github.com/untillpro/goutils/logger"
"github.com/untillpro/qs/gitcmds"
Expand Down Expand Up @@ -96,49 +95,30 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return err
}

issueNum, githubIssueURL, ok, err := argContainsGithubIssueLink(wd, args...)
issueNum, githubIssueURL, isGithubIssue, err := argContainsGithubIssueLink(wd, args...)
if err != nil {
return err
}

checkRemoteBranchExistence := true
if ok { // github issue
fmt.Print("Dev branch for issue #" + strconv.Itoa(issueNum) + " will be created. Agree?(y/n)")
_, _ = fmt.Scanln(&response)
if response == pushYes {
branch, notes, err = gitcmds.CreateGithubLinkToIssue(wd, parentRepo, githubIssueURL, issueNum, args...)
if err != nil {
return err
}
checkRemoteBranchExistence = false // no need to check remote branch existence for issue branch
}
} else { // PK topic or Jira issue
if _, ok := jira.GetJiraTicketIDFromArgs(args...); ok { // Jira issue
branch, notes, err = jira.GetJiraBranchName(args...)
} else {
branch, notes, err = utils.GetBranchName(false, args...)
branch += "-dev" // Add suffix "-dev" for a dev branch
}
if err != nil {
// Show suggestion if issue is not found or insufficient permission to see it
// And exit silently
if errors.Is(err, jira.ErrJiraIssueNotFoundOrInsufficientPermission) {
fmt.Print(jira.NotFoundIssueOrInsufficientAccessRightSuggestion)

return nil
}
_, isJiraIssue := jira.GetJiraTicketIDFromArgs(args...)

return err
switch {
case isGithubIssue:
branch, notes, err = gitcmds.BuildDevBranchName(githubIssueURL)
case isJiraIssue:
branch, notes, err = jira.GetJiraBranchName(args...)
default:
branch, notes, err = utils.GetBranchName(false, args...)
branch += "-dev"
}
if err != nil {
if errors.Is(err, jira.ErrJiraIssueNotFoundOrInsufficientPermission) {
fmt.Print(jira.NotFoundIssueOrInsufficientAccessRightSuggestion)
return nil
}

devMsg := strings.ReplaceAll("Dev branch '$reponame' will be created. Continue(y/n)? ", "$reponame", branch)
fmt.Print(devMsg)
_, _ = fmt.Scanln(&response)
return err
}

// put branch name to command context
cmd.SetContext(context.WithValue(cmd.Context(), utils.CtxKeyDevBranchName, branch))

exists, err := branchExists(wd, branch)
if err != nil {
return fmt.Errorf("error checking branch existence: %w", err)
Expand All @@ -147,31 +127,38 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return fmt.Errorf("dev branch '%s' already exists", branch)
}

cmd.SetContext(context.WithValue(cmd.Context(), utils.CtxKeyDevBranchName, branch))

fmt.Print("Dev branch '" + branch + "' will be created. Continue(y/n)? ")
_, _ = fmt.Scanln(&response)

switch response {
case pushYes:
// Remote developer branch, linked to issue is created
var response string
// Only add upstream if we have a parent repo and upstream doesn't exist
// In single remote mode (no parent repo), we don't need upstream
if len(parentRepo) > 0 && !upstreamExists {
var upstreamResponse string
fmt.Print("Upstream not found.\nRepository " + parentRepo + " will be added as upstream. Agree[y/n]?")
_, _ = fmt.Scanln(&response)
if response != pushYes {
_, _ = fmt.Scanln(&upstreamResponse)
if upstreamResponse != pushYes {
fmt.Print(msgOkSeeYou)
return nil
}
response = ""
if err := gitcmds.MakeUpstreamForBranch(wd, parentRepo); err != nil {
return err
}
}

if err := gitcmds.CreateDevBranch(wd, branch, mainBranch, notes, checkRemoteBranchExistence); err != nil {
if err := gitcmds.CreateDevBranch(wd, branch, mainBranch, notes); err != nil {
return err
}

if isGithubIssue {
notes, err = gitcmds.LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL, issueNum, branch, args...)
if err != nil {
return err
}
}
default:
fmt.Print(msgOkSeeYou)

return nil
}

Expand All @@ -194,32 +181,24 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return nil
}

// branchExists checks if a branch with the given name already exists in the current git repository.
func branchExists(wd, branchName string) (bool, error) {
repo, err := gitcmds.OpenGitRepository(wd)
cmd := exec.Command("git", "branch", "--list", branchName)
cmd.Dir = wd
output, err := cmd.Output()
if err != nil {
return false, err
return false, fmt.Errorf("failed to check local branches: %w", err)
}
if strings.TrimSpace(string(output)) != "" {
return true, nil
}

branches, err := repo.Branches()
cmd = exec.Command("git", "ls-remote", "--heads", "origin", branchName)
cmd.Dir = wd
output, err = cmd.Output()
if err != nil {
return false, fmt.Errorf("failed to get branches: %w", err)
return false, fmt.Errorf("failed to check remote branches: %w", err)
}

// Find development branch name that starts with the issue ID
exists := false
_ = branches.ForEach(func(ref *plumbing.Reference) error {
nextBranchName := ref.Name().Short()
if nextBranchName == branchName {
exists = true

return nil
}

return nil
})

return exists, nil
return strings.TrimSpace(string(output)) != "", nil
}

// getArgStringFromClipboard retrieves a string from the clipboard, or uses the context value if available.
Expand Down
Loading
Loading