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
2 changes: 1 addition & 1 deletion gitcmds/gitcmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ func GetIssueDescription(notes []string) (string, error) {
description, err = GetGitHubIssueDescription(notesObj.GithubIssueURL)
case len(notesObj.JiraTicketURL) > 0:
var jiraTicketID string
description, jiraTicketID, err = jira.GetJiraIssueName(notesObj.JiraTicketURL, "")
description, jiraTicketID, err = jira.GetJiraIssueTitle(notesObj.JiraTicketURL, "")
if err != nil {
return "", err
}
Expand Down
66 changes: 2 additions & 64 deletions gitcmds/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,16 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"

"github.com/untillpro/goutils/exec"
"github.com/untillpro/goutils/logger"
notesPkg "github.com/untillpro/qs/internal/notes"
"github.com/untillpro/qs/utils"
)

// 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) {
func LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL, issueNumber, branchName string, args ...string) (notes []string, err error) {
repo, org, err := GetRepoAndOrgName(wd)
if err != nil {
return nil, fmt.Errorf("GetRepoAndOrgName failed: %w", err)
Expand All @@ -31,7 +28,6 @@ func LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL string, issueNumber
return nil, errors.New(repoNotFound)
}

strIssueNum := strconv.Itoa(issueNumber)
myrepo := org + slash + repo

if len(args) > 0 {
Expand Down Expand Up @@ -63,7 +59,7 @@ func LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL string, issueNumber
}

stdout, stderr, err = new(exec.PipedExec).
Command("gh", "issue", "develop", strIssueNum, "--branch-repo="+myrepo, "--repo="+parentRepo, "--name="+branchName, "--base="+mainBranch).
Command("gh", "issue", "develop", issueNumber, "--branch-repo="+myrepo, "--repo="+parentRepo, "--name="+branchName, "--base="+mainBranch).
WorkingDir(wd).
RunToStrings()
if err != nil {
Expand Down Expand Up @@ -100,64 +96,6 @@ func GetGithubIssueRepoFromURL(url string) (repoName string) {
return
}

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

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

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 "", nil, errors.New(stderr)
}
return "", nil, fmt.Errorf("failed to get issue title: %w", err)
}
logger.Verbose(stdout)

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

kebabTitle := strings.ToLower(issueData.Title)
kebabTitle = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(kebabTitle, "-")
kebabTitle = strings.Trim(kebabTitle, "-")

branchName := fmt.Sprintf("%s-%s", issueNumber, kebabTitle)
if len(branchName) > maximumBranchNameLength {
branchName = branchName[:maximumBranchNameLength]
}
branchName = utils.CleanArgFromSpecSymbols(branchName)
branchName += "-dev"

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) {
stdout, stderr, err := new(exec.PipedExec).
Command("gh", "issue", "view", issueNum, "--repo", parentrepo, "--json", "title").
Expand Down
26 changes: 9 additions & 17 deletions internal/commands/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return deleteBranches(wd, parentRepo)
}
// qs dev is running
var branch string
var devBranchName string
var notes []string
var response string

Expand Down Expand Up @@ -95,20 +95,12 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return err
}

issueInfo, err := issue.ParseIssueFromArgs(wd, args...)
issueInfo, err := issue.ParseIssueFromArgs(args...)
if err != nil {
return err
}

switch issueInfo.Type {
case issue.GitHub:
branch, notes, err = gitcmds.BuildDevBranchName(issueInfo.URL)
case issue.Jira:
branch, notes, err = jira.GetJiraBranchName(args...)
default:
branch, notes, err = utils.GetBranchName(false, args...)
branch += "-dev"
}
devBranchName, notes, err = issue.BuildDevBranchName(issueInfo)
if err != nil {
if errors.Is(err, jira.ErrJiraIssueNotFoundOrInsufficientPermission) {
fmt.Print(jira.NotFoundIssueOrInsufficientAccessRightSuggestion)
Expand All @@ -117,17 +109,17 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
return err
}

exists, err := branchExists(wd, branch)
exists, err := branchExists(wd, devBranchName)
if err != nil {
return fmt.Errorf("error checking branch existence: %w", err)
}
if exists {
return fmt.Errorf("dev branch '%s' already exists", branch)
return fmt.Errorf("dev branch '%s' already exists", devBranchName)
}

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

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

switch response {
Expand All @@ -145,12 +137,12 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s
}
}

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

