From cce27a1fdc0bbe8bde8c1a8a71fd3d7859234432 Mon Sep 17 00:00:00 2001 From: Markus Waldheim Date: Mon, 6 Jul 2026 10:16:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20contributor=20recognition=20=E2=80=94?= =?UTF-8?q?=20first-time=20contributors=20and=20release=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pattern already shipped in generator-changelog-md and generator-changelog-html: - SEMREL_PLUGIN_NEW_CONTRIBUTORS (default true) renders a New Contributors section from SEMREL_PLUGIN_CONTRIBUTORS_JSON. - SEMREL_PLUGIN_MVP (default false) highlights the top contributor by PR reference count (SEMREL_PLUGIN_MVP_METRIC=commits|impact). - SEMREL_REPOSITORY_URL now feeds contributor/MVP mention links. Fixes #27 (previously closed without an actual implementation landing in this repo — reopened and implemented here). --- README.md | 8 ++ cmd/plugin/main.go | 14 +++ internal/plugin/generator.go | 160 +++++++++++++++++++++++++++++- internal/plugin/generator_test.go | 87 ++++++++++++++++ 4 files changed, 264 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2a69439..012e5b2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 5212f1d..943b29e 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -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) @@ -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 } diff --git a/internal/plugin/generator.go b/internal/plugin/generator.go index 960058b..7516a54 100644 --- a/internal/plugin/generator.go +++ b/internal/plugin/generator.go @@ -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. @@ -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{} @@ -71,6 +98,9 @@ func DefaultGenerateOptions() GenerateOptions { AIDisclosure: false, AIDisclosureBadge: "🤖", AIDisclosureSection: false, + NewContributors: true, + MVP: false, + MVPMetric: "commits", } } @@ -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
\n🤖 AI-Assisted Contributions\n\nThe following changes were co-authored with an AI assistant:\n\n") for _, e := range aiEntries { @@ -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 +} diff --git a/internal/plugin/generator_test.go b/internal/plugin/generator_test.go index 88a0248..e78590e 100644 --- a/internal/plugin/generator_test.go +++ b/internal/plugin/generator_test.go @@ -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") + }) +}