From 9500df5484cbfe1d18e3165e133bbe1a6245a33b Mon Sep 17 00:00:00 2001 From: Denis Gribanov Date: Mon, 23 Feb 2026 17:19:45 +0300 Subject: [PATCH 1/3] [AIR-3036] qs: combine argContainsGithubIssueLink() and GetJiraTicketIDFromArgs() funcs --- internal/commands/dev.go | 52 +++------------ internal/issue/issue.go | 56 ++++++++++++++++ internal/issue/issue_test.go | 64 +++++++++++++++++++ .../change.md | 1 + .../impl.md | 14 ++-- .../2602231419-unify-issue-detection/impl.md | 31 +++++++++ 6 files changed, 167 insertions(+), 51 deletions(-) create mode 100644 internal/issue/issue.go create mode 100644 internal/issue/issue_test.go rename uspecs/changes/{2602231024-fix-dev-branch-creation => archive/2602/2602231419-fix-dev-branch-creation}/change.md (98%) rename uspecs/changes/{2602231024-fix-dev-branch-creation => archive/2602/2602231419-fix-dev-branch-creation}/impl.md (80%) create mode 100644 uspecs/changes/archive/2602/2602231419-unify-issue-detection/impl.md diff --git a/internal/commands/dev.go b/internal/commands/dev.go index de6249f..e0ca753 100644 --- a/internal/commands/dev.go +++ b/internal/commands/dev.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os/exec" - "strconv" "strings" "github.com/atotto/clipboard" @@ -13,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/untillpro/goutils/logger" "github.com/untillpro/qs/gitcmds" + "github.com/untillpro/qs/internal/issue" "github.com/untillpro/qs/internal/jira" "github.com/untillpro/qs/internal/notes" "github.com/untillpro/qs/utils" @@ -95,17 +95,15 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s return err } - issueNum, githubIssueURL, isGithubIssue, err := argContainsGithubIssueLink(wd, args...) + issueInfo, err := issue.ParseIssueFromArgs(wd, args...) if err != nil { return err } - _, isJiraIssue := jira.GetJiraTicketIDFromArgs(args...) - - switch { - case isGithubIssue: - branch, notes, err = gitcmds.BuildDevBranchName(githubIssueURL) - case isJiraIssue: + 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...) @@ -151,8 +149,8 @@ func Dev(cmd *cobra.Command, wd string, doDelete bool, ignoreHook bool, args []s return err } - if isGithubIssue { - notes, err = gitcmds.LinkBranchToGithubIssue(wd, parentRepo, githubIssueURL, issueNum, branch, args...) + if issueInfo.Type == issue.GitHub { + notes, err = gitcmds.LinkBranchToGithubIssue(wd, parentRepo, issueInfo.URL, issueInfo.Number, branch, args...) if err != nil { return err } @@ -231,40 +229,6 @@ func setPreCommitHook(wd string) error { return gitcmds.SetLocalPreCommitHook(wd) } -func argContainsGithubIssueLink(wd string, args ...string) (issueNum int, issueURL string, ok bool, err error) { - ok = false - if len(args) != 1 { - return - } - url := args[0] - if strings.Contains(url, "/issues") { - if err := checkIssueLink(wd, url); err != nil { - return 0, "", false, fmt.Errorf("invalid GitHub issue link: %w", err) - } - segments := strings.Split(url, "/") - strIssueNum := segments[len(segments)-1] - i, err := strconv.Atoi(strIssueNum) - if err != nil { - return 0, "", false, fmt.Errorf("failed to convert issue number from string to int: %w", err) - } - - return i, url, true, nil - } - - return 0, "", false, nil -} - -func checkIssueLink(wd, issueURL string) error { - // This function checks if the provided issueURL is a valid GitHub issue link via `gh issue view`. - cmd := exec.Command("gh", "issue", "view", "--json", "title,state", issueURL) - cmd.Dir = wd - if _, err := cmd.Output(); err != nil { - return fmt.Errorf("failed to check issue link: %w", err) - } - - return nil -} - func deleteBranches(wd, parentRepo string) error { // Step 1: qs d if err := gitcmds.Download(wd); err != nil { diff --git a/internal/issue/issue.go b/internal/issue/issue.go new file mode 100644 index 0000000..a6e6835 --- /dev/null +++ b/internal/issue/issue.go @@ -0,0 +1,56 @@ +package issue + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/untillpro/qs/internal/jira" +) + +type IssueType int + +const ( + FreeForm IssueType = iota + GitHub + Jira +) + +type IssueInfo struct { + Type IssueType + URL string + ID string + Number int +} + +func ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error) { + if len(args) != 1 { + return IssueInfo{}, nil + } + url := args[0] + if strings.Contains(url, "/issues/") { + if err := checkGitHubIssue(wd, url); err != nil { + return IssueInfo{}, fmt.Errorf("invalid GitHub issue link: %w", err) + } + 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) + } + return IssueInfo{Type: GitHub, URL: url, ID: segments[len(segments)-1], Number: num}, nil + } + if id, ok := jira.GetJiraTicketIDFromArgs(args...); ok { + return IssueInfo{Type: Jira, URL: url, ID: id}, nil + } + return IssueInfo{}, nil +} + +func checkGitHubIssue(wd, issueURL string) error { + cmd := exec.Command("gh", "issue", "view", "--json", "title,state", issueURL) + cmd.Dir = wd + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("failed to check issue link: %w", err) + } + return nil +} diff --git a/internal/issue/issue_test.go b/internal/issue/issue_test.go new file mode 100644 index 0000000..75f56a7 --- /dev/null +++ b/internal/issue/issue_test.go @@ -0,0 +1,64 @@ +package issue + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseIssueFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantType IssueType + wantID string + wantNum int + wantErr bool + }{ + { + name: "Empty args returns FreeForm", + args: []string{}, + wantType: FreeForm, + }, + { + name: "Multiple args returns FreeForm", + args: []string{"arg1", "arg2"}, + wantType: FreeForm, + }, + { + name: "Plain text returns FreeForm", + args: []string{"some-feature-branch"}, + wantType: FreeForm, + }, + { + name: "Jira URL returns Jira type", + args: []string{"https://untill.atlassian.net/browse/AIR-270"}, + wantType: Jira, + wantID: "AIR-270", + }, + { + name: "GitHub URL without gh CLI returns error", + args: []string{"https://github.com/owner/repo/issues/42"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + info, err := ParseIssueFromArgs(".", tt.args...) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + require.Equal(tt.wantType, info.Type) + if tt.wantID != "" { + require.Equal(tt.wantID, info.ID) + } + if tt.wantNum != 0 { + require.Equal(tt.wantNum, info.Number) + } + }) + } +} diff --git a/uspecs/changes/2602231024-fix-dev-branch-creation/change.md b/uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/change.md similarity index 98% rename from uspecs/changes/2602231024-fix-dev-branch-creation/change.md rename to uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/change.md index 7e592da..1ae47e2 100644 --- a/uspecs/changes/2602231024-fix-dev-branch-creation/change.md +++ b/uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/change.md @@ -2,6 +2,7 @@ registered_at: 2026-02-23T10:24:58Z change_id: 2602231024-fix-dev-branch-creation baseline: b7c87527aa6501a58538c46ba20fcb43c9962e45 +archived_at: 2026-02-23T14:19:09Z --- # Change request: Fix dev branch creation flow diff --git a/uspecs/changes/2602231024-fix-dev-branch-creation/impl.md b/uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/impl.md similarity index 80% rename from uspecs/changes/2602231024-fix-dev-branch-creation/impl.md rename to uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/impl.md index 2d14f0c..4f8e062 100644 --- a/uspecs/changes/2602231024-fix-dev-branch-creation/impl.md +++ b/uspecs/changes/archive/2602/2602231419-fix-dev-branch-creation/impl.md @@ -4,38 +4,38 @@ ### Remove redundant branch existence checks -- [x] update: [gitcmds/github.go](../../../gitcmds/github.go) +- [x] update: [gitcmds/github.go](../../../../../gitcmds/github.go) - remove: Remote branch existence check (lines 64-81) from `CreateGithubLinkToIssue` — it uses uninitialized `branch` variable (bug) and is redundant since `Dev()` already checks existence -- [x] update: [gitcmds/dev.go](../../../gitcmds/dev.go) +- [x] update: [gitcmds/dev.go](../../../../../gitcmds/dev.go) - remove: `checkRemoteBranchExistence` parameter from `CreateDevBranch` signature - remove: Remote branch existence check block (lines 48-64) inside `CreateDevBranch` ### Consolidate existence check in Dev() -- [x] update: [internal/commands/dev.go](../../../internal/commands/dev.go) +- [x] update: [internal/commands/dev.go](../../../../../internal/commands/dev.go) - update: Replace local-only `branchExists` check with a combined local+remote existence check that works uniformly for both GitHub and Jira flows - update: Remove `checkRemoteBranchExistence` variable and pass updated signature to `CreateDevBranch` - update: Move branch existence check to happen after branch name is resolved but before user confirmation prompt, so the user is not asked to confirm creation of an already-existing branch ### Reorder GitHub branch creation and issue linking -- [x] update: [gitcmds/github.go](../../../gitcmds/github.go) +- [x] update: [gitcmds/github.go](../../../../../gitcmds/github.go) - update: Renamed `CreateGithubLinkToIssue` to `LinkBranchToGithubIssue`, changed signature to return `(notes []string, err error)` instead of `(branch string, notes []string, err error)` since branch name is already known -- [x] update: [internal/commands/dev.go](../../../internal/commands/dev.go) +- [x] update: [internal/commands/dev.go](../../../../../internal/commands/dev.go) - update: Reordered calls so `CreateDevBranch` runs first (creates local branch + pushes to origin), then `LinkBranchToGithubIssue` links the existing remote branch to the GitHub issue ### Unify dev branch name building -- [x] update: [gitcmds/github.go](../../../gitcmds/github.go) and [internal/commands/dev.go](../../../internal/commands/dev.go) +- [x] update: [gitcmds/github.go](../../../../../gitcmds/github.go) and [internal/commands/dev.go](../../../../../internal/commands/dev.go) - update: Changed `BuildDevBranchName` signature from `(string, error)` to `(string, []string, error)` — now returns notes (old-style comment, body, and JSON notes) alongside the branch name, matching the Jira/PK pattern - update: Removed note preparation from `LinkBranchToGithubIssue` (now returns `nil, nil` — linking only) - update: Unified `Dev()` branch resolution into a single `switch` dispatching to `BuildDevBranchName` / `GetJiraBranchName` / `GetBranchName`, all producing `(branch, notes, error)` ### Tests and review -- [ ] review +- [x] review - `go build ./...` compiles without errors - `go vet ./...` passes diff --git a/uspecs/changes/archive/2602/2602231419-unify-issue-detection/impl.md b/uspecs/changes/archive/2602/2602231419-unify-issue-detection/impl.md new file mode 100644 index 0000000..d56160f --- /dev/null +++ b/uspecs/changes/archive/2602/2602231419-unify-issue-detection/impl.md @@ -0,0 +1,31 @@ +# Implementation plan: Unify issue detection into single function + +## Construction + +### New shared types and function + +- [x] create: [internal/issue/issue.go](../../../../../internal/issue/issue.go) + - add: `IssueType` enum (`FreeForm`, `GitHub`, `Jira`) + - add: `IssueInfo` struct with fields `Type IssueType`, `URL string`, `ID string`, `Number int` (GitHub issue number) + - add: `ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error)` that checks GitHub URL (contains `/issues/`, validates via `gh issue view`), then Jira URL (delegates to `jira.GetJiraTicketIDFromArgs`), returns typed result + +### Caller updates + +- [x] update: [internal/commands/dev.go](../../../../../internal/commands/dev.go) + - remove: `argContainsGithubIssueLink` and `checkIssueLink` functions + - update: replace two-call detection (`argContainsGithubIssueLink` + `jira.GetJiraTicketIDFromArgs`) with single `issue.ParseIssueFromArgs` call + - update: simplify switch to use `issue.GitHub`, `issue.Jira`, `issue.FreeForm` + - remove: unused imports (`strconv`) + +### Tests + +- [x] create: [internal/issue/issue_test.go](../../../../../internal/issue/issue_test.go) + - add: table-driven tests for `ParseIssueFromArgs` covering GitHub URL, Jira URL, plain text, empty args + +### Review + +- [x] review + - `go build ./...` compiles without errors + - `go vet ./...` passes + - existing tests in `internal/jira/` still pass (GetJiraTicketIDFromArgs unchanged) + From 2eaf82e69e31c80339ea9574d96acb694f016916 Mon Sep 17 00:00:00 2001 From: Denis Gribanov Date: Mon, 23 Feb 2026 17:35:26 +0300 Subject: [PATCH 2/3] qs: simplify ParseIssueFromArgs by removing unnecessary argument length check --- internal/issue/issue.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/issue/issue.go b/internal/issue/issue.go index a6e6835..5bfb234 100644 --- a/internal/issue/issue.go +++ b/internal/issue/issue.go @@ -25,10 +25,7 @@ type IssueInfo struct { } func ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error) { - if len(args) != 1 { - return IssueInfo{}, nil - } - url := args[0] + url := args[0] // protected by caller side if strings.Contains(url, "/issues/") { if err := checkGitHubIssue(wd, url); err != nil { return IssueInfo{}, fmt.Errorf("invalid GitHub issue link: %w", err) From ab6b61d7d8e7323a7e96e2b95559a2e32a3732a2 Mon Sep 17 00:00:00 2001 From: Denis Gribanov Date: Mon, 23 Feb 2026 17:40:47 +0300 Subject: [PATCH 3/3] qs: remove GitHub issue validation and related tests from ParseIssueFromArgs --- internal/issue/issue.go | 15 +-------------- internal/issue/issue_test.go | 10 ---------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/internal/issue/issue.go b/internal/issue/issue.go index 5bfb234..b222682 100644 --- a/internal/issue/issue.go +++ b/internal/issue/issue.go @@ -2,7 +2,6 @@ package issue import ( "fmt" - "os/exec" "strconv" "strings" @@ -27,9 +26,6 @@ type IssueInfo struct { func ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error) { url := args[0] // protected by caller side if strings.Contains(url, "/issues/") { - if err := checkGitHubIssue(wd, url); err != nil { - return IssueInfo{}, fmt.Errorf("invalid GitHub issue link: %w", err) - } segments := strings.Split(url, "/") num, err := strconv.Atoi(segments[len(segments)-1]) if err != nil { @@ -40,14 +36,5 @@ func ParseIssueFromArgs(wd string, args ...string) (IssueInfo, error) { if id, ok := jira.GetJiraTicketIDFromArgs(args...); ok { return IssueInfo{Type: Jira, URL: url, ID: id}, nil } - return IssueInfo{}, nil -} - -func checkGitHubIssue(wd, issueURL string) error { - cmd := exec.Command("gh", "issue", "view", "--json", "title,state", issueURL) - cmd.Dir = wd - if _, err := cmd.Output(); err != nil { - return fmt.Errorf("failed to check issue link: %w", err) - } - return nil + return IssueInfo{Type: FreeForm}, nil } diff --git a/internal/issue/issue_test.go b/internal/issue/issue_test.go index 75f56a7..3c30b3a 100644 --- a/internal/issue/issue_test.go +++ b/internal/issue/issue_test.go @@ -15,11 +15,6 @@ func TestParseIssueFromArgs(t *testing.T) { wantNum int wantErr bool }{ - { - name: "Empty args returns FreeForm", - args: []string{}, - wantType: FreeForm, - }, { name: "Multiple args returns FreeForm", args: []string{"arg1", "arg2"}, @@ -36,11 +31,6 @@ func TestParseIssueFromArgs(t *testing.T) { wantType: Jira, wantID: "AIR-270", }, - { - name: "GitHub URL without gh CLI returns error", - args: []string{"https://github.com/owner/repo/issues/42"}, - wantErr: true, - }, } for _, tt := range tests {