From e44a68ffba54c8fb6a36c1771d20427a3f2cc0f2 Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 12:45:58 +0100 Subject: [PATCH 1/6] Ignore binary, not dirs --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9ce6631..57bfe31 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -jt +/jt From e82a5522f1aadadd280b669317f184f25efc6378 Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 12:46:10 +0100 Subject: [PATCH 2/6] Write a whole query builder for JQL for no reason --- jql/jql.go | 244 ++++++++++++++++++++++++++++++++++++++++++++++++ jql/jql_test.go | 196 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 jql/jql.go create mode 100644 jql/jql_test.go diff --git a/jql/jql.go b/jql/jql.go new file mode 100644 index 0000000..5937406 --- /dev/null +++ b/jql/jql.go @@ -0,0 +1,244 @@ +package jql + +import ( + "fmt" + "strings" +) + +type jqlWord interface { + Type() wordType + String() string +} + +type wordType int + +const ( + unknownType wordType = iota + operatorType + keywordType + endKeywordType +) + +// Operator represents a basic field-operator-value component +type Operator struct { + field string + operator string + value string +} + +func (c Operator) String() string { + return fmt.Sprintf("%s %s %s", c.field, c.operator, c.value) +} + +func (c Operator) Type() wordType { + return operatorType +} + +// Keyword represents logical keywords such as AND, OR, etc. +type Keyword struct { + name string + wordType wordType + isEnding bool +} + +func (k Keyword) String() string { + return k.name +} + +func (k Keyword) Type() wordType { + return k.wordType +} + +var ( + And = Keyword{"AND", keywordType, false} + Or = Keyword{"OR", keywordType, false} + Not = Keyword{"NOT", keywordType, false} + OrderByKeyword = Keyword{"ORDER BY", endKeywordType, true} +) + +// OrderBy represents the ORDER BY component in JQL, with fields and sorting direction +type OrderBy struct { + fields []string + ascending bool +} + +func (o OrderBy) String() string { + order := "ASC" + if !o.ascending { + order = "DESC" + } + return fmt.Sprintf("ORDER BY %s %s", strings.Join(o.fields, ", "), order) +} + +func (o OrderBy) Type() wordType { + return endKeywordType +} + +// JQLQueryBuilder is the main query builder struct +type JQLQueryBuilder struct { + qt []jqlWord + rawQueryString string +} + +// NewJQLQuery initializes a new JQLQuery +func NewBuilder() *JQLQueryBuilder { + return &JQLQueryBuilder{ + qt: make([]jqlWord, 0), + } +} + +func (q *JQLQueryBuilder) SetJQLString(jqlString string) *JQLQueryBuilder { + q.rawQueryString = jqlString + return q +} + +func (q *JQLQueryBuilder) And() *JQLQueryBuilder { + q.qt = append(q.qt, And) + return q +} + +func (q *JQLQueryBuilder) Or() *JQLQueryBuilder { + q.qt = append(q.qt, Or) + return q +} + +// OrderBy adds an ORDER BY clause with specified fields and sorting direction +func (q *JQLQueryBuilder) OrderBy(ascending bool, fields ...string) *JQLQueryBuilder { + q.qt = append(q.qt, OrderBy{fields: fields, ascending: ascending}) + return q +} + +// Equals adds an equality operator for a field +func (q *JQLQueryBuilder) Equals(field string, value string) *JQLQueryBuilder { + q.qt = append(q.qt, Operator{field: field, operator: "=", value: fmt.Sprintf("'%s'", value)}) + return q +} + +// NotEquals adds an inequality operator for a field +func (q *JQLQueryBuilder) NotEquals(field string, value string) *JQLQueryBuilder { + q.qt = append(q.qt, Operator{field: field, operator: "!=", value: fmt.Sprintf("'%s'", value)}) + return q +} + +// In adds an IN operator for a field with a list of values +// +// field IN ('value1', 'value2', ...) +func (q *JQLQueryBuilder) In(field string, values ...string) *JQLQueryBuilder { + valueList := "('" + strings.Join(values, "', '") + "')" + q.qt = append(q.qt, Operator{field: field, operator: "IN", value: valueList}) + return q +} + +// NotIn adds a NOT IN operator for a field with a list of values +func (q *JQLQueryBuilder) NotIn(field string, values []string) *JQLQueryBuilder { + valueList := "('" + strings.Join(values, "', '") + "')" + q.qt = append(q.qt, Operator{field: field, operator: "NOT IN", value: valueList}) + return q +} + +func (q *JQLQueryBuilder) Contains(field string, value string) *JQLQueryBuilder { + q.qt = append(q.qt, Operator{field: field, operator: "~", value: fmt.Sprintf("'%s'", value)}) + return q +} + +// Build constructs the final JQL query string from the query parts +// It returns an error if the query is invalid. +// Only syntactic validation is done, stopping at the first detected error while still building the full query. +func (q *JQLQueryBuilder) Build() (string, error) { + if len(q.qt) == 0 && q.rawQueryString == "" { + return "", fmt.Errorf("no query parts added") + } + + // If a raw query string was set, return it directly + if q.rawQueryString != "" { + return q.rawQueryString, nil + } + + var builder strings.Builder + var lastWord jqlWord + var err error + var errCharPos int + currentPosition := 0 + + for i, word := range q.qt { + wordStr := word.String() + + if err == nil { + // Validate the current word if we haven't encountered an error yet + // If we have, we skip validation to avoid duplicate error messages + err = validateWord(i, word, lastWord) + errCharPos = currentPosition + } + + // Append word to builder and update the current character position + if builder.Len() > 0 { + builder.WriteString(" ") + currentPosition++ // Account for added space between words + } + builder.WriteString(wordStr) + currentPosition += len(wordStr) // Account for word length + lastWord = word + } + + finalQuery := builder.String() + + // If we haven't encountered an error yet, validate the final query + if err == nil { + // Final validation: Ensure the query does not end with a keyword + // Example of invalid query: "status = 'Open' AND" + if lastWord.Type() == keywordType { + err = fmt.Errorf("query cannot end with a keyword") + errCharPos = currentPosition + } + } + + // If error was captured, return it with the full query and a pointer to the error position + if err != nil { + pointerLine := strings.Repeat(" ", errCharPos) + "^" + return "", fmt.Errorf("invalid query:\n%q\n%s\nError: %s", finalQuery, pointerLine, err.Error()) + } + + return finalQuery, nil +} + +func validateWord(i int, word jqlWord, lastWord jqlWord) error { + // Validate the first word + // The first word of the query must be an operator (e.g., "status = 'Open'"). + // If the first word is an operator, keyword, or end keyword, it's invalid. + if i == 0 && word.Type() != operatorType { + return fmt.Errorf("first word must be an operator, got %q", word.String()) + } + + if lastWord == nil { + lastWord = &Keyword{"", unknownType, false} + } + + // Validation rules based on last and current types + switch word.Type() { + case operatorType: + // Check for consecutive operators + // Two operators cannot appear consecutively without a keyword in between. + // Example of invalid query: "status = 'Open' assignee = 'JohnDoe'" + if lastWord.Type() == operatorType { + return fmt.Errorf("consecutive operators %q & %q", lastWord.String(), word.String()) + } + case keywordType: + if lastWord.Type() == keywordType { + return fmt.Errorf("consecutive keywords %q & %q", lastWord.String(), word.String()) + } + case endKeywordType: + // Ensure end keywords appear at the end of the query + // End keywords (e.g., "ORDER BY") should only appear at the end. + // Example of invalid query: "ORDER BY created ASC status = 'Open'" + if lastWord.Type() != operatorType { + return fmt.Errorf("consecutive keywords %q & %q", lastWord.String(), word.String()) + } + } + + // Check if the lastWord was an end keyword + if lastWord.Type() == endKeywordType { + return fmt.Errorf("keyword %q must be the last part of the query", lastWord.String()) + } + + return nil +} diff --git a/jql/jql_test.go b/jql/jql_test.go new file mode 100644 index 0000000..8746327 --- /dev/null +++ b/jql/jql_test.go @@ -0,0 +1,196 @@ +package jql + +import ( + "strings" + "testing" +) + +func TestValidSimpleQuery(t *testing.T) { + + qb := NewBuilder() + + // Construct the following query: + // project = 'My Project' AND status IN ('In Progress','To do') ORDER BY created DESC + qb.Equals("project", "My Project").And().In("status", "In Progress", "To do").OrderBy(false, "created") + + s, err := qb.Build() + if err != nil { + t.Fatalf("failed to build query: %s", err) + } + + expected := `project = 'My Project' AND status IN ('In Progress', 'To do') ORDER BY created DESC` + if s != expected { + t.Fatalf("expected %q, got %q", expected, s) + } +} + +func TestValidComplexQuery(t *testing.T) { + builder := NewBuilder() + + query, err := builder. + // Add conditions with Equals, NotEquals, In, and NotIn + Equals("status", "Open"). + And(). + NotEquals("priority", "Low"). + And(). + In("assignee", "Alice", "Bob", "Charlie"). + And(). + NotIn("project", []string{"ProjectA", "ProjectB"}). + + // Use OR to add branching conditions + Or(). + Equals("reporter", "Dave"). + And(). + NotEquals("issueType", "Bug"). + + // Nested conditions with different operators + Or(). + In("labels", "critical", "urgent"). + And(). + Equals("resolution", "Unresolved"). + + // More conditions to increase complexity + And(). + Equals("created", "2023-10-01"). + And(). + NotEquals("updated", "2023-11-01"). + And(). + In("component", "Backend", "Frontend"). + + // Add ORDER BY at the end + OrderBy(true, "priority", "created"). + Build() + + if err != nil { + t.Fatalf("failed to build query: %s", err) + } + + expQuery := `status = 'Open' AND priority != 'Low' AND assignee IN ('Alice', 'Bob', 'Charlie') AND project NOT IN ('ProjectA', 'ProjectB') OR reporter = 'Dave' AND issueType != 'Bug' OR labels IN ('critical', 'urgent') AND resolution = 'Unresolved' AND created = '2023-10-01' AND updated != '2023-11-01' AND component IN ('Backend', 'Frontend') ORDER BY priority, created ASC` + + if query != expQuery { + t.Fatalf("expected %q, got %q", expQuery, query) + } +} +func TestInvalidQueries(t *testing.T) { + testData := []struct { + name string + fn func(*JQLQueryBuilder) *JQLQueryBuilder + errMsg string + }{ + { + name: "consecutive operators", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").Equals("priority", "High") + }, + errMsg: "consecutive operators", + }, + { + // Invalid because "ORDER BY" must follow an operator and not a keyword like "AND" + // Query: "status = 'Open' AND ORDER BY created ASC" + name: "ORDER BY after keyword", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").And().OrderBy(false, "created") + }, + errMsg: "consecutive keywords", + }, + { + // Invalid because "ORDER BY" should not follow another "ORDER BY" directly + // Query: "ORDER BY created ASC ORDER BY updated DESC" + name: "consecutive ORDER BY", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").OrderBy(true, "created").OrderBy(false, "updated") + }, + errMsg: "consecutive keywords", + }, + { + // Invalid because an operator cannot directly follow a keyword like "OR" + // Query: "OR = 'Open'" + name: "operator after keyword", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Or().Equals("status", "Open") + }, + errMsg: "first word must be an operator", + }, + { + // Invalid because two operators cannot appear consecutively + // Query: "status = 'Open' IN 'In Progress'" + name: "consecutive operators", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").In("priority") + }, + errMsg: "consecutive operators", + }, + { + // Invalid because the "ORDER BY" keyword cannot be the first part of the query + // Query: "ORDER BY created ASC status = 'Open'" + name: "ORDER BY at the start", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.OrderBy(false, "created").Equals("status", "Open") + }, + errMsg: "first word must be an operator", + }, + { + // Invalid because "ORDER BY" must be the last part of the query + // Query: "status = 'Open' ORDER BY created ASC AND priority = 'High'" + name: "ORDER BY in the middle", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").OrderBy(false, "created").And().Equals("priority", "High") + }, + errMsg: "must be the last part of the query", + }, + { + // Invalid because a query cannot contain only "ORDER BY" without a preceding opertor + // Query: "ORDER BY created ASC" + name: "only ORDER BY", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.OrderBy(true, "created") + }, + errMsg: "first word must be an operator", + }, + { + // Invalid because a query cannot end with a keyword + // Query: "status = 'Open' AND priority = 'High' AND" + name: "end with AND", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").And().Equals("priority", "High").And() + }, + errMsg: "query cannot end with a keyword", + }, + { + // Invalid because a query cannot start with a keyword + // Query: "OR status = 'Open'" + name: "start with OR", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Or().Equals("status", "Open") + }, + errMsg: "first word must be an operator", + }, + { + // Invalid because a query cannot contain consecutive keywords + // Query: "status = 'Open' AND AND priority = 'High'" + name: "consecutive keywords", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { + return q.Equals("status", "Open").And().And().Equals("priority", "High") + }, + }, + { + // No query parts + name: "empty query", + fn: func(q *JQLQueryBuilder) *JQLQueryBuilder { return q }, + errMsg: "no query parts added", + }, + } + + for _, tt := range testData { + t.Run(tt.name, func(t *testing.T) { + qb := NewBuilder() + _, err := tt.fn(qb).Build() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Fatalf("expected error message to contain %q, got %q", tt.errMsg, err.Error()) + } + }) + } +} From f4ac12a0c33679334bc306d2f67e0fc7b37fff38 Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 17:35:52 +0100 Subject: [PATCH 3/6] Add support for parent issues and queries. Query command for querying for issues to be used as parent issues. Zsh completion script for parent issue searching. Query builder for JQL. Rename ticket to issue everywhere. --- README.md | 68 +++++++++-- cmd/jt/completion.go | 13 ++ cmd/jt/completion/jt_completion.zsh | 109 +++++++++++++++++ cmd/jt/main.go | 63 +++++++--- cmd/jt/query.go | 149 +++++++++++++++++++++++ config.go | 2 + jira.go | 181 +++++++++++++++++++++++++--- 7 files changed, 537 insertions(+), 48 deletions(-) create mode 100644 cmd/jt/completion.go create mode 100644 cmd/jt/completion/jt_completion.zsh create mode 100644 cmd/jt/query.go diff --git a/README.md b/README.md index 968edcb..1e3c1e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jt -Tiny command-line tool for creating JIRA tickets with a summary/title and optionally a description. +Tiny command-line tool for creating JIRA issues with a summary/title and optionally a description. ## Installation If you have go install locally, compile and install with: @@ -19,24 +19,38 @@ defaultIssueType: Task defaultComponentNames: - Team A - Development +DefaultParentIssueTypes: + - Epic + - Initiative ``` -Then you can create a ticket with: +Then you can create an issue with: ```bash -# Create ticket with only a summary -jt My new ticket +# Create issue with only a summary +jt My new issue -# Create ticket with a summary and a description -jt My new ticket -m "With a description!" +# Create issue with a summary and a description +jt My new issue -m "With a description!" -# Or create a ticket with $EDITOR +# Or create an issue with $EDITOR jt -# The first line is the ticket summary/title +# The first line is the issue summary/title # # The description is everything after a blank line # which can be multiline. ``` +If you want to add the issue to a parent Epic or Initiative, use `-p`: +```bash +jt -p ABC-12345 Add a feature +``` + +### Setting up JIRA API access +The first time you run it, it will prompt for an access token for JIRA. +You can generate one at https://id.atlassian.com/manage-profile/security/api-tokens. + +It will be stored in your system's keyring, so you won't have to enter it again until you restart or lock your keychain. + ### gitcommit-style vim highlighting Add this to your `.vimrc` to get gitcommit-style highlighting for the summary and description: ```vim @@ -44,8 +58,38 @@ Add this to your `.vimrc` to get gitcommit-style highlighting for the summary an au BufReadPost *.jt set syntax=gitcommit ``` -### Setting up JIRA API access -The first time you run it, it will prompt for an access token for JIRA. -You can generate one at https://id.atlassian.com/manage-profile/security/api-tokens. +### auto-completion and in-line search for parent issues +The provided completion script for `zsh` allows you to not only get completion for available flags, but also +to search easily for parent issues with descriptions. + +```bash +jt -p +PROJ-12345 [Initiative]: Initiative A +PROJ-12344 [Initiative]: Initiative B +PROJ-12343 [Epic]: Epic A +PROJ-12342 [Epic]: Epic B + +# You can also search for a specific description by typing it after `-p` +jt -p image +```bash +PROJ-12340 [Initiative]: Immutable Docker Images +PROJ-12341 [Initiative]: Image Storage Project +PROJ-12338 [Epic]: Scan Images +PROJ-12339 [Epic]: Optimize Go image + +# Or search for a specific issue key +jt -p PROJ-123 +PROJ-12346 [Initiative]: Initiative C +PROJ-12347 [Initiative]: Initiative D +PROJ-12348 [Epic]: Epic C +PROJ-12349 [Epic]: Epic D +``` + +To enable `zsh` completion, run the following: +```bash +source <(jt --completion) +``` +> **Note:** Currently only `zsh` is supported. If you want to add support for another shell, feel free to open a PR. -It will be stored in your system's keyring, so you won't have to enter it again until you restart or lock your keychain. \ No newline at end of file +### TODO +- [ ] Add support for creating sub-Tasks with Tasks as parents. Currently we don't know what type the issue passed to `-p` is. \ No newline at end of file diff --git a/cmd/jt/completion.go b/cmd/jt/completion.go new file mode 100644 index 0000000..33137cd --- /dev/null +++ b/cmd/jt/completion.go @@ -0,0 +1,13 @@ +package main + +import ( + _ "embed" + "fmt" +) + +//go:embed completion/jt_completion.zsh +var completionZSH string + +func printCompletionZSH() { + fmt.Println(completionZSH) +} diff --git a/cmd/jt/completion/jt_completion.zsh b/cmd/jt/completion/jt_completion.zsh new file mode 100644 index 0000000..5106587 --- /dev/null +++ b/cmd/jt/completion/jt_completion.zsh @@ -0,0 +1,109 @@ +# Zsh completion script for the 'jt' command + +# Always show the list, even if there's only one option. +zstyle ':completion:*:jt:*' force-list always + +# Disable sorting to preserve the custom order from jt -q. +zstyle ':completion::complete:jt::' sort false + +# Define the jt completion function for Zsh +_jt_completions() { + # Define the arguments with exclusivity + _arguments -C \ + '(-m --msg)'{-m,--msg}'[Issue description, optional]:description' \ + '(-e --edit)'{-e,--edit}'[Open default editor for summary and description, optional]' \ + '(-p --parent)'{-p,--parent}'[Assign the issue to a parent Epic or Initiative, optional]:project:->parent_completion' \ + '(-c --completion)'{-c,--completion}'[Print zsh shell completion script to stdout and exit]' \ + '(-q --query)'{-q,--query}'[Query issues and exit. Options: parents, epics, initiatives, tasks, bugs. Comma followed by string for description search]:query:->query_completion' \ + '(-h --help)'{-h,--help}'[Show help]' && + return 0 + + # Handle completion based on the context + case $state in + parent_completion) + # Define the completion options for the -p/--parent flag + + # Enable immediate menu display and prevent sorting + compstate[insert]=menu + # Prevent sorting to preserve custom order + compstate[nosort]=true + + local -a insertions descriptions + local search_term="${words[CURRENT]}" + local query_param="parents" + local has_matches=0 # Flag to check if matches are found in this section + + # If the search term is empty, or an issue ID prefix (e.g., PLT-77), fetch the full list + if [[ -z "$search_term" || "$search_term" =~ '^([[:alpha:]]{3,4})-[0-9]*' ]]; then + # Fetch the full list for normal Zsh prefix-based filtering + while IFS= read -r line; do + local id="${line%% *}" + local description="${line#* }" + insertions+=("$id") + descriptions+=("${id} ${description}") + done < <(jt -q "$query_param") + + # Use compadd to display results with the following flags: + # -Q: Suppresses quoting of completions with special characters. The returned IDs don't need to be quoted. + # -V jt_issues: Groups completions under the label "jt_issues". This seems to allow + # zstyle ':completion::complete:jt::' sort false to work correctly and return the list in the order + # that jt -q parents returns them. + # -d descriptions: Provides descriptions alongside each completion item. Length of insertions and descriptions + # must match. + # -l: Forces a single-column list display regardless of terminal width or number of items + if compadd -Q -V jt_issues -d descriptions -l -- "${insertions[@]}"; then + has_matches=1 + fi + else + # Perform a text search by appending the search term with a comma. + query_param+=",$search_term" + while IFS= read -r line; do + local id="${line%% *}" + local description="${line#* }" + insertions+=("$id") + descriptions+=("${id} ${description}") + done < <(jt -q "$query_param") + + # Use compadd to display results. The same flags as above but with and + # important difference. The -U flag: + # -U: Ensures Zsh doesn't further filter returned values. + # This would filter out the results since the search string would not match the returned IDs. + if compadd -Q -U -V jt_issues -d descriptions -l-- "${insertions[@]}"; then + has_matches=1 + fi + fi + + # Show "No issues found" message if no matches were added + ((!has_matches)) && compadd -x 'No issues found' + return 0 + ;; + query_completion) + # Define completion options for the -q/--query flag + local -a query_options + query_options=("parents" "epics" "initiatives" "tasks" "bugs") + + # Check if the user has entered a comma + if [[ "$words[CURRENT]" == *,* ]]; then + # After a comma, allow free text input (no specific completion) + compadd -U "$words[CURRENT]" + else + local current_word="$words[CURRENT]" + # Filter options based on the current input + local -a filtered_options + for opt in "${query_options[@]}"; do + if [[ -z "$current_word" || "$opt" = ${current_word}* ]]; then + filtered_options+=("$opt") + fi + done + + # Provide filtered options with no space after completion + # -S '': Adds an empty string suffix to avoid adding a space after the completion since we support , + compadd -Q -U -S '' -- "${filtered_options[@]}" + fi + return 0 + ;; + esac +} + +# Register the _jt_completions function for the jt command in Zsh +compdef _jt_completions jt diff --git a/cmd/jt/main.go b/cmd/jt/main.go index ef2e327..8806927 100644 --- a/cmd/jt/main.go +++ b/cmd/jt/main.go @@ -12,8 +12,16 @@ import ( ) var ( - msg = pflag.StringP("msg", "m", "", "issue description, optional") - edit = pflag.BoolP("edit", "e", false, "open the issue in your default editor, optional") + issueFlags = pflag.NewFlagSet("issues", pflag.ContinueOnError) + exclusiveFlags = pflag.NewFlagSet("exclusive", pflag.ContinueOnError) + msg = issueFlags.StringP("msg", "m", "", "Issue description, optional") + edit = issueFlags.BoolP("edit", "e", false, "Open default editor for summary and description, optional") + parent = issueFlags.StringP("parent", "p", "", "Assign the issue to a parent Epic or Initiative, optional") + query = exclusiveFlags.StringSliceP("query", "q", []string{}, `Query issues and exit. Available queries are: "parents", "epics", "initiatives", "tasks", and "bugs". +The "parents" query will search for parent issues (Epics, Initiatives by default). +A wildcard text search term can also be provided after a comma. +For example: jt -q "parents,some issue". Double quote if the search text contains spaces.`) + completion = exclusiveFlags.BoolP("completion", "c", false, "Print zsh shell completion script to stdout and exit") ) func main() { @@ -24,25 +32,37 @@ func main() { } func run() error { - fl := pflag.NewFlagSet("jt", pflag.ContinueOnError) - fl.AddFlagSet(pflag.CommandLine) - fl.Usage = func() { + rootFlags := pflag.NewFlagSet("root", pflag.ContinueOnError) + rootFlags.Usage = func() { fmt.Println("Usage: jt [flags] [summary]") fmt.Println("\nIf summary is not provided, jt will open your default editor and prompt you for a summary and description.") - fmt.Println("\nFlags:") - pflag.PrintDefaults() + fmt.Println("\nIssue Creation Flags:") + issueFlags.PrintDefaults() + fmt.Println("\nExclusive Flags:") + exclusiveFlags.PrintDefaults() } - + rootFlags.AddFlagSet(issueFlags) + rootFlags.AddFlagSet(exclusiveFlags) // Parse flags - err := fl.Parse(os.Args[1:]) + err := rootFlags.Parse(os.Args[1:]) if err != nil { if !errors.Is(err, pflag.ErrHelp) { - fl.Usage() + rootFlags.Usage() fmt.Printf("\n%s\n", err) } return nil } + if *completion { + printCompletionZSH() + return nil + } + + // If a query is provided, run the query and return. + if len(*query) > 0 { + return runQuery(*query) + } + var desc string // Check if msg is set if *msg != "" { @@ -50,7 +70,7 @@ func run() error { } // Read the issue summary from the command line arguments. - summary := strings.Join(fl.Args(), " ") + summary := strings.Join(rootFlags.Args(), " ") if summary == "" || *edit { var err error @@ -77,20 +97,29 @@ func run() error { } jc := jt.JiraConfig{ - URL: parsedURL.String(), - Email: conf.Email, - Token: t, + URL: parsedURL.String(), + Email: conf.Email, + Token: t, + } + + ic := jt.IssueConfig{ + Summary: summary, + Description: desc, ProjectKey: conf.DefaultProjectKey, IssueType: conf.DefaultIssueType, ComponentNames: conf.DefaultComponentNames, } + if parent != nil && *parent != "" { + ic.ParentIssueKey = *parent + } + c := jt.NewJiraClient(jc) - key, err := c.NewJIRATicket(summary, desc) + key, err := c.NewJIRAIssue(ic) if err != nil { - return fmt.Errorf("failed to create ticket: %s\n", err) + return fmt.Errorf("failed to create issue: %s\n", err) } - fmt.Printf("created ticket: %s\tURL: %s\n", key, parsedURL.String()+"/browse/"+key) + fmt.Printf("created issue: %s\tURL: %s\n", key, parsedURL.String()+"/browse/"+key) return nil } diff --git a/cmd/jt/query.go b/cmd/jt/query.go new file mode 100644 index 0000000..527717d --- /dev/null +++ b/cmd/jt/query.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "net/url" + "sort" + + "github.com/leosunmo/jt" + "github.com/leosunmo/jt/jql" +) + +func runQuery(queryStrings []string) error { + // Split the queryStrings to get the query type from the first element + queryType := queryStrings[0] + + t, err := jt.GetToken() + if err != nil { + return fmt.Errorf("failed to get token: %s\n", err) + } + + conf, err := jt.ReadConfig(jt.DefaultConfigLocation) + if err != nil { + return fmt.Errorf("failed to read config: %s\n", err) + } + + parsedURL, err := url.Parse(conf.URL) + if err != nil { + return fmt.Errorf("failed to parse URL, %w", err) + } + + jc := jt.JiraConfig{ + URL: parsedURL.String(), + Email: conf.Email, + Token: t, + } + + c := jt.NewJiraClient(jc) + + qb := jql.NewBuilder() + qb. + Equals("project", conf.DefaultProjectKey). + And(). + In("component", conf.DefaultComponentNames...) + + // If a string is provided after the `,`, add it as a summary search term with a wildcard. + if len(queryStrings) > 1 { + qb.And().Contains("summary", queryStrings[1]+"*") + } + + switch queryType { + case "parents": + // Query for parent issues (Epics, Initiatives by default) + if conf.DefaultParentIssueTypes != nil { + qb.And().In("type", conf.DefaultParentIssueTypes...) + } else { + qb.And().In("type", jt.IssueTypeEpic, jt.IssueTypeInitiative) + } + parents, err := doQuery(c, qb) + if err != nil { + return fmt.Errorf("failed to query parents: %s\n", err) + } + for _, parent := range parents { + fmt.Printf("%s\n", parent) + } + case "epics": + // Query for Epics and Initiatives + qb.And().Equals("type", jt.IssueTypeEpic) + e, err := doQuery(c, qb) + if err != nil { + return fmt.Errorf("failed to query epics: %s\n", err) + } + for _, epic := range e { + fmt.Printf("%s\n", epic) + } + case "initiatives": + // Query for Initiatives + qb.And().Equals("type", jt.IssueTypeInitiative) + initiatives, err := doQuery(c, qb) + if err != nil { + return fmt.Errorf("failed to query initiatives: %s\n", err) + } + for _, initiative := range initiatives { + fmt.Printf("%s\n", initiative) + } + case "tasks": + // Query for Tasks and Bugs + qb.And().Equals("type", jt.IssueTypeTask) + tasks, err := doQuery(c, qb) + if err != nil { + return fmt.Errorf("failed to query tasks: %s\n", err) + } + for _, task := range tasks { + fmt.Printf("%s\n", task) + } + case "bugs": + // Query for Bugs + qb.And().Equals("type", jt.IssueTypeBug) + bugs, err := doQuery(c, qb) + if err != nil { + return fmt.Errorf("failed to query bugs: %s\n", err) + } + for _, bug := range bugs { + fmt.Printf("%s\n", bug) + } + default: + return fmt.Errorf("unsupported query type: %s", queryType) + } + return nil +} + +func doQuery(c *jt.JiraClient, qb *jql.JQLQueryBuilder) ([]string, error) { + q, err := qb.Build() + + if err != nil { + return nil, fmt.Errorf("failed to build query: %s\n", err) + } + + queryReq := jt.JQLSearchRequest{ + JQL: q, + IncludedFields: []jt.Field{ + jt.FieldComponents, + jt.FieldIssuetype, + jt.FieldSummary, + }, + } + + issues, err := c.SearchJiraIssues(queryReq) + if err != nil { + return nil, fmt.Errorf("failed to query issues: %s\n", err) + } + // Sort issues by type: Initiatives first, then Epics, then Stories, lastly Tasks + sortOrder := map[string]int{ + jt.IssueTypeInitiative: 1, + jt.IssueTypeEpic: 2, + jt.IssueTypeStory: 3, + jt.IssueTypeTask: 4, + } + + sort.SliceStable(issues, func(i, j int) bool { + return sortOrder[issues[i].Fields.Issuetype.Name] < sortOrder[issues[j].Fields.Issuetype.Name] + }) + + output := make([]string, 0, len(issues)) + + for _, issue := range issues { + output = append(output, fmt.Sprintf("%s [%s]: %s", issue.Key, issue.Fields.Issuetype.Name, issue.Fields.Summary)) + } + return output, nil +} diff --git a/config.go b/config.go index 2710948..ec11d82 100644 --- a/config.go +++ b/config.go @@ -25,6 +25,8 @@ type JTConfig struct { DefaultIssueType string `yaml:"defaultIssueType"` // Default component names are the default components that will be added to issues. DefaultComponentNames []string `yaml:"defaultComponentNames"` + // Default parent issue types are the issue types that will be searched for when querying for parent issues. + DefaultParentIssueTypes []string `yaml:"defaultParentIssueTypes"` } // ReadConfig reads config file from the default location. diff --git a/jira.go b/jira.go index df24c28..1fa6428 100644 --- a/jira.go +++ b/jira.go @@ -10,12 +10,9 @@ import ( ) type JiraConfig struct { - URL string - Email string - Token string - ProjectKey string - IssueType string - ComponentNames []string + URL string + Email string + Token string } type JiraClient struct { @@ -45,6 +42,15 @@ func NewJiraClient(conf JiraConfig) *JiraClient { } } +type IssueConfig struct { + Summary string + Description string + ProjectKey string + IssueType string + ComponentNames []string + ParentIssueKey string +} + type CreateIssueRequest struct { Fields Fields `json:"fields,omitempty"` Update struct { @@ -52,9 +58,30 @@ type CreateIssueRequest struct { } `json:"update"` } +const ( + IssueTypeBug = "Bug" + IssueTypeTask = "Task" + IssueTypeStory = "Story" + IssueTypeEpic = "Epic" + IssueTypeSubTask = "Sub-task" + IssueTypeInitiative = "Initiative" +) + +type Field string + +const ( + FieldSummary Field = "summary" + FieldDescription Field = "description" + FieldProject Field = "project" + FieldIssuetype Field = "issuetype" + FieldComponents Field = "components" + FieldParent Field = "parent" +) + type Fields struct { Components []Components `json:"components,omitempty"` Issuetype Issuetype `json:"issuetype,omitempty"` + Parent *Parent `json:"parent,omitempty"` Project Project `json:"project,omitempty"` Description *Description `json:"description,omitempty"` Summary string `json:"summary,omitempty"` @@ -65,8 +92,14 @@ type Components struct { Name string `json:"name,omitempty"` } type Issuetype struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description"` + Subtask bool `json:"subtask"` +} + +type Parent struct { + Key string `json:"key,omitempty"` } type Project struct { @@ -89,6 +122,30 @@ type Description struct { Content []Content `json:"content,omitempty"` } +type JQLSearchRequest struct { + JQL string `json:"jql"` + IncludedFields []Field `json:"fields"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +type JQLSearchResponse struct { + Issues []Issue `json:"issues"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +type Issue struct { + ID string `json:"id"` + Key string `json:"key"` + Self string `json:"self"` + Fields Fields `json:"fields,omitempty"` +} + +type Component struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + type CreatedIssueResponse struct { ID string `json:"id"` Key string `json:"key"` @@ -104,25 +161,30 @@ type CreatedIssueResponse struct { Errors map[string]string `json:"errors"` } -// NewJIRATicket creates a new JIRA ticket using the JIRA REST API v3. -// The function returns the key of the created ticket and an error if the ticket could not be created. +// NewJIRAIssue creates a new JIRA issue using the JIRA REST API v3. +// The function returns the key of the created issue and an error if the issue could not be created. // https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post -func (jc JiraClient) NewJIRATicket(summary string, desc string) (string, error) { - +func (jc JiraClient) NewJIRAIssue(conf IssueConfig) (string, error) { // Build the body of the request using a CreateIssueRequest reqBody := CreateIssueRequest{} - reqBody.Fields.Project.Key = jc.config.ProjectKey - reqBody.Fields.Issuetype.Name = jc.config.IssueType - reqBody.Fields.Components = make([]Components, len(jc.config.ComponentNames)) - for i, name := range jc.config.ComponentNames { + reqBody.Fields.Summary = conf.Summary + reqBody.Fields.Project.Key = conf.ProjectKey + reqBody.Fields.Issuetype.Name = conf.IssueType + + reqBody.Fields.Components = make([]Components, len(conf.ComponentNames)) + for i, name := range conf.ComponentNames { reqBody.Fields.Components[i].Name = name } - reqBody.Fields.Summary = summary - if desc != "" { - reqBody.Fields.Description = setDescription(desc) + if conf.Description != "" { + reqBody.Fields.Description = setDescription(conf.Description) + } + + if conf.ParentIssueKey != "" { + reqBody.Fields.Parent = &Parent{Key: conf.ParentIssueKey} } + jsonBody, err := json.MarshalIndent(reqBody, "", " ") if err != nil { return "", fmt.Errorf("failed to marshal body, %w", err) @@ -183,3 +245,84 @@ func setDescription(msg string) *Description { } return &desc } + +// SearchJiraIssues searches for JIRA issues using the JIRA REST API v3. +// The function returns a slice of JQLSearchResponse and an error if the search request failed. +func (jc JiraClient) SearchJiraIssues(jqlReq JQLSearchRequest) ([]Issue, error) { + var allIssues []Issue + var nextPageToken string + + for { + reqBody := struct { + JQL string `json:"jql"` + Fields []string `json:"fields"` + NextPageToken string `json:"nextPageToken,omitempty"` + }{ + JQL: jqlReq.JQL, + Fields: convertFields(jqlReq.IncludedFields), + NextPageToken: nextPageToken, + } + + queryResp, err := jc.doJiraSearchRequest(reqBody) + if err != nil { + return nil, fmt.Errorf("search request failed: %w", err) + } + + allIssues = append(allIssues, queryResp.Issues...) + + // Check for the next page token + if queryResp.NextPageToken == "" { + break // No more pages, exit the loop + } + nextPageToken = queryResp.NextPageToken + } + + return allIssues, nil +} + +// doJiraSearchRequest is a helper to perform the request and handle pagination token +func (jc JiraClient) doJiraSearchRequest(reqBody interface{}) (JQLSearchResponse, error) { + var queryResp JQLSearchResponse + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return queryResp, fmt.Errorf("failed to marshal body, %w", err) + } + + req, err := http.NewRequest("POST", jc.config.URL+"/rest/api/3/search/jql", bytes.NewReader(jsonBody)) + if err != nil { + return queryResp, fmt.Errorf("failed to create request, %w", err) + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + resp, err := jc.c.Do(req) + if err != nil { + return queryResp, err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return queryResp, fmt.Errorf("failed to read body, %w", err) + } + + if resp.StatusCode != http.StatusOK { + return queryResp, fmt.Errorf("non-200 status %d\nmessage: %s", resp.StatusCode, string(b)) + } + + if err := json.Unmarshal(b, &queryResp); err != nil { + return queryResp, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return queryResp, nil +} + +// convertFields converts the IncludedFields to a slice of strings +func convertFields(fields []Field) []string { + result := make([]string, len(fields)) + for i, f := range fields { + result[i] = string(f) + } + return result +} From 8bcc483dcd966f44b48dff9fdeb7681705e311af Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 17:37:57 +0100 Subject: [PATCH 4/6] Update tagline --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e3c1e4..e6cad46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jt -Tiny command-line tool for creating JIRA issues with a summary/title and optionally a description. +Tiny command-line tool for creating JIRA issues with a summary, description and optionally a parent. ## Installation If you have go install locally, compile and install with: From 2ede79e83d339486807ccbf1d5740d00ffa94460 Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 17:41:50 +0100 Subject: [PATCH 5/6] Upgrade go version to 1.23.2 --- .github/workflows/build.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3b5c13..a1a885c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.21' + go-version: '1.23' cache: true - run: go mod tidy - run: go test -v ./... diff --git a/go.mod b/go.mod index c2b0e15..f8f838b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/leosunmo/jt -go 1.21.0 +go 1.23.2 require ( github.com/99designs/keyring v1.2.2 From c947c87f782c70866addede14aaa77fd8f95d017 Mon Sep 17 00:00:00 2001 From: Leo Palmer Date: Sun, 3 Nov 2024 17:43:52 +0100 Subject: [PATCH 6/6] Upgrade the action steps --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1a885c..77952e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,18 +18,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: go-version: '1.23' cache: true - run: go mod tidy - run: go test -v ./... - name: Install Cosign - uses: sigstore/cosign-installer@v3.3.0 + uses: sigstore/cosign-installer@v3.7.0 - uses: goreleaser/goreleaser-action@v4 if: success() && startsWith(github.ref, 'refs/tags/') with: