Skip to content

Commit 5428f2c

Browse files
committed
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.
1 parent 699c994 commit 5428f2c

8 files changed

Lines changed: 190 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
- name: Generate release notes
4747
env:
4848
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
49+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4950
run: |
5051
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
5152
ARGS="generate --use-git-fallback --output /tmp/release-notes.md"

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ inputs:
3131
template-name:
3232
description: "Built-in template: semver-release-notes, conventional-changelog, version-analysis"
3333
required: false
34+
github-token:
35+
description: "GitHub token for resolving contributor usernames from emails"
36+
required: false
3437
use-git-fallback:
3538
description: "Fall back to git commit log analysis if LLM fails"
3639
required: false
@@ -87,6 +90,7 @@ runs:
8790
GEMINI_API_KEY: ${{ inputs.api-key }}
8891
OPENAI_API_KEY: ${{ inputs.api-key }}
8992
ANTHROPIC_API_KEY: ${{ inputs.api-key }}
93+
GITHUB_TOKEN: ${{ inputs.github-token }}
9094
run: |
9195
CMD="${{ inputs.command }}"
9296

actions/generate.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
4343
return err
4444
}
4545

46+
// Setup GitHub service for contributor resolution
47+
ghSvc := services.NewGitHubService(c.String("github-token"))
48+
if ghSvc.HasToken() {
49+
helpers.Log.Info().Msg("GitHub token provided — will resolve contributor usernames")
50+
}
51+
4652
// Get detailed commits
4753
var detailedCommits []domain.DetailedCommit
4854
if len(commits) > 0 {
49-
detailedCommits, err = a.gitSvc.GetCommitDetails(commits)
55+
detailedCommits, err = a.gitSvc.GetCommitDetails(commits, ghSvc)
5056
if err != nil {
5157
helpers.Log.Warn().Msgf("Could not get commit details: %v", err)
5258
}

flags.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ var forceGitModeFlag = cli.BoolFlag{
9595
Usage: "Force using git commit log analysis instead of LLM (no API key needed)",
9696
}
9797

98+
var githubTokenFlag = cli.StringFlag{
99+
Name: "github-token",
100+
Usage: "GitHub token for resolving contributor usernames from emails",
101+
EnvVar: "GITHUB_TOKEN",
102+
}
103+
98104
var verboseFlag = cli.BoolFlag{
99105
Name: "verbose, v",
100106
Usage: "Enable verbose/debug logging",

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func main() {
5252
outputFlag,
5353
useGitFallbackFlag,
5454
forceGitModeFlag,
55+
githubTokenFlag,
5556
verboseFlag,
5657
}
5758

services/git.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (g *GitService) parseAndFilterCommits(output string, ignoreList []string) (
118118
return commits, nil
119119
}
120120

121-
func (g *GitService) GetCommitDetails(commits []domain.CommitInfo) ([]domain.DetailedCommit, error) {
121+
func (g *GitService) GetCommitDetails(commits []domain.CommitInfo, ghSvc *GitHubService) ([]domain.DetailedCommit, error) {
122122
helpers.Log.Info().Msg("Gathering detailed commit information...")
123123
var detailed []domain.DetailedCommit
124124

@@ -137,7 +137,9 @@ func (g *GitService) GetCommitDetails(commits []domain.CommitInfo) ([]domain.Det
137137
emailOut, err := g.runGit("show", c.Hash, "--format=%ae", "--no-patch")
138138
if err == nil {
139139
dc.AuthorEmail = strings.TrimSpace(emailOut)
140-
if ghUser := extractGitHubUser(dc.AuthorEmail); ghUser != "" {
140+
if ghSvc != nil {
141+
dc.Author = ghSvc.ResolveAuthor(dc.Author, dc.AuthorEmail)
142+
} else if ghUser := extractGitHubUser(dc.AuthorEmail); ghUser != "" {
141143
dc.Author = "@" + ghUser
142144
}
143145
}

services/github.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package services
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"sync"
10+
11+
"github.com/AxeForging/releaseforge/helpers"
12+
)
13+
14+
type GitHubService struct {
15+
token string
16+
cache map[string]string
17+
mu sync.Mutex
18+
}
19+
20+
func NewGitHubService(token string) *GitHubService {
21+
return &GitHubService{
22+
token: token,
23+
cache: make(map[string]string),
24+
}
25+
}
26+
27+
func (gh *GitHubService) HasToken() bool {
28+
return gh.token != ""
29+
}
30+
31+
func (gh *GitHubService) ResolveUsername(email string) string {
32+
if email == "" {
33+
return ""
34+
}
35+
36+
// Check noreply format first (no API needed)
37+
if user := extractGitHubUser(email); user != "" {
38+
return user
39+
}
40+
41+
if !gh.HasToken() {
42+
return ""
43+
}
44+
45+
gh.mu.Lock()
46+
if cached, ok := gh.cache[email]; ok {
47+
gh.mu.Unlock()
48+
return cached
49+
}
50+
gh.mu.Unlock()
51+
52+
username := gh.searchUserByEmail(email)
53+
54+
gh.mu.Lock()
55+
gh.cache[email] = username
56+
gh.mu.Unlock()
57+
58+
return username
59+
}
60+
61+
func (gh *GitHubService) searchUserByEmail(email string) string {
62+
url := fmt.Sprintf("https://api.github.com/search/users?q=%s+in:email", email)
63+
64+
req, err := http.NewRequest("GET", url, nil)
65+
if err != nil {
66+
return ""
67+
}
68+
69+
req.Header.Set("Authorization", "Bearer "+gh.token)
70+
req.Header.Set("Accept", "application/vnd.github+json")
71+
72+
resp, err := http.DefaultClient.Do(req)
73+
if err != nil {
74+
helpers.Log.Debug().Msgf("GitHub API request failed for %s: %v", email, err)
75+
return ""
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode != 200 {
80+
helpers.Log.Debug().Msgf("GitHub API returned %d for email %s", resp.StatusCode, email)
81+
return ""
82+
}
83+
84+
body, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
return ""
87+
}
88+
89+
var result struct {
90+
TotalCount int `json:"total_count"`
91+
Items []struct {
92+
Login string `json:"login"`
93+
} `json:"items"`
94+
}
95+
96+
if err := json.Unmarshal(body, &result); err != nil {
97+
return ""
98+
}
99+
100+
if result.TotalCount > 0 && len(result.Items) > 0 {
101+
username := result.Items[0].Login
102+
helpers.Log.Info().Msgf("Resolved email %s → @%s", email, username)
103+
return username
104+
}
105+
106+
// Try commit search as fallback (works even when email isn't public)
107+
return gh.searchCommitAuthor(email)
108+
}
109+
110+
func (gh *GitHubService) searchCommitAuthor(email string) string {
111+
url := fmt.Sprintf("https://api.github.com/search/commits?q=author-email:%s", email)
112+
113+
req, err := http.NewRequest("GET", url, nil)
114+
if err != nil {
115+
return ""
116+
}
117+
118+
req.Header.Set("Authorization", "Bearer "+gh.token)
119+
req.Header.Set("Accept", "application/vnd.github+json")
120+
121+
resp, err := http.DefaultClient.Do(req)
122+
if err != nil {
123+
return ""
124+
}
125+
defer resp.Body.Close()
126+
127+
if resp.StatusCode != 200 {
128+
return ""
129+
}
130+
131+
body, err := io.ReadAll(resp.Body)
132+
if err != nil {
133+
return ""
134+
}
135+
136+
var result struct {
137+
TotalCount int `json:"total_count"`
138+
Items []struct {
139+
Author struct {
140+
Login string `json:"login"`
141+
} `json:"author"`
142+
} `json:"items"`
143+
}
144+
145+
if err := json.Unmarshal(body, &result); err != nil {
146+
return ""
147+
}
148+
149+
if result.TotalCount > 0 && len(result.Items) > 0 {
150+
login := result.Items[0].Author.Login
151+
if login != "" {
152+
helpers.Log.Info().Msgf("Resolved email %s → @%s (via commit search)", email, login)
153+
return login
154+
}
155+
}
156+
157+
return ""
158+
}
159+
160+
// ResolveAuthor takes an author name and email and returns the best display name
161+
func (gh *GitHubService) ResolveAuthor(name, email string) string {
162+
if username := gh.ResolveUsername(email); username != "" {
163+
return "@" + strings.ToLower(username)
164+
}
165+
return name
166+
}

services/semver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ func (s *SemverService) GetCommitsBetween(fromTag, toRef string, maxCommits int)
293293
return nil, err
294294
}
295295

296-
detailed, err := s.git.GetCommitDetails(commits)
296+
detailed, err := s.git.GetCommitDetails(commits, nil)
297297
if err != nil {
298298
return nil, err
299299
}

0 commit comments

Comments
 (0)