Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ plugins:
| `SEMREL_PLUGIN_MAX_COMMITS` | Optional | Maximum number of commits to include. | 50 |
| `SEMREL_PLUGIN_INCLUDE_BODY` | Optional | Include full commit bodies in the generated notes. | false |
| `SEMREL_PLUGIN_SIGNATURE` | Optional | Append an opt-in footer line linking to `semrel.io` after the generated release notes output. | `false` |
| `SEMREL_PLUGIN_AI_DISCLOSURE` | Optional | Detect AI co-author trailers and append a badge to each AI-assisted commit line. | `false` |
| `SEMREL_PLUGIN_AI_DISCLOSURE_BADGE` | Optional | Badge appended to AI-assisted lines. | `🤖` |
| `SEMREL_PLUGIN_AI_DISCLOSURE_SECTION` | Optional | Append a collapsible "🤖 AI-Assisted Contributions" section (requires `SEMREL_PLUGIN_AI_DISCLOSURE=true`). | `false` |
| `SEMREL_PLUGIN_NEW_CONTRIBUTORS` | Optional | Render a "New Contributors" section listing first-time contributors for this release (requires contributor data via `SEMREL_PLUGIN_CONTRIBUTORS_JSON`). | `true` |
| `SEMREL_PLUGIN_CONTRIBUTORS_JSON` | Optional | JSON array of first-time contributors for this release, e.g. `[{"name":"Alice","login":"alice","pr":42}]`. Can be populated from semrel core's `SEMREL_CONTRIBUTORS` (filter to `firstContribution:true`). | — |
| `SEMREL_PLUGIN_MVP` | Optional | Render a "🏆 MVP" section highlighting the top contributor for this release (requires `SEMREL_PLUGIN_NEW_CONTRIBUTORS=true` and non-empty contributors). | `false` |
| `SEMREL_PLUGIN_MVP_METRIC` | Optional | MVP ranking metric: `commits` (default, PR-reference count) or `impact` (weights breaking changes/features more heavily). | `commits` |

## `SEMREL_*` release context used

Expand All @@ -61,6 +68,7 @@ plugins:
| `SEMREL_NEXT_VERSION` | Next version computed by semrel for the release. |
| `SEMREL_CURRENT_VERSION` | Current version before the new release is applied. |
| `SEMREL_BRANCH` | Git branch associated with the current release run. |
| `SEMREL_REPOSITORY_URL` | Repository URL used to render contributor links and MVP mentions. |

## Example behavior

Expand Down
14 changes: 14 additions & 0 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ func run(stdout, stderr io.Writer, getenv func(string) string) int {
if badge := strings.TrimSpace(getenv("SEMREL_PLUGIN_AI_DISCLOSURE_BADGE")); badge != "" {
options.AIDisclosureBadge = badge
}
options.NewContributors = envBool(getenv, "SEMREL_PLUGIN_NEW_CONTRIBUTORS", true)
options.MVP = envBool(getenv, "SEMREL_PLUGIN_MVP", false)
if mv := strings.TrimSpace(getenv("SEMREL_PLUGIN_MVP_METRIC")); mv != "" {
options.MVPMetric = mv
}
if raw := strings.TrimSpace(getenv("SEMREL_PLUGIN_CONTRIBUTORS_JSON")); raw != "" {
var contributors []plugin.Contributor
if err := json.Unmarshal([]byte(raw), &contributors); err != nil {
fmt.Fprintln(stderr, "generator-release-notes: invalid SEMREL_PLUGIN_CONTRIBUTORS_JSON:", err)
} else {
options.Contributors = contributors
}
}

if _, err := io.WriteString(stdout, plugin.New().Generate(ctx, options)); err != nil {
fmt.Fprintln(stderr, "generator-release-notes:", err)
Expand All @@ -58,6 +71,7 @@ func releaseContextFromEnv(getenv func(string) string) (plugin.ReleaseContext, e
Version: firstNonEmpty(getenv("SEMREL_VERSION"), getenv("SEMREL_TAG_NAME"), getenv("SEMREL_NEXT_VERSION")),
CurrentVersion: strings.TrimSpace(getenv("SEMREL_CURRENT_VERSION")),
Branch: strings.TrimSpace(getenv("SEMREL_BRANCH")),
RepositoryURL: strings.TrimSpace(getenv("SEMREL_REPOSITORY_URL")),
Commits: commits,
}, nil
}
Expand Down
160 changes: 155 additions & 5 deletions internal/plugin/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ var aiTrailerPatterns = []struct {
pattern *regexp.Regexp
label string
}{
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*copilot`), "GitHub Copilot"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*copilot`), "GitHub Copilot"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*github-copilot`), "GitHub Copilot"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*claude`), "Claude (Anthropic)"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*chatgpt`), "ChatGPT (OpenAI)"},
{regexp.MustCompile(`(?i)(?m)^ai-assisted:\s*true`), "AI"},
{regexp.MustCompile(`(?i)(?m)^generated-by:`), "AI"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*claude`), "Claude (Anthropic)"},
{regexp.MustCompile(`(?i)co-authored-by:[^\n]*chatgpt`), "ChatGPT (OpenAI)"},
{regexp.MustCompile(`(?i)(?m)^ai-assisted:\s*true`), "AI"},
{regexp.MustCompile(`(?i)(?m)^generated-by:`), "AI"},
}

// detectAIAuthors returns deduplicated AI tool labels found in commit trailers.
Expand Down Expand Up @@ -47,14 +47,41 @@ type ReleaseContext struct {
Version string
CurrentVersion string
Branch string
RepositoryURL string
Commits []string
}

// Contributor represents a person who contributed to a release.
type Contributor struct {
// Name is the display name (git author name or GitHub login).
Name string `json:"name"`
// Login is the optional GitHub/Gitea username (e.g. "alice"). When set,
// the entry is rendered as @alice; otherwise Name is used as plain text.
Login string `json:"login,omitempty"`
// PR is the optional pull-request number associated with this contributor's
// first contribution in the release. Zero means no PR is available.
PR int `json:"pr,omitempty"`
}

type GenerateOptions struct {
Signature bool
AIDisclosure bool
AIDisclosureBadge string
AIDisclosureSection bool
// NewContributors controls whether a "New Contributors" section is appended.
// The section is only rendered when Contributors is non-empty.
NewContributors bool
// MVP controls whether a "🏆 MVP" section is rendered after the contributor
// list. Requires NewContributors=true and a non-empty Contributors slice.
MVP bool
// MVPMetric determines how the MVP is chosen: "commits" (default) picks the
// contributor with the highest commit count; "impact" weights breaking/feat
// commits more heavily.
MVPMetric string
// Contributors is the pre-computed list of first-time contributors for this
// release. It is populated by the caller (e.g. from SEMREL_PLUGIN_CONTRIBUTORS_JSON).
// When empty the section is silently skipped regardless of NewContributors.
Contributors []Contributor
}

type Generator struct{}
Expand All @@ -71,6 +98,9 @@ func DefaultGenerateOptions() GenerateOptions {
AIDisclosure: false,
AIDisclosureBadge: "🤖",
AIDisclosureSection: false,
NewContributors: true,
MVP: false,
MVPMetric: "commits",
}
}

Expand Down Expand Up @@ -144,6 +174,28 @@ func (g *Generator) Generate(ctx ReleaseContext, options ...GenerateOptions) str
fmt.Fprintf(&builder, "\n\n_Target branch: %s_", strings.TrimSpace(ctx.Branch))
}

if generateOptions.NewContributors && len(generateOptions.Contributors) > 0 {
builder.WriteString("\n\n### New Contributors\n")
for _, c := range generateOptions.Contributors {
builder.WriteString("* ")
builder.WriteString(formatContributorEntry(c, ctx.RepositoryURL))
builder.WriteString(" made their first contribution")
if c.PR > 0 && ctx.RepositoryURL != "" {
repoURL := strings.TrimRight(strings.TrimSpace(ctx.RepositoryURL), "/")
fmt.Fprintf(&builder, " in [#%d](%s/pull/%d)", c.PR, repoURL, c.PR)
}
builder.WriteString("\n")
}

if generateOptions.MVP {
mvp := pickMVP(generateOptions.Contributors, ctx.Commits, generateOptions.MVPMetric)
if mvp != nil {
builder.WriteString("\n### 🏆 MVP\n")
fmt.Fprintf(&builder, "%s led the contributors this release.\n", formatContributorEntry(*mvp, ctx.RepositoryURL))
}
}
}

