diff --git a/.github/workflows/product-intelligence.yml b/.github/workflows/product-intelligence.yml index 8d76622..035c935 100644 --- a/.github/workflows/product-intelligence.yml +++ b/.github/workflows/product-intelligence.yml @@ -2,6 +2,9 @@ name: Daily Product Intelligence on: workflow_dispatch: + schedule: + # 06:00 UTC daily + - cron: "0 6 * * *" permissions: actions: read diff --git a/.github/workflows/triage-reviews.yml b/.github/workflows/triage-reviews.yml new file mode 100644 index 0000000..c35d4ca --- /dev/null +++ b/.github/workflows/triage-reviews.yml @@ -0,0 +1,35 @@ +name: Play Store Review Triage + +on: + workflow_dispatch: + schedule: + # 07:00 UTC daily (staggered after Daily Product Intelligence at 06:00 UTC) + - cron: "0 7 * * *" + +permissions: + contents: read + issues: write + +concurrency: + group: triage-reviews-${{ github.ref }} + cancel-in-progress: false + +jobs: + triage: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install google-auth google-auth-httplib2 google-api-python-client + + - name: Triage Play Store reviews + env: + GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }} + GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + run: python scripts/triage-reviews.py diff --git a/package-lock.json b/package-lock.json index 5d580ab..5c08463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencode-ai/mobile", - "version": "0.4.3", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencode-ai/mobile", - "version": "0.4.3", + "version": "0.4.7", "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", @@ -26,6 +26,7 @@ "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -5254,6 +5255,16 @@ "react-native": "*" } }, + "node_modules/expo-store-review": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-9.0.9.tgz", + "integrity": "sha512-99vS7edXlKzPcdjrzVlMQWc4zOyq4khQfFjhNqJgpGP+AgRn4U0LaZkHIrVjmzolryD3rcHJSiUQH9Vi0sD0MQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.24", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz", diff --git a/package.json b/package.json index e6cd92b..7b9a698 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -55,4 +56,4 @@ ] } } -} \ No newline at end of file +} diff --git a/scripts/triage-reviews.py b/scripts/triage-reviews.py index cf80b41..d8165fc 100644 --- a/scripts/triage-reviews.py +++ b/scripts/triage-reviews.py @@ -109,7 +109,7 @@ def create_issue(review: dict): --- -*This issue was automatically created by the [monitor-reviews workflow](/.github/workflows/monitor-reviews.yml). +*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` diff --git a/src/lib/store-review-policy.test.ts b/src/lib/store-review-policy.test.ts new file mode 100644 index 0000000..4b4ae2a --- /dev/null +++ b/src/lib/store-review-policy.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { shouldRequestReview, DEFAULT_REVIEW_THRESHOLD } from "./store-review-policy.ts" + +test("below the default threshold -> false", () => { + assert.equal(shouldRequestReview(0), false) + assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD - 1), false) +}) + +test("at or above the default threshold -> true", () => { + assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD), true) + assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD + 5), true) +}) + +test("custom threshold is respected", () => { + assert.equal(shouldRequestReview(2, 5), false) + assert.equal(shouldRequestReview(5, 5), true) +}) diff --git a/src/lib/store-review-policy.ts b/src/lib/store-review-policy.ts new file mode 100644 index 0000000..556b873 --- /dev/null +++ b/src/lib/store-review-policy.ts @@ -0,0 +1,18 @@ +// Pure success-count threshold logic for the in-app store-review prompt. +// +// Kept free of expo imports (SecureStore, expo-store-review) so it's +// unit-testable with plain `node --test`, the same split used for +// buildAuth() in auth.ts: "Extracted ... so the ... rule is unit-testable +// without pulling in zustand/expo (which have no resolver outside Metro)." + +export const DEFAULT_REVIEW_THRESHOLD = 3 + +/** + * Decide whether a running count of genuinely successful sessions (never + * incremented on error) has reached the point where we should attempt a + * store review prompt. The caller is responsible for tracking the + * "already asked" flag separately and never calling this again once true. + */ +export function shouldRequestReview(successCount: number, threshold: number = DEFAULT_REVIEW_THRESHOLD): boolean { + return successCount >= threshold +} diff --git a/src/lib/store-review.ts b/src/lib/store-review.ts new file mode 100644 index 0000000..21be248 --- /dev/null +++ b/src/lib/store-review.ts @@ -0,0 +1,72 @@ +/** + * In-app store review prompt. + * + * Nudges the user toward an App Store / Play Store rating after they've hit + * a few genuinely successful sessions — never from an error path. Persisted + * state lives in expo-secure-store, mirroring the consent pattern in + * telemetry.ts: + * - a running count of successful sessions + * - a one-time "already asked" flag so we prompt at most once, ever + * + * expo-store-review is safe to call unconditionally: requestReview() is only + * invoked after isAvailableAsync() returns true, and isAvailableAsync() + * resolves to false wherever the native review API is absent (F-Droid builds + * with no Play Services, older platform versions, etc.) — see + * https://docs.expo.dev/versions/latest/sdk/storereview/. In that case this + * module simply never prompts; it does not fall back to opening a store URL. + * + * Usage: call recordSuccessfulSession() from a success-only code path (e.g. + * the SSE busy -> idle "session completed" transition in stores/events.ts). + */ + +import * as SecureStore from "expo-secure-store" +import * as StoreReview from "expo-store-review" +import { shouldRequestReview, DEFAULT_REVIEW_THRESHOLD } from "./store-review-policy" + +const COUNT_KEY = "opencode_review_success_count" +const ASKED_KEY = "opencode_review_asked" + +// Serialize calls so rapid-fire session completions can't race the +// read-increment-write of the persisted counter. +let transition = Promise.resolve() + +/** + * Record a genuinely positive moment (a session finished successfully). + * Never call this from an error path. At most one review request is ever + * issued per install. + */ +export function recordSuccessfulSession(): Promise { + const next = transition.then(recordSuccessfulSessionInternal) + transition = next.catch(() => undefined) + return next +} + +async function recordSuccessfulSessionInternal(): Promise { + try { + const [asked, storedCount] = await Promise.all([ + SecureStore.getItemAsync(ASKED_KEY), + SecureStore.getItemAsync(COUNT_KEY), + ]) + if (asked === "true") return + + // Clamp the persisted counter at the threshold — we only need to know + // "have we reached it", not track an ever-growing lifetime total. + const count = Math.min(Number(storedCount ?? "0") + 1, DEFAULT_REVIEW_THRESHOLD) + await SecureStore.setItemAsync(COUNT_KEY, String(count)) + + if (!shouldRequestReview(count)) return + + const available = await StoreReview.isAvailableAsync() + if (!available) return + + // Mark as asked BEFORE requesting: on iOS requestReview() can throw + // (e.g. MissingCurrentWindowSceneException while backgrounded — likely, + // since sessions often complete in the background). A failed attempt + // consumes the one shot; that beats retrying and violating the + // "at most once, ever" contract. + await SecureStore.setItemAsync(ASKED_KEY, "true") + await StoreReview.requestReview() + } catch { + // A review-prompt failure must never affect session handling. + } +} diff --git a/src/stores/events.ts b/src/stores/events.ts index 8a26428..9164598 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -5,6 +5,7 @@ import { send as notify } from "../lib/notifications" import { sanitizeBody } from "../lib/notify-format" import { statusFromPart } from "../lib/status-labels" import { addBreadcrumb } from "../lib/sentry" +import { recordSuccessfulSession } from "../lib/store-review" import type { Client, Part, Session, Message } from "../lib/sdk" // Session status from the server @@ -51,6 +52,12 @@ interface EventsState { let controller: AbortController | null = null let reconnectTimer: ReturnType | null = null +// Sessions that emitted session.error since they last went busy. SessionStatus +// has no error variant — an errored session still ends with a busy -> idle +// transition — so without this mark an errored run would count as a success +// toward the once-ever store review prompt. +const erroredSessions = new Set() + const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const const STABLE_CONNECTION_MS = 10_000 const PROLONGED_DISCONNECT_MS = 30_000 @@ -160,6 +167,9 @@ export const useEvents = create((set, get) => ({ const previous = get().sessionStatus[sessionID] const completed = previous?.type === "busy" && status.type === "idle" + // A new run starts — forget any error from the previous one + if (status.type === "busy") erroredSessions.delete(sessionID) + set((state) => ({ sessionStatus: { ...state.sessionStatus, [sessionID]: status }, // Clear status text when idle @@ -186,6 +196,11 @@ export const useEvents = create((set, get) => ({ body: sanitizeBody(match?.title, "Session finished processing"), sessionId: sessionID, }) + // Genuinely positive moment — count it toward the one-time + // store review prompt, but only if this run never errored + // (session.error doesn't touch sessionStatus, so an errored + // session still lands here via busy -> idle). + if (!erroredSessions.has(sessionID)) void recordSuccessfulSession() } break } @@ -236,6 +251,9 @@ export const useEvents = create((set, get) => ({ const error = props.error as { message?: string } | undefined const sessionID = props.sessionID as string if (!sessionID) break + // Mark so the eventual busy -> idle transition is not counted + // as a success for the store review prompt + erroredSessions.add(sessionID) // Clear sending state unconditionally — SSE is truth useSessions.setState((state) => ({ sending: { ...state.sending, [sessionID]: false }, @@ -357,6 +375,7 @@ export const useEvents = create((set, get) => ({ } controller?.abort() controller = null + erroredSessions.clear() set({ connected: false, reconnectAttempts: 0,