Skip to content

Commit 4c1f9ec

Browse files
authored
ticket url pattern (#34)
1 parent 5bcfade commit 4c1f9ec

7 files changed

Lines changed: 107 additions & 15 deletions

File tree

commands/command_new.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ func createNewCommand() *cobra.Command {
5959
" the prefix of the ticket number\n" +
6060
" FeatureFlag Value passed to feature-flag flag\n" +
6161
" TicketNumber Jira ticket as parsed from the commit summary\n" +
62+
" TicketUrlPattern URL for the ticket, with {TicketNumber}\n" +
63+
" replaced by the actual ticket number.\n" +
64+
" Configured via config.yaml or --config.\n" +
6265
" Username Name as parsed from git config email.\n" +
6366
" UsernameCleaned Username with dots (.) converted to dashes (-).\n",
6467
Args: cobra.MaximumNArgs(1),
@@ -88,8 +91,16 @@ func createNewCommand() *cobra.Command {
8891
if *baseBranch == "" {
8992
*baseBranch = util.GetMainBranchOrDie()
9093
}
94+
ticketUrlPattern := userConfig.TicketUrlPattern
95+
if ticketUrlPattern == "" && templates.TemplateUsesTicketUrlPattern() {
96+
ticketUrlPattern = interactive.PromptForStringOrDie(
97+
"Ticket URL pattern (use {TicketNumber} as placeholder):",
98+
nil,
99+
)
100+
util.SaveTicketUrlPattern(ticketUrlPattern)
101+
}
91102
selectedReviewers, markReady := promptForReviewers(len(args) == 0 && *draft && *reviewers == "", userConfig, *merge)
92-
createNewPr(*draft, *featureFlag, *baseBranch, targetCommits[0])
103+
createNewPr(*draft, *featureFlag, ticketUrlPattern, *baseBranch, targetCommits[0])
93104
maybeAddReviewers(*reviewers, selectedReviewers, markReady, targetCommits, AddReviewersOptions{
94105
WhenChecksPass: true,
95106
Silent: *silent,
@@ -103,11 +114,11 @@ func createNewCommand() *cobra.Command {
103114
}
104115

105116
// Creates a new pull request via Github CLI.
106-
func createNewPr(draft bool, featureFlag string, baseBranch string, gitLog templates.GitLog) {
117+
func createNewPr(draft bool, featureFlag string, ticketUrlPattern string, baseBranch string, gitLog templates.GitLog) {
107118
templates.RequireCommitOnMain(gitLog.Commit)
108119
util.WithStashAndRollback("sd new "+gitLog.Commit+" "+gitLog.Subject, func(rollbackManager *util.GitRollbackManager) {
109120
createBranchAndCherryPick(rollbackManager, baseBranch, gitLog)
110-
pushAndCreateGhPr(draft, featureFlag, baseBranch, gitLog)
121+
pushAndCreateGhPr(draft, featureFlag, ticketUrlPattern, baseBranch, gitLog)
111122
rollbackManager.Clear()
112123
openPrAndSwitchBack(gitLog)
113124
})
@@ -129,11 +140,11 @@ func createBranchAndCherryPick(rollbackManager *util.GitRollbackManager, baseBra
129140
util.ExecuteOrDie(util.ExecuteOptions{}, "git", "cherry-pick", gitLog.Commit)
130141
}
131142

132-
func pushAndCreateGhPr(draft bool, featureFlag string, baseBranch string, gitLog templates.GitLog) {
143+
func pushAndCreateGhPr(draft bool, featureFlag string, ticketUrlPattern string, baseBranch string, gitLog templates.GitLog) {
133144
slog.Info("Pushing to remote")
134145
// -u is required because in newer versions of Github CLI the upstream must be set.
135146
util.ExecuteOrDie(util.ExecuteOptions{}, "git", "-c", "push.default=current", "push", "--force-with-lease", "-u")
136-
prText := templates.GetPullRequestText(gitLog.Commit, featureFlag)
147+
prText := templates.GetPullRequestText(gitLog.Commit, featureFlag, ticketUrlPattern)
137148
slog.Info("Creating PR via gh")
138149
createPrOutput := createPr(prText, baseBranch, draft)
139150
slog.Info(fmt.Sprint("Created PR ", createPrOutput))

commands/parse_arguments.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,14 @@ func buildRootCommand() *cobra.Command {
8282
"~/.gh-stacked-diff/config.yaml. Supported keys:\n"+
8383
" promptForReview=never|promptY|promptN (default: promptN)\n"+
8484
" pollInterval=<duration> (default: 30s, e.g. 1m, 10s)\n"+
85+
" ticketUrlPattern=<url> URL pattern for tickets, e.g.\n"+
86+
" https://jira.example.com/browse/{TicketNumber}\n"+
8587
"Can be specified multiple times for different keys.\n"+
8688
"\n"+
8789
"Equivalent config.yaml:\n"+
8890
" promptForReview: promptY\n"+
89-
" pollInterval: 1m")
91+
" pollInterval: 1m\n"+
92+
" ticketUrlPattern: https://jira.example.com/browse/{TicketNumber}")
9093
rootCmd.PersistentFlags().Lookup("config").DefValue = ""
9194

9295
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

commands/test_parse_arguments.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ func getTestConfigHome() string {
8787
configHome := filepath.Join(parentDir, ".gh-stacked-diff")
8888
// nolint:errcheck
8989
os.Mkdir(configHome, os.ModePerm)
90+
// Write a default config.yaml so that tests don't trigger interactive
91+
// prompts for ticketUrlPattern.
92+
configFile := filepath.Join(configHome, "config.yaml")
93+
if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
94+
// nolint:errcheck
95+
os.WriteFile(configFile, []byte("ticketUrlPattern: https://example.com/browse/{TicketNumber}\n"), 0644)
96+
}
9097
return configHome
9198
}
9299

commands/user_config_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,26 @@ func TestNewUserConfig_InvalidPollInterval(t *testing.T) {
7373
util.NewUserConfig(util.YamlConfig{}, map[string]string{"pollInterval": "notaduration"})
7474
})
7575
}
76+
77+
func TestNewUserConfig_TicketUrlPatternDefault(t *testing.T) {
78+
config := util.NewUserConfig(util.YamlConfig{}, nil)
79+
assert.Equal(t, "", config.TicketUrlPattern)
80+
}
81+
82+
func TestNewUserConfig_TicketUrlPatternFromFlag(t *testing.T) {
83+
config := util.NewUserConfig(util.YamlConfig{}, map[string]string{"ticketUrlPattern": "https://jira.example.com/browse/{TicketNumber}"})
84+
assert.Equal(t, "https://jira.example.com/browse/{TicketNumber}", config.TicketUrlPattern)
85+
}
86+
87+
func TestNewUserConfig_TicketUrlPatternFromFile(t *testing.T) {
88+
config := util.NewUserConfig(util.YamlConfig{TicketUrlPattern: "https://jira.example.com/browse/{TicketNumber}"}, nil)
89+
assert.Equal(t, "https://jira.example.com/browse/{TicketNumber}", config.TicketUrlPattern)
90+
}
91+
92+
func TestNewUserConfig_TicketUrlPatternFlagOverridesFile(t *testing.T) {
93+
config := util.NewUserConfig(
94+
util.YamlConfig{TicketUrlPattern: "https://file.example.com/{TicketNumber}"},
95+
map[string]string{"ticketUrlPattern": "https://flag.example.com/{TicketNumber}"},
96+
)
97+
assert.Equal(t, "https://flag.example.com/{TicketNumber}", config.TicketUrlPattern)
98+
}

templates/config/pr_description.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424

2525
-->
2626

27-
#### Ticket: [{{.TicketNumber}}](https://jira.slackhq.com/browse/{{.TicketNumber}})
27+
#### Ticket: [{{.TicketNumber}}]({{.TicketUrlPattern}})
2828

2929
#### Feature flag(s): `{{.FeatureFlag}}`

templates/templates.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
_ "embed"
66
"fmt"
77
"log/slog"
8+
"os"
89
"regexp"
910
"strconv"
1011
"strings"
@@ -34,6 +35,7 @@ type branchTemplateData struct {
3435

3536
type templateData struct {
3637
TicketNumber string
38+
TicketUrlPattern string
3739
Username string
3840
CommitBody string
3941
CommitSummary string
@@ -157,8 +159,8 @@ func truncateString(str string, maxBytes int) string {
157159
return str
158160
}
159161

160-
func GetPullRequestText(commitHash string, featureFlag string) PullRequestText {
161-
data := getPullRequestTemplateData(commitHash, featureFlag)
162+
func GetPullRequestText(commitHash string, featureFlag string, ticketUrlPattern string) PullRequestText {
163+
data := getPullRequestTemplateData(commitHash, featureFlag, ticketUrlPattern)
162164
title := RunTemplate("pr-title.template", prTitleTemplateText, data)
163165
description := RunTemplate("pr-description.template", prDescriptionTemplateText, data)
164166
return PullRequestText{Description: description, Title: title}
@@ -186,17 +188,20 @@ func RunTemplate(configFilename string, defaultTemplateText string, data any) st
186188
return output.String()
187189
}
188190

189-
func getPullRequestTemplateData(commitHash string, featureFlag string) templateData {
191+
func getPullRequestTemplateData(commitHash string, featureFlag string, ticketUrlPattern string) templateData {
190192
commitSummary := util.ExecuteOrDieTrimmed(util.ExecuteOptions{}, "git", "--no-pager", "show", "--no-patch", "--format=%s", commitHash)
191193
commitBody := util.ExecuteOrDieTrimmed(util.ExecuteOptions{}, "git", "--no-pager", "show", "--no-patch", "--format=%b", commitHash)
192194
commentLineRegex := regexp.MustCompile("(?m)^#.*$")
193195
commitBody = commentLineRegex.ReplaceAllString(commitBody, "")
194196
commitSummaryCleaned := util.ExecuteOrDieTrimmed(util.ExecuteOptions{}, "git", "show", "--no-patch", "--format=%f", commitHash)
195197
expression := regexp.MustCompile(`^(\S+-[[:digit:]]+ )?(.*)`)
196198
summaryMatches := expression.FindStringSubmatch(commitSummary)
199+
ticketNumber := strings.TrimSpace(summaryMatches[1])
200+
resolvedTicketUrl := strings.ReplaceAll(ticketUrlPattern, "{TicketNumber}", ticketNumber)
197201
return templateData{
198202
Username: util.GetUsername(),
199-
TicketNumber: strings.TrimSpace(summaryMatches[1]),
203+
TicketNumber: ticketNumber,
204+
TicketUrlPattern: resolvedTicketUrl,
200205
CommitBody: commitBody,
201206
CommitSummary: commitSummary,
202207
CommitSummaryWithoutTicket: summaryMatches[2],
@@ -205,6 +210,25 @@ func getPullRequestTemplateData(commitHash string, featureFlag string) templateD
205210
}
206211
}
207212

213+
// TemplateUsesTicketUrlPattern returns true if the PR description or title
214+
// template references the TicketUrlPattern variable.
215+
func TemplateUsesTicketUrlPattern() bool {
216+
return templateTextContains("pr-description.template", prDescriptionTemplateText, "TicketUrlPattern") ||
217+
templateTextContains("pr-title.template", prTitleTemplateText, "TicketUrlPattern")
218+
}
219+
220+
func templateTextContains(configFilename string, defaultText string, search string) bool {
221+
configFile := util.GetConfigFile(configFilename)
222+
if configFile != "" {
223+
data, err := os.ReadFile(configFile)
224+
if err != nil {
225+
panic(fmt.Sprint("Could not read ", configFile, ": ", err))
226+
}
227+
return strings.Contains(string(data), search)
228+
}
229+
return strings.Contains(defaultText, search)
230+
}
231+
208232
func getBranchTemplateData(sanitizedSummary string) branchTemplateData {
209233
// Dots are not allowed in branch names of some Github configurations.
210234
username := strings.ReplaceAll(util.GetUsername(), ".", "-")

util/user_config.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"os"
8+
"path/filepath"
89
"time"
910

1011
"gopkg.in/yaml.v3"
@@ -31,13 +32,15 @@ func (t PromptForReviewType) IsValid() bool {
3132

3233
// UserConfig holds runtime configuration from config file and --config flag key=value entries.
3334
type UserConfig struct {
34-
PromptForReview PromptForReviewType
35-
PollInterval time.Duration
35+
PromptForReview PromptForReviewType
36+
PollInterval time.Duration
37+
TicketUrlPattern string
3638
}
3739

3840
type YamlConfig struct {
39-
PromptForReview PromptForReviewType `yaml:"promptForReview"`
40-
PollInterval string `yaml:"pollInterval"`
41+
PromptForReview PromptForReviewType `yaml:"promptForReview,omitempty"`
42+
PollInterval string `yaml:"pollInterval,omitempty"`
43+
TicketUrlPattern string `yaml:"ticketUrlPattern,omitempty"`
4144
}
4245

4346
// LoadUserConfigFile reads config.yaml from ConfigHome if it exists.
@@ -78,6 +81,9 @@ func NewUserConfig(fileConfig YamlConfig, flagValues map[string]string) UserConf
7881
d, _ := time.ParseDuration(fileConfig.PollInterval)
7982
config.PollInterval = d
8083
}
84+
if fileConfig.TicketUrlPattern != "" {
85+
config.TicketUrlPattern = fileConfig.TicketUrlPattern
86+
}
8187
for key, value := range flagValues {
8288
switch key {
8389
case "promptForReview":
@@ -92,9 +98,27 @@ func NewUserConfig(fileConfig YamlConfig, flagValues map[string]string) UserConf
9298
panic("invalid pollInterval value: " + value)
9399
}
94100
config.PollInterval = d
101+
case "ticketUrlPattern":
102+
config.TicketUrlPattern = value
95103
default:
96104
panic("unknown --config key: " + key)
97105
}
98106
}
99107
return config
100108
}
109+
110+
// SaveTicketUrlPattern saves the ticketUrlPattern value to the config file,
111+
// preserving any existing config values.
112+
func SaveTicketUrlPattern(pattern string) {
113+
fileConfig := LoadUserConfigFile()
114+
fileConfig.TicketUrlPattern = pattern
115+
configPath := filepath.Join(GetAppConfig().ConfigHome, "config.yaml")
116+
data, err := yaml.Marshal(fileConfig)
117+
if err != nil {
118+
panic(fmt.Sprint("Could not marshal config: ", err))
119+
}
120+
if err := os.WriteFile(configPath, data, 0644); err != nil {
121+
panic(fmt.Sprint("Could not write config file: ", err))
122+
}
123+
slog.Info(fmt.Sprint("Saved ticketUrlPattern to ", configPath))
124+
}

0 commit comments

Comments
 (0)