if generateOptions.AIDisclosure && generateOptions.AIDisclosureSection && len(aiEntries) > 0 {
builder.WriteString("\n\n<details>\n<summary>🤖 AI-Assisted Contributions</summary>\n\nThe following changes were co-authored with an AI assistant:\n\n")
for _, e := range aiEntries {
Expand Down Expand Up @@ -220,3 +272,101 @@ func displayVersion(version string) string {
}
return "v" + version
}

// formatContributorEntry returns a Markdown-formatted mention for a contributor.
// When Login is set it renders as @login (linked when a repositoryURL is available);
// otherwise it falls back to plain Name.
func formatContributorEntry(c Contributor, repositoryURL string) string {
login := strings.TrimSpace(c.Login)
if login == "" {
name := strings.TrimSpace(c.Name)
if name == "" {
return "unknown"
}
return name
}

repositoryURL = strings.TrimRight(strings.TrimSpace(repositoryURL), "/")
if repositoryURL == "" {
return "@" + login
}

// Derive the base host URL from the repository URL (e.g. https://github.com).
// We can't assume a specific host; strip the path to get the root.
base := repositoryURL
if idx := strings.Index(repositoryURL, "//"); idx >= 0 {
rest := repositoryURL[idx+2:]
if slash := strings.Index(rest, "/"); slash >= 0 {
base = repositoryURL[:idx+2+slash]
}
}
return fmt.Sprintf("[@%s](%s/%s)", login, base, login)
}

// pullRequestPattern matches "(#123)" style PR references used for MVP scoring.
var pullRequestPattern = regexp.MustCompile(`\(#(\d+)\)`)

func pickMVP(contributors []Contributor, commits []string, mvpMetric string) *Contributor {
if len(contributors) == 0 {
return nil
}
if len(contributors) == 1 {
return &contributors[0]
}

// Without per-commit author attribution we rank contributors by the number
// of PR references in commit messages that match their PR number.
scores := make(map[string]int, len(contributors))
for _, commit := range commits {
prs := pullRequestPattern.FindAllStringSubmatch(commit, -1)
for _, match := range prs {
prNum := match[1]
for _, c := range contributors {
if c.PR > 0 && fmt.Sprintf("%d", c.PR) == prNum {
key := contributorKey(c)
if mvpMetric == "impact" {
scores[key] += impactWeight(commit)
} else {
scores[key]++
}
}
}
}
}

// Fall back to first contributor in list (ordering preserved from caller).
best := &contributors[0]
bestScore := scores[contributorKey(contributors[0])]
for i := range contributors[1:] {
c := &contributors[i+1]
if s := scores[contributorKey(*c)]; s > bestScore {
bestScore = s
best = c
}
}
return best
}

func contributorKey(c Contributor) string {
if c.Login != "" {
return c.Login
}
return c.Name
}

func impactWeight(commit string) int {
lower := strings.ToLower(commit)
if strings.Contains(lower, "breaking change") {
return 3
}
if conventionalHeaderPattern.MatchString(firstLine(commit)) {
matches := conventionalHeaderPattern.FindStringSubmatch(firstLine(commit))
if len(matches) > 3 && matches[3] == "!" {
return 3
}
if len(matches) > 1 && matches[1] == "feat" {
return 2
}
}
return 1
}
87 changes: 87 additions & 0 deletions internal/plugin/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,90 @@ func TestGeneratorAIDisclosureOffByDefault(t *testing.T) {
require.NotContains(t, output, "🤖")
require.NotContains(t, output, "AI-Assisted Contributions")
}

func TestGeneratorNewContributors(t *testing.T) {
t.Parallel()

generator := New()
ctx := ReleaseContext{
Version: "1.4.0",
RepositoryURL: "https://github.com/SemRels/semrel",
Commits: []string{"feat: add login (#42)"},
}

t.Run("new contributors section rendered when contributors provided", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.Contributors = []Contributor{
{Name: "Alice", Login: "alice", PR: 42},
}

output := generator.Generate(ctx, opts)

require.Contains(t, output, "### New Contributors")
require.Contains(t, output, "[@alice](https://github.com/alice)")
require.Contains(t, output, "[#42](https://github.com/SemRels/semrel/pull/42)")
})

t.Run("new contributors section skipped when contributors empty", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.Contributors = nil

output := generator.Generate(ctx, opts)

require.NotContains(t, output, "New Contributors")
})

t.Run("new contributors section skipped when disabled", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.NewContributors = false
opts.Contributors = []Contributor{{Name: "Alice", Login: "alice"}}

output := generator.Generate(ctx, opts)

require.NotContains(t, output, "New Contributors")
})

t.Run("contributor without login uses plain name", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.Contributors = []Contributor{{Name: "Alice Smith"}}

output := generator.Generate(ctx, opts)

require.Contains(t, output, "Alice Smith made their first contribution")
})

t.Run("MVP section rendered when enabled", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.MVP = true
opts.Contributors = []Contributor{
{Name: "Alice", Login: "alice", PR: 42},
}

output := generator.Generate(ctx, opts)

require.Contains(t, output, "### 🏆 MVP")
require.Contains(t, output, "[@alice](https://github.com/alice) led the contributors this release.")
})

t.Run("MVP section skipped when disabled", func(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.MVP = false
opts.Contributors = []Contributor{{Name: "Alice", Login: "alice", PR: 42}}

output := generator.Generate(ctx, opts)

require.NotContains(t, output, "MVP")
})
}
Loading