diff --git a/.github/workflows/product-intelligence.yml b/.github/workflows/product-intelligence.yml new file mode 100644 index 0000000..8d76622 --- /dev/null +++ b/.github/workflows/product-intelligence.yml @@ -0,0 +1,126 @@ +name: Daily Product Intelligence + +on: + workflow_dispatch: + +permissions: + actions: read + contents: read + issues: write + +concurrency: + group: product-intelligence-${{ github.ref }} + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Collect aggregate product signals + id: collect + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + node scripts/product-intelligence.mjs \ + --report "$RUNNER_TEMP/product-intelligence.md" \ + --json "$RUNNER_TEMP/product-intelligence.json" + + - name: Create material-signal issue + if: always() + uses: actions/github-script@v8 + env: + REPORT_PATH: ${{ runner.temp }}/product-intelligence.json + with: + script: | + const fs = require("fs") + + if (!fs.existsSync(process.env.REPORT_PATH)) { + core.setFailed("Product intelligence collector produced no report.") + return + } + + const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, "utf8")) + const allowedSignals = new Set([ + "new-sentry-issue", + "repeated-workflow-failure", + ]) + const validDate = typeof report.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(report.date) + const validSignals = Array.isArray(report.signals) && + report.signals.length > 0 && + report.signals.every((signal) => allowedSignals.has(signal)) + + if (!report.material) { + core.info("No material signal. No GitHub issue created.") + return + } + if (!validDate || !validSignals) { + core.setFailed("Collector report failed public issue allowlist validation.") + return + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }) + const title = "Product intelligence: material signal" + const marker = `` + const body = [ + marker, + "", + "## Material aggregate signal", + "", + `The daily product-intelligence run detected: ${[...report.signals].sort().join(", ")}.`, + "", + "This issue intentionally contains no raw diagnostic, review, request, or user-generated content. Review the sanitized Actions artifact and reproduce the behavior in the affected user channel before implementing a fix.", + ].join("\n") + const existing = issues.find((issue) => issue.title === title) + + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + title, + body, + }) + core.info(`Updated #${existing.number}.`) + return + } + + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ["P1"], + }) + core.info(`Created #${issue.number}.`) + + - name: Upload sanitized report + if: always() + uses: actions/upload-artifact@v7 + with: + name: product-intelligence-${{ github.run_id }} + path: | + ${{ runner.temp }}/product-intelligence.md + ${{ runner.temp }}/product-intelligence.json + if-no-files-found: error + + - name: Fail when a required source is unavailable + if: steps.collect.outcome == 'failure' + run: | + echo "::error::A required product-intelligence source was unavailable. See the report summary for the exact source and error." + exit 1 diff --git a/distribution/reference-screenshots/play-v1/01.png b/distribution/reference-screenshots/play-v1/01.png new file mode 100644 index 0000000..1207783 Binary files /dev/null and b/distribution/reference-screenshots/play-v1/01.png differ diff --git a/distribution/reference-screenshots/play-v1/02.png b/distribution/reference-screenshots/play-v1/02.png new file mode 100644 index 0000000..704598d Binary files /dev/null and b/distribution/reference-screenshots/play-v1/02.png differ diff --git a/distribution/reference-screenshots/play-v1/03.png b/distribution/reference-screenshots/play-v1/03.png new file mode 100644 index 0000000..84d4d19 Binary files /dev/null and b/distribution/reference-screenshots/play-v1/03.png differ diff --git a/distribution/reference-screenshots/play-v1/04.png b/distribution/reference-screenshots/play-v1/04.png new file mode 100644 index 0000000..458e754 Binary files /dev/null and b/distribution/reference-screenshots/play-v1/04.png differ diff --git a/distribution/reference-screenshots/play-v1/05.png b/distribution/reference-screenshots/play-v1/05.png new file mode 100644 index 0000000..b57c46e Binary files /dev/null and b/distribution/reference-screenshots/play-v1/05.png differ diff --git a/distribution/reference-screenshots/play-v1/06.png b/distribution/reference-screenshots/play-v1/06.png new file mode 100644 index 0000000..d8af538 Binary files /dev/null and b/distribution/reference-screenshots/play-v1/06.png differ diff --git a/distribution/reference-screenshots/play-v1/07.png b/distribution/reference-screenshots/play-v1/07.png new file mode 100644 index 0000000..7085a15 Binary files /dev/null and b/distribution/reference-screenshots/play-v1/07.png differ diff --git a/distribution/reference-screenshots/play-v1/manifest.json b/distribution/reference-screenshots/play-v1/manifest.json new file mode 100644 index 0000000..5e31eab --- /dev/null +++ b/distribution/reference-screenshots/play-v1/manifest.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "source": { + "platform": "Google Play", + "listing": "https://play.google.com/store/apps/details?id=cc.agentlabs.opencode&hl=en_US", + "packageName": "cc.agentlabs.opencode", + "retrievedAt": "2026-07-14", + "ownership": "OpenCode Mobile listing assets" + }, + "assetPolicy": { + "purpose": "Canonical reference assets for the OpenCode Mobile website and future store-listing updates.", + "doNotModify": "Do not crop, add marketing copy, or otherwise alter these files in place. Create a separately named derivative for a channel-specific composition.", + "websiteSource": "Copy selected originals into website/public/screenshots/ during the website deployment change." + }, + "screenshots": [ + { + "file": "01.png", + "sourceUrl": "https://play-lh.googleusercontent.com/0TndhVWCZHGC0UpZNEWBJfhAsRI1NPeg5aevMqdTTEmCQ1zE-HPwMVt0eGxxW2ljPAhGynebJ346MKP2JYAf9YU", + "sha256": "9c2c1648458d52e9fdc6f9b45777d0fbf20f07fa3369e7aa747b708c4c7a5102", + "width": 1280, + "height": 2560, + "feature": "Model and settings selection", + "websitePlacement": "gallery-07" + }, + { + "file": "02.png", + "sourceUrl": "https://play-lh.googleusercontent.com/l9Q1182qqZIJ6fSoXUMmlctaDMAt6RJbgZYVUA4akx2Fd7UKQ5R8MH0jbWGFI0_RVf-vBQMiTTixrTANWJKWpw", + "sha256": "7928699bd4ea3c64d22bce60d05854d09699bf74b23f8390e62ed900fdf0d1e8", + "width": 1280, + "height": 2560, + "feature": "Live tool activity", + "websitePlacement": "gallery-04" + }, + { + "file": "03.png", + "sourceUrl": "https://play-lh.googleusercontent.com/m1EkbhW1W17NNt1wyMOl7pGpdRgzwbULsxkrZ-KVuVlppTnYfN6dmXWmIoy2a8obp3uY6yg3LCuDN5_obHopTiQ", + "sha256": "8987a963d708563f5ff2c0f1c127d852bcbd3759cd9eef2b462cee4d0d8e8658", + "width": 1280, + "height": 2560, + "feature": "Streaming task conversation", + "websitePlacement": "hero-02" + }, + { + "file": "04.png", + "sourceUrl": "https://play-lh.googleusercontent.com/n3CtQbxCaV9V715VC9Dk5LYqP-ufQr2TA9Jsm-iKowTgN562_SWX663a_FpS1iGhAIFJ1QCSRAz5jtEinuosig", + "sha256": "d7af61652e9a1521457e87c54c1702a1318e811201a46c40af9b59b85e09e2a2", + "width": 1280, + "height": 2560, + "feature": "Connection setup", + "websitePlacement": "hero-01" + }, + { + "file": "05.png", + "sourceUrl": "https://play-lh.googleusercontent.com/nkYKYJzFD3Q3hVHpsUUhbd6ICirWLUYMcuxTK3Tg_QiCA3YPI_8PZiepI98BY9gddaWzF3xBZEDHmEo15dfyBQ", + "sha256": "6acbbd87f4f48e5cf53deddd562c2099f8bab892bbd8f0bb00aac24e6f63ed85", + "width": 1280, + "height": 2560, + "feature": "Session list", + "websitePlacement": "gallery-02" + }, + { + "file": "06.png", + "sourceUrl": "https://play-lh.googleusercontent.com/npsXfCkwCWEqJ9o56ZCENBTTrvefgnA54fGQLc6L1USc6wp9oFsfC_m_orBZ_hsZsE7pUuCDl51QSvkLy7w7m8c", + "sha256": "4d68454a602be5546085ceea7f261aadb332973721bde3123d01304dc95d1651", + "width": 1280, + "height": 2560, + "feature": "Agent writes code", + "websitePlacement": "gallery-05" + }, + { + "file": "07.png", + "sourceUrl": "https://play-lh.googleusercontent.com/zXUoTydFFH3U-Hc1hemnXFaxluRrWdDuRweatkuc5zRQQH1f806lN7ZY_go23OMxs5E5-7rD5JOZHlI-M9ACcQ", + "sha256": "02be91c4ab35e5ab30f7e310ea5bcf98e0485b14b7d3bbe7f161a38e1642c427", + "width": 1280, + "height": 2560, + "feature": "Completed coding task", + "websitePlacement": "gallery-06" + } + ] +} diff --git a/docs/prd.md b/docs/prd.md index 2fbb75f..6b5ee99 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -119,3 +119,76 @@ git push origin main --tags - Multi-user / shared sessions. - Voice input beyond what the OS dictation keyboard provides. - iPad / tablet-optimised layout (phone-first only; tablet works but is not designed for). + +## 8. Continuous Product Intelligence + +### Problem + +At 1,000 downloads, acquisition, reliability, feedback, and visual-conversion +signals are fragmented across Sentry, GitHub, Google Play, and Vercel. The app +has opted-in diagnostics but no daily operating loop that turns aggregate, +privacy-safe evidence into a small, prioritized queue. The public Vercel site +also deploys from an untracked Next.js working directory, so its screenshots +cannot be traced to a reviewed source commit. + +### Scope + +- A daily GitHub Action, triggered only by `schedule` and maintainer + `workflow_dispatch`, that publishes an aggregate-only product-intelligence + report to the Actions summary and artifact. +- Rollout starts with maintainer dispatch only. Enable the daily schedule after + the dedicated read-only Sentry token succeeds against production. +- A material-signal issue upsert: create or update a GitHub issue only when a + defined threshold is crossed, never one issue per uneventful day. +- A metric contract covering acquisition, activation, reliability, retention, + feedback, and visual conversion, with explicit data sources and gaps. +- Sentry diagnostic payloads that match the consent copy: no server address, + port, URL, credential, code, prompt, session title, or directory leaves the + device. +- Versioned Play screenshots, checksums, provenance, and intended website + placement in this repository. +- A versioned Next.js source for the Vercel site, replacing its untracked + working copy before future visual changes. + +### Non-Goals + +- Automatic code changes, merges, releases, deployments, or Play Console edits + from a daily report. +- Sending raw Sentry issue text, review body text, reviewer names, device + details, or user-generated content to a GitHub issue or Actions artifact. +- Treating a download count as active-user, activation, or retention evidence. +- Adding product-usage analytics without a separate explicit consent, + disclosure, and Data Safety review. + +### Metric Contract + +| Decision | Metric | Current source | Gap / next step | +| --- | --- | --- | --- | +| Is acquisition growing? | Play listing visitors, installs, uninstall rate, release APK downloads, repo views, stars | Play Console; GitHub Releases and Traffic APIs | Automate GitHub now; add read-only Play reporting only after access is verified. | +| Do new users reach value? | Connection success, time to first connection, sessions loaded, first prompt, first response | Sentry records opted-in failures only | Design explicit opt-in aggregate activation measurement before implementation. | +| Is app reliable? | Crash-free sessions, unresolved/new/regressed Sentry issues, error volume by release, connection classification, CUA pass rate | Sentry Release Health / Issues; GitHub Actions | Daily report initially aggregates Sentry Issues only; add Release Health after its aggregate query is verified. | +| Do users return? | 7-day / 30-day returning active installations | None | Do not infer this. Evaluate a privacy-reviewed aggregate telemetry design. | +| What should be fixed next? | Low-rating review themes, GitHub issue themes, issue age, CI failures | Android Publisher reviews script; GitHub Issues; Actions | Repair review ingestion and dedupe before enabling it. | +| Does site convert? | Unique visitors, install/beta CTA clicks, screenshot engagement, listing conversion | Vercel Analytics; Play Console | Define aggregate event names and exports; do not fingerprint visitors. | + +### Acceptance Criteria + +- [ ] A manual or scheduled daily run creates or updates exactly one report for + its UTC day in its Actions summary/artifact, exposes source freshness, and + distinguishes unavailable data from zero. +- [ ] The first report populates only GitHub Traffic, Releases, Issues, and + Actions plus Sentry Issues aggregates. Release Health, activation, retention, + Play, review-theme, and site-conversion rows appear as `deferred` with their + reason, never zero or blank. +- [ ] Scheduled runs create a GitHub issue only for a documented material + threshold; otherwise they update no public issue and require no daily manual + review. +- [ ] No Sentry payload of any kind - event, breadcrumb, tag, context, + transaction/span name, or `serverName` - contains a server host, port, URL, + credential, prompt, code, session title, or directory. +- [ ] Seven full-resolution Play screenshots are versioned with source URL, + retrieval date, SHA-256, dimensions, and website placement. +- [ ] `https://opencode.agentlabs.cc` serves the approved screenshots from + versioned source after its Vercel deployment. +- [ ] Material signals become implementation issues only after a maintainer + reproduces them in the affected user channel. diff --git a/docs/tdd.md b/docs/tdd.md index 584ee98..8bb46c3 100644 --- a/docs/tdd.md +++ b/docs/tdd.md @@ -182,3 +182,74 @@ A test for this would feed a basic-auth URL into a fake event and assert the scr - **No fallback UI for SSE disconnect.** Today the user sees the existing chat with a (small) banner; a more deliberate "Reconnecting…" affordance would help. - **Silent `.catch(() => null)` in stores.** Intentional today (these are non-critical fetches), but should be revisited once we have proper severity tiers for breadcrumbs. - **PRD analytics.** No usage analytics; only crash telemetry. A future opt-in product-analytics provider could close that loop without compromising the privacy posture. + +## 10. Continuous Product Intelligence + +```text +Maintainer manual dispatch + | + v +scripts/product-intelligence.mjs + | GitHub REST: releases, traffic, issues, Actions + | Sentry REST: aggregate unresolved/new issue health + | (future) Android Publisher: aggregate review and Play signals + | + v +sanitized Markdown report + JSON evidence + | + v +Actions summary + artifact per UTC day + | + v +material threshold -> deduplicated GitHub issue -> reviewed PR +``` + +### Components + +| Component | Location | Purpose | +| --- | --- | --- | +| Product-intelligence workflow | `.github/workflows/product-intelligence.yml` | Starts with maintainer dispatch only. Enable its cron after the dedicated Sentry token passes a production run. Uses `actions: read`, `contents: read`, and `issues: write`. | +| Collector | `scripts/product-intelligence.mjs` | Validates sources, collects aggregate signals, renders a sanitized report, and fails visibly when a source is unavailable. | +| Material issue upsert | Workflow `actions/github-script` | Locates a stable signal fingerprint and creates or updates an issue only after a material threshold. | +| Sentry privacy implementation | `src/lib/sentry.ts`, `src/lib/scrub.ts` | Separate P1 prerequisite: remove user-controlled server data from events, breadcrumbs, tags, contexts, and span names before an event leaves device. | +| Screenshot source of truth | `distribution/reference-screenshots/play-v1/manifest.json` | Records Play asset provenance, checksum, dimensions, and page placement. | +| Vercel website | `website/` | Versioned Next.js source deployed to existing `opencode-mobile-site` project. | + +### Data and Privacy Boundaries + +- The initial collector contains numeric aggregates only. It must not publish + Sentry issue titles, exception text, culprits, request URL, device metadata, + raw review content, author names, or user-generated text. +- The material-issue upsert independently reconstructs its body from an + allowlisted UTC date and allowlisted signal names. It rejects malformed + report data and never uses collector-provided Markdown, URLs, or Sentry + response fields. +- A missing credential or failed request is reported as unavailable and exits + non-zero; it must never become a success-shaped zero. +- The first report supports only GitHub and Sentry aggregates. Every other + metric-contract row is rendered as `deferred` with its source/access reason. +- The first delivery introduces no new analytics SDK or user identifier. +- Sentry remains explicitly opt-in. The consent UI, Settings UI, privacy + policy, and runtime scrubber must agree on what leaves the device. +- Future activation and retention metrics require a separate product decision + that updates consent language and Play Data Safety before code ships. +- The `SENTRY_PRODUCT_INTELLIGENCE_TOKEN` repository secret is a read-only, + single-project Sentry token with only `project:read`, `event:read`, and + `org:read` scopes; it is distinct from release-upload credentials. Any + future Android Publisher credential is read-only and limited to + reporting/reviews. + +### Rollout + +1. Provision the dedicated read-only Sentry repository secret, then manually + run the daily workflow against production credentials and verify a + sanitized Actions summary and artifact for its UTC date. +2. Enable the cron, then observe one scheduled run and verify it creates a new UTC-day artifact but + no public issue unless a documented material threshold is crossed. + Back-to-back manual and scheduled runs must update at most one open + material-signal issue. +3. Merge the Sentry privacy boundary before exposing Sentry detail links. +4. Import and manifest Play screenshots, deploy versioned web source, and + verify every screenshot loads at the public domain. +5. Enable Android Publisher review/Play data collection only after + least-privilege credentials and real-data validation are complete. diff --git a/scripts/product-intelligence.mjs b/scripts/product-intelligence.mjs new file mode 100644 index 0000000..43216f4 --- /dev/null +++ b/scripts/product-intelligence.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises" +import { dirname } from "node:path" + +const DAY = 24 * 60 * 60 * 1000 +const WEEK = 7 * DAY +const GITHUB_API = "https://api.github.com" +const SENTRY_API = "https://sentry.io/api/0" + +function args(values) { + const result = {} + for (let index = 0; index < values.length; index += 2) { + const key = values[index] + const value = values[index + 1] + if (!key?.startsWith("--") || !value) throw new Error(`Expected --name value, received ${key ?? ""}`) + result[key.slice(2)] = value + } + return result +} + +function unavailable(reason) { + return { status: "unavailable", reason } +} + +function available(data) { + return { status: "available", data } +} + +async function request(source, url, headers) { + try { + const response = await fetch(url, { headers }) + if (!response.ok) return unavailable(`${source} returned HTTP ${response.status}`) + return available(await response.json()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return unavailable(`${source} request failed: ${message}`) + } +} + +function githubHeaders(token) { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "opencode-mobile-product-intelligence", + "X-GitHub-Api-Version": "2022-11-28", + } +} + +function sum(values, key) { + return values.reduce((total, value) => total + Number(value[key] ?? 0), 0) +} + +function recent(values, key, since) { + return values.filter((value) => Date.parse(value[key] ?? "") >= since) +} + +async function collectGithub(token, repo, now) { + if (!token) return unavailable("GITHUB_TOKEN is not set") + const headers = githubHeaders(token) + const base = `${GITHUB_API}/repos/${repo}` + const issueQuery = encodeURIComponent(`repo:${repo} is:issue is:open`) + const [repository, releases, issues, workflows, views, clones] = await Promise.all([ + request("GitHub repository", base, headers), + request("GitHub releases", `${base}/releases?per_page=100`, headers), + request("GitHub issue count", `${GITHUB_API}/search/issues?q=${issueQuery}`, headers), + request("GitHub workflow runs", `${base}/actions/runs?per_page=100`, headers), + request("GitHub traffic views", `${base}/traffic/views`, headers), + request("GitHub traffic clones", `${base}/traffic/clones`, headers), + ]) + + const required = [repository, releases, issues, workflows] + const failed = required.find((source) => source.status !== "available") + if (failed) return unavailable(failed.reason) + + const releaseDownloads = releases.data.reduce( + (total, release) => total + sum(release.assets ?? [], "download_count"), + 0, + ) + const issueCount = Number(issues.data.total_count ?? 0) + const runs = workflows.data.workflow_runs ?? [] + const failedWorkflowRuns7d = recent(runs, "created_at", now - WEEK).filter((run) => { + const path = String(run.path ?? "") + return run.conclusion === "failure" && path !== ".github/workflows/product-intelligence.yml" + }).length + + return available({ + stars: Number(repository.data.stargazers_count ?? 0), + forks: Number(repository.data.forks_count ?? 0), + openIssues: issueCount, + releaseDownloads, + failedWorkflowRuns7d, + traffic: { + views: + views.status === "available" + ? { + count: Number(views.data.count ?? 0), + uniques: Number(views.data.uniques ?? 0), + } + : unavailable(views.reason), + clones: + clones.status === "available" + ? { + count: Number(clones.data.count ?? 0), + uniques: Number(clones.data.uniques ?? 0), + } + : unavailable(clones.reason), + }, + }) +} + +async function collectSentry(token, organization, project, now) { + if (!token || !organization || !project) { + return unavailable("SENTRY_AUTH_TOKEN, SENTRY_ORG, or SENTRY_PROJECT is not set") + } + const url = new URL(`${SENTRY_API}/projects/${organization}/${project}/issues/`) + url.searchParams.set("query", "is:unresolved") + url.searchParams.set("statsPeriod", "14d") + url.searchParams.set("sort", "date") + url.searchParams.set("limit", "100") + const response = await request("Sentry issues", url, { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "opencode-mobile-product-intelligence", + }) + if (response.status !== "available") return response + + const issues = response.data + const newIssues24h = recent(issues, "firstSeen", now - DAY).length + const newIssues = recent(issues, "firstSeen", now - WEEK) + return available({ + unresolvedIssues: issues.length, + newIssues24h, + newIssues7d: newIssues.length, + eventCount: sum(issues, "count"), + }) +} + +function sourceLine(name, source) { + if (source.status === "available") return `| ${name} | available | current run |` + return `| ${name} | unavailable | ${source.reason} |` +} + +function metric(value) { + return Number.isFinite(value) ? String(value) : "unavailable" +} + +function trafficMetric(value) { + if (value?.status === "unavailable") return `unavailable (${value.reason})` + return `${metric(value.count)} total / ${metric(value.uniques)} unique` +} + +function render(report) { + const github = report.github.status === "available" ? report.github.data : null + const sentry = report.sentry.status === "available" ? report.sentry.data : null + const state = report.material ? "material signal detected" : "no material signal detected" + const lines = [ + `# Daily Product Intelligence - ${report.date}`, + "", + `**Status:** ${state}`, + "", + "## Source freshness", + "", + "| Source | Status | Detail |", + "| --- | --- | --- |", + sourceLine("GitHub", report.github), + sourceLine("Sentry", report.sentry), + "", + "## Current aggregate signals", + "", + "| Metric | Value |", + "| --- | --- |", + `| GitHub stars | ${github ? metric(github.stars) : "unavailable"} |`, + `| GitHub forks | ${github ? metric(github.forks) : "unavailable"} |`, + `| Open GitHub issues | ${github ? metric(github.openIssues) : "unavailable"} |`, + `| GitHub release asset downloads | ${github ? metric(github.releaseDownloads) : "unavailable"} |`, + `| GitHub repository views (14-day window) | ${github ? trafficMetric(github.traffic.views) : "unavailable"} |`, + `| GitHub repository clones (14-day window) | ${github ? trafficMetric(github.traffic.clones) : "unavailable"} |`, + `| Failed GitHub workflow runs (7 days) | ${github ? metric(github.failedWorkflowRuns7d) : "unavailable"} |`, + `| Sentry unresolved issues returned (14-day query, max 100) | ${sentry ? metric(sentry.unresolvedIssues) : "unavailable"} |`, + `| Sentry newly seen issues returned (24 hours, max 100) | ${sentry ? metric(sentry.newIssues24h) : "unavailable"} |`, + `| Sentry newly seen issues returned (7 days, max 100) | ${sentry ? metric(sentry.newIssues7d) : "unavailable"} |`, + `| Sentry events across returned unresolved issues | ${sentry ? metric(sentry.eventCount) : "unavailable"} |`, + "", + "## Deferred metrics", + "", + "| Metric | Status | Reason |", + "| --- | --- | --- |", + "| Sentry release health | deferred | Requires a verified aggregate release-health query contract. |", + "| Play acquisition and uninstall metrics | deferred | Requires a verified least-privilege reporting source. |", + "| Play review themes | deferred | Review ingestion needs privacy-safe dedupe and redaction. |", + "| Activation funnel | deferred | Requires explicit product-usage consent and disclosure review. |", + "| Retention | deferred | No consent-safe active-install measurement is implemented. |", + "| Website conversion | deferred | Vercel Analytics export contract is not implemented. |", + "", + "## Triage rule", + "", + "A single deduplicated implementation issue is created only when at least one Sentry issue is newly seen in 24 hours or two or more non-monitor GitHub workflow runs failed in seven days. This report intentionally contains no raw diagnostic, review, request, or user-generated content.", + ] + return `${lines.join("\n")}\n` +} + +async function write(path, contents) { + if (!path) return + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) +} + +async function main() { + const options = args(process.argv.slice(2)) + const now = Date.now() + const date = new Date(now).toISOString().slice(0, 10) + const repo = process.env.GITHUB_REPOSITORY ?? "dzianisv/opencode-mobile" + const [github, sentry] = await Promise.all([ + collectGithub(process.env.GITHUB_TOKEN, repo, now), + collectSentry(process.env.SENTRY_AUTH_TOKEN, process.env.SENTRY_ORG, process.env.SENTRY_PROJECT, now), + ]) + const githubData = github.status === "available" ? github.data : null + const sentryData = sentry.status === "available" ? sentry.data : null + const signals = [] + if (sentryData?.newIssues24h >= 1) signals.push("new-sentry-issue") + if (githubData?.failedWorkflowRuns7d >= 2) signals.push("repeated-workflow-failure") + + const report = { + date, + generatedAt: new Date(now).toISOString(), + repo, + github, + sentry, + material: signals.length > 0, + signals, + } + const markdown = render(report) + + await write(options.report, markdown) + await write(options.json, `${JSON.stringify(report, null, 2)}\n`) + if (process.env.GITHUB_STEP_SUMMARY) await write(process.env.GITHUB_STEP_SUMMARY, markdown) + + console.log(`Product intelligence report written for ${date}.`) + if (github.status !== "available" || sentry.status !== "available") { + process.exitCode = 1 + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(`Product intelligence failed: ${message}`) + process.exitCode = 1 +}) diff --git a/tsconfig.json b/tsconfig.json index d2c8fa1..edc1a72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,5 @@ } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], - "exclude": ["**/*.test.ts", "**/*.test.tsx"] + "exclude": ["**/*.test.ts", "**/*.test.tsx", "website"] } diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..92061a3 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +node_modules +.next +.vercel diff --git a/website/app/api/beta-signup/route.ts b/website/app/api/beta-signup/route.ts new file mode 100644 index 0000000..d4a6848 --- /dev/null +++ b/website/app/api/beta-signup/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const runtime = 'edge' + +export async function POST(request: NextRequest) { + try { + const { email } = await request.json() + + if (!email || typeof email !== 'string') { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }) + } + + const normalized = email.trim().toLowerCase() + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) { + return NextResponse.json({ error: 'Invalid email address' }, { status: 400 }) + } + + // Log for Vercel Function Logs (always available) + console.log(JSON.stringify({ + event: 'beta_signup', + email: normalized, + timestamp: new Date().toISOString(), + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'), + })) + + // If a webhook URL is configured, forward the signup there + const webhookUrl = process.env.BETA_SIGNUP_WEBHOOK_URL + if (webhookUrl) { + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: normalized, source: 'opencode-mobile-beta' }), + }) + } catch (e) { + console.error('[beta-signup] Webhook failed:', e) + } + } + + return NextResponse.json({ message: 'Signed up successfully' }) + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } +} diff --git a/website/app/beta/layout.tsx b/website/app/beta/layout.tsx new file mode 100644 index 0000000..7753efe --- /dev/null +++ b/website/app/beta/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Join the Beta', + description: + 'Sign up as a beta tester for OpenCode Mobile on Google Play. Help us test, find bugs, and shape the product before public launch.', + alternates: { canonical: 'https://opencode.agentlabs.cc/beta' }, + openGraph: { + title: 'Join the OpenCode Mobile Beta', + description: + 'Get early access to OpenCode Mobile on Google Play. We need 20 testers to unlock the public release.', + url: 'https://opencode.agentlabs.cc/beta', + }, +} + +export default function BetaLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/website/app/beta/page.tsx b/website/app/beta/page.tsx new file mode 100644 index 0000000..353448b --- /dev/null +++ b/website/app/beta/page.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useState, FormEvent } from 'react' + +export default function BetaPage() { + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [message, setMessage] = useState('') + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setStatus('loading') + + try { + const res = await fetch('/api/beta-signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + + if (res.ok) { + setStatus('success') + setMessage('You\'re in! We\'ll add you to the closed beta testers list.') + setEmail('') + } else { + const data = await res.json() + setStatus('error') + setMessage(data.error || 'Something went wrong. Please try again.') + } + } catch { + setStatus('error') + setMessage('Network error. Please try again.') + } + } + + return ( + <> + {/* Hero */} +
+
+

