merge: feat/feedback-automation — cron product-intelligence, review-triage workflow, store-review prompt (reviewed, 2 findings fixed)
This commit is contained in:
3
.github/workflows/product-intelligence.yml
vendored
3
.github/workflows/product-intelligence.yml
vendored
@@ -2,6 +2,9 @@ name: Daily Product Intelligence
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# 06:00 UTC daily
|
||||||
|
- cron: "0 6 * * *"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
35
.github/workflows/triage-reviews.yml
vendored
Normal file
35
.github/workflows/triage-reviews.yml
vendored
Normal file
@@ -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
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/mobile",
|
"name": "@opencode-ai/mobile",
|
||||||
"version": "0.4.3",
|
"version": "0.4.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@opencode-ai/mobile",
|
"name": "@opencode-ai/mobile",
|
||||||
"version": "0.4.3",
|
"version": "0.4.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-store-review": "~9.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
@@ -5254,6 +5255,16 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.24",
|
"version": "54.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-store-review": "~9.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
|||||||
@@ -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.*
|
Reply to the user in Play Console if applicable.*
|
||||||
|
|
||||||
**Labels**: `user-feedback`, `play-store-review`
|
**Labels**: `user-feedback`, `play-store-review`
|
||||||
|
|||||||
18
src/lib/store-review-policy.test.ts
Normal file
18
src/lib/store-review-policy.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
18
src/lib/store-review-policy.ts
Normal file
18
src/lib/store-review-policy.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
72
src/lib/store-review.ts
Normal file
72
src/lib/store-review.ts
Normal file
@@ -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<void> {
|
||||||
|
const next = transition.then(recordSuccessfulSessionInternal)
|
||||||
|
transition = next.catch(() => undefined)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSuccessfulSessionInternal(): Promise<void> {
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { send as notify } from "../lib/notifications"
|
|||||||
import { sanitizeBody } from "../lib/notify-format"
|
import { sanitizeBody } from "../lib/notify-format"
|
||||||
import { statusFromPart } from "../lib/status-labels"
|
import { statusFromPart } from "../lib/status-labels"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
import { recordSuccessfulSession } from "../lib/store-review"
|
||||||
import type { Client, Part, Session, Message } from "../lib/sdk"
|
import type { Client, Part, Session, Message } from "../lib/sdk"
|
||||||
|
|
||||||
// Session status from the server
|
// Session status from the server
|
||||||
@@ -51,6 +52,12 @@ interface EventsState {
|
|||||||
let controller: AbortController | null = null
|
let controller: AbortController | null = null
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
let reconnectTimer: ReturnType<typeof setTimeout> | 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<string>()
|
||||||
|
|
||||||
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
|
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
|
||||||
const STABLE_CONNECTION_MS = 10_000
|
const STABLE_CONNECTION_MS = 10_000
|
||||||
const PROLONGED_DISCONNECT_MS = 30_000
|
const PROLONGED_DISCONNECT_MS = 30_000
|
||||||
@@ -160,6 +167,9 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
const previous = get().sessionStatus[sessionID]
|
const previous = get().sessionStatus[sessionID]
|
||||||
const completed = previous?.type === "busy" && status.type === "idle"
|
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) => ({
|
set((state) => ({
|
||||||
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
|
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
|
||||||
// Clear status text when idle
|
// Clear status text when idle
|
||||||
@@ -186,6 +196,11 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
body: sanitizeBody(match?.title, "Session finished processing"),
|
body: sanitizeBody(match?.title, "Session finished processing"),
|
||||||
sessionId: sessionID,
|
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
|
break
|
||||||
}
|
}
|
||||||
@@ -236,6 +251,9 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
const error = props.error as { message?: string } | undefined
|
const error = props.error as { message?: string } | undefined
|
||||||
const sessionID = props.sessionID as string
|
const sessionID = props.sessionID as string
|
||||||
if (!sessionID) break
|
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
|
// Clear sending state unconditionally — SSE is truth
|
||||||
useSessions.setState((state) => ({
|
useSessions.setState((state) => ({
|
||||||
sending: { ...state.sending, [sessionID]: false },
|
sending: { ...state.sending, [sessionID]: false },
|
||||||
@@ -357,6 +375,7 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
controller?.abort()
|
controller?.abort()
|
||||||
controller = null
|
controller = null
|
||||||
|
erroredSessions.clear()
|
||||||
set({
|
set({
|
||||||
connected: false,
|
connected: false,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user