diff --git a/cmd/generate.go b/cmd/generate.go index 4118ec6..e163580 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -18,6 +18,7 @@ var ( fromRef string toRef string writeTo string + usePR bool DefaultTimeout = 10 * time.Second ) @@ -47,7 +48,10 @@ gh-jira-changelog generate --config=".yaml" --from="v0.1.0" # Using it as GH plugin # assuming jira plugin installed -gh jira-changelog generate --config=".yaml" --from="v0.1.0" --to="v0.2.0"`, +gh jira-changelog generate --config=".yaml" --from="v0.1.0" --to="v0.2.0" + +# using PR titles to generate changelog +gh jira-changelog generate --config=".yaml" --from="v0.1.0" --to="v0.2.0" --use_pr`, PreRunE: func(cmd *cobra.Command, args []string) error { apiToken := viper.GetString("api_token") emailID := viper.GetString("email_id") @@ -62,11 +66,12 @@ gh jira-changelog generate --config=".yaml" --from="v0.1.0" defer cancel() changelog := jira_changelog.NewGenerator( - jira.NewContext(jira.Options{ + jira.NewClient(jira.NewContext(jira.Options{ jira.BaseURL: viper.GetString("base_url"), jira.ApiToken: viper.GetString("api_token"), jira.User: viper.GetString("email_id"), - }), + })), + usePR, fromRef, toRef, viper.GetString("repo_url"), @@ -100,6 +105,7 @@ func writer(writeTo string) io.Writer { func init() { generateCmd.Flags().StringVar(&fromRef, "from", "", "Git ref to start from") generateCmd.Flags().StringVar(&toRef, "to", "main", "Git ref to end at") + generateCmd.Flags().BoolVar(&usePR, "use_pr", false, "use PR titles to generate changelog. Note: only works if used as gh plugin") generateCmd.Flags().StringVar(&writeTo, "write_to", "/dev/stdout", "File stream to write the changelog") generateCmd.MarkFlagRequired("from") diff --git a/go.mod b/go.mod index ac321dd..7b8a81f 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.8.3 + github.com/whilp/git-urls v1.0.0 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 golang.org/x/oauth2 v0.13.0 ) diff --git a/go.sum b/go.sum index c17c6c6..09a96df 100644 --- a/go.sum +++ b/go.sum @@ -217,6 +217,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/whilp/git-urls v1.0.0 h1:95f6UMWN5FKW71ECsXRUd3FVYiXdrE7aX4NZKcPmIjU= +github.com/whilp/git-urls v1.0.0/go.mod h1:J16SAmobsqc3Qcy98brfl5f5+e0clUvg1krgwk/qCfE= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/jira_changelog/generator.go b/pkg/jira_changelog/generator.go index 626f149..1f26d6b 100644 --- a/pkg/jira_changelog/generator.go +++ b/pkg/jira_changelog/generator.go @@ -2,10 +2,9 @@ package jira_changelog import ( "context" - "fmt" - "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/git" "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/jira" + "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/messages" "github.com/samber/lo" "golang.org/x/exp/slog" ) @@ -16,23 +15,26 @@ type Generator struct { toRef string repoURL string client jira.Client + usePR bool } -func NewGenerator(jiraCtx *jira.Context, fromRef, toRef, repoURL string) *Generator { - client := jira.NewClient(jiraCtx) +func NewGenerator(client jira.Client, usePR bool, fromRef, toRef, repoURL string) *Generator { g := &Generator{ - JiraConfig: jiraCtx, - fromRef: fromRef, - toRef: toRef, - repoURL: repoURL, - client: client, + fromRef: fromRef, + toRef: toRef, + repoURL: repoURL, + client: client, + usePR: usePR, } return g } func (c *Generator) Generate(ctx context.Context) *Changelog { - commits, err := git.NewCommitPopulator(c.fromRef, c.toRef).Commits(ctx) + populator, err := messages.NewCommitOrPRPopualtor(c.usePR, c.fromRef, c.toRef, c.repoURL) + panicIfErr(err) + + commits, err := populator.Populate(ctx) panicIfErr(err) issues, err := c.fetchJiraIssues(commits) @@ -45,7 +47,7 @@ func (c *Generator) Generate(ctx context.Context) *Changelog { return NewChangelog(c.fromRef, c.toRef, c.repoURL, issuesByEpic) } -func (c *Generator) fetchJiraIssues(commits []git.Commit) ([]jira.Issue, error) { +func (c *Generator) fetchJiraIssues(commits []messages.Message) ([]jira.Issue, error) { slog.Debug("Total commit messages", "count", len(commits)) jiraIssues := make([]jira.Issue, 0) @@ -62,17 +64,17 @@ func (c *Generator) fetchJiraIssues(commits []git.Commit) ([]jira.Issue, error) return lo.Uniq(jiraIssues), nil } -func (c *Generator) fetchJiraIssue(commit git.Commit) (jira.Issue, error) { - issueId := jira.IssueId(commit.Message) +func (c *Generator) fetchJiraIssue(commit messages.Message) (jira.Issue, error) { + issueId := jira.IssueId(commit.Message()) if issueId == "" { slog.Warn("commit message does not contain issue jira id of the project", "commit", commit) - return jira.NewIssue("", fmt.Sprintf("%s (%s)", commit.Message, commit.Sha), "done", ""), nil + return jira.NewIssue("", commit.Message(), "done", ""), nil } issue, err := c.client.FetchIssue(string(issueId)) if err != nil { slog.Warn("failed to fetch jira issue", "commit", commit) - return jira.NewIssue("", fmt.Sprintf("%s (%s)", commit.Message, commit.Sha), "done", ""), nil + return jira.NewIssue("", commit.Message(), "done", ""), nil } return issue, nil } diff --git a/pkg/jira_changelog/generator_test.go b/pkg/jira_changelog/generator_test.go index d949e5a..6225cb0 100644 --- a/pkg/jira_changelog/generator_test.go +++ b/pkg/jira_changelog/generator_test.go @@ -5,28 +5,29 @@ import ( "time" "github.com/handofgod94/gh-jira-changelog/mocks" - "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/git" "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/jira" + "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/messages" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) func TestFetchJiraIssuesEvent(t *testing.T) { - commits := []git.Commit{ - {Time: time.Now(), Message: "[TEST-1234] commit message1", Sha: "3245vw"}, - {Time: time.Now(), Message: "[TEST-4546] commit message sample1", Sha: "3245vw"}, - {Time: time.Now(), Message: "[TEST-1234] commit message2", Sha: "3245vw"}, - {Time: time.Now(), Message: "[TEST-4546] commit message sample2", Sha: "3245vw"}, - {Time: time.Now(), Message: "[TEST-12345] commit message from same epic", Sha: "3245vw"}, - {Time: time.Now(), Message: "[NO-CARD] commit message random", Sha: "3245vw"}, - {Time: time.Now(), Message: "foobar commit message random", Sha: "3245vw"}, + commits := []messages.Commit{ + {Time: time.Now(), Summary: "[TEST-1234] commit message1", Sha: "3245vw"}, + {Time: time.Now(), Summary: "[TEST-4546] commit message sample1", Sha: "3245vw"}, + {Time: time.Now(), Summary: "[TEST-1234] commit message2", Sha: "3245vw"}, + {Time: time.Now(), Summary: "[TEST-4546] commit message sample2", Sha: "3245vw"}, + {Time: time.Now(), Summary: "[TEST-12345] commit message from same epic", Sha: "3245vw"}, + {Time: time.Now(), Summary: "[NO-CARD] commit message random", Sha: "3245vw"}, + {Time: time.Now(), Summary: "foobar commit message random", Sha: "3245vw"}, } want := []jira.Issue{ jira.NewIssue("TEST-1234", "Ticket description", "done", "Epic1"), jira.NewIssue("TEST-4546", "Ticket description for 4546 issue", "done", "Epic2"), jira.NewIssue("TEST-12345", "Ticket description of another card from same epic", "done", "Epic1"), - jira.NewIssue("", "[NO-CARD] commit message random (3245vw)", "done", ""), - jira.NewIssue("", "foobar commit message random (3245vw)", "done", ""), + jira.NewIssue("", "[NO-CARD] commit message random", "done", ""), + jira.NewIssue("", "foobar commit message random", "done", ""), } mockedClient := mocks.NewClient(t) @@ -34,10 +35,11 @@ func TestFetchJiraIssuesEvent(t *testing.T) { mockedClient.On("FetchIssue", "TEST-4546").Return(want[1], nil).Twice() mockedClient.On("FetchIssue", "TEST-12345").Return(want[2], nil) - generator := NewGenerator(jira.NewContext(nil), "fromRef", "toRef", "http://example-repo.com") + generator := NewGenerator(jira.NewClient(jira.NewContext(nil)), false, "fromRef", "toRef", "http://example-repo.com") generator.client = mockedClient - got, err := generator.fetchJiraIssues(commits) + changeMessages := lo.Map(commits, func(commit messages.Commit, i int) messages.Message { return commit }) + got, err := generator.fetchJiraIssues(changeMessages) assert.NoError(t, err) assert.Equal(t, len(want), len(got)) diff --git a/pkg/jira_changelog/github/pull_request_populator.go b/pkg/jira_changelog/github/pull_request_populator.go deleted file mode 100644 index 54c7424..0000000 --- a/pkg/jira_changelog/github/pull_request_populator.go +++ /dev/null @@ -1,68 +0,0 @@ -package github - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - - "github.com/cli/go-gh/v2/pkg/api" - "golang.org/x/exp/slog" -) - -type PullRequest struct { - Title string - Author string - Number int -} - -type pullRequestPopulator struct { - fromRef string - toRef string - apiClient *api.RESTClient - repoOwner string - repoName string -} - -func NewPullRequestPopulator(fromRef, toRef, repoOwner, repoName string) (*pullRequestPopulator, error) { - apiClient, err := api.DefaultRESTClient() - if err != nil { - return nil, err - } - - return &pullRequestPopulator{ - fromRef, - toRef, - apiClient, - repoOwner, - repoName, - }, nil -} - -func (p *pullRequestPopulator) PullRequests(ctx context.Context) ([]PullRequest, error) { - response := struct { - Name string - Body string - }{} - - requestBody, err := json.Marshal(map[string]string{ - "owner": p.repoOwner, - "repo": p.repoName, - "tag_name": p.toRef, - "target_commitish": "main", // TODO: make this configurable - "previous_tag_name": p.fromRef, - }) - if err != nil { - return []PullRequest{}, err - } - - err = p.apiClient.Post(fmt.Sprintf("repos/%s/%s/releases/generate-notes", p.repoOwner, p.repoName), bytes.NewBuffer(requestBody), &response) - if err != nil { - return []PullRequest{}, err - } - - slog.Info("successfully fetched changelog from github") - slog.Debug("here's the changelog provided by github", "changelog", response.Body) - - return []PullRequest{}, nil -} diff --git a/pkg/jira_changelog/git/commit_populator.go b/pkg/jira_changelog/messages/git_commits_populator.go similarity index 57% rename from pkg/jira_changelog/git/commit_populator.go rename to pkg/jira_changelog/messages/git_commits_populator.go index c0cda4f..ffe1083 100644 --- a/pkg/jira_changelog/git/commit_populator.go +++ b/pkg/jira_changelog/messages/git_commits_populator.go @@ -1,43 +1,53 @@ -package git +package messages import ( "context" "fmt" "os/exec" "time" + + "github.com/samber/lo" ) +var _ Populator = &commitPopulator{} +var _ Message = &Commit{} + type Commit struct { - Message string + Summary string Time time.Time Sha string } +func (c Commit) Message() string { + return c.Summary +} + type commitPopulator struct { fromRef string toRef string } -func NewCommitPopulator(fromRef, toRef string) *commitPopulator { +func NewCommitPopulator(fromRef, toRef string) (Populator, error) { cpw := &commitPopulator{ fromRef: fromRef, toRef: toRef, } - return cpw + return cpw, nil } -func (cpw *commitPopulator) Commits(ctx context.Context) ([]Commit, error) { +func (cpw *commitPopulator) Populate(ctx context.Context) ([]Message, error) { gitOutput, err := execGitLog(ctx, cpw.fromRef, cpw.toRef) if err != nil { - return []Commit{}, fmt.Errorf("failed to execute git log. %w", err) + return nil, fmt.Errorf("failed to execute git log. %w", err) } commits, err := gitOutput.Commits() if err != nil { - return []Commit{}, fmt.Errorf("failed to parse output. %w", err) + return nil, fmt.Errorf("failed to parse output. %w", err) } - return commits, nil + messages := lo.Map(commits, func(commit Commit, i int) Message { return commit }) + return messages, nil } func execGitLog(ctx context.Context, fromRef, toRef string) (GitOutput, error) { diff --git a/pkg/jira_changelog/git/git_output.go b/pkg/jira_changelog/messages/git_output.go similarity index 98% rename from pkg/jira_changelog/git/git_output.go rename to pkg/jira_changelog/messages/git_output.go index c33b8bc..5458398 100644 --- a/pkg/jira_changelog/git/git_output.go +++ b/pkg/jira_changelog/messages/git_output.go @@ -1,4 +1,4 @@ -package git +package messages import ( "fmt" @@ -37,7 +37,7 @@ func (gt GitOutput) Commits() ([]Commit, error) { } commits = append(commits, Commit{ - Message: message, + Summary: message, Time: commitTime, Sha: sha, }) diff --git a/pkg/jira_changelog/git/git_output_test.go b/pkg/jira_changelog/messages/git_output_test.go similarity index 65% rename from pkg/jira_changelog/git/git_output_test.go rename to pkg/jira_changelog/messages/git_output_test.go index 4c20adc..3819f73 100644 --- a/pkg/jira_changelog/git/git_output_test.go +++ b/pkg/jira_changelog/messages/git_output_test.go @@ -1,40 +1,40 @@ -package git_test +package messages_test import ( "testing" "time" - "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/git" + "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/messages" "github.com/stretchr/testify/assert" ) func TestCommits(t *testing.T) { testCases := []struct { desc string - gitOutput git.GitOutput - want []git.Commit + gitOutput messages.GitOutput + want []messages.Commit wantErr bool }{ { desc: "returns commits when gitoutput is valid", - gitOutput: git.GitOutput(` + gitOutput: messages.GitOutput(` (1687839814) {3cefgdr} use extra space while generating template (1688059937) {4567uge} [JIRA-123] refactor: extract out structs from jira/types (1687799347) {3456cdw} add warning emoji for changelog lineitem `), - want: []git.Commit{ + want: []messages.Commit{ { - Message: "use extra space while generating template", + Summary: "use extra space while generating template", Time: time.Unix(1687839814, 0), Sha: "3cefgdr", }, { - Message: "[JIRA-123] refactor: extract out structs from jira/types", + Summary: "[JIRA-123] refactor: extract out structs from jira/types", Time: time.Unix(1688059937, 0), Sha: "4567uge", }, { - Message: "add warning emoji for changelog lineitem", + Summary: "add warning emoji for changelog lineitem", Time: time.Unix(1687799347, 0), Sha: "3456cdw", }, @@ -42,19 +42,19 @@ func TestCommits(t *testing.T) { }, { desc: "returns single commit if gitoutput has single line", - gitOutput: git.GitOutput(` + gitOutput: messages.GitOutput(` (1688059937) {3456cdw} refactor: extract out structs from jira/types `), - want: []git.Commit{{Message: "refactor: extract out structs from jira/types", Time: time.Unix(1688059937, 0), Sha: "3456cdw"}}, + want: []messages.Commit{{Summary: "refactor: extract out structs from jira/types", Time: time.Unix(1688059937, 0), Sha: "3456cdw"}}, }, { desc: "returns error when output is not in correct format", - gitOutput: git.GitOutput(`foobar`), + gitOutput: messages.GitOutput(`foobar`), wantErr: true, }, { desc: "returns error when output is empty", - gitOutput: git.GitOutput(""), + gitOutput: messages.GitOutput(""), wantErr: true, }, } diff --git a/pkg/jira_changelog/messages/populator.go b/pkg/jira_changelog/messages/populator.go new file mode 100644 index 0000000..7718e2f --- /dev/null +++ b/pkg/jira_changelog/messages/populator.go @@ -0,0 +1,25 @@ +package messages + +import ( + "context" + + "golang.org/x/exp/slog" +) + +type Message interface { + Message() string +} + +type Populator interface { + Populate(ctx context.Context) ([]Message, error) +} + +func NewCommitOrPRPopualtor(usePR bool, fromRef, toRef, repoURL string) (Populator, error) { + if usePR { + slog.Debug("using github PR titles to generate changelog") + return NewPullRequestPopulator(fromRef, toRef, repoURL) + } else { + slog.Debug("using commit messages to generate changelog") + return NewCommitPopulator(fromRef, toRef) + } +} diff --git a/pkg/jira_changelog/messages/pull_request_populator.go b/pkg/jira_changelog/messages/pull_request_populator.go new file mode 100644 index 0000000..30a7b9c --- /dev/null +++ b/pkg/jira_changelog/messages/pull_request_populator.go @@ -0,0 +1,174 @@ +package messages + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/samber/lo" + giturls "github.com/whilp/git-urls" + "golang.org/x/exp/slog" +) + +var _ Populator = &pullRequestPopulator{} +var _ Message = &PullRequest{} + +var prRegexPattern = `\* (?P.+) by @(?P<author>\S+) in (?P<url>\S+)` + +type PullRequest struct { + Title string + Author string + URL string +} + +func (p PullRequest) Message() string { + return p.Title +} + +type pullRequestPopulator struct { + fromRef string + toRef string + apiClient *api.RESTClient + repoOwner string + repoName string +} + +func NewPullRequestPopulator(fromRef, toRef, repoURL string) (Populator, error) { + apiClient, err := api.DefaultRESTClient() + if err != nil { + return nil, err + } + + repoOwner, err := repoOwner(repoURL) + if err != nil { + return nil, err + } + + repoName, err := repoName(repoURL) + if err != nil { + return nil, err + } + + return &pullRequestPopulator{ + fromRef, + toRef, + apiClient, + repoOwner, + repoName, + }, nil +} + +func (p *pullRequestPopulator) Populate(ctx context.Context) ([]Message, error) { + pullRequests, err := p.PullRequests(ctx) + if err != nil { + return []Message{}, err + } + + messages := lo.Map(pullRequests, func(pullRequest PullRequest, i int) Message { return pullRequest }) + return messages, nil +} + +func (p *pullRequestPopulator) PullRequests(ctx context.Context) ([]PullRequest, error) { + response := struct { + Name string + Body string + }{} + + requestBody, err := json.Marshal(map[string]string{ + "owner": p.repoOwner, + "repo": p.repoName, + "tag_name": p.toRef, + "target_commitish": "main", // TODO: make this configurable + "previous_tag_name": p.fromRef, + }) + if err != nil { + return []PullRequest{}, err + } + + slog.Debug("fetching changelog from github") + + err = p.apiClient.Post(fmt.Sprintf("repos/%s/%s/releases/generate-notes", p.repoOwner, p.repoName), bytes.NewBuffer(requestBody), &response) + if err != nil { + return []PullRequest{}, err + } + + pullRequests, err := parsePullRequestBody(response.Body) + if err != nil { + slog.Error("error parsing pull request body", "error", err, "body", response.Body) + return []PullRequest{}, err + } + + slog.Info("successfully fetched changelog from github") + slog.Debug("here's the changelog provided by github", "changelog", response.Body) + + return pullRequests, nil +} + +func parsePullRequestBody(body string) ([]PullRequest, error) { + pullrequests := make([]PullRequest, 0) + lines := strings.Split(body, "\n") + lines = lo.Map(lines, func(line string, i int) string { return strings.TrimSpace(line) }) + lines = lo.Filter(lines, func(line string, i int) bool { return line != "" }) + lines = lo.Filter(lines, func(line string, i int) bool { return !strings.HasPrefix(line, "## What's Changed") }) + lines = lo.Filter(lines, func(line string, i int) bool { return !strings.HasPrefix(line, "**Full Changelog**") }) + + for _, line := range lines { + pullrequest, err := parsePullRequestMessage(line) + if err != nil { + slog.Error("error parsing pull request message", "error", err, "line", line) + return []PullRequest{}, err + } + pullrequests = append(pullrequests, pullrequest) + } + + return pullrequests, nil +} + +func parsePullRequestMessage(line string) (PullRequest, error) { + re := regexp.MustCompile(prRegexPattern) + result := re.FindStringSubmatch(line) + if len(result) < 3 { + return PullRequest{}, fmt.Errorf("invalid pull request title: %s", line) + } + + title := re.SubexpIndex("title") + author := re.SubexpIndex("author") + url := re.SubexpIndex("url") + return PullRequest{ + Title: result[title], + Author: result[author], + URL: result[url], + }, nil +} + +func repoOwner(repoURL string) (string, error) { + url, err := giturls.Parse(repoURL) + if err != nil { + return "", fmt.Errorf("error parsing repo url: %w", err) + } + + path := strings.Split(url.Path, "/") + if len(path) < 2 { + return "", fmt.Errorf("invalid repo url: %s", repoURL) + } + + return path[len(path)-2], nil +} + +func repoName(repoURL string) (string, error) { + url, err := giturls.Parse(repoURL) + if err != nil { + return "", fmt.Errorf("error parsing repo url: %w", err) + } + + path := strings.Split(url.Path, "/") + if len(path) < 2 { + return "", fmt.Errorf("invalid repo url: %s", repoURL) + } + + return path[len(path)-1], nil +}