From e1aecf88c4a6dcbbf97f6a4d00cb3217dd0ad2d6 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 21 May 2026 14:54:54 +0200 Subject: [PATCH 1/6] Add a bunch of aliases to align with gh See #86 --- internal/cli/issue.go | 5 ++- internal/cli/label.go | 15 ++++--- internal/cli/pr.go | 5 ++- internal/cli/root.go | 2 +- internal/resolve/resolve.go | 18 +++++--- internal/resolve/resolve_test.go | 73 ++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/internal/cli/issue.go b/internal/cli/issue.go index 659c94d..7e44a40 100644 --- a/internal/cli/issue.go +++ b/internal/cli/issue.go @@ -191,6 +191,7 @@ func issueListCmd() *cobra.Command { cmd.Flags().StringVarP(&flagAssignee, "assignee", "a", "", "Filter by assignee") cmd.Flags().StringVarP(&flagAuthor, "author", "A", "", "Filter by author") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Filter by label") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Filter by label") cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultIssueLimit, "Maximum number of issues") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated, comments") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") @@ -246,6 +247,7 @@ func issueCreateCmd() *cobra.Command { cmd.Flags().StringVarP(&flagBody, "body", "b", "", "Issue body") cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Assign to a user") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Add a label") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Add a label") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Assign to a milestone") return cmd } @@ -336,7 +338,7 @@ func issueEditCmd() *cobra.Command { if cmd.Flags().Changed("assignee") { opts.Assignees = flagAssignees } - if cmd.Flags().Changed("label") { + if cmd.Flags().Changed("label") || cmd.Flags().Changed("labels") { opts.Labels = flagLabels } if cmd.Flags().Changed("milestone") { @@ -362,6 +364,7 @@ func issueEditCmd() *cobra.Command { cmd.Flags().StringVarP(&flagBody, "body", "b", "", "Set the body") cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Set assignees") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Set labels") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Set labels") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Set the milestone") return cmd } diff --git a/internal/cli/label.go b/internal/cli/label.go index 7417bac..ccac986 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -86,11 +86,16 @@ func labelCreateCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "create", + Use: "create [name]", Short: "Create a label", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if flagName == "" { - return fmt.Errorf("--name is required") + name := flagName + if len(args) > 0 { + name = args[0] + } + if name == "" { + return fmt.Errorf("label name is required (provide as argument or --name)") } forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) @@ -99,7 +104,7 @@ func labelCreateCmd() *cobra.Command { } opts := forges.CreateLabelOpts{ - Name: flagName, + Name: name, Color: flagColor, Description: flagDescription, } @@ -119,7 +124,7 @@ func labelCreateCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&flagName, "name", "n", "", "Label name") + cmd.Flags().StringVarP(&flagName, "name", "n", "", "Label name (can also be provided as first argument)") cmd.Flags().StringVarP(&flagColor, "color", "c", "", "Label color (hex without #)") cmd.Flags().StringVarP(&flagDescription, "description", "d", "", "Label description") return cmd diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 398766b..9257a41 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -204,6 +204,7 @@ func prListCmd() *cobra.Command { cmd.Flags().StringVar(&flagHead, "head", "", "Filter by head branch") cmd.Flags().StringVar(&flagBase, "base", "", "Filter by base branch") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Filter by label") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Filter by label") cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultPRLimit, "Maximum number of PRs") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") @@ -274,6 +275,7 @@ func prCreateCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagReviewers, "reviewer", "r", nil, "Request a reviewer") cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Assign to a user") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Add a label") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Add a label") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Assign to a milestone") return cmd } @@ -371,7 +373,7 @@ func prEditCmd() *cobra.Command { if cmd.Flags().Changed("assignee") { opts.Assignees = flagAssignees } - if cmd.Flags().Changed("label") { + if cmd.Flags().Changed("label") || cmd.Flags().Changed("labels") { opts.Labels = flagLabels } @@ -396,6 +398,7 @@ func prEditCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagReviewers, "reviewer", "r", nil, "Set reviewers") cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Set assignees") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Set labels") + cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Set labels") return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index 83aa24f..e2d3558 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -44,7 +44,7 @@ func Execute() error { } func init() { - rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO)") + rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)") rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket") rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 348e68f..a5b0f0f 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -67,13 +67,21 @@ func Repo(flagRepo, flagForgeType string) (forge forges.Forge, owner, repo, doma } func repoFromFlag(flagRepo, flagForgeType string) (forges.Forge, string, string, string, error) { - lastSlash := strings.LastIndex(flagRepo, "/") - if lastSlash < 0 { - return nil, "", "", "", fmt.Errorf("invalid repo format %q, expected OWNER/REPO", flagRepo) + parts := strings.Split(flagRepo, "/") + + var domain, owner, repo string + switch len(parts) { + case 2: + // owner/repo + owner, repo = parts[0], parts[1] + domain = Domain(flagForgeType) + case 3: + // host/owner/repo + domain, owner, repo = parts[0], parts[1], parts[2] + default: + return nil, "", "", "", fmt.Errorf("invalid repo format %q, expected OWNER/REPO or HOST/OWNER/REPO", flagRepo) } - owner, repo := flagRepo[:lastSlash], flagRepo[lastSlash+1:] - domain := Domain(flagForgeType) client := newClient(domain) f, err := forgeForDomainMaybeConfig(context.Background(), client, domain) if err != nil { diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 7f717d1..7b3b48e 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -361,3 +361,76 @@ func mustGit(t *testing.T, args ...string) { t.Fatalf("git %v: %v\n%s", args, err, out) } } + +func TestRepoFromFlag(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + tests := []struct { + name string + flagRepo string + wantDomain string + wantOwner string + wantRepo string + wantErr bool + }{ + { + name: "owner/repo uses default domain", + flagRepo: "owner/repo", + wantDomain: "github.com", + wantOwner: "owner", + wantRepo: "repo", + }, + { + name: "host/owner/repo", + flagRepo: "codeberg.org/owner/repo", + wantDomain: "codeberg.org", + wantOwner: "owner", + wantRepo: "repo", + }, + { + name: "single part is invalid", + flagRepo: "repo", + wantErr: true, + }, + { + name: "empty is invalid", + flagRepo: "", + wantErr: true, + }, + { + name: "too many parts is invalid", + flagRepo: "host/group/subgroup/repo", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, owner, repo, domain, err := repoFromFlag(tt.flagRepo, "") + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f == nil { + t.Error("expected forge instance, got nil") + } + if domain != tt.wantDomain { + t.Errorf("domain = %q, want %q", domain, tt.wantDomain) + } + if owner != tt.wantOwner { + t.Errorf("owner = %q, want %q", owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("repo = %q, want %q", repo, tt.wantRepo) + } + }) + } +} From f24e5b9f06fd3be7339fb5ba98d0bee74d60c9d9 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 21 May 2026 15:33:14 +0200 Subject: [PATCH 2/6] Add ls and new aliases to list and create commands Aligns with gh CLI which supports: - gh pr ls / gh issue ls / gh label ls - gh pr new / gh issue new Co-Authored-By: Claude Opus 4.5 --- internal/cli/issue.go | 10 ++++++---- internal/cli/label.go | 5 +++-- internal/cli/pr.go | 8 +++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/cli/issue.go b/internal/cli/issue.go index 7e44a40..abf7e75 100644 --- a/internal/cli/issue.go +++ b/internal/cli/issue.go @@ -126,8 +126,9 @@ func issueListCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "list", - Short: "List issues", + Use: "list", + Aliases: []string{"ls"}, + Short: "List issues", RunE: func(cmd *cobra.Command, args []string) error { forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { @@ -208,8 +209,9 @@ func issueCreateCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "create", - Short: "Create a new issue", + Use: "create", + Aliases: []string{"new"}, + Short: "Create a new issue", RunE: func(cmd *cobra.Command, args []string) error { if flagTitle == "" { return fmt.Errorf("--title is required") diff --git a/internal/cli/label.go b/internal/cli/label.go index ccac986..a5cd57a 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -29,8 +29,9 @@ func labelListCmd() *cobra.Command { var flagLimit int cmd := &cobra.Command{ - Use: "list", - Short: "List labels", + Use: "list", + Aliases: []string{"ls"}, + Short: "List labels", RunE: func(cmd *cobra.Command, args []string) error { forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 9257a41..ac33651 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -141,7 +141,8 @@ func prListCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "list", + Use: "list", + Aliases: []string{"ls"}, Short: "List pull requests", RunE: func(cmd *cobra.Command, args []string) error { forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) @@ -225,8 +226,9 @@ func prCreateCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "create", - Short: "Create a pull request", + Use: "create", + Aliases: []string{"new"}, + Short: "Create a pull request", RunE: func(cmd *cobra.Command, args []string) error { if flagTitle == "" { return fmt.Errorf("--title is required") From c94ada28039b0dd4d301cff5a4e80f9ffd1016e4 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 21 May 2026 15:35:12 +0200 Subject: [PATCH 3/6] Add --web flag to view and list commands Opens the resource in the browser instead of displaying it in the terminal. Aligns with gh CLI behavior. Supported commands: - forge pr view --web - forge pr list --web - forge issue view --web - forge issue list --web - forge label list --web Co-Authored-By: Claude Opus 4.5 --- internal/cli/issue.go | 22 +++++++++++++++++++--- internal/cli/label.go | 13 +++++++++++-- internal/cli/pr.go | 24 ++++++++++++++++++++---- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/internal/cli/issue.go b/internal/cli/issue.go index abf7e75..ae6bb27 100644 --- a/internal/cli/issue.go +++ b/internal/cli/issue.go @@ -38,7 +38,10 @@ func init() { } func issueViewCmd() *cobra.Command { - var flagComments bool + var ( + flagComments bool + flagWeb bool + ) cmd := &cobra.Command{ Use: "view ", @@ -50,11 +53,16 @@ func issueViewCmd() *cobra.Command { return fmt.Errorf("invalid issue number: %s", args[0]) } - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } + if flagWeb { + url := fmt.Sprintf("https://%s/%s/%s/issues/%d", domain, owner, repoName, number) + return openBrowser(url) + } + issue, err := forge.Issues().Get(cmd.Context(), owner, repoName, number) if err != nil { return fmt.Errorf("getting issue #%d: %w", number, err) @@ -111,6 +119,7 @@ func issueViewCmd() *cobra.Command { } cmd.Flags().BoolVarP(&flagComments, "comments", "c", false, "Show comments") + cmd.Flags().BoolVarP(&flagWeb, "web", "w", false, "Open in browser") return cmd } @@ -123,6 +132,7 @@ func issueListCmd() *cobra.Command { flagLimit int flagSort string flagOrder string + flagWeb bool ) cmd := &cobra.Command{ @@ -130,11 +140,16 @@ func issueListCmd() *cobra.Command { Aliases: []string{"ls"}, Short: "List issues", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } + if flagWeb { + url := fmt.Sprintf("https://%s/%s/%s/issues", domain, owner, repoName) + return openBrowser(url) + } + opts := forges.ListIssueOpts{ State: flagState, Assignee: flagAssignee, @@ -196,6 +211,7 @@ func issueListCmd() *cobra.Command { cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultIssueLimit, "Maximum number of issues") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated, comments") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") + cmd.Flags().BoolVarP(&flagWeb, "web", "w", false, "Open in browser") return cmd } diff --git a/internal/cli/label.go b/internal/cli/label.go index a5cd57a..e76e3f7 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -26,18 +26,26 @@ func init() { } func labelListCmd() *cobra.Command { - var flagLimit int + var ( + flagLimit int + flagWeb bool + ) cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List labels", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } + if flagWeb { + url := fmt.Sprintf("https://%s/%s/%s/labels", domain, owner, repoName) + return openBrowser(url) + } + opts := forges.ListLabelOpts{ Limit: flagLimit, } @@ -76,6 +84,7 @@ func labelListCmd() *cobra.Command { } cmd.Flags().IntVarP(&flagLimit, "limit", "L", 0, "Maximum number of labels") + cmd.Flags().BoolVarP(&flagWeb, "web", "w", false, "Open in browser") return cmd } diff --git a/internal/cli/pr.go b/internal/cli/pr.go index ac33651..5fba80b 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -39,7 +39,10 @@ func init() { } func prViewCmd() *cobra.Command { - var flagComments bool + var ( + flagComments bool + flagWeb bool + ) cmd := &cobra.Command{ Use: "view ", @@ -51,11 +54,16 @@ func prViewCmd() *cobra.Command { return fmt.Errorf("invalid PR number: %s", args[0]) } - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } + if flagWeb { + url := fmt.Sprintf("https://%s/%s/%s/pull/%d", domain, owner, repoName, number) + return openBrowser(url) + } + pr, err := forge.PullRequests().Get(cmd.Context(), owner, repoName, number) if err != nil { return fmt.Errorf("getting PR #%d: %w", number, err) @@ -85,6 +93,7 @@ func prViewCmd() *cobra.Command { } cmd.Flags().BoolVarP(&flagComments, "comments", "c", false, "Show comments") + cmd.Flags().BoolVarP(&flagWeb, "web", "w", false, "Open in browser") return cmd } @@ -138,18 +147,24 @@ func prListCmd() *cobra.Command { flagLimit int flagSort string flagOrder string + flagWeb bool ) cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, - Short: "List pull requests", + Short: "List pull requests", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } + if flagWeb { + url := fmt.Sprintf("https://%s/%s/%s/pulls", domain, owner, repoName) + return openBrowser(url) + } + opts := forges.ListPROpts{ State: flagState, Author: flagAuthor, @@ -209,6 +224,7 @@ func prListCmd() *cobra.Command { cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultPRLimit, "Maximum number of PRs") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") + cmd.Flags().BoolVarP(&flagWeb, "web", "w", false, "Open in browser") return cmd } From e244f71253b405b4b3a9cb2eccd51cea57fd9c90 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 21 May 2026 15:35:52 +0200 Subject: [PATCH 4/6] Add --force flag to label create When --force is set and the label already exists, update it instead of failing. Aligns with gh CLI behavior. Co-Authored-By: Claude Opus 4.5 --- internal/cli/label.go | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/cli/label.go b/internal/cli/label.go index e76e3f7..af4ada2 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "os" + "strings" "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/internal/output" @@ -10,6 +11,13 @@ import ( "github.com/spf13/cobra" ) +func isLabelExistsError(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already exists") || + strings.Contains(msg, "already_exists") || + strings.Contains(msg, "label with this name already exists") +} + const maxLabelDescLength = 50 var labelCmd = &cobra.Command{ @@ -93,6 +101,7 @@ func labelCreateCmd() *cobra.Command { flagName string flagColor string flagDescription string + flagForce bool ) cmd := &cobra.Command{ @@ -108,7 +117,7 @@ func labelCreateCmd() *cobra.Command { return fmt.Errorf("label name is required (provide as argument or --name)") } - forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + f, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } @@ -119,9 +128,23 @@ func labelCreateCmd() *cobra.Command { Description: flagDescription, } - label, err := forge.Labels().Create(cmd.Context(), owner, repoName, opts) + label, err := f.Labels().Create(cmd.Context(), owner, repoName, opts) if err != nil { - return notSupported(err, "labels") + if flagForce && isLabelExistsError(err) { + updateOpts := forges.UpdateLabelOpts{} + if flagColor != "" { + updateOpts.Color = &flagColor + } + if flagDescription != "" { + updateOpts.Description = &flagDescription + } + label, err = f.Labels().Update(cmd.Context(), owner, repoName, name, updateOpts) + if err != nil { + return notSupported(err, "labels") + } + } else { + return notSupported(err, "labels") + } } p := printer() @@ -137,6 +160,7 @@ func labelCreateCmd() *cobra.Command { cmd.Flags().StringVarP(&flagName, "name", "n", "", "Label name (can also be provided as first argument)") cmd.Flags().StringVarP(&flagColor, "color", "c", "", "Label color (hex without #)") cmd.Flags().StringVarP(&flagDescription, "description", "d", "", "Label description") + cmd.Flags().BoolVarP(&flagForce, "force", "f", false, "Update the label if it already exists") return cmd } From 661de94bfadc4bd6b566ce079f826548be69db37 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 22 May 2026 15:32:35 +0200 Subject: [PATCH 5/6] Address review feedback, move URL building fully into forge-specific services --- bitbucket/bitbucket.go | 16 ++++++++++++++++ bitbucket/issues.go | 4 ++++ bitbucket/labels.go | 4 ++++ bitbucket/prs.go | 4 ++++ forges_test.go | 28 ++++++++++++++++++++++++++++ gitea/gitea.go | 16 ++++++++++++++++ gitea/issues.go | 4 ++++ gitea/labels.go | 4 ++++ gitea/prs.go | 4 ++++ github/github.go | 16 ++++++++++++++++ github/issues.go | 4 ++++ github/labels.go | 4 ++++ github/prs.go | 4 ++++ gitlab/gitlab.go | 16 ++++++++++++++++ gitlab/issues.go | 4 ++++ gitlab/labels.go | 4 ++++ gitlab/prs.go | 4 ++++ internal/cli/browse.go | 29 ++++++++++++++++++----------- internal/cli/issue.go | 23 ++++++++++++++--------- internal/cli/label.go | 11 +++++++---- internal/cli/pr.go | 23 ++++++++++++++--------- services.go | 8 ++++++++ 22 files changed, 201 insertions(+), 33 deletions(-) diff --git a/bitbucket/bitbucket.go b/bitbucket/bitbucket.go index 031c64a..aecaab8 100644 --- a/bitbucket/bitbucket.go +++ b/bitbucket/bitbucket.go @@ -370,3 +370,19 @@ func (s *bitbucketRepoService) Search(ctx context.Context, opts forge.SearchRepo // The closest is searching within a workspace, which requires an owner. return nil, forge.ErrNotSupported } + +func (s *bitbucketRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL + "/admin" +} + +func (s *bitbucketRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL + "/wiki" +} + +func (s *bitbucketRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL + "/pipelines" +} + +func (s *bitbucketRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL + "/downloads" +} diff --git a/bitbucket/issues.go b/bitbucket/issues.go index f7e171e..ad1a8b5 100644 --- a/bitbucket/issues.go +++ b/bitbucket/issues.go @@ -306,3 +306,7 @@ func (s *bitbucketIssueService) ListComments(ctx context.Context, owner, repo st } return all, nil } + +func (s *bitbucketIssueService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/issues" +} diff --git a/bitbucket/labels.go b/bitbucket/labels.go index 643092a..e89609b 100644 --- a/bitbucket/labels.go +++ b/bitbucket/labels.go @@ -31,3 +31,7 @@ func (s *bitbucketLabelService) Update(_ context.Context, _, _, _ string, _ forg func (s *bitbucketLabelService) Delete(_ context.Context, _, _, _ string) error { return forge.ErrNotSupported } + +func (s *bitbucketLabelService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/issues" +} diff --git a/bitbucket/prs.go b/bitbucket/prs.go index 962a282..7d8ae61 100644 --- a/bitbucket/prs.go +++ b/bitbucket/prs.go @@ -327,3 +327,7 @@ func (s *bitbucketPRService) ListComments(ctx context.Context, owner, repo strin } return all, nil } + +func (s *bitbucketPRService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/pull-requests" +} diff --git a/forges_test.go b/forges_test.go index 228acbd..cae5fc9 100644 --- a/forges_test.go +++ b/forges_test.go @@ -602,6 +602,22 @@ func (m *mockRepoService) Search(_ context.Context, opts SearchRepoOpts) ([]Repo return m.repos, nil } +func (m *mockRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL + "/settings" +} + +func (m *mockRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL + "/wiki" +} + +func (m *mockRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL + "/actions" +} + +func (m *mockRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL + "/releases" +} + type mockIssueService struct { issue *Issue issues []Issue @@ -681,6 +697,10 @@ func (m *mockIssueService) AddReaction(_ context.Context, owner, repo string, nu return nil, nil } +func (m *mockIssueService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/issues" +} + type mockPRService struct { pr *PullRequest prs []PullRequest @@ -768,6 +788,10 @@ func (m *mockPRService) AddReaction(_ context.Context, owner, repo string, numbe return nil, nil } +func (m *mockPRService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/pulls" +} + type mockLabelService struct { label *Label labels []Label @@ -809,6 +833,10 @@ func (m *mockLabelService) Delete(_ context.Context, owner, repo, name string) e return nil } +func (m *mockLabelService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/labels" +} + type mockMilestoneService struct { milestone *Milestone milestones []Milestone diff --git a/gitea/gitea.go b/gitea/gitea.go index 0ee8bcb..07e01b6 100644 --- a/gitea/gitea.go +++ b/gitea/gitea.go @@ -400,4 +400,20 @@ func (s *giteaRepoService) Search(ctx context.Context, opts forge.SearchRepoOpts return repos, nil } +func (s *giteaRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL + "/settings" +} + +func (s *giteaRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL + "/wiki" +} + +func (s *giteaRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL + "/actions" +} + +func (s *giteaRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL + "/releases" +} + func boolPtr(b bool) *bool { return &b } diff --git a/gitea/issues.go b/gitea/issues.go index 0ac641a..1df6aa2 100644 --- a/gitea/issues.go +++ b/gitea/issues.go @@ -339,3 +339,7 @@ func (s *giteaIssueService) ListComments(ctx context.Context, owner, repo string } return all, nil } + +func (s *giteaIssueService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/issues" +} diff --git a/gitea/labels.go b/gitea/labels.go index 0968492..75d2b35 100644 --- a/gitea/labels.go +++ b/gitea/labels.go @@ -210,3 +210,7 @@ func (s *giteaLabelService) Delete(ctx context.Context, owner, repo, name string } return nil } + +func (s *giteaLabelService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/labels" +} diff --git a/gitea/prs.go b/gitea/prs.go index 09bf78f..ceeb335 100644 --- a/gitea/prs.go +++ b/gitea/prs.go @@ -338,3 +338,7 @@ func (s *giteaPRService) ListComments(ctx context.Context, owner, repo string, n } return all, nil } + +func (s *giteaPRService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/pulls" +} diff --git a/github/github.go b/github/github.go index 5015729..2430ea5 100644 --- a/github/github.go +++ b/github/github.go @@ -429,3 +429,19 @@ func (s *gitHubRepoService) Search(ctx context.Context, opts forge.SearchRepoOpt } return repos, nil } + +func (s *gitHubRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL + "/settings" +} + +func (s *gitHubRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL + "/wiki" +} + +func (s *gitHubRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL + "/actions" +} + +func (s *gitHubRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL + "/releases" +} diff --git a/github/issues.go b/github/issues.go index d953ea6..0f687c7 100644 --- a/github/issues.go +++ b/github/issues.go @@ -312,3 +312,7 @@ func (s *gitHubIssueService) ListComments(ctx context.Context, owner, repo strin } return all, nil } + +func (s *gitHubIssueService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/issues" +} diff --git a/github/labels.go b/github/labels.go index e20c5f2..cb95a06 100644 --- a/github/labels.go +++ b/github/labels.go @@ -133,3 +133,7 @@ func (s *gitHubLabelService) Delete(ctx context.Context, owner, repo, name strin } return nil } + +func (s *gitHubLabelService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/labels" +} diff --git a/github/prs.go b/github/prs.go index 16161df..0e1be56 100644 --- a/github/prs.go +++ b/github/prs.go @@ -372,3 +372,7 @@ func (s *gitHubPRService) ListComments(ctx context.Context, owner, repo string, } return all, nil } + +func (s *gitHubPRService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/pulls" +} diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index bea05a8..f42c730 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -416,3 +416,19 @@ func (s *gitLabRepoService) Search(ctx context.Context, opts forge.SearchRepoOpt } return repos, nil } + +func (s *gitLabRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/settings" +} + +func (s *gitLabRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/wikis" +} + +func (s *gitLabRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/pipelines" +} + +func (s *gitLabRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/releases" +} diff --git a/gitlab/issues.go b/gitlab/issues.go index 503a93f..71abe02 100644 --- a/gitlab/issues.go +++ b/gitlab/issues.go @@ -319,3 +319,7 @@ func (s *gitLabIssueService) ListComments(ctx context.Context, owner, repo strin } return all, nil } + +func (s *gitLabIssueService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/issues" +} diff --git a/gitlab/labels.go b/gitlab/labels.go index 5deafb7..5220315 100644 --- a/gitlab/labels.go +++ b/gitlab/labels.go @@ -140,3 +140,7 @@ func (s *gitLabLabelService) Delete(ctx context.Context, owner, repo, name strin } return nil } + +func (s *gitLabLabelService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/labels" +} diff --git a/gitlab/prs.go b/gitlab/prs.go index cc2f0f2..bc26a03 100644 --- a/gitlab/prs.go +++ b/gitlab/prs.go @@ -434,3 +434,7 @@ func (s *gitLabPRService) ListComments(ctx context.Context, owner, repo string, } return all, nil } + +func (s *gitLabPRService) ListURL(repoHTMLURL string) string { + return repoHTMLURL + "/-/merge_requests" +} diff --git a/internal/cli/browse.go b/internal/cli/browse.go index ce144b7..bc5c7ff 100644 --- a/internal/cli/browse.go +++ b/internal/cli/browse.go @@ -26,33 +26,40 @@ var browseCmd = &cobra.Command{ return err } - url := repo.HTMLURL - if url == "" { - url = fmt.Sprintf("https://%s/%s/%s", domain, owner, repoName) + repoURL := repo.HTMLURL + if repoURL == "" { + repoURL = fmt.Sprintf("https://%s/%s/%s", domain, owner, repoName) } + var url string if flagBrowseSettings { - url += "/settings" + url = forge.Repos().SettingsURL(repoURL) } else if flagBrowseWiki { - url += "/wiki" + url = forge.Repos().WikiURL(repoURL) } else if flagBrowseActions { - url += "/actions" + url = forge.Repos().ActionsURL(repoURL) } else if flagBrowseReleases { - url += "/releases" + url = forge.Repos().ReleasesURL(repoURL) } else if flagBrowseIssues { - url += "/issues" + url = forge.Issues().ListURL(repoURL) } else if flagBrowsePulls { - url += "/pulls" + url = forge.PullRequests().ListURL(repoURL) } else if len(args) > 0 { if n, err := strconv.Atoi(args[0]); err == nil { - url += fmt.Sprintf("/issues/%d", n) + issue, err := forge.Issues().Get(cmd.Context(), owner, repoName, n) + if err != nil { + return fmt.Errorf("getting issue #%d: %w", n, err) + } + url = issue.HTMLURL } else { branch := flagBrowseBranch if branch == "" { branch = repo.DefaultBranch } - url += fmt.Sprintf("/blob/%s/%s", branch, args[0]) + url = repoURL + fmt.Sprintf("/blob/%s/%s", branch, args[0]) } + } else { + url = repoURL } if flagNoBrowser { diff --git a/internal/cli/issue.go b/internal/cli/issue.go index ae6bb27..fb13b93 100644 --- a/internal/cli/issue.go +++ b/internal/cli/issue.go @@ -53,21 +53,20 @@ func issueViewCmd() *cobra.Command { return fmt.Errorf("invalid issue number: %s", args[0]) } - forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } - if flagWeb { - url := fmt.Sprintf("https://%s/%s/%s/issues/%d", domain, owner, repoName, number) - return openBrowser(url) - } - issue, err := forge.Issues().Get(cmd.Context(), owner, repoName, number) if err != nil { return fmt.Errorf("getting issue #%d: %w", number, err) } + if flagWeb { + return openBrowser(issue.HTMLURL) + } + p := printer() if p.Format == output.JSON { return p.PrintJSON(issue) @@ -140,14 +139,17 @@ func issueListCmd() *cobra.Command { Aliases: []string{"ls"}, Short: "List issues", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } if flagWeb { - url := fmt.Sprintf("https://%s/%s/%s/issues", domain, owner, repoName) - return openBrowser(url) + repo, err := forge.Repos().Get(cmd.Context(), owner, repoName) + if err != nil { + return fmt.Errorf("getting repository: %w", err) + } + return openBrowser(forge.Issues().ListURL(repo.HTMLURL)) } opts := forges.ListIssueOpts{ @@ -208,6 +210,7 @@ func issueListCmd() *cobra.Command { cmd.Flags().StringVarP(&flagAuthor, "author", "A", "", "Filter by author") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Filter by label") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Filter by label") + _ = cmd.Flags().MarkHidden("labels") cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultIssueLimit, "Maximum number of issues") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated, comments") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") @@ -266,6 +269,7 @@ func issueCreateCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Assign to a user") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Add a label") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Add a label") + _ = cmd.Flags().MarkHidden("labels") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Assign to a milestone") return cmd } @@ -383,6 +387,7 @@ func issueEditCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Set assignees") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Set labels") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Set labels") + _ = cmd.Flags().MarkHidden("labels") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Set the milestone") return cmd } diff --git a/internal/cli/label.go b/internal/cli/label.go index af4ada2..8aaca7e 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -44,21 +44,24 @@ func labelListCmd() *cobra.Command { Aliases: []string{"ls"}, Short: "List labels", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + f, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } if flagWeb { - url := fmt.Sprintf("https://%s/%s/%s/labels", domain, owner, repoName) - return openBrowser(url) + repo, err := f.Repos().Get(cmd.Context(), owner, repoName) + if err != nil { + return fmt.Errorf("getting repository: %w", err) + } + return openBrowser(f.Labels().ListURL(repo.HTMLURL)) } opts := forges.ListLabelOpts{ Limit: flagLimit, } - labels, err := forge.Labels().List(cmd.Context(), owner, repoName, opts) + labels, err := f.Labels().List(cmd.Context(), owner, repoName, opts) if err != nil { return notSupported(err, "labels") } diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 5fba80b..e17b501 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -54,21 +54,20 @@ func prViewCmd() *cobra.Command { return fmt.Errorf("invalid PR number: %s", args[0]) } - forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } - if flagWeb { - url := fmt.Sprintf("https://%s/%s/%s/pull/%d", domain, owner, repoName, number) - return openBrowser(url) - } - pr, err := forge.PullRequests().Get(cmd.Context(), owner, repoName, number) if err != nil { return fmt.Errorf("getting PR #%d: %w", number, err) } + if flagWeb { + return openBrowser(pr.HTMLURL) + } + p := printer() if p.Format == output.JSON { return p.PrintJSON(pr) @@ -155,14 +154,17 @@ func prListCmd() *cobra.Command { Aliases: []string{"ls"}, Short: "List pull requests", RunE: func(cmd *cobra.Command, args []string) error { - forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } if flagWeb { - url := fmt.Sprintf("https://%s/%s/%s/pulls", domain, owner, repoName) - return openBrowser(url) + repo, err := forge.Repos().Get(cmd.Context(), owner, repoName) + if err != nil { + return fmt.Errorf("getting repository: %w", err) + } + return openBrowser(forge.PullRequests().ListURL(repo.HTMLURL)) } opts := forges.ListPROpts{ @@ -221,6 +223,7 @@ func prListCmd() *cobra.Command { cmd.Flags().StringVar(&flagBase, "base", "", "Filter by base branch") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Filter by label") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Filter by label") + _ = cmd.Flags().MarkHidden("labels") cmd.Flags().IntVarP(&flagLimit, "limit", "L", defaultPRLimit, "Maximum number of PRs") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort by: created, updated") cmd.Flags().StringVar(&flagOrder, "order", "", "Sort order: asc, desc") @@ -294,6 +297,7 @@ func prCreateCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Assign to a user") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Add a label") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Add a label") + _ = cmd.Flags().MarkHidden("labels") cmd.Flags().StringVarP(&flagMilestone, "milestone", "m", "", "Assign to a milestone") return cmd } @@ -417,6 +421,7 @@ func prEditCmd() *cobra.Command { cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Set assignees") cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Set labels") cmd.Flags().StringSliceVar(&flagLabels, "labels", nil, "Set labels") + _ = cmd.Flags().MarkHidden("labels") return cmd } diff --git a/services.go b/services.go index 14c72b5..11893d2 100644 --- a/services.go +++ b/services.go @@ -18,6 +18,11 @@ type RepoService interface { ListTags(ctx context.Context, owner, repo string) ([]Tag, error) ListContributors(ctx context.Context, owner, repo string) ([]Contributor, error) Search(ctx context.Context, opts SearchRepoOpts) ([]Repository, error) + // URL builders for web pages (take repo.HTMLURL as input) + SettingsURL(repoHTMLURL string) string + WikiURL(repoHTMLURL string) string + ActionsURL(repoHTMLURL string) string + ReleasesURL(repoHTMLURL string) string } // PullRequestService provides operations on pull requests (merge requests on GitLab). @@ -34,6 +39,7 @@ type PullRequestService interface { ListComments(ctx context.Context, owner, repo string, number int) ([]Comment, error) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) + ListURL(repoHTMLURL string) string } // LabelService provides operations on repository labels. @@ -43,6 +49,7 @@ type LabelService interface { Create(ctx context.Context, owner, repo string, opts CreateLabelOpts) (*Label, error) Update(ctx context.Context, owner, repo, name string, opts UpdateLabelOpts) (*Label, error) Delete(ctx context.Context, owner, repo, name string) error + ListURL(repoHTMLURL string) string } // MilestoneService provides operations on repository milestones. @@ -147,4 +154,5 @@ type IssueService interface { ListComments(ctx context.Context, owner, repo string, number int) ([]Comment, error) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) + ListURL(repoHTMLURL string) string } From ca92a318a2945e949c2c839837894af3af64e003 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 22 May 2026 15:40:38 +0200 Subject: [PATCH 6/6] Create ErrLabelExists --- forge.go | 3 +++ gitea/labels.go | 9 +++++++-- github/labels.go | 9 +++++++-- gitlab/labels.go | 9 +++++++-- internal/cli/label.go | 13 +++---------- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/forge.go b/forge.go index 175e1f5..c944604 100644 --- a/forge.go +++ b/forge.go @@ -20,6 +20,9 @@ var ErrOwnerNotFound = errors.New("owner not found") // ErrNotSupported is returned when a forge does not support an operation. var ErrNotSupported = errors.New("not supported by this forge") +// ErrLabelExists is returned when creating a label that already exists. +var ErrLabelExists = errors.New("label already exists") + // HTTPError represents a non-OK HTTP response from a forge API. type HTTPError struct { StatusCode int diff --git a/gitea/labels.go b/gitea/labels.go index 75d2b35..ae4db2b 100644 --- a/gitea/labels.go +++ b/gitea/labels.go @@ -148,8 +148,13 @@ func (s *giteaLabelService) Create(ctx context.Context, owner, repo string, opts l, resp, err := s.client.CreateLabel(owner, repo, gOpts) if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - return nil, forge.ErrNotFound + if resp != nil { + switch resp.StatusCode { + case http.StatusNotFound: + return nil, forge.ErrNotFound + case http.StatusConflict, http.StatusUnprocessableEntity: + return nil, forge.ErrLabelExists + } } return nil, err } diff --git a/github/labels.go b/github/labels.go index cb95a06..7af5f0b 100644 --- a/github/labels.go +++ b/github/labels.go @@ -82,8 +82,13 @@ func (s *gitHubLabelService) Create(ctx context.Context, owner, repo string, opt l, resp, err := s.client.Issues.CreateLabel(ctx, owner, repo, ghLabel) if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - return nil, forge.ErrNotFound + if resp != nil { + switch resp.StatusCode { + case http.StatusNotFound: + return nil, forge.ErrNotFound + case http.StatusUnprocessableEntity: + return nil, forge.ErrLabelExists + } } return nil, err } diff --git a/gitlab/labels.go b/gitlab/labels.go index 5220315..70aa6ca 100644 --- a/gitlab/labels.go +++ b/gitlab/labels.go @@ -87,8 +87,13 @@ func (s *gitLabLabelService) Create(ctx context.Context, owner, repo string, opt l, resp, err := s.client.Labels.CreateLabel(pid, glOpts) if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - return nil, forge.ErrNotFound + if resp != nil { + switch resp.StatusCode { + case http.StatusNotFound: + return nil, forge.ErrNotFound + case http.StatusConflict: + return nil, forge.ErrLabelExists + } } return nil, err } diff --git a/internal/cli/label.go b/internal/cli/label.go index 8aaca7e..94d5eaa 100644 --- a/internal/cli/label.go +++ b/internal/cli/label.go @@ -1,23 +1,16 @@ package cli import ( + "errors" "fmt" "os" - "strings" - "github.com/git-pkgs/forge" + forges "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/internal/output" "github.com/git-pkgs/forge/internal/resolve" "github.com/spf13/cobra" ) -func isLabelExistsError(err error) bool { - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "already exists") || - strings.Contains(msg, "already_exists") || - strings.Contains(msg, "label with this name already exists") -} - const maxLabelDescLength = 50 var labelCmd = &cobra.Command{ @@ -133,7 +126,7 @@ func labelCreateCmd() *cobra.Command { label, err := f.Labels().Create(cmd.Context(), owner, repoName, opts) if err != nil { - if flagForce && isLabelExistsError(err) { + if flagForce && errors.Is(err, forges.ErrLabelExists) { updateOpts := forges.UpdateLabelOpts{} if flagColor != "" { updateOpts.Color = &flagColor