ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-17Advanced

Antigravity × Gemma 4: Build an AI System That Auto-Analyzes, Replies to, and Triages Your App Reviews

A complete implementation guide for building a review management AI system using Antigravity and Gemma 4. Automatically analyze App Store / Google Play reviews, generate replies, and create GitHub Issues — all without manual effort.

Antigravity338Gemma 422App Store Connect API2review automationbug triageindie dev18Node.js3GitHub Issues

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 dependencies

Then 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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-15
Delegating iOS App Maintenance to Antigravity — An Honest 6-Week Report
A real-world account of using Antigravity for maintenance work on Beautiful HD Wallpapers (50M+ downloads). What worked, what didn't, and what the honest conclusion is for solo developers.
App Dev2026-05-06
Running a Solo AI Studio with Antigravity × Google AI — Automating Every Stage of App Development as One Developer
How to combine Antigravity 2.0 and Antigravity CLI with Stitch and Veo 3 into a pipeline that takes an app from design through implementation, testing, asset generation, and App Store submission, driven by one developer. Covers where to delegate to agents and the operational pitfalls.
App Dev2026-05-04
Building a Veo 3 Video Generation App with Antigravity — From API to Launch
A hands-on guide to building a video generation iOS app using the Veo 3 API with Antigravity. Covers async polling architecture, SwiftUI implementation, and monetization design.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →