diff --git a/.github/workflows/product-intelligence.yml b/.github/workflows/product-intelligence.yml index 035c935..fc0e79e 100644 --- a/.github/workflows/product-intelligence.yml +++ b/.github/workflows/product-intelligence.yml @@ -31,7 +31,10 @@ jobs: continue-on-error: true env: GITHUB_TOKEN: ${{ github.token }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN }} + # SENTRY_PRODUCT_INTELLIGENCE_TOKEN is a dedicated read-only token and is + # preferred if/when it exists; falls back to the general-purpose + # SENTRY_AUTH_TOKEN so the daily cron doesn't silently no-op (#60). + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN || secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | diff --git a/.github/workflows/triage-reviews.yml b/.github/workflows/triage-reviews.yml index c35d4ca..9252019 100644 --- a/.github/workflows/triage-reviews.yml +++ b/.github/workflows/triage-reviews.yml @@ -30,6 +30,8 @@ jobs: - name: Triage Play Store reviews env: - GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }} + # Repo secret is named PLAY_STORE_SERVICE_ACCOUNT_JSON (see docs/playstore.md); + # mapped here to the env var name the script expects. + GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }} GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} run: python scripts/triage-reviews.py diff --git a/docs/playstore.md b/docs/playstore.md index 29661b6..c1d47e5 100644 --- a/docs/playstore.md +++ b/docs/playstore.md @@ -97,6 +97,27 @@ After 14 days on Closed testing with 12+ active testers → promote to Productio --- +## Acquisition metrics — trusted source + +There is currently no verified, least-privilege source wired up for Play +Store acquisition/uninstall metrics (installs, uninstalls, store listing +conversion). `scripts/product-intelligence.mjs` explicitly lists this as a +**deferred** metric until a proper reporting contract exists — do not treat +ad-hoc Play Console screenshots or manual exports as a trusted feed for +automated reporting. + +Until that's implemented, the Play Console UI +(https://play.google.com/console/u/2/developers/8842655543970815326) is the +only source of truth for acquisition numbers, checked manually. Review +volume/rating triage (a related but separate signal) is automated via +`.github/workflows/triage-reviews.yml` + `scripts/triage-reviews.py`, which +reads reviews through the Android Publisher API using the +`PLAY_STORE_SERVICE_ACCOUNT_JSON` GitHub secret (see "Linked GCP resources" +above) — this is the trusted source for review-based signals, not any +scraped or manually copied review text. + +--- + ## Sibling channels: F-Droid + IzzyOnDroid OpenCode Mobile is also distributed via F-Droid (mainline) and IzzyOnDroid — diff --git a/scripts/triage-reviews.py b/scripts/triage-reviews.py index d8165fc..cc7a7f7 100644 --- a/scripts/triage-reviews.py +++ b/scripts/triage-reviews.py @@ -2,9 +2,21 @@ """ Play Store review triage script. -Fetches recent Play Store reviews via the Android Publisher API and creates -GitHub issues for low-rated or bug-report reviews so they enter the normal -fix cycle without manual monitoring. +Fetches recent Play Store reviews via the Android Publisher API and maintains +a single, sanitized, aggregated GitHub issue summarizing low-rated reviews so +they enter the normal fix cycle without manual monitoring. + +Privacy: the created/updated issue is public, so it never contains a +reviewer's name or verbatim review text. It only contains star-rating +counts, a paraphrased common-terms summary derived from word frequency +(not quoted sentences), and opaque review_id references an operator can +look up in the Play Console. + +Dedup: an HTML comment marker embeds the sorted set of review_ids +represented in the issue (same pattern as scripts/product-intelligence.mjs). +If the current run's actionable review_id set is unchanged from the +marker, the run skips without touching the issue. Otherwise it +creates the issue (first run) or updates it in place. Required env vars: GOOGLE_SERVICE_ACCOUNT_JSON — full JSON key for the service account @@ -15,12 +27,13 @@ Required env vars: import json import os +import re import subprocess import sys -import time +import tempfile +from collections import Counter from datetime import datetime, timezone, timedelta -import google.auth from google.oauth2 import service_account from googleapiclient.discovery import build @@ -29,14 +42,30 @@ PACKAGE_NAME = os.environ.get("PACKAGE_NAME", "cc.agentlabs.opencode") DAYS_BACK = int(os.environ.get("DAYS_BACK", "7")) GH_TOKEN = os.environ.get("GH_TOKEN", "") +ISSUE_TITLE = "Play Store Review Triage" +MARKER_RE = re.compile(r"") + SCOPES = ["https://www.googleapis.com/auth/androidpublisher"] +STOPWORDS = { + "the", "and", "for", "that", "this", "with", "have", "has", "not", + "you", "your", "but", "app", "when", "just", "very", "also", "from", + "are", "was", "were", "its", "it's", "can't", "cant", "dont", "don't", + "wont", "won't", "them", "they", "their", "there", "here", "what", + "would", "could", "should", "been", "being", "than", "then", "into", + "about", "even", "still", "again", "after", "before", "which", "some", + "only", "more", "most", "much", "many", "will", "does", "did", "how", + "why", "get", "got", "use", "used", "using", "please", "make", "made", +} -def get_service(): + +def env_client(): raw = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "") if not raw: - print("GOOGLE_SERVICE_ACCOUNT_JSON not set — skipping.") - sys.exit(0) + # Fail visibly (issue #61 done-criteria): a missing credential must + # turn the workflow run red, not report success while doing nothing. + print("ERROR: GOOGLE_SERVICE_ACCOUNT_JSON not set — cannot fetch reviews.", file=sys.stderr) + sys.exit(1) info = json.loads(raw) creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPES) return build("androidpublisher", "v3", credentials=creds, cache_discovery=False) @@ -62,8 +91,9 @@ def fetch_reviews(service): continue results.append({ "review_id": review.get("reviewId", ""), - "author": review.get("authorName", "anonymous"), - "rating": comment.get("starRating", 0), + # author name is intentionally not carried past this point — + # it never enters the aggregated public issue. + "rating": int(comment.get("starRating", 0) or 0), "text": comment.get("text", ""), "date": dt.strftime("%Y-%m-%d"), "lang": comment.get("reviewerLanguage", "en"), @@ -74,81 +104,158 @@ def fetch_reviews(service): return results -def issue_exists(title_prefix: str) -> bool: - """Check if a GitHub issue with this title prefix already exists.""" +def common_terms(reviews, top_n=8): + """Word-frequency summary across review text. Deliberately NOT a + verbatim excerpt — single lowercased tokens only, no sentence + structure, no attribution to a specific review or author.""" + counts = Counter() + for review in reviews: + for word in re.findall(r"[a-zA-Z']{4,}", review["text"].lower()): + if word in STOPWORDS: + continue + counts[word] += 1 + return [word for word, _ in counts.most_common(top_n)] + + +def find_existing_issue(): result = subprocess.run( [ "gh", "issue", "list", "--repo", REPO, - "--search", title_prefix, + "--search", f'"{ISSUE_TITLE}" in:title', "--state", "open", - "--json", "title", + "--json", "number,title,body", + "--limit", "10", ], capture_output=True, text=True, env={**os.environ, "GH_TOKEN": GH_TOKEN}, ) if result.returncode != 0: - return False + print(f" ⚠️ gh issue list failed: {result.stderr.strip()}", file=sys.stderr) + return None issues = json.loads(result.stdout or "[]") - return any(title_prefix.lower() in i["title"].lower() for i in issues) + for issue in issues: + if issue["title"] == ISSUE_TITLE: + return issue + return None -def create_issue(review: dict): - stars = "⭐" * review["rating"] - title = f"[Play Store Review] {review['rating']}★ from {review['author']} on {review['date']}" - body = f"""**Rating**: {stars} ({review['rating']}/5) -**Author**: {review['author']} -**Date**: {review['date']} -**Language**: {review['lang']} -**Review ID**: `{review['review_id']}` +def build_body(reviews, review_ids_marker): + total = len(reviews) + avg = sum(r["rating"] for r in reviews) / total if total else 0 + by_rating = Counter(r["rating"] for r in reviews) + terms = common_terms(reviews) ---- + lines = [ + f"", + "", + f"## Play Store review triage — last {DAYS_BACK} days", + "", + f"**Reviews in window:** {total} | **Average rating:** {avg:.1f}★", + "", + "### Rating breakdown", + "", + "| Stars | Count |", + "| --- | --- |", + ] + for stars in (1, 2, 3): + lines.append(f"| {stars}★ | {by_rating.get(stars, 0)} |") -> {review['text']} + lines += [ + "", + "### Common terms (word-frequency summary, not verbatim quotes)", + "", + (", ".join(f"`{t}`" for t in terms) if terms else "_not enough signal_"), + "", + "### Review references", + "", + "Look these up in Play Console → Reviews by ID for full context. " + "No reviewer name or review text is reproduced here.", + "", + "| Review ID | Rating | Date | Language |", + "| --- | --- | --- | --- |", + ] + for r in sorted(reviews, key=lambda r: (r["rating"], r["date"])): + lines.append(f"| `{r['review_id']}` | {r['rating']}★ | {r['date']} | {r['lang']} |") ---- + lines += [ + "", + "---", + "", + "*This issue is automatically maintained by the " + "[triage-reviews workflow](/.github/workflows/triage-reviews.yml). " + "It is updated in place as new low-rated reviews appear in the window " + "and intentionally contains no author names or raw review text.*", + ] + return "\n".join(lines) + "\n" -*This issue was automatically created by the [triage-reviews workflow](/.github/workflows/triage-reviews.yml). -Reply to the user in Play Console if applicable.* -**Labels**: `user-feedback`, `play-store-review` -""" - subprocess.run( - [ - "gh", "issue", "create", - "--repo", REPO, - "--title", title, - "--body", body, - "--label", "user-feedback", - ], - check=True, - env={**os.environ, "GH_TOKEN": GH_TOKEN}, - ) - print(f" ✅ Created issue: {title}") +def write_issue(existing, body): + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f: + f.write(body) + body_path = f.name + try: + if existing: + subprocess.run( + [ + "gh", "issue", "edit", str(existing["number"]), + "--repo", REPO, + "--body-file", body_path, + ], + check=True, + env={**os.environ, "GH_TOKEN": GH_TOKEN}, + ) + print(f" ✅ Updated issue #{existing['number']}") + else: + subprocess.run( + [ + "gh", "issue", "create", + "--repo", REPO, + "--title", ISSUE_TITLE, + "--body-file", body_path, + "--label", "user-feedback", + ], + check=True, + env={**os.environ, "GH_TOKEN": GH_TOKEN}, + ) + print(" ✅ Created triage issue") + finally: + os.unlink(body_path) def main(): print(f"Fetching Play Store reviews for {PACKAGE_NAME} (last {DAYS_BACK} days)...") - service = get_service() + service = env_client() reviews = fetch_reviews(service) print(f"Found {len(reviews)} review(s) in window.") - # Triage: create issues for 1-3 star reviews (potential bugs/problems) - actionable = [r for r in reviews if r["rating"] <= 3] + # Material = actionable = potential bugs/problems worth triaging. + actionable = sorted({r["review_id"] for r in reviews if r["rating"] <= 3 and r["review_id"]}) + actionable_reviews = [r for r in reviews if r["review_id"] in set(actionable)] + current_marker = ",".join(actionable) print(f"Actionable (≤3★): {len(actionable)}") - for review in actionable: - title_prefix = f"Play Store Review] {review['rating']}★ from {review['author']} on {review['date']}" - if issue_exists(title_prefix): - print(f" ⏭ Already exists: {review['author']} {review['date']}") - continue - create_issue(review) - time.sleep(1) # avoid GitHub API rate limit + if not actionable: + print("No actionable reviews in window. Skipping.") + return + + existing = find_existing_issue() + old_marker = "" + if existing: + match = MARKER_RE.search(existing.get("body") or "") + if match: + old_marker = match.group(1) + + if current_marker == old_marker: + print(" ⏭ No change in actionable review set since last run. Skipping.") + return + + body = build_body(actionable_reviews, current_marker) + write_issue(existing, body) - # Summary avg = sum(r["rating"] for r in reviews) / len(reviews) if reviews else 0 - print(f"\nSummary: {len(reviews)} reviews, avg rating {avg:.1f}★, {len(actionable)} issues created/checked.") + print(f"\nSummary: {len(reviews)} reviews, avg rating {avg:.1f}★, {len(actionable)} actionable.") if __name__ == "__main__":