Skip to content
Open
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
85 changes: 85 additions & 0 deletions internal/cmd/config/category.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package config

import (
"strconv"

"github.com/MrJeffLarry/redmine-cli/internal/config"
"github.com/MrJeffLarry/redmine-cli/internal/print"
"github.com/spf13/cobra"
)

func getCategoryID(r *config.Red_t, cmd *cobra.Command) (int, error) {
var id int
var err error

if id, err = strconv.Atoi(cmd.Flags().Arg(0)); err != nil {
print.Error("ID is not an valid number, please use `category set 1` for category id 1")

Copilot AI Dec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message contains grammatical error 'an valid' which should be 'a valid'. The article 'an' is incorrectly used before a consonant sound.

Suggested change
print.Error("ID is not an valid number, please use `category set 1` for category id 1")
print.Error("ID is not a valid number, please use `category set 1` for category id 1")

Copilot uses AI. Check for mistakes.
return 0, err
}
return id, nil
}

func setLocalCategory(r *config.Red_t) *cobra.Command {
cmd := &cobra.Command{
Use: "set [id]",
Short: "set local category",
Long: "set a local category under .red/config.json",
Run: func(cmd *cobra.Command, args []string) {
var id int
var err error
if id, err = getCategoryID(r, cmd); err != nil {
return
}

if err := r.SaveLocalCategory(id); err != nil {
print.Error("%s [%s]", "Could not save category, please verify permissions", err)
return
}
print.OK("Category locally set to ID #%d", id)
},
}
return cmd
}

func setGlobalCategory(r *config.Red_t) *cobra.Command {
cmd := &cobra.Command{
Use: "set [id]",
Short: "set global category",
Long: "set a global category under ~/.red/config.json",
Run: func(cmd *cobra.Command, args []string) {
var id int
var err error
if id, err = getCategoryID(r, cmd); err != nil {
return
}

r.SetCategoryID(id)
if err := r.Save(); err != nil {
print.Error("%s [%s]", "Could not save category, please verify permissions", err)
return
}
print.OK("Category globally set to ID #%d", id)
},
}
return cmd
}

func localCategory(r *config.Red_t) *cobra.Command {
cmd := &cobra.Command{
Use: "category",
Short: "set or get local category",
Long: "set or get a local category under ~/.red/config.json",

Copilot AI Dec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description incorrectly states the config file location as '/.red/config.json' when it should be '.red/config.json' (without the tilde) for local category configuration. The tilde prefix (/) indicates a home directory path, which is used for global config, not local config.

Suggested change
Long: "set or get a local category under ~/.red/config.json",
Long: "set or get a local category under .red/config.json",

Copilot uses AI. Check for mistakes.
}
cmd.AddCommand(setLocalCategory(r))
return cmd
}

func globalCategory(r *config.Red_t) *cobra.Command {
cmd := &cobra.Command{
Use: "category",
Short: "set or get global category",
Long: "set or get a global category under ~/.red/config.json",
}
cmd.AddCommand(setGlobalCategory(r))
return cmd
}
Comment on lines +67 to +85

Copilot AI Dec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The localCategory and globalCategory functions are defined but never registered with the config command structure. These functions need to be added to the configLocal and configGlobal functions in config.go (similar to how localProject and globalProject are registered) for the category commands to be accessible to users.

Copilot uses AI. Check for mistakes.
28 changes: 28 additions & 0 deletions internal/cmd/global/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,41 @@ package global
import (
"encoding/json"
"errors"
"strconv"

"github.com/MrJeffLarry/redmine-cli/internal/api"
"github.com/MrJeffLarry/redmine-cli/internal/config"
"github.com/MrJeffLarry/redmine-cli/internal/print"
"github.com/MrJeffLarry/redmine-cli/internal/util"
)

