diff --git a/Makefile b/Makefile index 89006c7..7511229 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ setup-tools: go install github.com/uudashr/gocognit/cmd/gocognit@latest go install honnef.co/go/tools/cmd/staticcheck@latest go install github.com/mcubik/goverreport@latest + go install github.com/vektra/mockery/v2@v2.32.0 sync-docs: mdsh diff --git a/go.mod b/go.mod index 26bb395..f1dd0a2 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect diff --git a/go.sum b/go.sum index 57e7a99..33e1622 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,7 @@ github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/mocks/Client.go b/mocks/Client.go new file mode 100644 index 0000000..bb2a982 --- /dev/null +++ b/mocks/Client.go @@ -0,0 +1,51 @@ +// Code generated by mockery v2.32.0. DO NOT EDIT. + +package mocks + +import ( + jira "github.com/handofgod94/gh-jira-changelog/pkg/jira_changelog/jira" + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// FetchIssue provides a mock function with given fields: issueId +func (_m *Client) FetchIssue(issueId string) (jira.Issue, error) { + ret := _m.Called(issueId) + + var r0 jira.Issue + var r1 error + if rf, ok := ret.Get(0).(func(string) (jira.Issue, error)); ok { + return rf(issueId) + } + if rf, ok := ret.Get(0).(func(string) jira.Issue); ok { + r0 = rf(issueId) + } else { + r0 = ret.Get(0).(jira.Issue) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(issueId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/jira_changelog/changelog_test.go b/pkg/jira_changelog/changelog_test.go index bf99bbb..6e9ca02 100644 --- a/pkg/jira_changelog/changelog_test.go +++ b/pkg/jira_changelog/changelog_test.go @@ -20,8 +20,8 @@ func TestRender(t *testing.T) { changelog: jira_changelog.Changelog{ Changes: map[string][]jira.Issue{ "TestEpic": { - jira.NewIssue("TEST-1", "foobar is new", "done"), - jira.NewIssue("TEST-2", "fizzbuzz is something else", "done"), + jira.NewIssue("TEST-1", "foobar is new", "done", "TestEpic"), + jira.NewIssue("TEST-2", "fizzbuzz is something else", "done", "TestEpic"), }, }, }, @@ -37,9 +37,9 @@ func TestRender(t *testing.T) { changelog: jira_changelog.Changelog{ Changes: map[string][]jira.Issue{ "TestEpic": { - jira.NewIssue("TEST-1", "foobar is new", "done"), - jira.NewIssue("TEST-2", "fizzbuzz is something else", "in progress"), - jira.NewIssue("TEST-3", "fizzbuzz is something else", "done"), + jira.NewIssue("TEST-1", "foobar is new", "done", "TestEpic"), + jira.NewIssue("TEST-2", "fizzbuzz is something else", "in progress", "TestEpic"), + jira.NewIssue("TEST-3", "fizzbuzz is something else", "done", "TestEpic"), }, }, }, @@ -56,14 +56,14 @@ func TestRender(t *testing.T) { changelog: jira_changelog.Changelog{ Changes: map[string][]jira.Issue{ "TestEpic1": { - jira.NewIssue("TEST-1", "foobar is new", "done"), - jira.NewIssue("TEST-2", "fizzbuzz is something else", "in progress"), - jira.NewIssue("TEST-3", "fizzbuzz is something else", "done"), + jira.NewIssue("TEST-1", "foobar is new", "done", "TestEpic1"), + jira.NewIssue("TEST-2", "fizzbuzz is something else", "in progress", "TestEpic1"), + jira.NewIssue("TEST-3", "fizzbuzz is something else", "done", "TestEpic1"), }, "TestEpic2": { - jira.NewIssue("TEST-4", "foobar is new", "done"), - jira.NewIssue("TEST-5", "fizzbuzz is something else", "done"), - jira.NewIssue("TEST-6", "fizzbuzz is something else", "done"), + jira.NewIssue("TEST-4", "foobar is new", "done", "TestEpic2"), + jira.NewIssue("TEST-5", "fizzbuzz is something else", "done", "TestEpic2"), + jira.NewIssue("TEST-6", "fizzbuzz is something else", "done", "TestEpic2"), }, }, }, diff --git a/pkg/jira_changelog/generator.go b/pkg/jira_changelog/generator.go index 62ab35b..3e0193e 100644 --- a/pkg/jira_changelog/generator.go +++ b/pkg/jira_changelog/generator.go @@ -2,6 +2,7 @@ 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" @@ -13,10 +14,11 @@ type Generator struct { JiraConfig jira.Config fromRef string toRef string - client *jira.Client + client jira.Client } -func checkErr(err error) { +func panicIfErr(err error, args ...interface{}) { + slog.Error(err.Error(), args...) if err != nil { panic(err) } @@ -24,11 +26,15 @@ func checkErr(err error) { func (c Generator) Generate(ctx context.Context) *Changelog { gitOutput, err := git.ExecGitLog(ctx, c.fromRef, c.toRef) - checkErr(err) + panicIfErr(fmt.Errorf("failed to execute git command. %w", err)) commits, err := gitOutput.Commits() - checkErr(err) + panicIfErr(fmt.Errorf("failed to parse git output. %w", err)) + return c.changelogFromCommits(commits) +} + +func (c Generator) changelogFromCommits(commits []git.Commit) *Changelog { slog.Debug("Total commit messages", "count", len(commits)) jiraIssueIds := lo.Map(commits, func(commit git.Commit, index int) jira.JiraIssueId { @@ -40,17 +46,14 @@ func (c Generator) Generate(ctx context.Context) *Changelog { issues := lo.Map(jiraIssueIds, func(jiraIssueId jira.JiraIssueId, index int) jira.Issue { issue, err := c.client.FetchIssue(string(jiraIssueId)) - if err != nil { - slog.Error("Error fetching issue", "issue", jiraIssueId, "error", err) - panic(err) - } - slog.Debug("Fetched issue", "issue", issue) - return *issue + panicIfErr(fmt.Errorf("failed to fetch issue. %w", err), "issue", jiraIssueId) + + slog.Debug("fetched issue", "issue", issue) + return issue }) slog.Debug("Total issues", "count", len(issues)) issuesByEpic := lo.GroupBy(issues, func(issue jira.Issue) string { return issue.Epic() }) - slog.Debug("Issues grouped by epic", "issues", issuesByEpic) return &Changelog{Changes: issuesByEpic} } diff --git a/pkg/jira_changelog/generator_test.go b/pkg/jira_changelog/generator_test.go new file mode 100644 index 0000000..57d01e5 --- /dev/null +++ b/pkg/jira_changelog/generator_test.go @@ -0,0 +1,51 @@ +package jira_changelog + +import ( + "testing" + "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/stretchr/testify/assert" +) + +func TestChangelogFromCommits(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"}, + } + + expected := &Changelog{ + Changes: map[string][]jira.Issue{ + "Epic1": { + jira.NewIssue("TEST-1234", "Ticket description", "done", "Epic1"), + jira.NewIssue("TEST-12345", "Ticket description of another from same epic", "done", "Epic1"), + }, + "Epic2": { + jira.NewIssue("TEST-4546", "Ticket description for 4546 issue", "done", "Epic2"), + }, + "Misc": { + jira.NewIssue("NO-CARD", "Ticket description for no card issue", "done", ""), + jira.NewIssue("", "foobar commit message random", "", ""), + }, + }, + } + + mockedClient := mocks.NewClient(t) + mockedClient.On("FetchIssue", "TEST-1234").Return(jira.NewIssue("TEST-1234", "Ticket description", "done", "Epic1"), nil).Times(2) + mockedClient.On("FetchIssue", "TEST-4546").Return(jira.NewIssue("TEST-4546", "Ticket description", "done", "Epic2"), nil).Times(2) + mockedClient.On("FetchIssue", "TEST-12345").Return(jira.NewIssue("TEST-12345", "Ticket description", "done", "Epic1"), nil) + mockedClient.On("FetchIssue", "NO-CARD").Return(jira.NewIssue("", "", "", ""), nil) + generator := Generator{JiraConfig: jira.Config{ProjectName: "TEST"}} + generator.client = mockedClient + + result := generator.changelogFromCommits(commits) + + assert.Equal(t, expected, result) +} diff --git a/pkg/jira_changelog/git/commits.go b/pkg/jira_changelog/git/commits.go index 470cc74..1999536 100644 --- a/pkg/jira_changelog/git/commits.go +++ b/pkg/jira_changelog/git/commits.go @@ -15,14 +15,15 @@ import ( type Commit struct { Message string Time time.Time + Sha string } type GitOutput string -var gitoutputPattern = regexp.MustCompile(`^\((\d+)\)\s+(.*)`) +var gitoutputPattern = regexp.MustCompile(`^\((\d+)\)\s+\{(\w+)\}\s*(.*)`) func ExecGitLog(ctx context.Context, fromRef, toRef string) (GitOutput, error) { - cmd := exec.CommandContext(ctx, "git", "log", "--decorate-refs-exclude=refs/tags", "--pretty=(%ct) %d %s", "--no-merges", fromRef+".."+toRef) + cmd := exec.CommandContext(ctx, "git", "log", "--decorate-refs-exclude=refs/tags", "--pretty=(%ct) {%h} %d %s", "--no-merges", fromRef+".."+toRef) stdout, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to execute git command: %v", err) @@ -48,9 +49,16 @@ func (gt GitOutput) Commits() ([]Commit, error) { return []Commit{}, fmt.Errorf("failed to extract timestamp. %w", err) } + sha, err := extractSha(line) + if err != nil { + slog.Error("failed to extract sha", "gitlogLine", line) + return []Commit{}, fmt.Errorf("failed to extract sha. %w", err) + } + commits = append(commits, Commit{ Message: message, Time: commitTime, + Sha: sha, }) } return commits, nil @@ -59,11 +67,11 @@ func (gt GitOutput) Commits() ([]Commit, error) { func extractCommitMessage(gitlogLine string) (string, error) { gitlogLine = strings.TrimSpace(gitlogLine) result := gitoutputPattern.FindStringSubmatch(gitlogLine) - if len(result) < 3 { + if len(result) < 4 { return "", fmt.Errorf("couldn't find commit message in git log. %v", gitlogLine) } - return result[2], nil + return result[3], nil } func extractTime(gitlogLine string) (time.Time, error) { @@ -78,3 +86,12 @@ func extractTime(gitlogLine string) (time.Time, error) { } return time.Unix(int64(timestamp), 0), nil } + +func extractSha(gitlogLine string) (string, error) { + result := gitoutputPattern.FindStringSubmatch(gitlogLine) + if len(result) < 3 { + return "", fmt.Errorf("couldn't find sha in commit message. %v", gitlogLine) + } + + return result[2], nil +} diff --git a/pkg/jira_changelog/git/commits_test.go b/pkg/jira_changelog/git/commits_test.go index 5cf8027..4c20adc 100644 --- a/pkg/jira_changelog/git/commits_test.go +++ b/pkg/jira_changelog/git/commits_test.go @@ -18,31 +18,34 @@ func TestCommits(t *testing.T) { { desc: "returns commits when gitoutput is valid", gitOutput: git.GitOutput(` -(1687839814) use extra space while generating template -(1688059937) [JIRA-123] refactor: extract out structs from jira/types -(1687799347) add warning emoji for changelog lineitem +(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{ { Message: "use extra space while generating template", Time: time.Unix(1687839814, 0), + Sha: "3cefgdr", }, { Message: "[JIRA-123] refactor: extract out structs from jira/types", Time: time.Unix(1688059937, 0), + Sha: "4567uge", }, { Message: "add warning emoji for changelog lineitem", Time: time.Unix(1687799347, 0), + Sha: "3456cdw", }, }, }, { desc: "returns single commit if gitoutput has single line", gitOutput: git.GitOutput(` -(1688059937) refactor: extract out structs from jira/types +(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)}}, + want: []git.Commit{{Message: "refactor: extract out structs from jira/types", Time: time.Unix(1688059937, 0), Sha: "3456cdw"}}, }, { desc: "returns error when output is not in correct format", diff --git a/pkg/jira_changelog/jira/client.go b/pkg/jira_changelog/jira/client.go index 982ff8f..83204cd 100644 --- a/pkg/jira_changelog/jira/client.go +++ b/pkg/jira_changelog/jira/client.go @@ -10,55 +10,59 @@ import ( "golang.org/x/exp/slog" ) -type Client struct { +type Client interface { + FetchIssue(issueId string) (Issue, error) +} + +type client struct { config Config httpClient *http.Client } -func (c *Client) setupClient() { +func (c *client) setupClient() { c.httpClient = &http.Client{ Timeout: 5 * time.Second, } } -func (c *Client) attachDefaultHeaders(r *http.Request) { +func (c *client) attachDefaultHeaders(r *http.Request) { r.Header.Add("Accept", "application/json") r.SetBasicAuth(c.config.User, c.config.ApiToken) } -func (c *Client) FetchIssue(issueId string) (*Issue, error) { +func (c *client) FetchIssue(issueId string) (Issue, error) { requestUrl, err := url.JoinPath(c.config.BaseUrl, "rest", "api", "3", "issue", issueId) slog.Debug("Preparing fetch request", "url", requestUrl) if err != nil { - return nil, fmt.Errorf("failed to create request url. %w", err) + return Issue{}, fmt.Errorf("failed to create request url. %w", err) } req, err := http.NewRequest("GET", requestUrl, nil) if err != nil { - return nil, fmt.Errorf("failed to create request. %w", err) + return Issue{}, fmt.Errorf("failed to create request. %w", err) } c.attachDefaultHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch issue. %w", err) + return Issue{}, fmt.Errorf("failed to fetch issue. %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch issue. status code: %d", resp.StatusCode) + return Issue{}, fmt.Errorf("failed to fetch issue. status code: %d", resp.StatusCode) } var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { - return nil, fmt.Errorf("failed to decode issue. %w", err) + return Issue{}, fmt.Errorf("failed to decode issue. %w", err) } - return &issue, nil + return issue, nil } -func NewClient(config Config) *Client { - c := &Client{config: config} +func NewClient(config Config) Client { + c := &client{config: config} c.setupClient() return c } diff --git a/pkg/jira_changelog/jira/issue.go b/pkg/jira_changelog/jira/issue.go index 2c7a899..bb06475 100644 --- a/pkg/jira_changelog/jira/issue.go +++ b/pkg/jira_changelog/jira/issue.go @@ -20,12 +20,13 @@ type Issue struct { } `json:"fields"` } -func NewIssue(key, summary, status string) Issue { - issues := &Issue{} - issues.Key = key - issues.Fields.Summary = summary - issues.Fields.Status.StatusCategory.Key = status - return *issues +func NewIssue(key, summary, status, epic string) Issue { + issue := &Issue{} + issue.Key = key + issue.Fields.Summary = summary + issue.Fields.Status.StatusCategory.Key = status + issue.Fields.Parent.Fields.Summary = epic + return *issue } func (i Issue) IsWip() bool {