+ Closed beta · Limited spots +

+ +

+ Get early access +

+ +

+ Sign up to join the OpenCode Mobile closed beta on Google Play. + We need 20 testers to unlock the public release. +

+ + {/* Signup Form */} + {status === 'success' ? ( +
+
+ + + +

You're on the list

+
+

+ {message} +

+

+ We'll send you a Google Play beta invite within 24 hours. +

+
+ ) : ( +
+ setEmail(e.target.value)} + className="flex-1 px-4 py-3 rounded-lg text-sm outline-none transition-colors" + style={{ + backgroundColor: 'var(--card)', + border: '1px solid var(--border)', + color: 'var(--fg)', + }} + disabled={status === 'loading'} + /> + +
+ )} + + {status === 'error' && ( +

+ {message} +

+ )} + +

+ Use the Gmail linked to your Google Play account. No spam, no commitment. +

+
+
+ + {/* What you get */} +
+
+

+ What beta testers get +

+
+ {[ + { + title: 'Early access', + desc: 'Install from Google Play before anyone else. Get updates as we ship them.', + }, + { + title: 'Direct feedback channel', + desc: 'Report bugs and request features directly to the dev team.', + }, + { + title: 'Shape the product', + desc: 'Your input decides what we build next. Beta feedback has outsized impact.', + }, + ].map((item) => ( +
+ [*] +
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+
+ + {/* FAQ */} +
+
+

+ FAQ +

+
+ {[ + { + q: 'What is OpenCode Mobile?', + a: 'A free, open-source mobile client for the opencode AI coding agent. Connect to your own server and code from anywhere.', + }, + { + q: 'Why do you need my email?', + a: 'Google Play requires us to add tester email addresses explicitly. We use the same Gmail you use on your Android device.', + }, + { + q: 'Is it free?', + a: 'Yes. Free and open source, MIT license. No in-app purchases, no ads.', + }, + { + q: 'When does the public release happen?', + a: 'Google requires 14 days of closed testing with at least 20 testers. The sooner we hit 20, the sooner we launch.', + }, + { + q: 'What Android version?', + a: 'Android 8.0 (API 26) or newer.', + }, + ].map((faq) => ( +
+

+ {faq.q} +

+

+ {faq.a} +

+
+ ))} +
+
+
+ + ) +} diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx new file mode 100644 index 0000000..ebad827 --- /dev/null +++ b/website/app/docs/page.tsx @@ -0,0 +1,106 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +export const metadata: Metadata = { + title: 'Documentation', + description: + 'OpenCode Mobile documentation. Quick start guide, API reference, and self-hosting instructions.', + alternates: { canonical: 'https://opencode.agentlabs.cc/docs' }, + openGraph: { + title: 'Documentation | OpenCode Mobile', + description: 'OpenCode Mobile docs — coming soon. See README on GitHub for now.', + url: 'https://opencode.agentlabs.cc/docs', + }, +} + +const quickLinks = [ + { + title: 'README', + description: 'Installation, building from source, and quick start guide.', + href: 'https://github.com/dzianisv/opencode-mobile#readme', + external: true, + }, + { + title: 'Releases', + description: 'Download APKs and view changelog for every release.', + href: 'https://github.com/dzianisv/opencode-mobile/releases', + external: true, + }, + { + title: 'Bug Reports', + description: 'Found a bug? Open an issue on GitHub.', + href: 'https://github.com/dzianisv/opencode-mobile/issues/new', + external: true, + }, + { + title: 'Contributing', + description: 'Want to contribute? See CONTRIBUTING.md in the repo.', + href: 'https://github.com/dzianisv/opencode-mobile/blob/main/CONTRIBUTING.md', + external: true, + }, +] + +export default function DocsPage() { + return ( +
+
+
+ + + +
+

+ Documentation +

+

+ A full documentation site is in progress. For now, everything you need is in the + GitHub repository. +

+
+ +
+ {quickLinks.map((link) => ( + +

+ {link.title} + {link.external && ( + + + + )} +

+

{link.description}

+
+ ))} +
+ +
+

+ Full docs site coming soon +

+

+ We're building a proper docs site with guides for connecting, self-hosting, contributing, + and the opencode Cloud. Want to help write docs? +

+ + Join the Discussion + +
+
+ ) +} diff --git a/website/app/globals.css b/website/app/globals.css new file mode 100644 index 0000000..e18b011 --- /dev/null +++ b/website/app/globals.css @@ -0,0 +1,129 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --bg: #000000; + --fg: #F1ECEC; + --accent: #F1ECEC; + --accent-hover: #ffffff; + --muted: #8E8B8B; + --card: #0A0A0A; + --border: #1F1F1F; + --green: #03B000; + } + + * { + box-sizing: border-box; + } + + html { + scroll-behavior: smooth; + } + + body { + background-color: var(--bg); + color: var(--fg); + font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +} + +@layer components { + .btn-primary { + @apply inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold transition-all; + background-color: var(--fg); + color: var(--bg); + } + .btn-primary:hover { + background-color: #ffffff; + transform: translateY(-1px); + } + + .btn-secondary { + @apply inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold transition-all; + border: 1px solid var(--border); + color: var(--fg); + } + .btn-secondary:hover { + background-color: var(--card); + border-color: #333; + } + + .card { + background-color: var(--card); + border: 1px solid var(--border); + @apply rounded-xl p-6; + } + .card:hover { + border-color: #333; + } + + .prose-site h2 { + @apply text-2xl font-bold mt-10 mb-4; + color: var(--fg); + border-left: 4px solid var(--fg); + padding-left: 12px; + } + + .prose-site h3 { + @apply text-xl font-semibold mt-8 mb-3; + color: var(--fg); + } + + .prose-site p { + @apply mb-4 leading-relaxed; + color: var(--muted); + } + + .prose-site ul { + @apply mb-4 ml-6 space-y-2; + color: var(--muted); + } + + .prose-site ul li { + list-style-type: disc; + } + + .prose-site a { + color: var(--fg); + text-decoration: underline; + text-underline-offset: 3px; + } + + .prose-site a:hover { + opacity: 0.8; + } + + .prose-site table { + @apply w-full text-sm mb-6; + border-collapse: collapse; + } + + .prose-site th { + @apply px-4 py-2 text-left font-semibold; + background-color: var(--card); + border: 1px solid var(--border); + color: var(--fg); + } + + .prose-site td { + @apply px-4 py-2 align-top; + border: 1px solid var(--border); + color: var(--muted); + } + + .prose-site tr:nth-child(even) td { + background-color: rgba(10, 10, 10, 0.5); + } + + /* Install command box */ + .install-box { + @apply flex items-center gap-3 px-5 py-4 rounded-lg font-mono text-sm; + background-color: var(--card); + border: 1px solid var(--border); + color: var(--fg); + } +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000..9902e01 --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,98 @@ +import type { Metadata } from 'next' +import { GeistSans } from 'geist/font/sans' +import { GeistMono } from 'geist/font/mono' +import './globals.css' +import Nav from '@/components/Nav' +import Footer from '@/components/Footer' + +export const metadata: Metadata = { + metadataBase: new URL('https://opencode.agentlabs.cc'), + title: { + default: 'OpenCode Mobile — Your AI Coding Agent, On the Go', + template: '%s | OpenCode Mobile', + }, + description: + 'OpenCode Mobile is a free, open-source Android & iOS client for the opencode AI coding agent. Connect to your own server, stream diffs, and code from anywhere.', + keywords: [ + 'opencode mobile', + 'AI coding agent', + 'mobile coding', + 'self-hosted AI', + 'opencode client', + 'open source', + 'Android AI coding', + ], + authors: [{ name: 'Vibe Technologies' }], + creator: 'Vibe Technologies', + publisher: 'Vibe Technologies', + openGraph: { + type: 'website', + locale: 'en_US', + url: 'https://opencode.agentlabs.cc', + siteName: 'OpenCode Mobile', + title: 'OpenCode Mobile — Your AI Coding Agent, On the Go', + description: + 'Free, open-source mobile client for opencode. Multi-connection, biometric unlock, streaming diffs — code from anywhere.', + images: [ + { + url: '/og.png', + width: 1200, + height: 630, + alt: 'OpenCode Mobile', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'OpenCode Mobile — Your AI Coding Agent, On the Go', + description: + 'Free, open-source mobile client for opencode. Multi-connection, biometric unlock, streaming diffs.', + images: ['/og.png'], + creator: '@vibebrowserapp', + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, + alternates: { + canonical: 'https://opencode.agentlabs.cc', + }, + icons: { + icon: [ + { url: '/favicon.svg', type: 'image/svg+xml' }, + ], + shortcut: '/favicon.svg', + }, +} + +export const viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 5, + themeColor: '#000000', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + +