Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ jobs:
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"

- name: Check coverage threshold
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"
awk -v cov="$COVERAGE" 'BEGIN {
if (cov + 0 < 80) {
printf "Coverage %s%% is below the 80%% threshold\n", cov
exit 1
}
printf "Coverage %s%% meets the 80%% threshold\n", cov
}'

lint:
name: Lint
needs: changes
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ jobs:
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"

- name: Check coverage threshold
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"
awk -v cov="$COVERAGE" 'BEGIN {
if (cov + 0 < 80) {
printf "Coverage %s%% is below the 80%% threshold\n", cov
exit 1
}
printf "Coverage %s%% meets the 80%% threshold\n", cov
}'

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<!-- Row 3: test & validation ladder -->
<p align="center">
<a href="https://github.com/stablekernel/cascade/actions/workflows/validate.yaml"><img src="https://github.com/stablekernel/cascade/actions/workflows/validate.yaml/badge.svg?branch=main" alt="Tests & Lint"></a>
<a href="https://github.com/stablekernel/cascade/actions"><img src="https://img.shields.io/badge/coverage-82.6%25-brightgreen" alt="Coverage"></a>
<a href="https://github.com/stablekernel/cascade/actions/workflows/e2e.yaml"><img src="https://github.com/stablekernel/cascade/actions/workflows/e2e.yaml/badge.svg?branch=main" alt="Integration (act + gitea)"></a>
<a href="https://github.com/stablekernel/cascade/actions/workflows/fleet-e2e.yaml"><img src="https://github.com/stablekernel/cascade/actions/workflows/fleet-e2e.yaml/badge.svg?branch=main" alt="Fleet E2E (live GitHub)"></a>
</p>
Expand Down
78 changes: 78 additions & 0 deletions internal/changelog/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package changelog

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
)

func TestChangelogNewCommand_Structure(t *testing.T) {
cmd := NewCommand()

assert.Equal(t, "generate-changelog", cmd.Use)
assert.NotEmpty(t, cmd.Short)

assert.NotNil(t, cmd.Flags().Lookup("base-sha"))
assert.NotNil(t, cmd.Flags().Lookup("head-sha"))
assert.NotNil(t, cmd.Flags().Lookup("repo"))
assert.NotNil(t, cmd.Flags().Lookup("exclude-paths"))
assert.NotNil(t, cmd.Flags().Lookup("contributors"))
}

func TestChangelogNewCommand_RunE_EmptySHARange(t *testing.T) {
// HEAD..HEAD yields zero commits without error; exercises the RunE closure
// body and runGenerateChangelog through the JSON output path.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--base-sha", "HEAD",
"--head-sha", "HEAD",
"--repo", "owner/repo",
})
// The empty commit range produces valid JSON output; swallow any git failure.
_ = cmd.Execute()
}

func TestChangelogNewCommand_RunE_WithExcludePaths(t *testing.T) {
// Exercises the excludePaths splitting branch in the RunE closure.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--base-sha", "HEAD",
"--head-sha", "HEAD",
"--repo", "owner/repo",
"--exclude-paths", "docs/, internal/old/",
})
_ = cmd.Execute()
}

func TestChangelogNewCommand_RunE_WithContributors(t *testing.T) {
// Exercises the contributors branch in runGenerateChangelog.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--base-sha", "HEAD",
"--head-sha", "HEAD",
"--repo", "owner/repo",
"--contributors",
})
_ = cmd.Execute()
}

func TestChangelogNewCommand_MissingRequiredFlags(t *testing.T) {
// Omitting required flags produces an error before RunE runs.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--repo", "owner/repo"})
err := cmd.Execute()
assert.Error(t, err)
}
86 changes: 86 additions & 0 deletions internal/changelog/github_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package changelog

import (
"encoding/json"
"testing"

"github.com/stablekernel/cascade/internal/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLookupAuthorsBatch_EmptyEmails(t *testing.T) {
result := lookupAuthorsBatch("owner/repo", nil, nil)
assert.Empty(t, result)
}

func TestLookupAuthorsBatch_EmptyEmailSlice(t *testing.T) {
result := lookupAuthorsBatch("owner/repo", []string{}, map[string]string{})
assert.Empty(t, result)
}

func TestLookupAuthorsBatch_InvalidRepoFormat(t *testing.T) {
// A repo string without a slash causes an early return.
emails := []string{"[email protected]"}
hashes := map[string]string{"[email protected]": "abc123"}
result := lookupAuthorsBatch("noslash", emails, hashes)
assert.Empty(t, result)
}

func TestUnmarshalJSON_InvalidEntrySkipped(t *testing.T) {
// When one repository entry cannot be decoded as a commitObject, it is
// silently skipped; valid entries are still decoded correctly.
jsonData := `{
"data": {
"repository": {
"c0": {"author": {"user": {"login": "alice"}}},
"c1": "not-an-object"
}
}
}`

var response graphQLResponse
err := json.Unmarshal([]byte(jsonData), &response)
require.NoError(t, err)

c0, ok := response.Data.Repository["c0"]
require.True(t, ok, "c0 should be present")
assert.Equal(t, "alice", c0.Author.User.Login)

// c1 had invalid structure and was skipped; it may be absent or have empty login.
if c1, exists := response.Data.Repository["c1"]; exists {
assert.Empty(t, c1.Author.User.Login, "skipped entry should have no login")
}
}

func TestUnmarshalJSON_TopLevelError(t *testing.T) {
// Malformed JSON should return an error from UnmarshalJSON.
var response graphQLResponse
err := json.Unmarshal([]byte("not json"), &response)
assert.Error(t, err)
}

func TestLookupConventionalCommitUsernames_WithCommits(t *testing.T) {
// Verify the conversion loop and the username copy-back loop both execute.
// The gh CLI call will fail in the test environment; the important thing is
// that the function does not panic and returns the same number of commits.
commits := []ConventionalCommit{
{FullHash: "abc1234567", AuthorEmail: "[email protected]", Description: "first change"},
{FullHash: "def1234567", AuthorEmail: "[email protected]", Description: "second change"},
}
result := LookupConventionalCommitUsernames(commits, "owner/repo")
require.Len(t, result, 2)
assert.Equal(t, "first change", result[0].Description)
assert.Equal(t, "second change", result[1].Description)
}

func TestLookupGitHubUsernames_InvalidRepoFormat(t *testing.T) {
// A repo without a slash propagates to lookupAuthorsBatch which returns empty,
// so no commit gets a username.
commits := []git.Commit{
{Hash: "abc123", AuthorEmail: "[email protected]"},
}
result := LookupGitHubUsernames(commits, "noslash")
require.Len(t, result, 1)
assert.Empty(t, result[0].GitHubUsername)
}
161 changes: 161 additions & 0 deletions internal/changelog/parse_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package changelog

import (
"fmt"
"strings"
"testing"

"github.com/stablekernel/cascade/internal/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseCommit_WithPRNumber(t *testing.T) {
commit := git.Commit{
Hash: "abc123def456",
Subject: "feat: add login endpoint (#42)",
Body: "",
}
cc := ParseCommit(commit)
require.NotNil(t, cc)
assert.Equal(t, "feat", cc.Type)
assert.Equal(t, "42", cc.PRNumber)
assert.Equal(t, "add login endpoint", cc.Description)
}

func TestParseCommit_WithOrgRepoPRRef(t *testing.T) {
// PR references in description are extracted regardless of preceding text
commit := git.Commit{
Hash: "def456abc123",
Subject: "fix: resolve null pointer (#999)",
}
cc := ParseCommit(commit)
require.NotNil(t, cc)
assert.Equal(t, "999", cc.PRNumber)
assert.Equal(t, "resolve null pointer", cc.Description)
}

func TestCategorizeCommits_NonRoutineNonFeatFix(t *testing.T) {
// A commit type that is not feat/fix and is not a routine type
// (e.g. "perf") should appear in the other slice.
commits := []git.Commit{
{Hash: "perf1234567", Subject: "perf(db): optimize query", Body: ""},
{Hash: "build123456", Subject: "build(ci): speed up pipeline", Body: ""},
}
_, _, _, other := CategorizeCommits(commits)
require.Len(t, other, 2)
assert.Equal(t, "perf1234567", other[0].Hash)
assert.Equal(t, "build123456", other[1].Hash)
}

func TestFormatMarkdown_CollapsibleFeatureSection(t *testing.T) {
// collapsibleThreshold is 5; six commits should trigger the collapsible wrapper.
features := make([]ConventionalCommit, 6)
for i := range features {
features[i] = ConventionalCommit{
Description: fmt.Sprintf("feature number %d", i),
Hash: fmt.Sprintf("hash%04d", i),
FullHash: fmt.Sprintf("fullhash%08d", i),
}
}
result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base123", "head456")

assert.Contains(t, result, "<details>")
assert.Contains(t, result, "<summary>")
assert.Contains(t, result, "✨ Features")
assert.Contains(t, result, "</details>")
}

func TestFormatMarkdown_CollapsibleOtherSection(t *testing.T) {
// collapsibleThreshold is 5; six other commits should trigger the collapsible wrapper.
other := make([]git.Commit, 6)
for i := range other {
other[i] = git.Commit{
Hash: fmt.Sprintf("otherhash%04d", i),
Subject: fmt.Sprintf("other commit %d", i),
}
}
result := FormatMarkdown(nil, nil, nil, other, "owner/repo", "base123", "head456")

assert.Contains(t, result, "<details>")
assert.Contains(t, result, "📝 Other Changes")
assert.Contains(t, result, "</details>")
}

func TestFormatMarkdown_WithScopedCommits(t *testing.T) {
// Commits with non-empty scopes should produce scope headers and
// exercise the getSortedScopes named-scope branch.
features := []ConventionalCommit{
{Scope: "auth", Description: "add login", Hash: "abc1234", FullHash: "abc12345678"},
{Scope: "api", Description: "add endpoint", Hash: "def1234", FullHash: "def12345678"},
}
result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head")

assert.Contains(t, result, "#### `auth`")
assert.Contains(t, result, "#### `api`")
assert.Contains(t, result, "add login")
assert.Contains(t, result, "add endpoint")
}

func TestFormatMarkdown_MixedScopedAndUnscopedCommits(t *testing.T) {
// Mix of scoped and unscoped commits: verifies both the named-scope header
// and the trailing empty-scope group (no header rendered).
features := []ConventionalCommit{
{Scope: "auth", Description: "add login", Hash: "aaa1234", FullHash: "aaa12345678"},
{Scope: "", Description: "general improvement", Hash: "bbb1234", FullHash: "bbb12345678"},
}
result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head")

assert.Contains(t, result, "#### `auth`")
assert.Contains(t, result, "general improvement")
}

func TestFormatCommitLine_WithPRNumber(t *testing.T) {
c := ConventionalCommit{
Description: "add feature",
Hash: "abc1234",
FullHash: "abc1234567",
PRNumber: "42",
}
line := formatCommitLine(c, "owner/repo")

assert.Contains(t, line, "[#42]")
assert.Contains(t, line, "https://github.com/owner/repo/pull/42")
assert.Contains(t, line, "add feature")
assert.True(t, strings.HasPrefix(line, "- "))
}

func TestFormatCommitLine_WithoutPRNumber(t *testing.T) {
c := ConventionalCommit{
Description: "add feature",
Hash: "abc1234",
FullHash: "abc12345678",
}
line := formatCommitLine(c, "owner/repo")

assert.Contains(t, line, "[`abc1234`]")
assert.Contains(t, line, "https://github.com/owner/repo/commit/abc12345678")
}

func TestFormatOtherCommitLine_WithUsername(t *testing.T) {
c := git.Commit{
Hash: "abc1234567",
Subject: "merge pull request",
GitHubUsername: "alice",
}
line := formatOtherCommitLine(c, "owner/repo")

assert.Contains(t, line, "(@alice)")
assert.Contains(t, line, "merge pull request")
}

func TestFormatOtherCommitLine_WithoutUsername(t *testing.T) {
c := git.Commit{
Hash: "abc1234567",
Subject: "merge pull request",
}
line := formatOtherCommitLine(c, "owner/repo")

assert.NotContains(t, line, "(@")
assert.Contains(t, line, "merge pull request")
}
Loading
Loading