Skip to content

Commit

Permalink
feat: add support for dynamic filling of missing vars for pr title
Browse files Browse the repository at this point in the history
fix: some readme additions and env vars support for providers
  • Loading branch information
ilaif committed Nov 13, 2023
1 parent 7ccf1a6 commit b87fc44
Show file tree
Hide file tree
Showing 12 changed files with 146 additions and 51 deletions.
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -79,6 +81,7 @@ checkout_new:
issue_jql: "[<jira_project>+AND+]assignee=currentUser()+AND+statusCategory!=Done+ORDER+BY+updated+DESC" # The Jira JQL to use when fetching issues. <jira_project> 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)
Expand Down Expand Up @@ -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.
Expand All @@ -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 <endpoint> --user <email> --token <token>`.
Alternatively, set the `JIRA_ENDPOINT`, `JIRA_USER` and `JIRA_TOKEN` env vars.
### Linear
To setup, run `gh prx setup provider linear --api-key <api-key>`.
Alternatively, set the `LINEAR_API_KEY` env var.
## Installation
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
4 changes: 3 additions & 1 deletion pkg/branch/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func ParseBranch(name string, cfg config.BranchConfig) (models.Branch, error) {
}
}

log.Debugf("Parsed branch: %+v", branch)

return branch, nil
}

Expand Down Expand Up @@ -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")
}
Expand Down
40 changes: 21 additions & 19 deletions pkg/cmd/checkout-new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}

Expand Down
8 changes: 5 additions & 3 deletions pkg/cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion pkg/cmd/setup/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewProviderCmd() *cobra.Command {
`, "`"),
Example: heredoc.Doc(`
// Setup a jira provider:
$ gh prx setup provider jira --user <email> --token <token>
$ gh prx setup provider jira --endpoint <endpoint> --user <email> --token <token>
// Setup a linear provider:
$ gh prx setup provider linear --api-key <api-key>
Expand All @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/config/repository_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{"-", "_"}
Expand Down
15 changes: 12 additions & 3 deletions pkg/config/setup_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}

Expand Down
7 changes: 4 additions & 3 deletions pkg/models/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 40 additions & 13 deletions pkg/pr/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -62,39 +87,41 @@ 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
}

if strings.Contains(prCfg.Body, ".AISummary") {
aiSummary, err := aiSummarizer()
if err != nil {
return models.PullRequest{}, err
return nil, err
}
bodyData["AISummary"] = aiSummary
}

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
Expand Down
Loading

0 comments on commit b87fc44

Please sign in to comment.