diff --git a/README.md b/README.md index 8f1b98a..2a5226d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Many of us find the terminal and CLI applications as our main toolkit. ## Features -* Automatically creating new branches named based on issues fetched from project management tools (GitHub, and more in the future...) +* Automatically creating new branches named based on issues fetched from project management tools + * Currently supported: GitHub, Jira, Linear * Extended PR creation: * Automatically push branch to origin * Parse branch names by a pattern into a customized PR title and description template @@ -60,7 +61,8 @@ branch: pattern: "{{.Type}}\/({{.Issue}}-)?{{.Description}}" # Branch name pattern variable_patterns: # A map of patterns to match for each template variable Type: "fix|feat|chore|docs|refactor|test|style|build|ci|perf|revert" - Issue: "[0-9]+" + Issue: "([a-zA-Z]+\-)*[0-9]+" + Author: "[a-zA-Z0-9]+" Description: ".*" token_separators: ["-", "_"] # Characters used to separate branch name into a human-readable string max_length: 60 # Max characters to allow for branch length without prompting for changing it @@ -79,6 +81,7 @@ checkout_new: issue_jql: "[+AND+]assignee=currentUser()+AND+statusCategory!=Done+ORDER+BY+updated+DESC" # The Jira JQL to use when fetching issues. is optional and will be replaced with the project key that is configured in the `project` field. github: issue_list_flags: ["--state", open", "--assignee", "@me"] # The flags to use when fetching issues from GitHub + # linear: # Due to Linear's GraphQL API, the issue list is not configurable. The default is: `assignedIssues(orderBy: updatedAt, filter: { state: { type: { neq: \"completed\" } } })` ``` ### PR Description (Body) @@ -132,6 +135,18 @@ Result: This is "my dashed string" ``` +`title`: + +Capitalizes the first letter of each word in a string. + +`lower`: + +Lower cases a string. + +`upper`: + +Upper cases a string. + ### Special template variable names * `{{.Type}}` - Used to interpret GitHub labels to add to the PR and issue type to add the branch name. @@ -144,7 +159,29 @@ This is "my dashed string" Just export your OpenAI key as an env var named `OPENAI_API_KEY` and you're good to go. -To disable the AI summary, use the `--no-ai-summary` flag. +If `OPENAI_API_KEY` is not set, the AI summary will not be used. + +To disable the AI summary explicitly, use the `--no-ai-summary` flag. + +## Providers + +There are currently 3 providers supported: GitHub, Jira and Linear. + +### GitHub + +The GitHub provider is the default provider and is configured simply by running `gh auth login`. + +### Jira + +To setup, run `gh prx setup provider jira --endpoint --user --token `. + +Alternatively, set the `JIRA_ENDPOINT`, `JIRA_USER` and `JIRA_TOKEN` env vars. + +### Linear + +To setup, run `gh prx setup provider linear --api-key `. + +Alternatively, set the `LINEAR_API_KEY` env var. ## Installation diff --git a/go.mod b/go.mod index b1e3d0d..d22c696 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 golang.org/x/sync v0.4.0 + golang.org/x/text v0.13.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/gotestsum v1.11.0 ) @@ -57,7 +58,6 @@ require ( golang.org/x/mod v0.13.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.14.0 // indirect nhooyr.io/websocket v1.8.7 // indirect ) diff --git a/pkg/branch/branch.go b/pkg/branch/branch.go index 81df804..a763178 100644 --- a/pkg/branch/branch.go +++ b/pkg/branch/branch.go @@ -45,6 +45,8 @@ func ParseBranch(name string, cfg config.BranchConfig) (models.Branch, error) { } } + log.Debugf("Parsed branch: %+v", branch) + return branch, nil } @@ -80,7 +82,7 @@ func TemplateBranchName(cfg *config.RepositoryConfig, issue *models.Issue) (stri if len(name) > cfg.Branch.MaxLength { var userReply bool if err := survey.AskOne(&survey.Confirm{ - Message: fmt.Sprintf("Branch name is too long, do you want to change it?\n>> %s", name), + Message: fmt.Sprintf("Branch name is somewhat long, do you want to change it?\n>> %s", name), }, &userReply); err != nil { return "", errors.Wrap(err, "Failed to prompt for branch name") } diff --git a/pkg/cmd/checkout-new.go b/pkg/cmd/checkout-new.go index 8666ce2..c38ada2 100644 --- a/pkg/cmd/checkout-new.go +++ b/pkg/cmd/checkout-new.go @@ -83,9 +83,13 @@ func checkoutNew(ctx context.Context, id string) error { return errors.Wrap(err, "Failed to get issue") } - branchName, err := branch.TemplateBranchName(cfg, issue) - if err != nil { - return err + branchName := issue.SuggestedBranchName + + if branchName == "" { + branchName, err = branch.TemplateBranchName(cfg, issue) + if err != nil { + return err + } } log.Debugf("Creating branch '%s' and checking out to it", branchName) @@ -111,26 +115,24 @@ func chooseIssue(ctx context.Context, provider providers.IssueProvider) (string, } if len(issues) == 0 { - return "", errors.New("No issues found") + return "", errors.New("No issues found. Please make sure that you have issues that match the configured filter") } var i int - if err := survey.Ask([]*survey.Question{ - { - Name: "issue", - Prompt: &survey.Select{ - Message: "Select an issue:", - Options: lo.Map(issues, func(i *models.Issue, _ int) string { - issueType := "" - if i.Type != "" { - issueType = fmt.Sprintf("(%s) ", i.Type) - } - - return fmt.Sprintf("%s%s - %s", issueType, i.Key, i.Title) - }), - }, + if err := survey.Ask([]*survey.Question{{ + Name: "issue", + Prompt: &survey.Select{ + Message: "Select an issue:", + Options: lo.Map(issues, func(i *models.Issue, _ int) string { + issueType := "" + if i.Type != "" { + issueType = fmt.Sprintf("(%s) ", i.Type) + } + + return fmt.Sprintf("%s%s - %s", issueType, i.Key, i.Title) + }), }, - }, &i); err != nil { + }}, &i); err != nil { return "", errors.Wrap(err, "Failed to prompt for issue") } diff --git a/pkg/cmd/create.go b/pkg/cmd/create.go index cc316f8..3293627 100644 --- a/pkg/cmd/create.go +++ b/pkg/cmd/create.go @@ -142,7 +142,7 @@ func create(ctx context.Context, opts *CreateOpts) error { base = strings.Trim(stdOut.String(), "\n") } - if cfg.IgnorePullRequestTemplate != nil && *cfg.IgnorePullRequestTemplate { + if cfg.IgnorePullRequestTemplate == nil || !*cfg.IgnorePullRequestTemplate { prTemplateBytes, err := utils.ReadFile(".github/pull_request_template.md") if err == nil { cfg.PR.Body = string(prTemplateBytes) @@ -171,7 +171,9 @@ func create(ctx context.Context, opts *CreateOpts) error { return err } + log.Debug(fmt.Sprintf("Pull request title: %s", pr.Title)) log.Debug(fmt.Sprintf("Pull request body:\n\n%s", pr.Body)) + log.Debug(fmt.Sprintf("Pull request labels: %v", pr.Labels)) if len(pr.Labels) > 0 { if err := createLabels(pr.Labels); err != nil { @@ -202,10 +204,10 @@ func createAISummary(ctx context.Context, defer s.Stop() gitDiffCmd := heredoc.Docf(` - git diff main --stat | + git diff %[1]s --stat | grep '|' | awk '{ if ($3 > 10) print $1 }' | - xargs git diff ^%s --ignore-all-space --ignore-blank-lines --ignore-space-change --unified=0 --word-diff -- + xargs git diff ^%[1]s --ignore-all-space --ignore-blank-lines --ignore-space-change --unified=0 --word-diff -- `, base) gitDiffOutput, err := utils.Exec("sh", "-c", gitDiffCmd) if err != nil { diff --git a/pkg/cmd/setup/provider.go b/pkg/cmd/setup/provider.go index 7541112..af513b4 100644 --- a/pkg/cmd/setup/provider.go +++ b/pkg/cmd/setup/provider.go @@ -41,7 +41,7 @@ func NewProviderCmd() *cobra.Command { `, "`"), Example: heredoc.Doc(` // Setup a jira provider: - $ gh prx setup provider jira --user --token + $ gh prx setup provider jira --endpoint --user --token // Setup a linear provider: $ gh prx setup provider linear --api-key @@ -57,6 +57,7 @@ func NewProviderCmd() *cobra.Command { fl.StringVarP(&opts.Endpoint, "endpoint", "e", "", "Endpoint of the provider.") fl.StringVarP(&opts.User, "user", "u", "", "The user to use for the provider.") fl.StringVarP(&opts.Token, "token", "t", "", "The token to use for the provider.") + fl.StringVarP(&opts.APIKey, "api-key", "a", "", "The api-key to use for the provider.") return cmd } diff --git a/pkg/config/repository_config.go b/pkg/config/repository_config.go index c561af5..4ed63ae 100644 --- a/pkg/config/repository_config.go +++ b/pkg/config/repository_config.go @@ -46,7 +46,8 @@ var ( } DefaultVariablePatterns = map[string]string{ "Type": `fix|feat|chore|docs|refactor|test|style|build|ci|perf|revert`, - "Issue": `[0-9]+`, + "Issue": `([a-zA-Z]+\-)*[0-9]+`, + "Author": `[a-zA-Z0-9]+`, "Description": `.*`, } DefaultTokenSeparators = []string{"-", "_"} diff --git a/pkg/config/setup_config.go b/pkg/config/setup_config.go index 417a5f2..ce35f73 100644 --- a/pkg/config/setup_config.go +++ b/pkg/config/setup_config.go @@ -32,6 +32,17 @@ type JiraConfig struct { } func (c *JiraConfig) SetDefaults() { + if c.Endpoint == "" { + c.Endpoint = os.Getenv("JIRA_ENDPOINT") + } + + if c.User == "" { + c.User = os.Getenv("JIRA_USER") + } + + if c.Token == "" { + c.Token = os.Getenv("JIRA_TOKEN") + } } func (c *JiraConfig) Validate() error { @@ -62,9 +73,7 @@ type LinearConfig struct { func (c *LinearConfig) SetDefaults() { if c.APIKey == "" { - if apiKey := os.Getenv("LINEAR_API_KEY"); apiKey != "" { - c.APIKey = apiKey - } + c.APIKey = os.Getenv("LINEAR_API_KEY") } } diff --git a/pkg/models/issue.go b/pkg/models/issue.go index 5e81431..4b3fd8f 100644 --- a/pkg/models/issue.go +++ b/pkg/models/issue.go @@ -10,9 +10,10 @@ var ( ) type Issue struct { - Key string - Title string - Type string + Key string + Title string + Type string + SuggestedBranchName string // Optional, populated for Linear issues } func (i *Issue) NormalizedTitle() string { diff --git a/pkg/pr/pr.go b/pkg/pr/pr.go index ddb0b8d..7174b08 100644 --- a/pkg/pr/pr.go +++ b/pkg/pr/pr.go @@ -23,11 +23,13 @@ var ( "docs": "documentation", } - mdCheckboxMatcher = regexp.MustCompile(`^\s*[\-\*]\s*\[(x|\s)\]`) - commitMsgSeparatorMatcher = regexp.MustCompile(`[\*\-]`) + mdCheckboxMatcher = regexp.MustCompile(`^\s*[\-\*]\s*\[(x|\s)\]`) + commitMsgSeparatorMatcher = regexp.MustCompile(`[\*\-]`) + mapHasNoEntryForKeyMatcher = regexp.MustCompile(`map has no entry for key "(.*)"`) ) type CommitsFetcher func() ([]string, error) +type AISummarizer func() (string, error) func TemplatePR( b models.Branch, @@ -36,24 +38,47 @@ func TemplatePR( tokenSeparators []string, commitsFetcher CommitsFetcher, aiSummarizer func() (string, error), -) (models.PullRequest, error) { +) (*models.PullRequest, error) { log.Debug("Templating PR") funcMaps, err := utils.GenerateTemplateFunctions(tokenSeparators) if err != nil { - return models.PullRequest{}, errors.Wrap(err, "Failed to generate template functions") + return nil, errors.Wrap(err, "Failed to generate template functions") } - pr := models.PullRequest{} + pr := &models.PullRequest{} res := bytes.Buffer{} titleTpl, err := template.New("pr-title-tpl").Funcs(funcMaps).Parse(prCfg.Title) if err != nil { - return models.PullRequest{}, errors.Wrap(err, "Failed to parse pr title template") + return nil, errors.Wrap(err, "Failed to parse pr title template") } - if err := titleTpl.Option("missingkey=error").Execute(&res, b.Fields); err != nil { - return models.PullRequest{}, errors.Wrap(err, "Failed to template pr title") + + for { + err := titleTpl.Option("missingkey=error").Execute(&res, b.Fields) + if err == nil { + break + } + + matches := mapHasNoEntryForKeyMatcher.FindStringSubmatch(err.Error()) + + if len(matches) == 0 { + return nil, errors.Wrap(err, "Failed to template pr title") + } + + log.Warn("Missing key in branch fields, prompting user to enter it manually") + answer := "" + if err := survey.AskOne(&survey.Input{ + Message: "Enter value for " + matches[1], + }, &answer, survey.WithValidator(survey.Required)); err != nil { + return nil, errors.Wrap(err, "Failed to ask for missing field value") + } + + b.Fields[matches[1]] = answer + + continue } + pr.Title = res.String() bodyData := lo.Assign(b.Fields, make(map[string]any)) @@ -62,7 +87,7 @@ func TemplatePR( log.Debug("Fetching commits") commits, err := fetchCommits(prCfg.IgnoreCommitsPatterns, commitsFetcher) if err != nil { - return models.PullRequest{}, err + return nil, err } bodyData["Commits"] = commits } @@ -70,7 +95,7 @@ func TemplatePR( if strings.Contains(prCfg.Body, ".AISummary") { aiSummary, err := aiSummarizer() if err != nil { - return models.PullRequest{}, err + return nil, err } bodyData["AISummary"] = aiSummary } @@ -78,23 +103,25 @@ func TemplatePR( res = bytes.Buffer{} bodyTpl, err := template.New("pr-body-tpl").Funcs(funcMaps).Parse(prCfg.Body) if err != nil { - return models.PullRequest{}, errors.Wrap(err, "Failed to parse pr body template") + return nil, errors.Wrap(err, "Failed to parse pr body template") } if err := bodyTpl.Option("missingkey=zero").Execute(&res, bodyData); err != nil { - return models.PullRequest{}, errors.Wrap(err, "Failed to template pr body") + return nil, errors.Wrap(err, "Failed to template pr body") } pr.Body = res.String() if *prCfg.AnswerChecklist { body, err := answerPRChecklist(pr.Body, confirm) if err != nil { - return models.PullRequest{}, err + return nil, err } pr.Body = body } if typeAny, ok := b.Fields["Type"]; ok { if typeStr, ok := typeAny.(string); ok { + typeStr = strings.ToLower(typeStr) + label, ok := TypeToLabel[typeStr] if !ok { label = typeStr diff --git a/pkg/providers/linear.go b/pkg/providers/linear.go index c3f256a..3b314ec 100644 --- a/pkg/providers/linear.go +++ b/pkg/providers/linear.go @@ -78,6 +78,7 @@ type LinearIssue struct { Name string } } + BranchName string } func (i *LinearIssue) ToIssue() *models.Issue { @@ -91,9 +92,10 @@ func (i *LinearIssue) ToIssue() *models.Issue { } return &models.Issue{ - Key: i.Identifier, - Title: i.Title, - Type: issueType, + Key: i.Identifier, + Title: i.Title, + Type: issueType, + SuggestedBranchName: i.BranchName, } } diff --git a/pkg/utils/template.go b/pkg/utils/template.go index 4957abd..551f55f 100644 --- a/pkg/utils/template.go +++ b/pkg/utils/template.go @@ -8,6 +8,8 @@ import ( "github.com/pkg/errors" "github.com/samber/lo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) func GenerateTemplateFunctions(tokenSeparators []string) (template.FuncMap, error) { @@ -21,5 +23,14 @@ func GenerateTemplateFunctions(tokenSeparators []string) (template.FuncMap, erro "humanize": func(text string) (string, error) { return tokenMatcher.ReplaceAllString(text, " "), nil }, + "title": func(text string) (string, error) { + return cases.Title(language.English).String(text), nil + }, + "lower": func(text string) (string, error) { + return strings.ToLower(text), nil + }, + "upper": func(text string) (string, error) { + return strings.ToUpper(text), nil + }, }, nil }