func GetCategories(r *config.Red_t, projectID int) ([]util.IdName, error) {
var payload issueCategories
var idNames []util.IdName

body, status, err := api.ClientGET(r, "/projects/"+strconv.Itoa(projectID)+"/issue_categories.json")

print.Debug(r, "%d %s", status, string(body))

if err != nil {
return idNames, err
}

if err := json.Unmarshal(body, &payload); err != nil {
print.Debug(r, err.Error())
return idNames, errors.New("Could not parse and read response from server")
}

for _, v := range payload.IssueCategories {
idNames = append(idNames, util.IdName{
ID: v.ID,
Name: v.Name,
})
}

return idNames, nil
}

func GetPriorities(r *config.Red_t) ([]util.IdName, error) {
var payload issuePriorities
var idNames []util.IdName
Expand Down
15 changes: 15 additions & 0 deletions internal/cmd/global/structs.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
package global

import (
"github.com/MrJeffLarry/redmine-cli/internal/util"
)

type issueCategory struct {
ID int `json:"id"`
Name string `json:"name"`
Project util.IdName `json:"project"`
AssignedTo util.IdName `json:"assignedTo"`
}

type issueCategories struct {
IssueCategories []issueCategory `json:"issue_categories"`
}

type issuePriorities struct {
IssuePriorities []issuePriority `json:"issue_priorities"`
}
Expand Down
11 changes: 11 additions & 0 deletions internal/cmd/issue/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func displayCreateIssue(r *config.Red_t, cmd *cobra.Command, path string) {
chooses := []string{
FIELD_SAVE,
FIELD_PRIORITY,
FIELD_CATEGORY,
FIELD_TARGET_VERSION,
FIELD_PARENT_ID,
FIELD_ASSIGN,
Expand Down Expand Up @@ -119,6 +120,16 @@ func displayCreateIssue(r *config.Red_t, cmd *cobra.Command, path string) {
if id >= 0 {
issue.Issue.PriorityID = id
}
case FIELD_CATEGORY:
if idNames, err = global.GetCategories(r, projectID); err != nil {
print.Error(err.Error())
}

id, _ := r.Term.Choose("Category", idNames)

if id >= 0 {
issue.Issue.CategoryID = id
}
case FIELD_TARGET_VERSION:
if idNames, err = project.GetVersions(r, projectID); err != nil {
print.Error(err.Error())
Expand Down
25 changes: 24 additions & 1 deletion internal/cmd/issue/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func cmdIssueEditIssueAssign(r *config.Red_t, projectID int) (util.IdName, error
idName.ID, idName.Name = r.Term.Choose("Assign", idNames)

if idName.ID < 0 {
return idName, errors.New("assigne ID not valid")
return idName, errors.New("assignee ID not valid")
}

return idName, nil
Expand Down Expand Up @@ -82,6 +82,19 @@ func cmdIssueEditIssueStatus(r *config.Red_t, allowedStatus []global.IssueStatus
return issueStatus, nil
}

func cmdIssueEditIssueCategory(r *config.Red_t, projectID int) (util.IdName, error) {
var err error
var idName util.IdName
var idNames []util.IdName

if idNames, err = global.GetCategories(r, projectID); err != nil {
return idName, err
}

idName.ID, idName.Name = r.Term.Choose("Choose Category", idNames)
return idName, nil
}

func cmdIssueEditIssuePriority(r *config.Red_t) (util.IdName, error) {
var err error
var idName util.IdName
Expand Down Expand Up @@ -148,6 +161,7 @@ func cmdIssueEditIssue(r *config.Red_t, cmd *cobra.Command, id, path string) {
FIELD_SAVE,
FIELD_SUBJECT,
FIELD_DESCRIPTION,
FIELD_CATEGORY,
FIELD_STATUS,
FIELD_PRIORITY,
FIELD_TRACKER,
Expand Down Expand Up @@ -231,6 +245,15 @@ func cmdIssueEditIssue(r *config.Red_t, cmd *cobra.Command, id, path string) {
case FIELD_DESCRIPTION:
issue.Issue.Description = editor.StartEdit(r.Config.Editor, viewIssue.Issue.Description)
viewIssue.Issue.Description = issue.Issue.Description
case FIELD_CATEGORY:
idName, err := cmdIssueEditIssueCategory(r, viewIssue.Issue.Project.ID)

if err != nil {
print.Error(err.Error())
} else {
issue.Issue.CategoryID = idName.ID
viewIssue.Issue.Category = idName
}
case FIELD_NOTE:
if err = cmdIssueEditIssueNote(r, &issue); err != nil {
print.Error(err.Error())
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/issue/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
const (
FIELD_SUBJECT = "Subject"
FIELD_DESCRIPTION = "Description"
FIELD_CATEGORY = "Category"
FIELD_STATUS = "Status"
FIELD_PRIORITY = "Priority"
FIELD_TRACKER = "Tracker"
Expand Down Expand Up @@ -55,6 +56,7 @@ type newIssue struct {
type issue struct {
ID int `json:"id,omitempty"`
Project util.IdName `json:"project,omitempty"`
Category util.IdName `json:"category,omitempty"`
Tracker util.IdName `json:"tracker,omitempty"`
Status global.IssueStatus `json:"status,omitempty"`
Priority util.IdName `json:"priority,omitempty"`
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/issue/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func displayIssue(r *config.Red_t, i issue, journalFlag bool) {
text.FgGreen.Sprint("Version")+" %s\n"+
text.FgGreen.Sprint("Status")+" %s\n"+
text.FgGreen.Sprint("Priority")+" %s\n"+
text.FgGreen.Sprint("Category")+" %s\n"+
text.FgGreen.Sprint("Description")+"\n"+
"\n%s\n\n",
text.FgYellow.Sprint(i.Tracker.Name),
Expand All @@ -67,6 +68,7 @@ func displayIssue(r *config.Red_t, i issue, journalFlag bool) {
i.FixedVersion.Name,
i.Status.Name,
i.Priority.Name,
i.Category.Name,
i.Description,
)

Expand Down
30 changes: 30 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const (
RED_CONFIG_REDMINE_API_KEY = "RED_CONFIG_REDMINE_API_KEY"
RED_CONFIG_REDMINE_PROJECT = "RED_CONFIG_REDMINE_PROJECT"
RED_CONFIG_REDMINE_PROJECT_ID = "RED_CONFIG_REDMINE_PROJECT_ID"
RED_CONFIG_REDMINE_CATEGORY = "RED_CONFIG_REDMINE_CATEGORY"
RED_CONFIG_REDMINE_CATEGORY_ID = "RED_CONFIG_REDMINE_CATEGORY_ID"
RED_CONFIG_REDMINE_USER_ID = "RED_CONFIG_REDMINE_USER_ID"
RED_CONFIG_EDITOR = "RED_CONFIG_EDITOR"
RED_CONFIG_PAGER = "RED_CONFIG_PAGER"
Expand All @@ -29,6 +31,8 @@ const (
CONFIG_REDMINE_API_KEY = "api-key"
CONFIG_REDMINE_PROJECT = "project"
CONFIG_REDMINE_PROJECT_ID = "project-id"
CONFIG_REDMINE_CATEGORY = "category"
CONFIG_REDMINE_CATEGORY_ID = "category-id"
CONFIG_REDMINE_USER_ID = "user-id"
CONFIG_EDITOR = "editor"
CONFIG_PAGER = "pager"
Expand All @@ -52,6 +56,8 @@ type Config_t struct {
ApiKey string `mapstructure:"api-key"`
Project string `json:"project"`
ProjectID int `mapstructure:"project-id"`
Category string `json:"category"`
CategoryID int `mapstructure:"category-id"`
UserID int `mapstructure:"user-id"`
Editor string `json:"editor"`
Pager string `json:"pager"`
Expand Down Expand Up @@ -103,6 +109,14 @@ func (r *Red_t) SetProjectID(id int) {
r.Config.ProjectID = id
}

func (r *Red_t) SetCategory(id string) {
r.Config.Category = id
Comment on lines +112 to +113

Copilot AI Dec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name 'id' is misleading - it should be 'category' or 'name' since this setter accepts a string value for the category name, not an ID. The CategoryID is set separately via SetCategoryID. This is inconsistent with the parallel function SetProject which correctly names its string parameter 'id' but actually stores a project name.

Suggested change
func (r *Red_t) SetCategory(id string) {
r.Config.Category = id
func (r *Red_t) SetCategory(category string) {
r.Config.Category = category

Copilot uses AI. Check for mistakes.
}

func (r *Red_t) SetCategoryID(id int) {
r.Config.CategoryID = id
}

func (r *Red_t) SetUserID(id int) {
r.Config.UserID = id
}
Expand All @@ -121,6 +135,8 @@ func (r *Red_t) ClearAll() {
r.Config.UserID = 0
r.Config.Project = ""
r.Config.ProjectID = 0
r.Config.Category = ""
r.Config.CategoryID = 0
r.Config.Editor = ""
r.Config.Pager = ""
}
Expand Down Expand Up @@ -238,6 +254,8 @@ func (r *Red_t) Save() error {
viper.Set(CONFIG_REDMINE_API_KEY, r.Config.ApiKey)
viper.Set(CONFIG_REDMINE_PROJECT, r.Config.Project)
viper.Set(CONFIG_REDMINE_PROJECT_ID, r.Config.ProjectID)
viper.Set(CONFIG_REDMINE_CATEGORY, r.Config.Category)
viper.Set(CONFIG_REDMINE_CATEGORY_ID, r.Config.CategoryID)
viper.Set(CONFIG_REDMINE_USER_ID, r.Config.UserID)
viper.Set(CONFIG_EDITOR, r.Config.Editor)
viper.Set(CONFIG_PAGER, r.Config.Pager)
Expand All @@ -258,6 +276,10 @@ func (r *Red_t) SaveLocalProject(projectID int) error {
return saveLocal(r, CONFIG_REDMINE_PROJECT_ID, projectID)
}

func (r *Red_t) SaveLocalCategory(categoryID int) error {
return saveLocal(r, CONFIG_REDMINE_CATEGORY_ID, categoryID)
}

func (r *Red_t) LoadConfig() error {
sep := string(os.PathSeparator)

Expand Down Expand Up @@ -327,6 +349,12 @@ func (r *Red_t) localConfig() error {
if c.ProjectID > 0 {
r.Config.ProjectID = c.ProjectID
}
if len(c.Category) > 0 {
r.Config.Category = c.Category
}
if c.CategoryID > 0 {
r.Config.CategoryID = c.CategoryID
}
if c.UserID > 0 {
r.Config.UserID = c.UserID
}
Expand All @@ -343,6 +371,8 @@ func InitConfig() *Red_t {
red.Config.ApiKey = exEnv(RED_CONFIG_REDMINE_API_KEY, "")
red.Config.Project = exEnv(RED_CONFIG_REDMINE_PROJECT, "")
red.Config.ProjectID, _ = strconv.Atoi(exEnv(RED_CONFIG_REDMINE_PROJECT_ID, ""))
red.Config.Category = exEnv(RED_CONFIG_REDMINE_CATEGORY, "")
red.Config.CategoryID, _ = strconv.Atoi(exEnv(RED_CONFIG_REDMINE_CATEGORY_ID, ""))
red.Config.UserID, _ = strconv.Atoi(exEnv(RED_CONFIG_REDMINE_USER_ID, ""))
red.Config.Editor = exEnv(RED_CONFIG_EDITOR, "")
red.Config.Pager = exEnv(RED_CONFIG_PAGER, "")
Expand Down