From fac20df7a77ad1522fb25de559838ce4d6f33dfd Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 8 Mar 2026 16:27:10 +0100 Subject: [PATCH 1/2] feat: resolve contributor GitHub usernames via API Add GitHubService that resolves git commit emails to GitHub usernames using the search/users and search/commits API endpoints. Falls back gracefully when no GITHUB_TOKEN is available. Results are cached to avoid duplicate API calls. --- .github/workflows/release.yml | 1 + action.yml | 4 + actions/generate.go | 8 +- flags.go | 6 ++ main.go | 1 + services/git.go | 6 +- services/github.go | 166 ++++++++++++++++++++++++++++++++++ services/semver.go | 2 +- 8 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 services/github.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19ea45d..c8a403f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: - name: Generate release notes env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") ARGS="generate --use-git-fallback --output /tmp/release-notes.md" diff --git a/action.yml b/action.yml index bf27d78..b60eaff 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,9 @@ inputs: template-name: description: "Built-in template: semver-release-notes, conventional-changelog, version-analysis" required: false + github-token: + description: "GitHub token for resolving contributor usernames from emails" + required: false use-git-fallback: description: "Fall back to git commit log analysis if LLM fails" required: false @@ -87,6 +90,7 @@ runs: GEMINI_API_KEY: ${{ inputs.api-key }} OPENAI_API_KEY: ${{ inputs.api-key }} ANTHROPIC_API_KEY: ${{ inputs.api-key }} + GITHUB_TOKEN: ${{ inputs.github-token }} run: | CMD="${{ inputs.command }}" diff --git a/actions/generate.go b/actions/generate.go index 727db4c..698a0d7 100644 --- a/actions/generate.go +++ b/actions/generate.go @@ -43,10 +43,16 @@ func (a *GenerateAction) Execute(c *cli.Context) error { return err } + // Setup GitHub service for contributor resolution + ghSvc := services.NewGitHubService(c.String("github-token")) + if ghSvc.HasToken() { + helpers.Log.Info().Msg("GitHub token provided — will resolve contributor usernames") + } + // Get detailed commits var detailedCommits []domain.DetailedCommit if len(commits) > 0 { - detailedCommits, err = a.gitSvc.GetCommitDetails(commits) + detailedCommits, err = a.gitSvc.GetCommitDetails(commits, ghSvc) if err != nil { helpers.Log.Warn().Msgf("Could not get commit details: %v", err) } diff --git a/flags.go b/flags.go index 7b63aaf..2b56f53 100644 --- a/flags.go +++ b/flags.go @@ -95,6 +95,12 @@ var forceGitModeFlag = cli.BoolFlag{ Usage: "Force using git commit log analysis instead of LLM (no API key needed)", } +var githubTokenFlag = cli.StringFlag{ + Name: "github-token", + Usage: "GitHub token for resolving contributor usernames from emails", + EnvVar: "GITHUB_TOKEN", +} + var verboseFlag = cli.BoolFlag{ Name: "verbose, v", Usage: "Enable verbose/debug logging", diff --git a/main.go b/main.go index 874e01a..603db14 100644 --- a/main.go +++ b/main.go @@ -52,6 +52,7 @@ func main() { outputFlag, useGitFallbackFlag, forceGitModeFlag, + githubTokenFlag, verboseFlag, } diff --git a/services/git.go b/services/git.go index 37efadb..93da7b0 100644 --- a/services/git.go +++ b/services/git.go @@ -118,7 +118,7 @@ func (g *GitService) parseAndFilterCommits(output string, ignoreList []string) ( return commits, nil } -func (g *GitService) GetCommitDetails(commits []domain.CommitInfo) ([]domain.DetailedCommit, error) { +func (g *GitService) GetCommitDetails(commits []domain.CommitInfo, ghSvc *GitHubService) ([]domain.DetailedCommit, error) { helpers.Log.Info().Msg("Gathering detailed commit information...") var detailed []domain.DetailedCommit @@ -137,7 +137,9 @@ func (g *GitService) GetCommitDetails(commits []domain.CommitInfo) ([]domain.Det emailOut, err := g.runGit("show", c.Hash, "--format=%ae", "--no-patch") if err == nil { dc.AuthorEmail = strings.TrimSpace(emailOut) - if ghUser := extractGitHubUser(dc.AuthorEmail); ghUser != "" { + if ghSvc != nil { + dc.Author = ghSvc.ResolveAuthor(dc.Author, dc.AuthorEmail) + } else if ghUser := extractGitHubUser(dc.AuthorEmail); ghUser != "" { dc.Author = "@" + ghUser } } diff --git a/services/github.go b/services/github.go new file mode 100644 index 0000000..def7b24 --- /dev/null +++ b/services/github.go @@ -0,0 +1,166 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/AxeForging/releaseforge/helpers" +) + +type GitHubService struct { + token string + cache map[string]string + mu sync.Mutex +} + +func NewGitHubService(token string) *GitHubService { + return &GitHubService{ + token: token, + cache: make(map[string]string), + } +} + +func (gh *GitHubService) HasToken() bool { + return gh.token != "" +} + +func (gh *GitHubService) ResolveUsername(email string) string { + if email == "" { + return "" + } + + // Check noreply format first (no API needed) + if user := extractGitHubUser(email); user != "" { + return user + } + + if !gh.HasToken() { + return "" + } + + gh.mu.Lock() + if cached, ok := gh.cache[email]; ok { + gh.mu.Unlock() + return cached + } + gh.mu.Unlock() + + username := gh.searchUserByEmail(email) + + gh.mu.Lock() + gh.cache[email] = username + gh.mu.Unlock() + + return username +} + +func (gh *GitHubService) searchUserByEmail(email string) string { + url := fmt.Sprintf("https://api.github.com/search/users?q=%s+in:email", email) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + + req.Header.Set("Authorization", "Bearer "+gh.token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + helpers.Log.Debug().Msgf("GitHub API request failed for %s: %v", email, err) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + helpers.Log.Debug().Msgf("GitHub API returned %d for email %s", resp.StatusCode, email) + return "" + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + var result struct { + TotalCount int `json:"total_count"` + Items []struct { + Login string `json:"login"` + } `json:"items"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return "" + } + + if result.TotalCount > 0 && len(result.Items) > 0 { + username := result.Items[0].Login + helpers.Log.Info().Msgf("Resolved email %s → @%s", email, username) + return username + } + + // Try commit search as fallback (works even when email isn't public) + return gh.searchCommitAuthor(email) +} + +func (gh *GitHubService) searchCommitAuthor(email string) string { + url := fmt.Sprintf("https://api.github.com/search/commits?q=author-email:%s", email) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + + req.Header.Set("Authorization", "Bearer "+gh.token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "" + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + var result struct { + TotalCount int `json:"total_count"` + Items []struct { + Author struct { + Login string `json:"login"` + } `json:"author"` + } `json:"items"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return "" + } + + if result.TotalCount > 0 && len(result.Items) > 0 { + login := result.Items[0].Author.Login + if login != "" { + helpers.Log.Info().Msgf("Resolved email %s → @%s (via commit search)", email, login) + return login + } + } + + return "" +} + +// ResolveAuthor takes an author name and email and returns the best display name +func (gh *GitHubService) ResolveAuthor(name, email string) string { + if username := gh.ResolveUsername(email); username != "" { + return "@" + strings.ToLower(username) + } + return name +} diff --git a/services/semver.go b/services/semver.go index 189b676..3c6fc82 100644 --- a/services/semver.go +++ b/services/semver.go @@ -293,7 +293,7 @@ func (s *SemverService) GetCommitsBetween(fromTag, toRef string, maxCommits int) return nil, err } - detailed, err := s.git.GetCommitDetails(commits) + detailed, err := s.git.GetCommitDetails(commits, nil) if err != nil { return nil, err } From 9a51bd9fc472e99807c6428dffde7e75a6b65df9 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 8 Mar 2026 16:30:06 +0100 Subject: [PATCH 2/2] chore: remove reusable workflow causing cosmetic failures --- .github/workflows/releaseforge.yml | 66 ------------------------------ 1 file changed, 66 deletions(-) delete mode 100644 .github/workflows/releaseforge.yml diff --git a/.github/workflows/releaseforge.yml b/.github/workflows/releaseforge.yml deleted file mode 100644 index 20bbdc1..0000000 --- a/.github/workflows/releaseforge.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: ReleaseForge - -on: - workflow_call: - inputs: - command: - description: "Command: bump, generate" - type: string - default: "bump" - tag: - description: "Base semver tag" - type: string - default: "" - branch: - description: "Target branch" - type: string - default: "HEAD" - provider: - description: "LLM provider for generate" - type: string - default: "gemini" - model: - description: "LLM model for generate" - type: string - default: "gemini-2.5-flash" - template-name: - description: "Built-in template name" - type: string - default: "" - max-commits: - description: "Max commits to analyze" - type: string - default: "200" - secrets: - api_key: - description: "LLM API key (for generate command)" - required: false - outputs: - next-version: - description: "Next semver version" - value: ${{ jobs.releaseforge.outputs.next-version }} - -permissions: - contents: read - -jobs: - releaseforge: - runs-on: ubuntu-latest - outputs: - next-version: ${{ steps.rf.outputs.next-version }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - id: rf - uses: AxeForging/releaseforge@main - with: - command: ${{ inputs.command }} - tag: ${{ inputs.tag }} - branch: ${{ inputs.branch }} - provider: ${{ inputs.provider }} - model: ${{ inputs.model }} - api-key: ${{ secrets.api_key }} - template-name: ${{ inputs.template-name }} - max-commits: ${{ inputs.max-commits }}