if issueInfo.Type == issue.GitHub {
notes, err = gitcmds.LinkBranchToGithubIssue(wd, parentRepo, issueInfo.URL, issueInfo.Number, branch, args...)
notes, err = gitcmds.LinkBranchToGithubIssue(wd, parentRepo, issueInfo.Text, issueInfo.ID, devBranchName, args...)
if err != nil {
return err
}
Expand Down
149 changes: 134 additions & 15 deletions internal/issue/issue.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package issue

import (
"encoding/json"
"errors"
"fmt"
"strconv"
"regexp"
"strings"

"github.com/untillpro/goutils/exec"
"github.com/untillpro/goutils/logger"
"github.com/untillpro/qs/internal/jira"
"github.com/untillpro/qs/internal/notes"
"github.com/untillpro/qs/utils"
)

type IssueType int
Expand All @@ -16,25 +22,138 @@ const (
Jira
)

const (
maximumBranchNameLength = 100
issuePRTitlePrefix = "Resolves issue"
issueSign = "Resolves #"
)

// IssueInfo holds parsed issue metadata.
// ID format depends on Type:
// - GitHub: issue number (e.g. "42")
// - Jira: ticket key (e.g. "AIR-270")
// - FreeForm: empty
type IssueInfo struct {
Type IssueType
URL string
ID string
Number int
Type IssueType
ID string
Text string
}

func ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error) {
url := args[0] // protected by caller side
if strings.Contains(url, "/issues/") {
segments := strings.Split(url, "/")
num, err := strconv.Atoi(segments[len(segments)-1])
if err != nil {
return IssueInfo{}, fmt.Errorf("failed to convert issue number from string to int: %w", err)
func BuildDevBranchName(info IssueInfo) (devBranchName string, comments []string, err error) {
var title string

switch info.Type {
case GitHub:
title, err = fetchGithubIssueTitle(info)
case Jira:
title, _, err = jira.GetJiraIssueTitle("", info.ID)
default:
title = info.Text
}
if err != nil {
return "", nil, err
}

// Convert issue title to kebab-style branch name
devBranchName = titleToKebabWithPrefix(info.ID, title)
devBranchName = utils.CleanArgFromSpecSymbols(devBranchName)

// Build notes (common for GitHub and Jira when title is available)
var githubURL, jiraURL string
switch info.Type {
case GitHub:
githubURL = info.Text
case Jira:
jiraURL = info.Text
}
notesObj, err := notes.Serialize(githubURL, jiraURL, notes.BranchTypeDev, title)
if err != nil {
return "", nil, err
}

// Build comments
switch info.Type {
case GitHub:
comment := issuePRTitlePrefix + " '" + title + "' "
body := ""
if len(title) > 0 {
body = issueSign + info.ID + " " + title
}
return IssueInfo{Type: GitHub, URL: url, ID: segments[len(segments)-1], Number: num}, nil
comments = []string{comment, body, notesObj}
case Jira:
if notesObj != "" {
comments = append(comments, notesObj)
}
comments = append(comments, "["+info.ID+"] "+title)
comments = append(comments, info.Text)
default:
comments = append(comments, title)
comments = append(comments, notesObj)
}

devBranchName += "-dev"

return devBranchName, comments, nil
}

// titleToKebabWithPrefix converts an issue title into a kebab-case branch name prefixed with the issue ID.
func titleToKebabWithPrefix(id, title string) string {
kebabTitle := strings.ToLower(title)
kebabTitle = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(kebabTitle, "-")
kebabTitle = strings.Trim(kebabTitle, "-")

branchName := fmt.Sprintf("%s-%s", id, kebabTitle)
if len(branchName) > maximumBranchNameLength {
branchName = branchName[:maximumBranchNameLength]
}
return branchName
}

// fetchGithubIssueTitle fetches the issue title from GitHub.
func fetchGithubIssueTitle(info IssueInfo) (string, error) {
parts := strings.Split(info.Text, "/")
if len(parts) < 2 {
return "", fmt.Errorf("invalid issue URL format: %s", info.Text)
}

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

stdout, stderr, err := new(exec.PipedExec).
Command("gh", "issue", "view", info.ID, "--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 "", fmt.Errorf("failed to get issue title: %w", err)
}
logger.Verbose(stdout)

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 issueData.Title, nil
}

func ParseIssueFromArgs(args ...string) (IssueInfo, error) {
text := args[0] // protected by caller side
if strings.Contains(text, "/issues/") {
segments := strings.Split(text, "/")
return IssueInfo{Type: GitHub, Text: text, ID: segments[len(segments)-1]}, nil
}
if id, ok := jira.GetJiraTicketIDFromArgs(args...); ok {
return IssueInfo{Type: Jira, URL: url, ID: id}, nil
return IssueInfo{Type: Jira, Text: text, ID: id}, nil
}
return IssueInfo{Type: FreeForm}, nil
return IssueInfo{Type: FreeForm, Text: text}, nil
}
Loading
Loading