Imagine waking up to find that overnight, your app reviews were automatically sorted, bug reports converted into GitHub Issues, and draft replies generated for every negative review — ready for your approval with a single click. This isn't a future scenario. Indie developers can build this today.
The problem scales with your success. When you have 10 reviews a day, manual management is fine. But once you're getting 100+ reviews, the math breaks down: reading every review, replying thoughtfully, catching buried bug reports — it becomes a part-time job. I've shipped enough apps to know this pain firsthand.
In this guide, we'll build a complete review automation pipeline using Antigravity IDE and Gemma 4. The system fetches reviews from App Store Connect API and Google Play Developer API, classifies and analyzes them with Gemma 4, generates contextual replies, and creates GitHub Issues for bug reports. Every step includes working code you can drop into your project.
System Architecture and Design Philosophy
Before diving into code, here's how the complete system fits together:
┌─────────────────────────────────────────────────────────┐
│ Review Collection Layer (hourly) │
├─────────────────────────────────────────────────────────┤
│ App Store Connect API │ Google Play Developer API │
└──────────┬──────────────────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Gemma 4 Analysis Engine (Firebase Functions) │
├─────────────┬────────────────┬───────────────┬──────────┤
│ Bug Report │ Feature Request│ Neg. Complaint│ Praise │
└──────┬──────┴───────┬────────┴───────┬───────┴────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
GitHub Issues Notion DB Reply Drafts Social Proof
The guiding principle here is semi-automation with human approval. Rather than posting replies automatically, the system generates draft replies and sends them to Slack for review. You approve or tweak and hit send. This design reduces risk (no accidental policy violations) while cutting your workload by over 90%.
Technology Stack
- Antigravity — Code generation, debugging, and refactoring across the entire system
- Gemma 4 — Review classification, sentiment analysis, and reply generation
- Node.js 22 — Backend processing
- Firebase Functions v2 — Serverless execution
- App Store Connect API / Google Play Developer API — Review retrieval and reply posting
- GitHub API — Automated bug Issue creation
Step 1: Scaffolding the Project in Antigravity
Open a terminal in Antigravity and initialize the project:
mkdir app-review-ai && cd app-review-ai
firebase init functions
# Select TypeScript, enable ESLint, install dependenciesThen give Antigravity's Agent Mode this prompt:
This project is a Firebase Functions app for automatically processing App Store
and Google Play reviews. Create a TypeScript project structure with:
- src/collectors/ : modules to fetch reviews from APIs
- src/analyzers/ : modules to analyze reviews with Gemma 4
- src/responders/ : modules to generate and post replies
- src/notifiers/ : Slack and email notification modules
- src/types/review.ts : shared type definitions
Antigravity will scaffold the complete project structure in under a minute. No more manually creating directories and deciding on file organization.
Define Your Types First
Strong typing is the foundation of a maintainable system. Define your core interfaces before writing any logic:
// src/types/review.ts
export interface AppReview {
id: string;
platform: 'ios' | 'android';
appId: string;
rating: number; // 1-5
title: string;
body: string;
authorName: string;
date: Date;
developerReply?: string;
territory: string; // ISO country code ('JP', 'US', etc.)
}
export type ReviewCategory = 'bug' | 'feature_request' | 'negative' | 'positive' | 'question';
export interface AnalyzedReview extends AppReview {
category: ReviewCategory;
sentiment: number; // -1.0 to 1.0
urgency: 'low' | 'medium' | 'high' | 'critical';
extractedBugs: BugReport[];
suggestedReply: string;
confidence: number; // 0.0 to 1.0
}
export interface BugReport {
title: string;
description: string;
reproductionSteps: string[];
severity: 'minor' | 'major' | 'critical';
labels: string[];
}When you define types upfront, Antigravity uses them as a contract when generating the rest of your modules. Tell it "fix any TypeScript strict mode errors" in your prompt and it will maintain consistency throughout.
Step 2: Fetching Reviews from App Store Connect API
The App Store Connect API uses JWT authentication. You'll need an API key from Apple Developer Console (a .p8 private key file).
// src/collectors/appStoreCollector.ts
import * as jwt from 'jsonwebtoken';
import * as fs from 'fs';
interface AppStoreCollectorConfig {
keyId: string;
issuerId: string;
privateKeyPath: string;
bundleId: string;
}
export class AppStoreCollector {
private config: AppStoreCollectorConfig;
constructor(config: AppStoreCollectorConfig) {
this.config = config;
}
// Generate a JWT token valid for 20 minutes
private generateToken(): string {
const privateKey = fs.readFileSync(this.config.privateKeyPath, 'utf8');
const token = jwt.sign(
{
iss: this.config.issuerId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 20 * 60,
aud: 'appstoreconnect-v1',
},
privateKey,
{
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: this.config.keyId,
typ: 'JWT',
},
}
);
return token;
}
// Fetch reviews created after the given date
async fetchRecentReviews(appId: string, since: Date): Promise<AppReview[]> {
const token = this.generateToken();
const reviews: AppReview[] = [];
let cursor: string | null = null;
do {
const url = new URL(
`https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews`
);
url.searchParams.set('filter[rating]', '1,2,3,4,5');
url.searchParams.set('sort', '-createdDate');
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (\!response.ok) {
const error = await response.text();
throw new Error(`App Store API error: ${response.status} - ${error}`);
}
const data = await response.json() as any;
for (const item of data.data) {
const reviewDate = new Date(item.attributes.createdDate);
// Stop paging once we've passed our cutoff date
if (reviewDate < since) {
cursor = null;
break;
}
reviews.push({
id: item.id,
platform: 'ios',
appId,
rating: item.attributes.rating,
title: item.attributes.title || '',
body: item.attributes.body,
authorName: item.attributes.reviewerNickname || 'Anonymous',
date: reviewDate,
developerReply: item.attributes.developerResponse?.body,
territory: item.attributes.territory,
});
}
cursor = data.links?.next
? new URL(data.links.next).searchParams.get('cursor')
: null;
} while (cursor);
return reviews;
}
}Why cursor-based pagination matters: The App Store Connect API returns at most 200 reviews per request. Popular apps can accumulate thousands of reviews, so pagination is non-negotiable. By passing a since parameter (e.g., the past 24 hours), your hourly job only processes what's new — efficient and cost-effective.
Step 3: Analyzing Reviews with Gemma 4
This is where the intelligence lives. Prompt design is the single most important decision in the whole system.
// src/analyzers/gemmaAnalyzer.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY\!);
const model = genAI.getGenerativeModel({ model: 'gemma-4' });
const ANALYSIS_SYSTEM_PROMPT = `
You are a mobile app customer support specialist. Analyze the user review provided
and return ONLY a JSON object in this exact format (no markdown, no code blocks):
{
"category": "bug" | "feature_request" | "negative" | "positive" | "question",
"sentiment": number between -1.0 and 1.0,
"urgency": "low" | "medium" | "high" | "critical",
"extractedBugs": [
{
"title": "concise bug title",
"description": "detailed description",
"reproductionSteps": ["step 1", "step 2"],
"severity": "minor" | "major" | "critical",
"labels": ["crash", "performance", "ui", "data", etc.]
}
],
"suggestedReply": "reply text (polite, under 250 characters)",
"confidence": number between 0.0 and 1.0
}
Urgency guidelines:
- critical: app crashes, data loss, payment failures
- high: core features broken, affects many users
- medium: inconvenient but has workarounds
- low: minor complaints, personal preferences
`;
export async function analyzeReview(review: AppReview): Promise<AnalyzedReview> {
const prompt = `
App: ${review.appId}
Rating: ${review.rating} stars
Title: ${review.title}
Review: ${review.body}
Country: ${review.territory}
`;
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
const result = await model.generateContent({
contents: [
{ role: 'user', parts: [{ text: ANALYSIS_SYSTEM_PROMPT + '\n\n' + prompt }] }
],
generationConfig: {
responseMimeType: 'application/json', // Forces structured output
temperature: 0.3, // More deterministic
},
});
const responseText = result.response.text().trim();
// Clean up in case the model wraps in markdown anyway
const cleanedResponse = responseText
.replace(/^```json\n?/, '')
.replace(/\n?```$/, '')
.trim();
const analysis = JSON.parse(cleanedResponse);
return {
...review,
category: analysis.category,
sentiment: Math.max(-1, Math.min(1, analysis.sentiment)),
urgency: analysis.urgency,
extractedBugs: analysis.extractedBugs || [],
suggestedReply: analysis.suggestedReply,
confidence: analysis.confidence,
};
} catch (error) {
retryCount++;
if (retryCount === maxRetries) {
// Fail gracefully — a degraded result is better than stopping the pipeline
console.error(`Review analysis failed for ${review.id}:`, error);
return {
...review,
category: 'negative',
sentiment: review.rating < 3 ? -0.5 : 0.5,
urgency: 'low',
extractedBugs: [],
suggestedReply: 'Thank you for your feedback. We apologize for the inconvenience and will look into this right away.',
confidence: 0,
};
}
// Exponential backoff: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, retryCount - 1)));
}
}
throw new Error('Unreachable');
}
// Batch processor with rate limiting
export async function analyzeReviews(
reviews: AppReview[],
options: { batchSize?: number; delayMs?: number } = {}
): Promise<AnalyzedReview[]> {
const { batchSize = 10, delayMs = 500 } = options;
const results: AnalyzedReview[] = [];
for (let i = 0; i < reviews.length; i += batchSize) {
const batch = reviews.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(analyzeReview));
results.push(...batchResults);
if (i + batchSize < reviews.length) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return results;
}A critical design choice: When analysis fails after three retries, we return a sensible default rather than throwing an exception. One bad review should never kill the pipeline for hundreds of others. The confidence: 0 field lets downstream code decide whether to skip low-confidence analyses or flag them for manual review.
Setting responseMimeType: 'application/json' in the generation config is something I discovered through trial and error. It dramatically reduces malformed JSON responses — Gemma 4 still occasionally wraps output in markdown blocks, but the cleanup regex handles that edge case.
Step 4: Generating and Routing Replies
// src/responders/appStoreResponder.ts
export async function generateAndPostReply(
review: AnalyzedReview,
appStoreCollector: AppStoreCollector,
options: { autoPost: boolean; notifySlack: boolean }
): Promise<void> {
// Skip 4-5 star reviews that are already positive
if (review.rating >= 4 && review.category === 'positive') {
return;
}
// Skip reviews that already have a developer reply
if (review.developerReply) {
return;
}
const replyDraft = review.suggestedReply;
if (options.notifySlack) {
await notifySlackForApproval(review, replyDraft);
}
// Only auto-post for critical bugs — everything else requires human approval
if (options.autoPost && review.urgency === 'critical') {
await appStoreCollector.postReply(review.id, replyDraft);
console.log(`✅ Auto-replied to critical review: ${review.id}`);
}
}
async function notifySlackForApproval(
review: AnalyzedReview,
replyDraft: string
): Promise<void> {
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL\!;
const urgencyEmoji = {
critical: '🚨',
high: '⚠️',
medium: '📋',
low: '💬',
}[review.urgency];
const payload = {
text: `${urgencyEmoji} New review reply draft ready for approval`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${review.rating}★ ${review.title || '(no title)'}*\n${review.body}`,
},
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Category:*\n${review.category}` },
{ type: 'mrkdwn', text: `*Urgency:*\n${review.urgency}` },
{ type: 'mrkdwn', text: `*Sentiment:*\n${review.sentiment.toFixed(2)}` },
{ type: 'mrkdwn', text: `*Country:*\n${review.territory}` },
],
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Suggested reply:*\n${replyDraft}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: '✅ Approve & Post' },
style: 'primary',
action_id: `approve_reply_${review.id}`,
value: JSON.stringify({ reviewId: review.id, reply: replyDraft }),
},
{
type: 'button',
text: { type: 'plain_text', text: '✏️ Edit Draft' },
action_id: `edit_reply_${review.id}`,
},
],
},
],
};
await fetch(slackWebhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}Step 5: Creating GitHub Issues from Bug Reports
// src/notifiers/githubIssueCreator.ts
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function createIssueFromReview(
review: AnalyzedReview,
repoOwner: string,
repoName: string
): Promise<void> {
if (review.extractedBugs.length === 0) return;
for (const bug of review.extractedBugs) {
// Duplicate detection: check for similar open issues in the past 7 days
const existingIssues = await octokit.issues.listForRepo({
owner: repoOwner,
repo: repoName,
state: 'open',
labels: 'from-app-review',
since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
});
const isDuplicate = existingIssues.data.some(issue =>
calculateSimilarity(issue.title, bug.title) > 0.8
);
if (isDuplicate) {
console.log(`⏭️ Skipping duplicate issue: ${bug.title}`);
continue;
}
const issueBody = `
## Bug Report from App Review
**Source**
- Platform: ${review.platform === 'ios' ? 'App Store' : 'Google Play'}
- Rating: ${review.rating}★
- Country: ${review.territory}
- Date: ${review.date.toISOString()}
---
## Description
${bug.description}
## Reproduction Steps
${bug.reproductionSteps.map((step, i) => `${i + 1}. ${step}`).join('\n')}
## Original Review
> **${review.title}**
>
> ${review.body}
---
*Auto-generated by App Review AI System*
`;
await octokit.issues.create({
owner: repoOwner,
repo: repoName,
title: `[App Review Bug] ${bug.title}`,
body: issueBody,
labels: [
'from-app-review',
`severity-${bug.severity}`,
`platform-${review.platform}`,
...bug.labels,
],
});
console.log(`✅ Created GitHub Issue: ${bug.title}`);
// Respect GitHub API rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Jaccard similarity for duplicate detection
function calculateSimilarity(str1: string, str2: string): number {
const tokens1 = new Set(str1.toLowerCase().split(/\s+/));
const tokens2 = new Set(str2.toLowerCase().split(/\s+/));
const intersection = new Set([...tokens1].filter(x => tokens2.has(x)));
const union = new Set([...tokens1, ...tokens2]);
return intersection.size / union.size;
}Step 6: Scheduling with Firebase Functions
// functions/src/index.ts
import { onSchedule } from 'firebase-functions/v2/scheduler';
export const processReviews = onSchedule(
{
schedule: '0 * * * *', // Every hour on the hour
timeZone: 'America/Los_Angeles',
memory: '512MiB',
timeoutSeconds: 540, // 9 minutes (v2 maximum)
},
async (event) => {
const since = new Date(Date.now() - 60 * 60 * 1000); // Past hour
const collector = new AppStoreCollector({
keyId: process.env.APP_STORE_KEY_ID\!,
issuerId: process.env.APP_STORE_ISSUER_ID\!,
privateKeyPath: '/tmp/AuthKey.p8',
bundleId: process.env.BUNDLE_ID\!,
});
const rawReviews = await collector.fetchRecentReviews(
process.env.APP_STORE_APP_ID\!,
since
);
if (rawReviews.length === 0) {
console.log('No new reviews found');
return;
}
console.log(`Processing ${rawReviews.length} new reviews...`);
// Analyze in batches of 5 with 1s delay — respects Gemma 4 rate limits
const analyzedReviews = await analyzeReviews(rawReviews, {
batchSize: 5,
delayMs: 1000,
});
for (const review of analyzedReviews) {
if (review.category === 'bug' && review.extractedBugs.length > 0) {
await createIssueFromReview(
review,
process.env.GITHUB_REPO_OWNER\!,
process.env.GITHUB_REPO_NAME\!
);
}
if (review.rating <= 3 || review.urgency === 'critical') {
await generateAndPostReply(review, collector, {
autoPost: review.urgency === 'critical',
notifySlack: true,
});
}
}
const summary = {
total: analyzedReviews.length,
bugs: analyzedReviews.filter(r => r.category === 'bug').length,
critical: analyzedReviews.filter(r => r.urgency === 'critical').length,
avgSentiment: analyzedReviews.reduce((sum, r) => sum + r.sentiment, 0) / analyzedReviews.length,
};
console.log('Hourly summary:', JSON.stringify(summary));
}
);Step 7: Cost Optimization for Production
Running Gemma 4 on thousands of reviews can get expensive without thoughtful design. Here's how to keep costs manageable in a real production environment.
Filter aggressively before analysis
const reviewsToAnalyze = rawReviews.filter(review => {
if (review.developerReply) return false; // Already handled
if (review.body.length < 10) return false; // Emoji-only reviews
if (review.rating === 5 && \!review.body) return false; // Blank 5-stars
return true;
});Use the right Gemma 4 model tier
// Short or high-rated reviews use the faster, cheaper model
// Long 1-2 star reviews with bug-like language get the more powerful model
const modelName = (review.rating <= 2 && review.body.length > 100)
? 'gemma-4'
: 'gemma-4-flash';
const model = genAI.getGenerativeModel({ model: modelName });Cache analysis results in Firestore
When the same bug gets reported by multiple users, you don't need to run Gemma 4 again. Store a fingerprint (hash of normalized review body) in Firestore and skip re-analysis for near-duplicates. In practice, this can reduce your API calls by 30-50% for popular apps.
With all three optimizations combined, you'll typically spend 20-30% of the naive implementation cost — often under $5/month for apps with under 10,000 reviews.
Step 8: Common Pitfalls and How to Avoid Them
Pitfall 1: App Store Connect reply rate limits
If you post too many replies in a short window from the same IP, Apple returns 429 Too Many Requests. Firebase Functions changes IP per invocation, which usually helps, but burst posting (say, 50 replies within a minute) can still trigger limits.
Fix: Rate-limit reply posting to 10 per minute. Queue excess replies in Firestore and process them in the next scheduled run.
Pitfall 2: Malformed JSON from Gemma 4
Reviews containing quotes, curly braces, or multilingual text can cause Gemma 4 to generate broken JSON. The responseMimeType: 'application/json' setting in generationConfig reduces this dramatically, but doesn't eliminate it entirely.
Fix: Always wrap your JSON.parse call in a try-catch, and apply the regex cleanup before parsing. If parsing fails after retries, use the fallback default rather than crashing.
Pitfall 3: Duplicate GitHub Issues
The same bug showing up in 20 reviews means 20 identical Issues without deduplication. Jaccard similarity works well for English titles, but can miss duplicates in other languages.
Fix: Store bug fingerprints (MD5 of normalized title) in Firestore with a 7-day TTL. Check the fingerprint before calling the GitHub API. This is faster and more reliable than full-text comparison.
Start Small, Iterate Fast
The fastest path to a working system is to resist the urge to build everything at once. Here's the progression that actually works:
Start with the review collector only. Get the raw JSON flowing into your console. Once you can see live reviews printing to your terminal, everything else becomes concrete.
Add Gemma 4 analysis next. Pick five representative reviews (one per category) and verify the classifications manually before trusting the system to run unsupervised.
Wire up Slack notifications before GitHub Issues. Seeing AI-generated reply drafts in your Slack channel is motivating, and it gives you a feedback loop to tune your prompt before any automation goes live.
Only then add GitHub Issues and the approval workflow. By this point, you understand how the system behaves and can make informed decisions about what to automate.
For more on App Store Connect API fundamentals, see our App Store Connect API Automation Guide. For combining Firebase analytics with revenue tracking, the AdMob × Firebase Revenue Master Guide covers the monitoring side of this stack.
Antigravity can generate the initial implementation of each module in minutes given the type definitions and architecture described here. The real work — and where the value is — is in tuning your Gemma 4 prompts to match the tone of your specific app's community. That takes a week of iteration, and it's worth every minute.