diff --git a/README.md b/README.md index 28a16856..49a84c49 100644 --- a/README.md +++ b/README.md @@ -543,7 +543,7 @@ EOF The `worklog` command provides a list of sub-commands to manage issue worklog (timelog). ##### Add -The `add` command lets you add a worklog to an issue. The command supports markdown for worklog comments. +The `add` command lets you add a worklog to an issue. The command supports markdown for worklog comments and returns the created worklog ID. ```sh # Add a worklog using an interactive prompt @@ -551,11 +551,63 @@ $ jira issue worklog add # Pass required parameters and use --no-input to skip prompt $ jira issue worklog add ISSUE-1 "2d 3h 30m" --no-input +✓ Worklog 10001 added to issue "ISSUE-1" # You can add a comment using --comment flag when adding a worklog $ jira issue worklog add ISSUE-1 "10m" --comment "This is a comment" --no-input ``` +##### List +The `list` command displays all worklogs for an issue. Supports both table and plain text output formats. + +```sh +# List worklogs in table format (default) +$ jira issue worklog list ISSUE-1 + +# List worklogs with detailed information +$ jira issue worklog list ISSUE-1 --plain + +# Using the alias +$ jira issue worklog ls ISSUE-1 +``` + +##### Edit +The `edit` command allows you to update an existing worklog. You can modify the time spent, comment, and start date. + +```sh +# Edit a worklog interactively (select from list) +$ jira issue worklog edit ISSUE-1 + +# Edit a specific worklog with new time +$ jira issue worklog edit ISSUE-1 10001 "3h 30m" --no-input + +# Edit worklog with new comment and start date +$ jira issue worklog edit ISSUE-1 10001 "2h" \ + --comment "Updated work description" \ + --started "2024-11-05 09:30:00" + +# Using the alias +$ jira issue worklog update ISSUE-1 10001 "4h" +``` + +##### Delete +The `delete` command removes a worklog from an issue. By default, it asks for confirmation before deleting. + +```sh +# Delete a worklog interactively (select from list) +$ jira issue worklog delete ISSUE-1 + +# Delete a specific worklog with confirmation +$ jira issue worklog delete ISSUE-1 10001 + +# Delete without confirmation prompt (use with caution) +$ jira issue worklog delete ISSUE-1 10001 --force + +# Using the aliases +$ jira issue worklog remove ISSUE-1 10001 +$ jira issue worklog rm ISSUE-1 10001 -f +``` + ### Epic Epics are displayed in an explorer view by default. You can output the results in a table view using the `--table` flag. When viewing epic issues, you can use all filters available for the issue command. diff --git a/internal/cmd/issue/worklog/add/add.go b/internal/cmd/issue/worklog/add/add.go index d9bed55a..d8520c08 100644 --- a/internal/cmd/issue/worklog/add/add.go +++ b/internal/cmd/issue/worklog/add/add.go @@ -97,7 +97,7 @@ func add(cmd *cobra.Command, args []string) { } } - err := func() error { + worklog, err := func() (*jira.Worklog, error) { s := cmdutil.Info("Adding a worklog") defer s.Stop() @@ -107,7 +107,7 @@ func add(cmd *cobra.Command, args []string) { server := viper.GetString("server") - cmdutil.Success("Worklog added to issue %q", ac.params.issueKey) + cmdutil.Success("Worklog %s added to issue %q", worklog.ID, ac.params.issueKey) fmt.Printf("%s\n", cmdutil.GenerateServerBrowseURL(server, ac.params.issueKey)) } diff --git a/internal/cmd/issue/worklog/delete/delete.go b/internal/cmd/issue/worklog/delete/delete.go new file mode 100644 index 00000000..e6f30a24 --- /dev/null +++ b/internal/cmd/issue/worklog/delete/delete.go @@ -0,0 +1,185 @@ +package delete + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" + "github.com/ankitpokhrel/jira-cli/internal/query" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const ( + helpText = `Delete removes a worklog from an issue.` + examples = `$ jira issue worklog delete + +# Delete a specific worklog +$ jira issue worklog delete ISSUE-1 10001 + +# Delete worklog without confirmation prompt +$ jira issue worklog delete ISSUE-1 10001 --force + +# Delete worklog interactively (select from list) +$ jira issue worklog delete ISSUE-1` +) + +// NewCmdWorklogDelete is a worklog delete command. +func NewCmdWorklogDelete() *cobra.Command { + cmd := cobra.Command{ + Use: "delete ISSUE-KEY [WORKLOG-ID]", + Short: "Delete a worklog from an issue", + Long: helpText, + Example: examples, + Aliases: []string{"remove", "rm"}, + Annotations: map[string]string{ + "help:args": "ISSUE-KEY\tIssue key of the source issue, eg: ISSUE-1\n" + + "WORKLOG-ID\tID of the worklog to delete (optional, will prompt to select if not provided)", + }, + Run: deleteWorklog, + } + + cmd.Flags().SortFlags = false + + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + + return &cmd +} + +func deleteWorklog(cmd *cobra.Command, args []string) { + params := parseArgsAndFlags(args, cmd.Flags()) + client := api.DefaultClient(params.debug) + dc := deleteCmd{ + client: client, + params: params, + } + + cmdutil.ExitIfError(dc.setIssueKey()) + cmdutil.ExitIfError(dc.setWorklogID()) + + if !params.force { + var confirm bool + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Are you sure you want to delete worklog %s from issue %s?", + dc.params.worklogID, dc.params.issueKey), + Default: false, + } + if err := survey.AskOne(prompt, &confirm); err != nil { + cmdutil.Failed("Confirmation failed: %s", err.Error()) + } + if !confirm { + cmdutil.Failed("Action cancelled") + } + } + + err := func() error { + s := cmdutil.Info("Deleting worklog") + defer s.Stop() + + return client.DeleteIssueWorklog(dc.params.issueKey, dc.params.worklogID) + }() + cmdutil.ExitIfError(err) + + server := viper.GetString("server") + + cmdutil.Success("Worklog deleted from issue %q", dc.params.issueKey) + fmt.Printf("%s\n", cmdutil.GenerateServerBrowseURL(server, dc.params.issueKey)) +} + +type deleteParams struct { + issueKey string + worklogID string + force bool + debug bool +} + +func parseArgsAndFlags(args []string, flags query.FlagParser) *deleteParams { + var issueKey, worklogID string + + nargs := len(args) + if nargs >= 1 { + issueKey = cmdutil.GetJiraIssueKey(viper.GetString("project.key"), args[0]) + } + if nargs >= 2 { + worklogID = args[1] + } + + debug, err := flags.GetBool("debug") + cmdutil.ExitIfError(err) + + force, err := flags.GetBool("force") + cmdutil.ExitIfError(err) + + return &deleteParams{ + issueKey: issueKey, + worklogID: worklogID, + force: force, + debug: debug, + } +} + +type deleteCmd struct { + client *jira.Client + params *deleteParams +} + +func (dc *deleteCmd) setIssueKey() error { + if dc.params.issueKey != "" { + return nil + } + + var ans string + + qs := &survey.Question{ + Name: "issueKey", + Prompt: &survey.Input{Message: "Issue key"}, + Validate: survey.Required, + } + if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil { + return err + } + dc.params.issueKey = cmdutil.GetJiraIssueKey(viper.GetString("project.key"), ans) + + return nil +} + +func (dc *deleteCmd) setWorklogID() error { + if dc.params.worklogID != "" { + return nil + } + + // Fetch worklogs for the issue + worklogs, err := dc.client.GetIssueWorklogs(dc.params.issueKey) + if err != nil { + return err + } + + if worklogs.Total == 0 { + return fmt.Errorf("no worklogs found for issue %s", dc.params.issueKey) + } + + // Create options for selection + options := make([]string, len(worklogs.Worklogs)) + for i, wl := range worklogs.Worklogs { + options[i] = fmt.Sprintf("%s - %s by %s (%s)", wl.ID, wl.TimeSpent, wl.Author.Name, wl.Started) + } + + var selected string + prompt := &survey.Select{ + Message: "Select worklog to delete:", + Options: options, + } + if err := survey.AskOne(prompt, &selected); err != nil { + return err + } + + // Extract worklog ID from selection (format: "ID - ...") + var id string + _, _ = fmt.Sscanf(selected, "%s -", &id) + dc.params.worklogID = id + + return nil +} diff --git a/internal/cmd/issue/worklog/edit/edit.go b/internal/cmd/issue/worklog/edit/edit.go new file mode 100644 index 00000000..5483d50e --- /dev/null +++ b/internal/cmd/issue/worklog/edit/edit.go @@ -0,0 +1,271 @@ +package edit + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdcommon" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" + "github.com/ankitpokhrel/jira-cli/internal/query" + "github.com/ankitpokhrel/jira-cli/pkg/jira" + "github.com/ankitpokhrel/jira-cli/pkg/surveyext" +) + +const ( + helpText = `Edit updates an existing worklog in an issue.` + examples = `$ jira issue worklog edit + +# Edit a specific worklog +$ jira issue worklog edit ISSUE-1 10001 "3h 30m" + +# Edit worklog with new comment +$ jira issue worklog edit ISSUE-1 10001 "3h 30m" --comment "Updated work description" + +# Edit worklog with new start date +$ jira issue worklog edit ISSUE-1 10001 "3h 30m" --started "2024-11-05 09:30:00" + +# Use --no-input to skip prompts +$ jira issue worklog edit ISSUE-1 10001 "3h 30m" --no-input` +) + +// NewCmdWorklogEdit is a worklog edit command. +func NewCmdWorklogEdit() *cobra.Command { + cmd := cobra.Command{ + Use: "edit ISSUE-KEY WORKLOG-ID TIME_SPENT", + Short: "Edit a worklog in an issue", + Long: helpText, + Example: examples, + Aliases: []string{"update"}, + Annotations: map[string]string{ + "help:args": "ISSUE-KEY\tIssue key of the source issue, eg: ISSUE-1\n" + + "WORKLOG-ID\tID of the worklog to edit\n" + + "TIME_SPENT\tNew time to log as days (d), hours (h), or minutes (m), eg: 2d 1h 30m", + }, + Run: edit, + } + + cmd.Flags().SortFlags = false + + cmd.Flags().String("started", "", "The datetime on which the worklog effort was started, eg: 2024-01-01 09:30:00") + cmd.Flags().String("timezone", "UTC", "The timezone to use for the started date in IANA timezone format, eg: Europe/Berlin") + cmd.Flags().String("comment", "", "Comment about the worklog") + cmd.Flags().Bool("no-input", false, "Disable prompt for non-required fields") + + return &cmd +} + +func edit(cmd *cobra.Command, args []string) { + params := parseArgsAndFlags(args, cmd.Flags()) + client := api.DefaultClient(params.debug) + ec := editCmd{ + client: client, + params: params, + } + + cmdutil.ExitIfError(ec.setIssueKey()) + cmdutil.ExitIfError(ec.setWorklogID()) + + qs := ec.getQuestions() + if len(qs) > 0 { + ans := struct{ TimeSpent, Comment string }{} + err := survey.Ask(qs, &ans) + cmdutil.ExitIfError(err) + + if params.timeSpent == "" { + params.timeSpent = ans.TimeSpent + } + if ans.Comment != "" { + params.comment = ans.Comment + } + } + + if !params.noInput { + answer := struct{ Action string }{} + err := survey.Ask([]*survey.Question{getNextAction()}, &answer) + cmdutil.ExitIfError(err) + + if answer.Action == cmdcommon.ActionCancel { + cmdutil.Failed("Action aborted") + } + } + + err := func() error { + s := cmdutil.Info("Updating worklog") + defer s.Stop() + + return client.UpdateIssueWorklog(ec.params.issueKey, ec.params.worklogID, ec.params.started, ec.params.timeSpent, ec.params.comment) + }() + cmdutil.ExitIfError(err) + + server := viper.GetString("server") + + cmdutil.Success("Worklog updated in issue %q", ec.params.issueKey) + fmt.Printf("%s\n", cmdutil.GenerateServerBrowseURL(server, ec.params.issueKey)) +} + +type editParams struct { + issueKey string + worklogID string + started string + timezone string + timeSpent string + comment string + noInput bool + debug bool +} + +func parseArgsAndFlags(args []string, flags query.FlagParser) *editParams { + var issueKey, worklogID, timeSpent string + + nargs := len(args) + if nargs >= 1 { + issueKey = cmdutil.GetJiraIssueKey(viper.GetString("project.key"), args[0]) + } + if nargs >= 2 { + worklogID = args[1] + } + if nargs >= 3 { + timeSpent = args[2] + } + + debug, err := flags.GetBool("debug") + cmdutil.ExitIfError(err) + + started, err := flags.GetString("started") + cmdutil.ExitIfError(err) + + timezone, err := flags.GetString("timezone") + cmdutil.ExitIfError(err) + + startedWithTZ, err := cmdutil.DateStringToJiraFormatInLocation(started, timezone) + cmdutil.ExitIfError(err) + + comment, err := flags.GetString("comment") + cmdutil.ExitIfError(err) + + noInput, err := flags.GetBool("no-input") + cmdutil.ExitIfError(err) + + return &editParams{ + issueKey: issueKey, + worklogID: worklogID, + started: startedWithTZ, + timezone: timezone, + timeSpent: timeSpent, + comment: comment, + noInput: noInput, + debug: debug, + } +} + +type editCmd struct { + client *jira.Client + params *editParams +} + +func (ec *editCmd) setIssueKey() error { + if ec.params.issueKey != "" { + return nil + } + + var ans string + + qs := &survey.Question{ + Name: "issueKey", + Prompt: &survey.Input{Message: "Issue key"}, + Validate: survey.Required, + } + if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil { + return err + } + ec.params.issueKey = cmdutil.GetJiraIssueKey(viper.GetString("project.key"), ans) + + return nil +} + +func (ec *editCmd) setWorklogID() error { + if ec.params.worklogID != "" { + return nil + } + + // Fetch worklogs for the issue + worklogs, err := ec.client.GetIssueWorklogs(ec.params.issueKey) + if err != nil { + return err + } + + if worklogs.Total == 0 { + return fmt.Errorf("no worklogs found for issue %s", ec.params.issueKey) + } + + // Create options for selection + options := make([]string, len(worklogs.Worklogs)) + for i, wl := range worklogs.Worklogs { + options[i] = fmt.Sprintf("%s - %s by %s (%s)", wl.ID, wl.TimeSpent, wl.Author.Name, wl.Started) + } + + var selected string + prompt := &survey.Select{ + Message: "Select worklog to edit:", + Options: options, + } + if err := survey.AskOne(prompt, &selected); err != nil { + return err + } + + // Extract worklog ID from selection (format: "ID - ...") + var id string + _, _ = fmt.Sscanf(selected, "%s -", &id) + ec.params.worklogID = id + + return nil +} + +func (ec *editCmd) getQuestions() []*survey.Question { + var qs []*survey.Question + + if ec.params.timeSpent == "" { + qs = append(qs, &survey.Question{ + Name: "timeSpent", + Prompt: &survey.Input{ + Message: "Time spent", + Help: "Time to log as days (d), hours (h), or minutes (m), separated by space eg: 2d 1h 30m", + }, + Validate: survey.Required, + }) + } + + if !ec.params.noInput && ec.params.comment == "" { + qs = append(qs, &survey.Question{ + Name: "comment", + Prompt: &surveyext.JiraEditor{ + Editor: &survey.Editor{ + Message: "Comment body", + HideDefault: true, + AppendDefault: true, + }, + BlankAllowed: true, + }, + }) + } + + return qs +} + +func getNextAction() *survey.Question { + return &survey.Question{ + Name: "action", + Prompt: &survey.Select{ + Message: "What's next?", + Options: []string{ + cmdcommon.ActionSubmit, + cmdcommon.ActionCancel, + }, + }, + Validate: survey.Required, + } +} diff --git a/internal/cmd/issue/worklog/list/list.go b/internal/cmd/issue/worklog/list/list.go new file mode 100644 index 00000000..6b397a7e --- /dev/null +++ b/internal/cmd/issue/worklog/list/list.go @@ -0,0 +1,111 @@ +package list + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" + "github.com/ankitpokhrel/jira-cli/internal/query" + "github.com/ankitpokhrel/jira-cli/internal/view" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const ( + helpText = `List displays worklogs for an issue.` + examples = `$ jira issue worklog list ISSUE-1 + +# List worklogs in plain mode +$ jira issue worklog list ISSUE-1 --plain + +# List worklogs in table mode +$ jira issue worklog list ISSUE-1 --table` +) + +// NewCmdWorklogList is a worklog list command. +func NewCmdWorklogList() *cobra.Command { + cmd := cobra.Command{ + Use: "list ISSUE-KEY", + Short: "List worklogs for an issue", + Long: helpText, + Example: examples, + Aliases: []string{"ls"}, + Annotations: map[string]string{ + "help:args": "ISSUE-KEY\tIssue key to list worklogs for, eg: ISSUE-1", + }, + Run: list, + } + + cmd.Flags().SortFlags = false + + cmd.Flags().Bool("plain", false, "Display output in plain mode") + cmd.Flags().Bool("table", false, "Display output in table mode") + + return &cmd +} + +func list(cmd *cobra.Command, args []string) { + params := parseArgsAndFlags(args, cmd.Flags()) + client := api.DefaultClient(params.debug) + + if params.issueKey == "" { + cmdutil.Failed("Issue key is required") + } + + worklogs, err := func() (*jira.WorklogResponse, error) { + s := cmdutil.Info(fmt.Sprintf("Fetching worklogs for issue %s...", params.issueKey)) + defer s.Stop() + + return client.GetIssueWorklogs(params.issueKey) + }() + cmdutil.ExitIfError(err) + + if worklogs.Total == 0 { + fmt.Println("No worklogs found") + return + } + + server := viper.GetString("server") + project := viper.GetString("project.key") + + v := view.WorklogList{ + Project: project, + Server: server, + Worklogs: worklogs.Worklogs, + Total: worklogs.Total, + Display: params.display, + } + + cmdutil.ExitIfError(v.Render()) +} + +type listParams struct { + issueKey string + display view.DisplayFormat + debug bool +} + +func parseArgsAndFlags(args []string, flags query.FlagParser) *listParams { + var issueKey string + + if len(args) >= 1 { + issueKey = cmdutil.GetJiraIssueKey(viper.GetString("project.key"), args[0]) + } + + debug, err := flags.GetBool("debug") + cmdutil.ExitIfError(err) + + plain, err := flags.GetBool("plain") + cmdutil.ExitIfError(err) + + _, err = flags.GetBool("table") + cmdutil.ExitIfError(err) + + return &listParams{ + issueKey: issueKey, + display: view.DisplayFormat{Plain: plain}, + debug: debug, + } +} diff --git a/internal/cmd/issue/worklog/worklog.go b/internal/cmd/issue/worklog/worklog.go index e6672107..48095746 100644 --- a/internal/cmd/issue/worklog/worklog.go +++ b/internal/cmd/issue/worklog/worklog.go @@ -4,6 +4,9 @@ import ( "github.com/spf13/cobra" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/worklog/add" + "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/worklog/delete" + "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/worklog/edit" + "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/worklog/list" ) const helpText = `Worklog command helps you manage issue worklogs. See available commands below.` @@ -19,6 +22,9 @@ func NewCmdWorklog() *cobra.Command { } cmd.AddCommand(add.NewCmdWorklogAdd()) + cmd.AddCommand(delete.NewCmdWorklogDelete()) + cmd.AddCommand(edit.NewCmdWorklogEdit()) + cmd.AddCommand(list.NewCmdWorklogList()) return &cmd } diff --git a/internal/view/worklog.go b/internal/view/worklog.go new file mode 100644 index 00000000..435f15e1 --- /dev/null +++ b/internal/view/worklog.go @@ -0,0 +1,163 @@ +package view + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const ( + worklogFieldID = "ID" + worklogFieldAuthor = "AUTHOR" + worklogFieldStarted = "STARTED" + worklogFieldTimeSpent = "TIME SPENT" + worklogFieldCreated = "CREATED" + worklogFieldUpdated = "UPDATED" + worklogFieldComment = "COMMENT" + + maxCommentLength = 60 +) + +// WorklogList is a list view for worklogs. +type WorklogList struct { + Project string + Server string + Worklogs []jira.Worklog + Total int + Display DisplayFormat +} + +// Render renders the worklog list view. +func (wl WorklogList) Render() error { + if wl.Display.Plain { + return wl.renderPlain(os.Stdout) + } + return wl.renderTable() +} + +func (wl WorklogList) renderPlain(w io.Writer) error { + for i, worklog := range wl.Worklogs { + _, _ = fmt.Fprintf(w, "Worklog #%d\n", i+1) + _, _ = fmt.Fprintf(w, " ID: %s\n", worklog.ID) + _, _ = fmt.Fprintf(w, " Author: %s\n", worklog.Author.Name) + _, _ = fmt.Fprintf(w, " Started: %s\n", formatWorklogDate(worklog.Started)) + _, _ = fmt.Fprintf(w, " Time Spent: %s (%d seconds)\n", worklog.TimeSpent, worklog.TimeSpentSeconds) + _, _ = fmt.Fprintf(w, " Created: %s\n", formatWorklogDate(worklog.Created)) + _, _ = fmt.Fprintf(w, " Updated: %s\n", formatWorklogDate(worklog.Updated)) + + if worklog.Comment != nil { + comment := extractWorklogComment(worklog.Comment) + if comment != "" { + _, _ = fmt.Fprintf(w, " Comment: %s\n", truncateString(comment, maxCommentLength)) + } + } + + _, _ = fmt.Fprintln(w) + } + + _, _ = fmt.Fprintf(w, "Total worklogs: %d\n", wl.Total) + + return nil +} + +func (wl WorklogList) renderTable() error { + data := wl.data() + tw := tabwriter.NewWriter(os.Stdout, 0, tabWidth, 1, '\t', 0) + + headers := []string{ + worklogFieldID, + worklogFieldAuthor, + worklogFieldStarted, + worklogFieldTimeSpent, + worklogFieldCreated, + } + _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t")) + + for _, row := range data { + _, _ = fmt.Fprintln(tw, strings.Join(row, "\t")) + } + + return tw.Flush() +} + +func (wl WorklogList) data() [][]string { + data := make([][]string, 0, len(wl.Worklogs)) + + for _, worklog := range wl.Worklogs { + data = append(data, []string{ + worklog.ID, + worklog.Author.Name, + formatWorklogDate(worklog.Started), + worklog.TimeSpent, + formatWorklogDate(worklog.Created), + }) + } + + return data +} + +func formatWorklogDate(dateStr string) string { + formats := []string{ + time.RFC3339, + jira.RFC3339, + jira.RFC3339MilliLayout, + "2006-01-02T15:04:05.000-0700", + } + + for _, format := range formats { + if t, err := time.Parse(format, dateStr); err == nil { + return t.Format("2006-01-02 15:04") + } + } + + return dateStr +} + +func extractWorklogComment(comment interface{}) string { + if comment == nil { + return "" + } + + switch v := comment.(type) { + case string: + return v + case map[string]interface{}: + // Handle ADF format + if content, ok := v["content"].([]interface{}); ok { + var text strings.Builder + extractTextFromADF(content, &text) + return text.String() + } + } + + return "" +} + +func extractTextFromADF(content []interface{}, builder *strings.Builder) { + for _, item := range content { + if node, ok := item.(map[string]interface{}); ok { + if nodeType, ok := node["type"].(string); ok { + if nodeType == "text" { + if text, ok := node["text"].(string); ok { + builder.WriteString(text) + } + } + } + if subContent, ok := node["content"].([]interface{}); ok { + extractTextFromADF(subContent, builder) + } + } + } +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/internal/view/worklog_test.go b/internal/view/worklog_test.go new file mode 100644 index 00000000..64c1b12d --- /dev/null +++ b/internal/view/worklog_test.go @@ -0,0 +1,287 @@ +package view + +import ( + "strings" + "testing" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func TestWorklogList_Render(t *testing.T) { + tests := []struct { + name string + worklogs []jira.Worklog + display DisplayFormat + wantErr bool + }{ + { + name: "empty worklogs", + worklogs: []jira.Worklog{}, + display: DisplayFormat{Plain: true}, + wantErr: false, + }, + { + name: "single worklog plain mode", + worklogs: []jira.Worklog{ + { + ID: "12345", + Author: jira.User{ + Name: "John Doe", + }, + Started: "2024-11-05T10:30:00.000+0000", + TimeSpent: "2h 30m", + TimeSpentSeconds: 9000, + Created: "2024-11-05T10:30:00.000+0000", + Updated: "2024-11-05T10:30:00.000+0000", + Comment: "Test comment", + }, + }, + display: DisplayFormat{Plain: true}, + wantErr: false, + }, + { + name: "multiple worklogs table mode", + worklogs: []jira.Worklog{ + { + ID: "12345", + Author: jira.User{ + Name: "John Doe", + }, + Started: "2024-11-05T10:30:00.000+0000", + TimeSpent: "2h 30m", + TimeSpentSeconds: 9000, + Created: "2024-11-05T10:30:00.000+0000", + Updated: "2024-11-05T10:30:00.000+0000", + }, + { + ID: "12346", + Author: jira.User{ + Name: "Jane Smith", + }, + Started: "2024-11-05T14:00:00.000+0000", + TimeSpent: "1h 15m", + TimeSpentSeconds: 4500, + Created: "2024-11-05T14:00:00.000+0000", + Updated: "2024-11-05T14:00:00.000+0000", + }, + }, + display: DisplayFormat{Plain: false}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wl := WorklogList{ + Project: "TEST", + Server: "https://test.atlassian.net", + Worklogs: tt.worklogs, + Total: len(tt.worklogs), + Display: tt.display, + } + + // Just test that it doesn't panic or error + // Full output testing would require capturing stdout + err := wl.Render() + if (err != nil) != tt.wantErr { + t.Errorf("WorklogList.Render() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatWorklogDate(t *testing.T) { + tests := []struct { + name string + dateStr string + wantFmt string + wantFail bool + }{ + { + name: "RFC3339 with milliseconds", + dateStr: "2024-11-05T10:30:00.000+0000", + wantFmt: "2024-11-05 10:30", + }, + { + name: "RFC3339", + dateStr: "2024-11-05T10:30:00Z", + wantFmt: "2024-11-05 10:30", + }, + { + name: "invalid date", + dateStr: "invalid", + wantFmt: "invalid", // Should return original string + wantFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatWorklogDate(tt.dateStr) + if got != tt.wantFmt { + t.Errorf("formatWorklogDate() = %v, want %v", got, tt.wantFmt) + } + }) + } +} + +func TestExtractWorklogComment(t *testing.T) { + tests := []struct { + name string + comment interface{} + want string + }{ + { + name: "nil comment", + comment: nil, + want: "", + }, + { + name: "string comment", + comment: "Simple text comment", + want: "Simple text comment", + }, + { + name: "ADF comment with text", + comment: map[string]interface{}{ + "type": "doc", + "version": 1, + "content": []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "ADF formatted text", + }, + }, + }, + }, + }, + want: "ADF formatted text", + }, + { + name: "ADF comment empty", + comment: map[string]interface{}{ + "type": "doc", + "version": 1, + "content": []interface{}{}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractWorklogComment(tt.comment) + if got != tt.want { + t.Errorf("extractWorklogComment() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + s string + maxLen int + want string + }{ + { + name: "short string", + s: "short", + maxLen: 10, + want: "short", + }, + { + name: "exact length", + s: "1234567890", + maxLen: 10, + want: "1234567890", + }, + { + name: "long string", + s: "This is a very long string that needs truncation", + maxLen: 20, + want: "This is a very lo...", + }, + { + name: "empty string", + s: "", + maxLen: 10, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateString(tt.s, tt.maxLen) + if got != tt.want { + t.Errorf("truncateString() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWorklogList_Data(t *testing.T) { + wl := WorklogList{ + Worklogs: []jira.Worklog{ + { + ID: "12345", + Author: jira.User{ + Name: "John Doe", + }, + Started: "2024-11-05T10:30:00.000+0000", + TimeSpent: "2h 30m", + Created: "2024-11-05T10:30:00.000+0000", + }, + }, + } + + data := wl.data() + + if len(data) != 1 { + t.Errorf("Expected 1 row, got %d", len(data)) + } + + if len(data[0]) != 5 { + t.Errorf("Expected 5 columns, got %d", len(data[0])) + } + + if data[0][0] != "12345" { + t.Errorf("Expected ID '12345', got '%s'", data[0][0]) + } + + if data[0][1] != "John Doe" { + t.Errorf("Expected author 'John Doe', got '%s'", data[0][1]) + } +} + +func TestExtractTextFromADF(t *testing.T) { + content := []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "Hello ", + }, + map[string]interface{}{ + "type": "text", + "text": "World", + }, + }, + }, + } + + var builder strings.Builder + extractTextFromADF(content, &builder) + + got := builder.String() + want := "Hello World" + + if got != want { + t.Errorf("extractTextFromADF() = %v, want %v", got, want) + } +} diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go index ef24c75d..0f9357e7 100644 --- a/pkg/jira/issue.go +++ b/pkg/jira/issue.go @@ -341,9 +341,57 @@ type issueWorklogRequest struct { Comment string `json:"comment"` } +// Worklog holds worklog info. +type Worklog struct { + ID string `json:"id"` + IssueID string `json:"issueId"` + Author User `json:"author"` + UpdateAuthor User `json:"updateAuthor"` + Comment interface{} `json:"comment"` // string in v1/v2, adf.ADF in v3 + Created string `json:"created"` + Updated string `json:"updated"` + Started string `json:"started"` + TimeSpent string `json:"timeSpent"` + TimeSpentSeconds int `json:"timeSpentSeconds"` +} + +// WorklogResponse holds response from GET /issue/{key}/worklog endpoint. +type WorklogResponse struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Worklogs []Worklog `json:"worklogs"` +} + +// GetIssueWorklogs fetches worklogs for an issue using GET /issue/{key}/worklog endpoint. +func (c *Client) GetIssueWorklogs(key string) (*WorklogResponse, error) { + path := fmt.Sprintf("/issue/%s/worklog", key) + + res, err := c.GetV2(context.Background(), path, Header{ + "Accept": "application/json", + }) + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, formatUnexpectedResponse(res) + } + + var out WorklogResponse + err = json.NewDecoder(res.Body).Decode(&out) + + return &out, err +} + // AddIssueWorklog adds worklog to an issue using POST /issue/{key}/worklog endpoint. // Leave param `started` empty to use the server's current datetime as start date. -func (c *Client) AddIssueWorklog(key, started, timeSpent, comment, newEstimate string) error { +// Returns the created worklog. +func (c *Client) AddIssueWorklog(key, started, timeSpent, comment, newEstimate string) (*Worklog, error) { worklogReq := issueWorklogRequest{ TimeSpent: timeSpent, Comment: md.ToJiraMD(comment), @@ -353,7 +401,7 @@ func (c *Client) AddIssueWorklog(key, started, timeSpent, comment, newEstimate s } body, err := json.Marshal(&worklogReq) if err != nil { - return err + return nil, err } path := fmt.Sprintf("/issue/%s/worklog", key) @@ -364,6 +412,45 @@ func (c *Client) AddIssueWorklog(key, started, timeSpent, comment, newEstimate s "Accept": "application/json", "Content-Type": "application/json", }) + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusCreated { + return nil, formatUnexpectedResponse(res) + } + + var worklog Worklog + if err := json.NewDecoder(res.Body).Decode(&worklog); err != nil { + return nil, err + } + + return &worklog, nil +} + +// UpdateIssueWorklog updates a worklog using PUT /issue/{key}/worklog/{worklogID} endpoint. +func (c *Client) UpdateIssueWorklog(key, worklogID, started, timeSpent, comment string) error { + worklogReq := issueWorklogRequest{ + TimeSpent: timeSpent, + Comment: md.ToJiraMD(comment), + } + if started != "" { + worklogReq.Started = started + } + body, err := json.Marshal(&worklogReq) + if err != nil { + return err + } + + path := fmt.Sprintf("/issue/%s/worklog/%s", key, worklogID) + res, err := c.PutV2(context.Background(), path, body, Header{ + "Accept": "application/json", + "Content-Type": "application/json", + }) if err != nil { return err } @@ -372,7 +459,28 @@ func (c *Client) AddIssueWorklog(key, started, timeSpent, comment, newEstimate s } defer func() { _ = res.Body.Close() }() - if res.StatusCode != http.StatusCreated { + if res.StatusCode != http.StatusOK { + return formatUnexpectedResponse(res) + } + return nil +} + +// DeleteIssueWorklog deletes a worklog using DELETE /issue/{key}/worklog/{worklogID} endpoint. +func (c *Client) DeleteIssueWorklog(key, worklogID string) error { + path := fmt.Sprintf("/issue/%s/worklog/%s", key, worklogID) + + res, err := c.DeleteV2(context.Background(), path, Header{ + "Accept": "application/json", + }) + if err != nil { + return err + } + if res == nil { + return ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusNoContent { return formatUnexpectedResponse(res) } return nil diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go index 85664231..1c06fec6 100644 --- a/pkg/jira/issue_test.go +++ b/pkg/jira/issue_test.go @@ -587,25 +587,30 @@ func TestAddIssueWorklog(t *testing.T) { w.WriteHeader(400) } else { w.WriteHeader(201) + _, _ = w.Write([]byte(`{"id":"10001","issueId":"10000","author":{"name":"user"},"updateAuthor":{"name":"user"},"created":"2022-01-01T01:02:02.000+0200","updated":"2022-01-01T01:02:02.000+0200","started":"2022-01-01T01:02:02.000+0200","timeSpent":"1h","timeSpentSeconds":3600}`)) } })) defer server.Close() client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) - err := client.AddIssueWorklog("TEST-1", "2022-01-01T01:02:02.000+0200", "1h", "comment", "") + worklog, err := client.AddIssueWorklog("TEST-1", "2022-01-01T01:02:02.000+0200", "1h", "comment", "") assert.NoError(t, err) + assert.NotNil(t, worklog) - err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "") + worklog, err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "") assert.NoError(t, err) + assert.NotNil(t, worklog) - err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "1d") + worklog, err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "1d") assert.NoError(t, err) + assert.NotNil(t, worklog) unexpectedStatusCode = true - err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "") + worklog, err = client.AddIssueWorklog("TEST-1", "", "1h", "comment", "") assert.Error(t, &ErrUnexpectedResponse{}, err) + assert.Nil(t, worklog) } func TestGetField(t *testing.T) {