1+ const { Octokit } = require ( "@octokit/rest" ) ;
2+ const { GoogleGenerativeAI } = require ( "@google/generative-ai" ) ;
3+ const fs = require ( 'fs' ) ;
4+ const path = require ( 'path' ) ;
5+
6+ const GITHUB_TOKEN = process . env . GITHUB_TOKEN ;
7+ const GEMINI_API_KEY = process . env . GEMINI_API_KEY ;
8+ const REPOSITORIES = process . env . REPOSITORIES || process . env . GITHUB_REPOSITORY ;
9+
10+ const octokit = new Octokit ( { auth : GITHUB_TOKEN } ) ;
11+ const genAI = new GoogleGenerativeAI ( GEMINI_API_KEY ) ;
12+
13+ async function getIssueDetails ( owner , repo , issueNumber ) {
14+ try {
15+ const { data : issue } = await octokit . request ( "GET /repos/{owner}/{repo}/issues/{issue_number}" , {
16+ owner,
17+ repo,
18+ issue_number : issueNumber
19+ } ) ;
20+ return `Issue Title: ${ issue . title } \nIssue Description: ${ issue . body ? issue . body . slice ( 0 , 500 ) + "..." : "No description provided." } ` ;
21+ } catch ( e ) {
22+ console . warn ( `Could not fetch issue #${ issueNumber } for ${ owner } /${ repo } : ${ e . message } ` ) ;
23+ return null ;
24+ }
25+ }
26+
27+ async function getMergedPRs ( owner , repo ) {
28+ try {
29+ const prs = await octokit . paginate ( "GET /repos/{owner}/{repo}/pulls" , { owner, repo, state : 'closed' , per_page : 50 } ) ;
30+ const thirtyDaysAgo = new Date ( ) ;
31+ thirtyDaysAgo . setDate ( thirtyDaysAgo . getDate ( ) - 30 ) ;
32+ const merged = prs . filter ( pr => pr . merged_at && new Date ( pr . merged_at ) > thirtyDaysAgo ) ;
33+
34+ const results = [ ] ;
35+ for ( const pr of merged ) {
36+ let issueContext = "" ;
37+ const issueMatch = pr . body && pr . body . match ( / (?: f i x | f i x e s | f i x e d | c l o s e | c l o s e s | c l o s e d | r e s o l v e | r e s o l v e s | r e s o l v e d ) \s + # ( \d + ) / i) ;
38+ if ( issueMatch ) {
39+ const issueNumber = issueMatch [ 1 ] ;
40+ const details = await getIssueDetails ( owner , repo , issueNumber ) ;
41+ if ( details ) issueContext = details ;
42+ }
43+ results . push ( { repository : `${ owner } /${ repo } ` , title : pr . title , context : issueContext } ) ;
44+ }
45+ return results ;
46+ } catch ( e ) { return [ ] ; }
47+ }
48+
49+ async function generateWithRetry ( modelName , prompt , attempt = 1 ) {
50+ try {
51+ const model = genAI . getGenerativeModel ( { model : modelName } ) ;
52+ const result = await model . generateContent ( prompt ) ;
53+ return result . response . text ( ) ;
54+ } catch ( e ) {
55+ if ( ( e . status === 503 || e . status === 429 ) && attempt < 3 ) {
56+ console . log ( `Model ${ modelName } busy. Waiting 3s...` ) ;
57+ await new Promise ( r => setTimeout ( r , 3000 ) ) ;
58+ return generateWithRetry ( modelName , prompt , attempt + 1 ) ;
59+ }
60+ throw e ;
61+ }
62+ }
63+
64+ async function main ( ) {
65+ try {
66+ const repoList = REPOSITORIES . split ( ',' ) . map ( r => r . trim ( ) ) ;
67+ let allUpdates = [ ] ;
68+ for ( const repoStr of repoList ) {
69+ const [ o , r ] = repoStr . split ( '/' ) ;
70+ const prs = await getMergedPRs ( o , r ) ;
71+ allUpdates = allUpdates . concat ( prs ) ;
72+ }
73+
74+ if ( allUpdates . length === 0 ) return console . log ( "Nothing to report." ) ;
75+
76+ let style = "Professional." ;
77+ // Prioritize .gemini/styleguide.md, fallback to root
78+ if ( fs . existsSync ( '.gemini/styleguide.md' ) ) {
79+ style = fs . readFileSync ( '.gemini/styleguide.md' , 'utf8' ) ;
80+ } else if ( fs . existsSync ( 'STYLE_GUIDE.md' ) ) {
81+ style = fs . readFileSync ( 'STYLE_GUIDE.md' , 'utf8' ) ;
82+ }
83+
84+ const prSummaries = allUpdates . map ( p => {
85+ let item = `- [${ p . repository } ] ${ p . title } ` ;
86+ if ( p . context ) item += `\n Context (Linked Issue): ${ p . context . replace ( / \n / g, ' ' ) } ` ;
87+ return item ;
88+ } ) . join ( "\n" ) ;
89+
90+ const instruction = `
91+ Role: You are a Product Marketing expert writing a customer-facing Product Update.
92+ Task: Transform raw technical pull request titles into a polished, user-friendly Product Update.
93+
94+ Instructions:
95+ 1. Categorize updates clearly (e.g., 🚀 New Features, ⚡ Improvements, 🐛 Bug Fixes).
96+ 2. Explain each item in customer-friendly, non-technical language.
97+ 3. Clearly state the User Benefit for every major update.
98+ 4. Use the provided 'Context (Linked Issue)' to explain the "why" and customer value.
99+ 5. STRICTLY follow the provided Style Guide (tone, formatting, structure).
100+ 6. Do not invent features or details; keep vague PRs high-level.
101+ 7. Write with a product marketing tone (value-led, confident, announcement-style — not technical).
102+ 8. Exclude internal maintenance tasks (e.g., dependency updates, CI/CD changes) that have no visible impact on the user.
103+
104+ Output: Scannable, customer-ready Product Update suitable for release notes or announcements.
105+ ` ;
106+
107+ const prompt = `${ instruction } \n\nStyle Guide:\n${ style } \n\nChanges:\n${ prSummaries } ` ;
108+
109+ const models = [ "gemini-3-flash-preview" , "gemini-2.5-flash" , "gemini-1.5-flash" ] ;
110+ let content = null ;
111+ for ( const m of models ) {
112+ try { content = await generateWithRetry ( m , prompt ) ; break ; }
113+ catch ( e ) { console . log ( `${ m } failed, trying next...` ) ; }
114+ }
115+
116+ if ( ! content ) throw new Error ( "AI servers are unavailable. Try again in 5 minutes." ) ;
117+
118+ if ( ! fs . existsSync ( 'product-updates' ) ) fs . mkdirSync ( 'product-updates' ) ;
119+ fs . writeFileSync ( `product-updates/${ new Date ( ) . getFullYear ( ) } .md` , content ) ;
120+ console . log ( "SUCCESS!" ) ;
121+ } catch ( e ) { console . error ( "FATAL:" , e . message ) ; process . exit ( 1 ) ; }
122+ }
123+ main ( ) ;
0 commit comments