Skip to content

ストアレビュー通知実装#4497

Merged
TinyKitten merged 6 commits intodevfrom
feature/store-review-notification
Sep 4, 2025
Merged

ストアレビュー通知実装#4497
TinyKitten merged 6 commits intodevfrom
feature/store-review-notification

Conversation

@TinyKitten
Copy link
Member

@TinyKitten TinyKitten commented Sep 4, 2025

close #1703

Summary by CodeRabbit

  • 新機能
    • App Store / Google Play の定期レビュー検出とDiscord通知を追加(重複防止、並び順制御、強制取得/ドライラン/デバッグ対応、スケジュール化)。
  • ドキュメント
    • 定期ジョブのREADME追記:各ジョブの環境変数、既定値、デバッグ/認証手順、フィード変数名の更新。
  • テスト
    • 各ワーカーの新着検出・通知・状態更新を検証するユニットテストを追加。
  • チョア/ビルド
    • ビルド設定・スクリプト整備、Jest設定調整、Play API用依存の追加。

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 4, 2025

Walkthrough

朕の命により、本PRはApp StoreとGoogle Playの定期レビュー取得→Discord通知ワーカー、対応する単体テスト、README追記、ビルド/テスト設定、および依存追加を導入した。

Changes

Cohort / File(s) Summary
Documentation
functions/README.md
定期ジョブ(App Store / Google Play)向け環境変数、既定値、認証・デバッグ・ドライラン手順を追加。
Workers(新規追加)
functions/src/workers/appStoreReviewNotifier.ts, functions/src/workers/googlePlayReviews.ts
App Store JSON と Android Publisher API からレビューを取得し、GCSでstate管理(lastUpdated/lastIds)、Discordへ送信、Firebaseスケジューラで公開するジョブを実装。
Public exports
functions/src/index.ts
appStoreReviewNotifiergooglePlayReviewNotifier を新規エクスポート。
Tests
functions/src/workers/__tests__/appStoreReviewNotifier.test.ts, functions/src/workers/__tests__/googlePlayReviews.test.ts
GCS・ネットワーク・Android Publisher API をモックしたユニットテストを追加。新着判定・状態更新・Discord送信を検証。
Config: Jest
functions/jest.config.js
watchman: falsetestPathIgnorePatterns: ["/node_modules/", "/lib/"] を追加。
Config: NPM / Deps
functions/package.json
build を tsconfig.build.json 指定へ変更、shell:dev/shell:prod スクリプト追加、@googleapis/androidpublishergoogle-auth-library を追加。
Config: TS Build
functions/tsconfig.build.json
テスト関連や __tests__ を除外するビルド用 tsconfig を新規追加。

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Scheduler as Cloud Scheduler
  participant Fn as appStoreReviewNotifier
  participant Feed as App Store JSON
  participant GCS as GCS (state)
  participant Discord as Discord Webhook

  Scheduler->>Fn: cron trigger
  Fn->>GCS: loadState(gs://.../appstore.json)
  Fn->>Feed: fetch JSON (UA/Accept headers)
  Fn->>Fn: parse & filter newcomers
  alt new items
    loop each new review (asc)
      Fn->>Discord: POST embed
    end
    Fn->>GCS: saveState(lastUpdated, lastIds)
  else no new items
    Fn-->>Scheduler: no-op
  end
Loading
sequenceDiagram
  autonumber
  actor Scheduler as Cloud Scheduler
  participant Fn as googlePlayReviewNotifier
  participant Auth as GoogleAuth (ADC)
  participant PlayAPI as Android Publisher API
  participant GCS as GCS (state)
  participant Discord as Discord Webhook

  Scheduler->>Fn: cron trigger
  Fn->>Auth: obtain credentials (androidpublisher scope)
  Fn->>GCS: loadState(gs://.../googleplay.json)
  loop pages (<=10)
    Fn->>PlayAPI: reviews.list(packageName, pageToken)
    PlayAPI-->>Fn: reviews batch
  end
  Fn->>Fn: normalize, sort, filter / apply force-latest / dry-run
  alt dry-run
    Fn-->>Scheduler: log planned posts
  else post
    loop each new review (asc)
      Fn->>Discord: POST embed
    end
    Fn->>GCS: saveState(merged lastIds, lastUpdated)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Assessment against linked issues

Objective (issue#) Addressed Explanation
App Store のレビューを定期取得して Discord に通知する (#1703)
Google Play のレビューを定期取得して Discord に通知する (#1703)
処理を定期実行(スケジュール化)する (#1703)

Possibly related PRs

Poem

朕、天に令しレビューを集めん。📜
録(GCS)に歴を留め、使者(Webhook)は朱に告げる。🔔
巡回は時に従い、偽りなき声のみを選ぶ。⚖️
録を保ち、史を継ぎ、朕は静かに耳を澄ます。


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3fc9f93 and 045705a.

📒 Files selected for processing (1)
  • functions/README.md (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • functions/README.md
✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/store-review-notification

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@TinyKitten TinyKitten self-assigned this Sep 4, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (17)
functions/README.md (1)

134-159: 新章「Scheduled Jobs」の記述、概ね周到である。だが一筆追記せよ。

  • 状態保存用GCSについて、必要権限(例: 対象オブジェクトへの書込権限、最低限「Storage Object Admin」相当)の明記を加えるがよい。
  • 既定の App Store フィードは日本向けである旨(/jp/)、国・言語切替の指針を一行添えると運用兵が迷わぬ。
functions/src/workers/appStoreReviewNotifier.ts (4)

125-125: 星表示は0..5で厳格に丸めよ。虚飾の一星は不要なり。

現在は0でも最低1つ星が灯る上、5超の表示も起こり得る。見目は整えよ。

-          { name: '評価', value: `${'★'.repeat(Math.max(1, r.rating))}${'☆'.repeat(Math.max(0, 5 - r.rating))} (${r.rating}/5)` },
+          { name: '評価', value: (() => {
+            const capped = Math.max(0, Math.min(5, Math.floor(r.rating)));
+            return `${'★'.repeat(capped)}${'☆'.repeat(5 - capped)} (${capped}/5)`;
+          })() },

173-181: 外部呼び出しに締切を設けよ。待ちぼうけは軍規違反なり。

fetchは無期限に待機し得る。AbortControllerで15秒程度の締切を。

-  const r = await fetch(appStoreUrl, {
+  const ac = new AbortController();
+  const tmo = setTimeout(() => ac.abort(), 15_000);
+  const r = await fetch(appStoreUrl, {
     headers: {
       'User-Agent':
         'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
       Accept: 'application/json',
     },
+    signal: ac.signal,
   });
+  clearTimeout(tmo);

118-147: Webhookは束ねて撃つがよい(最大10件/リクエスト)。

1件ずつ送るはDiscord側の制限に触れやすい。可能なら埋め込みを10件単位でバッチ送信せよ(任意)。

性能とレート制限の実測を見たい。必要なら朕がバッチ化の差分をしたためよう。


154-159: 本番では状態URIの未設定を許すな。

APPSTORE_REVIEW_STATE_GCS_URIが空だと既読管理が効かず、再通知が発生し得る。デプロイ時に必須チェックを入れるがよい(READMEにも強調を)。

必要なら環境検証スクリプトを添えようか。

functions/src/workers/__tests__/appStoreReviewNotifier.test.ts (2)

35-38: グローバル fetch の退避・復元を加えよ

朕は見るに、global 汚染は他試験へ波及しやすい。before/after で退避・復元せよ。

 const fetchMock = jest.fn();
-global.fetch = fetchMock as unknown as typeof fetch;
+const origFetch = global.fetch;
+global.fetch = fetchMock as unknown as typeof fetch;
+
+afterAll(() => {
+  global.fetch = origFetch;
+});

39-48: 環境変数の汚染を避けよ

試験後に環境を戻し、他試験との争いを断つのが善。

 beforeEach(() => {
   jest.resetModules();
   gcsStore.clear();
   fetchMock.mockReset();
-  process.env.REVIEWS_DEBUG = '1';
+  const prevEnv = { ...process.env };
+  process.env.REVIEWS_DEBUG = '1';
   process.env.DISCORD_REVIEW_WEBHOOK_URL = 'https://discord.test/webhook';
   process.env.APPSTORE_REVIEW_RSS_URL = 'https://example.test/rss.json';
   process.env.APPSTORE_REVIEW_STATE_GCS_URI = 'gs://test-bucket/states/appstore.json';
 });
+
+afterEach(() => {
+  process.env = prevEnv;
+});
functions/src/workers/googlePlayReviews.ts (4)

132-132: 環境変数名の一貫性を保て

App Store 版が REVIEWS_FORCE_LATEST_COUNT なるに、此処は単数形。名は体を表す、統一が吉。

-  const forceCount = Number(process.env.REVIEW_FORCE_LATEST_COUNT ?? 0);
+  const forceCount = Number(process.env.REVIEWS_FORCE_LATEST_COUNT ?? 0);

併せて README/設定も修正し、両ワーカーで同名となるか確認せよ。


98-105: 評価の表示を正しく丸め、0 を特別扱いせよ

星は 0〜5 に正規化し、0 のときは「評価なし」とせよ。

-    const star = `${'★'.repeat(Math.max(1, Math.min(5, r.rating)))}` +
-      `${'☆'.repeat(Math.max(0, 5 - Math.max(1, Math.min(5, r.rating))))}`;
+    const r5 = Math.max(0, Math.min(5, Math.floor(r.rating)));
+    const stars = '★'.repeat(r5) + '☆'.repeat(5 - r5);
+    const contentVal = (r.content || '(本文なし)').slice(0, 1000);
     const embeds: DiscordEmbed[] = [
       {
         fields: [
           { name: 'プラットフォーム', value: 'Google Play' },
-          { name: '評価', value: `${star} (${r.rating}/5)` },
-          { name: '本文', value: r.content || '(本文なし)' },
+          { name: '評価', value: r5 > 0 ? `${stars} (${r5}/5)` : '評価なし' },
+          { name: '本文', value: contentVal },

116-126: Discord 呼び出しにタイムアウトを付与せよ

外部召喚は掛け違いの元。AbortController で 10s 目安の見切りを入れるが良い。

-    const res = await fetch(webhookUrl, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ content, embeds }),
-    });
-    if (!res.ok) {
-      const msg = await res.text().catch(() => '');
-      console.error('Discord Review webhook failed (Play)', res.status, msg);
-    }
+    const ac = new AbortController();
+    const timer = setTimeout(() => ac.abort(), 10_000);
+    try {
+      const res = await fetch(webhookUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ content, embeds }),
+        signal: ac.signal,
+      });
+      if (!res.ok) {
+        const msg = await res.text().catch(() => '');
+        console.error('Discord Review webhook failed (Play)', res.status, msg);
+      }
+    } catch (e) {
+      if ((e as Error).name === 'AbortError') {
+        console.error('Discord Review webhook timeout (Play)');
+      } else {
+        throw e;
+      }
+    } finally {
+      clearTimeout(timer);
+    }

96-127: 大量投稿時のレート制限に備えよ

Discord は 429 を返す。キュー化やチャンク送信、退避・再試行の策を講じれば、王道也。

functions/src/workers/__tests__/googlePlayReviews.test.ts (4)

11-17: GCSモックの挙動を実機に寄せよ。

現状、未存在ファイルで download() が空文字を返す。実機は 404 エラーを投げるゆえ、初回実行時の例外取り回しを覆い隠す恐れがある。404 を投げる形に改め、ワーカー側が未初期化状態を正しく処理するか検証するが良い。

     async download(): Promise<[Buffer]> {
-      const v = store.get(this.key) ?? '';
-      return [Buffer.from(v)];
+      if (!store.has(this.key)) {
+        const err = new Error('Not Found') as NodeJS.ErrnoException & { code?: string };
+        err.code = '404';
+        throw err;
+      }
+      const v = store.get(this.key)!;
+      return [Buffer.from(v)];
     }

98-99: DiscordモックのHTTPステータスを実値に寄せよ。

Discord Webhook は 204 を返すこと多し。ok のみでは退路が広い。status: 204 を与え、将来のハンドリング分岐を取りこぼさぬようにせよ。

-    fetchMock.mockResolvedValue({ ok: true, text: async () => '' });
+    fetchMock.mockResolvedValue({ ok: true, status: 204, text: async () => '' });

Also applies to: 144-145


103-112: 通知内容も検証し、体裁崩れを即座に検知せよ。

件数のみの期待では、本文・embed 崩れを見落とす。最低限、POST先・メソッド・本文有無を確認し、本文にレビュー要素(例: Good/Bad など)が含まれるか緩く当てるが良い。

     // Discordへ2件
     expect(fetchMock).toHaveBeenCalledTimes(2);
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      1,
+      expect.stringContaining('discord.test/webhook'),
+      expect.objectContaining({ method: 'POST', body: expect.any(String) })
+    );
+    const bodies = fetchMock.mock.calls.map(([, init]) => String((init as any)?.body ?? ''));
+    expect(bodies.some(b => b.includes('Good'))).toBe(true);
+    expect(bodies.some(b => b.includes('Bad'))).toBe(true);

149-151: 既読時のstate不変も確かめよ。

送信ゼロに加え、lastUpdated/lastIds が変わらぬことを見張れば、重複抑止の回帰を早期に炙り出せる。

     // 既読のためDiscord送信なし
     expect(fetchMock).toHaveBeenCalledTimes(0);
+    const saved2 = gcsStore.get('test-bucket/states/googleplay.json');
+    expect(saved2).toBeTruthy();
+    expect(JSON.parse(String(saved2))).toEqual({
+      lastUpdated: latestIso,
+      lastIds: [latestId],
+    });
functions/tsconfig.build.json (1)

1-10: include を明示し、余計な編入を断て。

親設定の影響で不要TSが拾われる余地がある。src に絞り込むが良い。

 {
   "extends": "./tsconfig.json",
+  "include": ["src"],
   "exclude": [
     "src/**/*.test.ts",
     "src/**/*.test.tsx",
     "src/**/*.spec.ts",
     "src/**/*.spec.tsx",
     "src/**/__tests__/**"
   ]
 }
functions/package.json (1)

14-15: デプロイ前ビルドを確実化せよ。

serve/shell はビルドしておるが、deploy は素通しだ。古き成果物を上げぬよう、明示的にビルドを鎖すべし。

-    "deploy": "firebase deploy --only functions",
+    "deploy": "npm run build && firebase deploy --only functions",
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a71a8f8 and 5197fb7.

⛔ Files ignored due to path filters (1)
  • functions/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • functions/README.md (1 hunks)
  • functions/jest.config.js (1 hunks)
  • functions/package.json (2 hunks)
  • functions/src/index.ts (1 hunks)
  • functions/src/workers/__tests__/appStoreReviewNotifier.test.ts (1 hunks)
  • functions/src/workers/__tests__/googlePlayReviews.test.ts (1 hunks)
  • functions/src/workers/appStoreReviewNotifier.ts (1 hunks)
  • functions/src/workers/googlePlayReviews.ts (1 hunks)
  • functions/tsconfig.build.json (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TinyKitten
PR: TrainLCD/MobileApp#0
File: :0-0
Timestamp: 2025-08-11T21:13:39.083Z
Learning: TinyKitten prefers concise code reviews for the TrainLCD/MobileApp repository. Keep review comments brief and to the point.
📚 Learning: 2025-05-19T10:59:07.901Z
Learnt from: TinyKitten
PR: TrainLCD/MobileApp#4286
File: src/constants/simulationMode.ts:1-1
Timestamp: 2025-05-19T10:59:07.901Z
Learning: プロジェクトのtsconfig.jsonには "~/*": ["src/*"] のパスエイリアス設定があり、インポートパスの先頭に ~ を使用することで src/ ディレクトリを参照できる。

Applied to files:

  • functions/tsconfig.build.json
🪛 LanguageTool
functions/README.md

[grammar] ~134-~134: There might be a mistake here.
Context: ...transformation tasks ### Scheduled Jobs - **App Store Review Notifier (`appStoreRevi...

(QB_NEW_EN)


[grammar] ~135-~135: There might be a mistake here.
Context: ...ビューをJSONフィードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~136-~136: There might be a mistake here.
Context: ...ードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `APPSTOR...

(QB_NEW_EN)


[grammar] ~137-~137: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - APPSTORE_REVIEW_RSS_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定の...

(QB_NEW_EN)


[grammar] ~138-~138: There might be a mistake here.
Context: ...レビューのJSONフィードURL(任意)。未設定時は既定のJSONエンドポイント - 既定値: `https://itunes.apple.com/jp/rss/cu...

(QB_NEW_EN)


[grammar] ~139-~139: There might be a mistake here.
Context: ...RL(任意)。未設定時は既定のJSONエンドポイント - 既定値: https://itunes.apple.com/jp/rss/customerreviews/page=1/id=1486355943/sortBy=mostRecent/json - 互換性のため環境変数名は...RSS_URLのままですが、JSON URLを...

(QB_NEW_EN)


[grammar] ~141-~141: There might be a mistake here.
Context: ...//states/appstore-reviews.json) - REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes`)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~142-~142: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得内容...

(QB_NEW_EN)


[grammar] ~143-~143: There might be a mistake here.
Context: ...ry 60 minutes)。未設定時は毎時実行 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - REVIEW_FORCE_LAT...

(QB_NEW_EN)


[grammar] ~144-~144: There might be a mistake here.
Context: ... - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - REVIEW_FORCE_LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - **Google Pla...

(QB_NEW_EN)


[grammar] ~147-~147: There might be a mistake here.
Context: ...ublisher APIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~148-~148: There might be a mistake here.
Context: ...PIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `GOOGLE_...

(QB_NEW_EN)


[grammar] ~149-~149: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - GOOGLE_PLAY_PACKAGE_NAME: パッケージ名(既定: me.tinykitten.trainlcd`) ...

(QB_NEW_EN)


[grammar] ~150-~150: There might be a mistake here.
Context: ...E: パッケージ名(既定: me.tinykitten.trainlcd) - GOOGLEPLAY_REVIEW_STATE_GCS_URI: 既読状態を保存するGCSパス(例: gs:///state...

(QB_NEW_EN)


[grammar] ~151-~151: There might be a mistake here.
Context: ...bucket>/states/googleplay-reviews.json) - PLAY_REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes`)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~152-~152: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - 認証: Cloud Functionsのサービスアカウントに「Android P...

(QB_NEW_EN)


[grammar] ~153-~153: There might be a mistake here.
Context: ...(Application Default Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ペ...

(QB_NEW_EN)


[grammar] ~154-~154: There might be a mistake here.
Context: ...fault Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - `REVIEW_FORC...

(QB_NEW_EN)


[grammar] ~155-~155: There might be a mistake here.
Context: ...- REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - REVIEW_FORCE_LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - `REVIEWS_...

(QB_NEW_EN)


[grammar] ~156-~156: There might be a mistake here.
Context: ..._LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示 ### Fir...

(QB_NEW_EN)

🔇 Additional comments (7)
functions/jest.config.js (1)

4-5: Jest の無用な監視とビルド成果物の除外、よくやった。

watchman: false/lib/ 除外でCIの不安定要因と誤検知を断つ。朕は是を良しとする。

functions/src/workers/googlePlayReviews.ts (1)

26-52: GCS 状態管理、良し

URI 解析・読み書きは簡潔で堅牢、朕は是とする。

functions/src/index.ts (1)

5-13: 輸出の追補、異議なし

ワーカーの公開は正しく、他所への影響も軽微と見受ける。

functions/src/workers/__tests__/googlePlayReviews.test.ts (2)

48-57: 周到なモジュールリセットと環境初期化、良し。

jest.resetModules() による再読込と各モックのリセットで試験の独立性が保たれておる。環境変数の設定順も妥当だ。


55-57: 環境変数名の命名、一貫性確認済み
実装・テスト・README間でGOOGLEPLAY_REVIEW_STATE_GCS_URIの揺れはなく、そのままで問題なし。

Likely an incorrect or invalid review comment.

functions/tsconfig.build.json (1)

3-9: 試験資産の除外、良し。

__tests__*.test|spec.* の除外は妥当である。生産ビルドの純度が保たれておる。

functions/package.json (1)

24-26: 新規依存の選択、妥当である。

@googleapis/androidpublishergoogle-auth-library の導入は要件に沿う。Node 20/Functions v6 とも整合しておる。

Comment on lines +95 to +108
const reviews: AppStoreReview[] = [];
for (const e of entries) {
const id = labelOf(deepGet(e, 'id'));
const updated = labelOf(deepGet(e, 'updated'));
const title = labelOf(deepGet(e, 'title'));
const content = labelOf(deepGet(e, 'content'));
const ratingStr = labelOf(deepGet(e, 'im:rating'));
const version = labelOf(deepGet(e, 'im:version')) || undefined;
const author = labelOf(deepGet(e, 'author.name')) || labelOf(deepGet(e, 'author')) || undefined;
const url = hrefOf(deepGet(e, 'link')) || id;
if (!id || !updated) continue;
const rating = Number(ratingStr) || 0;
reviews.push({ id, updated, title, content, rating, version, author, url });
}
Copy link
Contributor

@coderabbitai coderabbitai bot Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

App Storeフィード先頭の「アプリ本体」エントリを弾け。評点欠如の項はレビューに非ず。

feed.entryの先頭はアプリ情報でありim:ratingを欠くこと多し。今の実装だと0点レビューとして混入し、誤通知の禍を招く。

適用せよ(評点1..5のみ採用):

-      const ratingStr = labelOf(deepGet(e, 'im:rating'));
+      const ratingStr = labelOf(deepGet(e, 'im:rating'));
       const version = labelOf(deepGet(e, 'im:version')) || undefined;
       const author = labelOf(deepGet(e, 'author.name')) || labelOf(deepGet(e, 'author')) || undefined;
       const url = hrefOf(deepGet(e, 'link')) || id;
-      if (!id || !updated) continue;
-      const rating = Number(ratingStr) || 0;
+      if (!id || !updated || !ratingStr) continue;
+      const rating = Number(ratingStr);
+      if (!(rating >= 1 && rating <= 5)) continue;
       reviews.push({ id, updated, title, content, rating, version, author, url });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reviews: AppStoreReview[] = [];
for (const e of entries) {
const id = labelOf(deepGet(e, 'id'));
const updated = labelOf(deepGet(e, 'updated'));
const title = labelOf(deepGet(e, 'title'));
const content = labelOf(deepGet(e, 'content'));
const ratingStr = labelOf(deepGet(e, 'im:rating'));
const version = labelOf(deepGet(e, 'im:version')) || undefined;
const author = labelOf(deepGet(e, 'author.name')) || labelOf(deepGet(e, 'author')) || undefined;
const url = hrefOf(deepGet(e, 'link')) || id;
if (!id || !updated) continue;
const rating = Number(ratingStr) || 0;
reviews.push({ id, updated, title, content, rating, version, author, url });
}
const reviews: AppStoreReview[] = [];
for (const e of entries) {
const id = labelOf(deepGet(e, 'id'));
const updated = labelOf(deepGet(e, 'updated'));
const title = labelOf(deepGet(e, 'title'));
const content = labelOf(deepGet(e, 'content'));
const ratingStr = labelOf(deepGet(e, 'im:rating'));
const version = labelOf(deepGet(e, 'im:version')) || undefined;
const author = labelOf(deepGet(e, 'author.name')) || labelOf(deepGet(e, 'author')) || undefined;
const url = hrefOf(deepGet(e, 'link')) || id;
if (!id || !updated || !ratingStr) continue;
const rating = Number(ratingStr);
if (!(rating >= 1 && rating <= 5)) continue;
reviews.push({ id, updated, title, content, rating, version, author, url });
}
🤖 Prompt for AI Agents
In functions/src/workers/appStoreReviewNotifier.ts around lines 95 to 108, the
loop currently treats entries without im:rating as rating 0 and includes the app
metadata entry as a review; change the logic to parse ratingStr and only accept
entries whose numeric rating is an integer between 1 and 5 (inclusive), skipping
any entries where im:rating is missing, non-numeric, or outside 1..5 while still
keeping the existing id/updated guards; push reviews only when the parsed rating
is valid (1-5).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai ちなみにどんな時にim:ratingがnullishになるんですか?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. フォーマット異常: 何らかの理由でJSONの構造が期待と異なる場合

これゆえに朕の提案せし修正にて、評点なき項目を適切に除外することが肝要である。App Storeの仕様上、真のレビューエントリには必ず1-5の評点が付与されているためなり。


✏️ Learnings added
Learnt from: TinyKitten
PR: TrainLCD/MobileApp#4497
File: functions/src/workers/appStoreReviewNotifier.ts:95-108
Timestamp: 2025-09-04T13:19:19.148Z
Learning: App Store JSONフィードの`feed.entry`配列の先頭には、アプリ本体の情報が混在することがあり、この場合は`im:rating`フィールドが存在しないため、適切にフィルタリングする必要がある。真のレビューエントリには必ず1-5の評点が付与されている。

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: TinyKitten
PR: TrainLCD/MobileApp#0
File: :0-0
Timestamp: 2025-08-11T21:13:39.083Z
Learning: TinyKitten prefers concise code reviews for the TrainLCD/MobileApp repository. Keep review comments brief and to the point.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai 「何らかの理由」だけであればim:ratingのチェックが必要なだけに限らないんじゃないですか?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TinyKitten その通りであり、朕の論理に隙あり。

im:ratingのみをチェックするは、他フィールドの異常を看過する可能性あり。しかし今回の主眼は、アプリメタデータとレビューの判別にあり。

im:ratingの存在確認により、確実にレビューエントリのみを抽出できる。他フィールド(idupdated等)は既に存在チェック済みなれど、より厳密な検証を望むならば全フィールドの妥当性確認も一考に値する。

ただし、現状の実装にて最も重要なる問題—アプリ情報の混入—は解決されるものと心得る。


🧠 Learnings used
Learnt from: TinyKitten
PR: TrainLCD/MobileApp#4497
File: functions/src/workers/appStoreReviewNotifier.ts:95-108
Timestamp: 2025-09-04T13:19:19.148Z
Learning: App Store JSONフィードの`feed.entry`配列の先頭には、アプリ本体の情報が混在することがあり、この場合は`im:rating`フィールドが存在しないため、適切にフィルタリングする必要がある。真のレビューエントリには必ず1-5の評点が付与されている。

Learnt from: TinyKitten
PR: TrainLCD/MobileApp#0
File: :0-0
Timestamp: 2025-08-11T21:13:39.083Z
Learning: TinyKitten prefers concise code reviews for the TrainLCD/MobileApp repository. Keep review comments brief and to the point.

Comment on lines +75 to +92
const userComments = comments
.map((c) => c.userComment)
.filter((u): u is NonNullable<typeof u> => !!u);
if (!reviewId || userComments.length === 0) continue;
const latest = userComments.reduce((p, c) => {
const pMs = dayjs(tsToIso(p.lastModified)).valueOf();
const cMs = dayjs(tsToIso(c.lastModified)).valueOf();
return cMs > pMs ? c : p;
});
const updated = tsToIso(latest.lastModified);
if (!updated) continue;
const language = latest.reviewerLanguage ?? undefined;
const versionName = latest.appVersionName ?? undefined;
const rating = Number(latest.starRating ?? 0) || 0;
const content = String(latest.text ?? '').trim();
const id = `${reviewId}:${updated}`;
out.push({ id, reviewId, updated, content, rating, versionName, author, language });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

最新コメント選定の NaN 比較バグを討て

Timestamp 無効値が先頭に来ると reduce 比較が常に偽となり、正しい最新が拾えぬ。無効値を除外してから比較せよ。

-    const userComments = comments
-      .map((c) => c.userComment)
-      .filter((u): u is NonNullable<typeof u> => !!u);
-    if (!reviewId || userComments.length === 0) continue;
-    const latest = userComments.reduce((p, c) => {
-      const pMs = dayjs(tsToIso(p.lastModified)).valueOf();
-      const cMs = dayjs(tsToIso(c.lastModified)).valueOf();
-      return cMs > pMs ? c : p;
-    });
-    const updated = tsToIso(latest.lastModified);
-    if (!updated) continue;
-    const language = latest.reviewerLanguage ?? undefined;
-    const versionName = latest.appVersionName ?? undefined;
-    const rating = Number(latest.starRating ?? 0) || 0;
-    const content = String(latest.text ?? '').trim();
+    const userComments = comments
+      .map((c) => c.userComment)
+      .filter((u): u is NonNullable<typeof u> => !!u);
+    const valid = userComments
+      .map((u) => ({ u, iso: tsToIso(u.lastModified) }))
+      .filter((x): x is { u: NonNullable<typeof x.u>; iso: string } => Boolean(x.iso));
+    if (!reviewId || valid.length === 0) continue;
+    const latest = valid.reduce((p, c) =>
+      dayjs(c.iso).isAfter(dayjs(p.iso)) ? c : p
+    );
+    const updated = latest.iso;
+    const language = latest.u.reviewerLanguage ?? undefined;
+    const versionName = latest.u.appVersionName ?? undefined;
+    const rating = Math.max(0, Math.min(5, Number(latest.u.starRating ?? 0) || 0));
+    const content = String(latest.u.text ?? '').trim();
🤖 Prompt for AI Agents
In functions/src/workers/googlePlayReviews.ts around lines 75 to 92, the reduce
that picks the latest userComment can produce NaN comparisons when a comment has
an invalid/empty lastModified; exclude any comments without a valid lastModified
(or where tsToIso(lastModified) yields a falsy/invalid date) before calling
reduce, then perform the reduce on that filtered list (or handle NaN by treating
invalid timestamps as -Infinity) so the comparison uses only valid numeric
milliseconds and the correct latest comment is selected.

Comment on lines +205 to +213
if (all.length) {
const newest = all.reduce((p, c) => (dayjs(c.updated).isAfter(dayjs(p.updated)) ? c : p), all[0]);
const updatedIds = [
...Array.from(new Set([...(state.lastIds ?? []).slice(-20), ...all.slice(0, 20).map((r) => r.id)])),
].slice(-60);
await saveState(stateUri, { lastUpdated: newest.updated, lastIds: updatedIds });
if (debug) {
console.log('[PlayJob] state saved', { lastUpdated: newest.updated, lastIds: updatedIds.length });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

lastIds 更新は最新20件を用いよ

現状は all.slice(0, 20) と順不同で古きを拾い、重複再通知の芽が残る。更新日時で整列し末尾20件を混ぜよ。

-  if (all.length) {
-    const newest = all.reduce((p, c) => (dayjs(c.updated).isAfter(dayjs(p.updated)) ? c : p), all[0]);
-    const updatedIds = [
-      ...Array.from(new Set([...(state.lastIds ?? []).slice(-20), ...all.slice(0, 20).map((r) => r.id)])),
-    ].slice(-60);
+  if (all.length) {
+    const sorted = all
+      .slice()
+      .sort((a, b) => dayjs(a.updated).valueOf() - dayjs(b.updated).valueOf());
+    const newest = sorted[sorted.length - 1];
+    const updatedIds = [
+      ...Array.from(
+        new Set([
+          ...(state.lastIds ?? []).slice(-20),
+          ...sorted.slice(-20).map((r) => r.id),
+        ])
+      ),
+    ].slice(-60);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (all.length) {
const newest = all.reduce((p, c) => (dayjs(c.updated).isAfter(dayjs(p.updated)) ? c : p), all[0]);
const updatedIds = [
...Array.from(new Set([...(state.lastIds ?? []).slice(-20), ...all.slice(0, 20).map((r) => r.id)])),
].slice(-60);
await saveState(stateUri, { lastUpdated: newest.updated, lastIds: updatedIds });
if (debug) {
console.log('[PlayJob] state saved', { lastUpdated: newest.updated, lastIds: updatedIds.length });
}
if (all.length) {
const sorted = all
.slice()
.sort((a, b) => dayjs(a.updated).valueOf() - dayjs(b.updated).valueOf());
const newest = sorted[sorted.length - 1];
const updatedIds = [
...Array.from(
new Set([
...(state.lastIds ?? []).slice(-20),
...sorted.slice(-20).map((r) => r.id),
])
),
].slice(-60);
await saveState(stateUri, { lastUpdated: newest.updated, lastIds: updatedIds });
if (debug) {
console.log('[PlayJob] state saved', { lastUpdated: newest.updated, lastIds: updatedIds.length });
}
}
🤖 Prompt for AI Agents
In functions/src/workers/googlePlayReviews.ts around lines 205 to 213, the logic
building lastIds uses all.slice(0, 20) which doesn't guarantee the newest 20
reviews and can leave duplicates causing re-notifications; sort the all array by
updated timestamp descending (newest first), then take the first 20 ids, merge
with the existing state.lastIds (keeping their most-recent 20 if needed),
deduplicate while preserving order, and finally keep the most-recent 60 ids
before saving as lastIds; keep the existing newest.updated selection and debug
log unchanged.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (7)
functions/README.md (7)

138-141: RSSという名にしてJSONを要求—用語の齟齬を正せ

変数名は ...RSS_URL だが本文は「JSONフィードURL」を要求しておる。読者の混乱を防ぐため、/json 固定である旨を明記し、将来の名称移行方針も記すが良い。

適用案(ドキュメント差分):

-    - `APPSTORE_REVIEW_RSS_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定のJSONエンドポイント(日本向け `/jp/`)。国・言語を変更したい場合はこのURLの地域コードを差し替えてください。
+    - `APPSTORE_REVIEW_RSS_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定のJSONエンドポイント(日本向け `/jp/`)。国・言語を変更したい場合はこのURLの地域コードを差し替えてください。必ず末尾が `/json` であるURLを指定すること(`/xml` は非対応)。
       - 既定値: `https://itunes.apple.com/jp/rss/customerreviews/page=1/id=1486355943/sortBy=mostRecent/json`
-      - 互換性のため環境変数名は`...RSS_URL`のままですが、JSON URLを指定してください
+      - 互換性のため環境変数名は `...RSS_URL` のままだが、実体は JSON フィードである。将来 `APPSTORE_REVIEW_FEED_URL` への改名を検討し、従来名は後方互換で読み替えると良い。

141-141: GCS権限は最小化せよ

書込権限に roles/storage.objectAdmin を示しておるが、必要最小は「作成+読取」で足りるはず。過剰権限を避ける指針を追記せよ。

-    - `APPSTORE_REVIEW_STATE_GCS_URI`: 既読状態を保存するGCSパス(例: `gs://<bucket>/states/appstore-reviews.json`)[本番必須]。バケットには少なくとも該当オブジェクトへの書込権限(例: `roles/storage.objectAdmin`)が必要です。
+    - `APPSTORE_REVIEW_STATE_GCS_URI`: 既読状態を保存するGCSパス(例: `gs://<bucket>/states/appstore-reviews.json`)[本番必須]。バケットには少なくとも該当オブジェクトへの読取/作成権限が必要(最小例: `roles/storage.objectViewer` + `roles/storage.objectCreator`)。運用上問題なければ `roles/storage.objectAdmin` でも可。

142-143: スケジュールのタイムゾーンを明示せよ

every 60 minutes の例のみでは実行時刻の基準が不明である。既定はUTCであるゆえ、JST運用なら明記するが良い。

-    - `REVIEWS_CRON_SCHEDULE`: スケジュール(例: `every 60 minutes`)。未設定時は毎時実行
+    - `REVIEWS_CRON_SCHEDULE`: スケジュール(例: `every 60 minutes`)。未設定時は毎時実行
+    - `REVIEWS_TIMEZONE`: タイムゾーン(例: `Asia/Tokyo`)。未設定時はUTC

145-145: 変数の型と意味を明文化せよ

REVIEWS_FORCE_LATEST_COUNT=1 は「整数N件」を意味すると読み取れる。型と用途を明記し、誤用を断て。

-    - `REVIEWS_FORCE_LATEST_COUNT=1`: 既読に関わらず最新N件を強制送信(検証用途)
+    - `REVIEWS_FORCE_LATEST_COUNT`: 整数N。既読に関わらず最新N件を強制送信(検証用途)

152-153: こちらも時刻の国境を越える—タイムゾーンを明示せよ

App Store 側と同様に、Google Play 側にもタイムゾーンを用意し、表記を揃えるが良い。

-    - `PLAY_REVIEWS_CRON_SCHEDULE`: スケジュール(例: `every 60 minutes`)。未設定時は毎時実行
+    - `PLAY_REVIEWS_CRON_SCHEDULE`: スケジュール(例: `every 60 minutes`)。未設定時は毎時実行
+    - `PLAY_REVIEWS_TIMEZONE`: タイムゾーン(例: `Asia/Tokyo`)。未設定時はUTC

156-156: 強制件数の表現を統一せよ

App Store 側と同じく、整数であることを明示せよ。表記ゆれは朕の嫌うところである。

-    - `REVIEWS_FORCE_LATEST_COUNT=1`: 既読に関わらず最新N件を強制送信(検証用途)
+    - `REVIEWS_FORCE_LATEST_COUNT`: 整数N。既読に関わらず最新N件を強制送信(検証用途)

144-146: 片方のみのDRY_RUNは不均衡—App Store側にも用意せよ

Google Play 側にのみ REVIEWS_DRY_RUN がある。検証性と対称性の観点から、App Store 側にも同変数を実装・記載するが良い。

   - デバッグ用(任意):
     - `REVIEWS_DEBUG=1`: 取得内容などの詳細ログを出力
-    - `REVIEWS_FORCE_LATEST_COUNT=1`: 既読に関わらず最新N件を強制送信(検証用途)
+    - `REVIEWS_FORCE_LATEST_COUNT`: 整数N。既読に関わらず最新N件を強制送信(検証用途)
+    - `REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示

Also applies to: 157-158

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5197fb7 and 4b8c834.

📒 Files selected for processing (4)
  • functions/README.md (1 hunks)
  • functions/src/workers/__tests__/appStoreReviewNotifier.test.ts (1 hunks)
  • functions/src/workers/appStoreReviewNotifier.ts (1 hunks)
  • functions/src/workers/googlePlayReviews.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • functions/src/workers/googlePlayReviews.ts
  • functions/src/workers/tests/appStoreReviewNotifier.test.ts
  • functions/src/workers/appStoreReviewNotifier.ts
🧰 Additional context used
🪛 LanguageTool
functions/README.md

[grammar] ~134-~134: There might be a mistake here.
Context: ...transformation tasks ### Scheduled Jobs - **App Store Review Notifier (`appStoreRevi...

(QB_NEW_EN)


[grammar] ~135-~135: There might be a mistake here.
Context: ...ビューをJSONフィードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~136-~136: There might be a mistake here.
Context: ...ードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `APPSTOR...

(QB_NEW_EN)


[grammar] ~137-~137: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - APPSTORE_REVIEW_RSS_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定の...

(QB_NEW_EN)


[grammar] ~138-~138: There might be a mistake here.
Context: ...p/)。国・言語を変更したい場合はこのURLの地域コードを差し替えてください。 - 既定値: https://itunes.apple.com/jp/rss/cu...

(QB_NEW_EN)


[grammar] ~139-~139: There might be a mistake here.
Context: ...い場合はこのURLの地域コードを差し替えてください。 - 既定値: https://itunes.apple.com/jp/rss/customerreviews/page=1/id=1486355943/sortBy=mostRecent/json - 互換性のため環境変数名は...RSS_URLのままですが、JSON URLを...

(QB_NEW_EN)


[grammar] ~141-~141: There might be a mistake here.
Context: ...権限(例: roles/storage.objectAdmin)が必要です。 - REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~142-~142: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得内容...

(QB_NEW_EN)


[grammar] ~143-~143: There might be a mistake here.
Context: ...ry 60 minutes)。未設定時は毎時実行 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - REVIEWS_FORCE_LA...

(QB_NEW_EN)


[grammar] ~144-~144: There might be a mistake here.
Context: ... - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - REVIEWS_FORCE_LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - **Google Pla...

(QB_NEW_EN)


[grammar] ~147-~147: There might be a mistake here.
Context: ...ublisher APIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~148-~148: There might be a mistake here.
Context: ...PIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `GOOGLE_...

(QB_NEW_EN)


[grammar] ~149-~149: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - GOOGLE_PLAY_PACKAGE_NAME: パッケージ名(既定: me.tinykitten.trainlcd`) ...

(QB_NEW_EN)


[grammar] ~150-~150: There might be a mistake here.
Context: ...E: パッケージ名(既定: me.tinykitten.trainlcd) - GOOGLEPLAY_REVIEW_STATE_GCS_URI: 既読状態を保存するGCSパス(例: gs:///state...

(QB_NEW_EN)


[grammar] ~151-~151: There might be a mistake here.
Context: ...bucket>/states/googleplay-reviews.json) - PLAY_REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes`)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~152-~152: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - 認証: Cloud Functionsのサービスアカウントに「Android P...

(QB_NEW_EN)


[grammar] ~153-~153: There might be a mistake here.
Context: ...(Application Default Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ペ...

(QB_NEW_EN)


[grammar] ~154-~154: There might be a mistake here.
Context: ...fault Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - `REVIEWS_FOR...

(QB_NEW_EN)


[grammar] ~155-~155: There might be a mistake here.
Context: ...- REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - REVIEWS_FORCE_LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - `REVIEWS_...

(QB_NEW_EN)


[grammar] ~156-~156: There might be a mistake here.
Context: ..._LATEST_COUNT=1: 既読に関わらず最新N件を強制送信(検証用途) - REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示 ### Fir...

(QB_NEW_EN)

🔇 Additional comments (1)
functions/README.md (1)

134-136: 良き追加、目的が明瞭である

「Scheduled Jobs」節の新設と2種の通知機構の概要は簡潔で、導線として十分である。続行せよ。

- README: /json 必須の明記、変数名移行方針、GCSの最小権限例(viewer+creator)、タイムゾーンENVを追加
- App Store: REVIEWS_DRY_RUN 対応、スケジュールのタイムゾーンをENV化(REVIEWS_TIMEZONE、既定UTC)
- Google Play: スケジュールのタイムゾーンをENV化(PLAY_REVIEWS_TIMEZONE、既定UTC)
- コード: FEED_URL のみ参照(なければ既定値)
- README: RSS_URL の記述と互換文を削除し、非対応を明記
- テスト: FEED_URL 想定のまま(変更なし)
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
functions/README.md (1)

155-155: Play Console 側の権限付与を明記せよ(ADCのみでは通らぬ)

朕の覧るところ、この一文だけでは動かぬ者多し。Play Console へのユーザー招待と権限付与を明記すべし。加えて使用スコープも記すが良い。

-  - 認証: Cloud Functionsのサービスアカウントに「Android Publisher API」へのアクセス権を付与し、ADC(Application Default Credentials)で認証します。
+  - 認証: Cloud Functionsのサービスアカウントに「Android Publisher API」へのアクセス権を付与し、ADC(Application Default Credentials)で認証します。
+    併せて、当該サービスアカウントを Google Play Console のユーザーとして招待し、レビュー閲覧が可能な権限を付与すること(例: 「アプリ情報の表示(閲覧のみ)」。返信も行うなら「レビューへの返信」)。Play Console 側の付与が無ければ API は権限不足で失敗する。
+    使用スコープ: `https://www.googleapis.com/auth/androidpublisher`
🧹 Nitpick comments (4)
functions/README.md (4)

152-152: GCS権限要件をApp Store側と同等に明記せよ

Google Play用の既読状態オブジェクトにも最小権限を明文化すべし。読者の迷いを断つ。

-    - `GOOGLEPLAY_REVIEW_STATE_GCS_URI`: 既読状態を保存するGCSパス(例: `gs://<bucket>/states/googleplay-reviews.json`)
+    - `GOOGLEPLAY_REVIEW_STATE_GCS_URI`: 既読状態を保存するGCSパス(例: `gs://<bucket>/states/googleplay-reviews.json`)。必要最小の権限は該当オブジェクトへの読取/作成(例: `roles/storage.objectViewer` + `roles/storage.objectCreator`)。運用都合であれば `roles/storage.objectAdmin` でも可。

138-140: 地域コードの具体例を一つ添え、迷いを討て

/json 必須は良い。加えて他地域の具体例を一つ示すと、導入が速やかになろう。

-    - `APPSTORE_REVIEW_FEED_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定のJSONエンドポイント(日本向け `/jp/`)。国・言語を変更したい場合はこのURLの地域コードを差し替えてください。必ず末尾が `/json` のURLを指定してください(`/xml` は非対応)。
+    - `APPSTORE_REVIEW_FEED_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定のJSONエンドポイント(日本向け `/jp/`)。国・言語を変更したい場合はこのURLの地域コードを差し替えること。必ず末尾が `/json` のURLを指定せよ(`/xml` は非対応)。例(米国英語): `https://itunes.apple.com/us/rss/customerreviews/page=1/id=<APP_ID>/sortBy=mostRecent/json`

141-142: 「every N minutes」とタイムゾーンの関係を注記せよ

朕の知る限り、「every 60 minutes」のような表現では timeZone が実質無効となる実装もある。cron式指定時のみ有効かを確認し、一言注記を入れると誤解が減ろう。

Also applies to: 153-154


144-146: Discord Webhook の rate limit 注意を一言添えよ

大量送信時は 429 の刑に処せられる。乾坤一擲の一文で運用事故を防げ。

   - デバッグ用(任意):
     - `REVIEWS_DEBUG=1`: 取得内容などの詳細ログを出力
     - `REVIEWS_FORCE_LATEST_COUNT`: 整数N。既読に関わらず最新N件を強制送信(検証用途)
     - `REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示
+    - 注意: 大量送信時は Discord Webhook のレート制限(429)が発生し得るため、必要に応じてバッチ処理や送信間隔の調整を行うこと。

(上同様、Google Play 側のデバッグ節にも同一の注意書きを加えるが良い)

Also applies to: 157-159

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4b8c834 and 3fc9f93.

📒 Files selected for processing (4)
  • functions/README.md (1 hunks)
  • functions/src/workers/__tests__/appStoreReviewNotifier.test.ts (1 hunks)
  • functions/src/workers/appStoreReviewNotifier.ts (1 hunks)
  • functions/src/workers/googlePlayReviews.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • functions/src/workers/tests/appStoreReviewNotifier.test.ts
  • functions/src/workers/appStoreReviewNotifier.ts
  • functions/src/workers/googlePlayReviews.ts
🧰 Additional context used
🪛 LanguageTool
functions/README.md

[grammar] ~134-~134: There might be a mistake here.
Context: ...transformation tasks ### Scheduled Jobs - **App Store Review Notifier (`appStoreRevi...

(QB_NEW_EN)


[grammar] ~135-~135: There might be a mistake here.
Context: ...ビューをJSONフィードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~136-~136: There might be a mistake here.
Context: ...ードから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `APPSTOR...

(QB_NEW_EN)


[grammar] ~137-~137: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - APPSTORE_REVIEW_FEED_URL`: App StoreレビューのJSONフィードURL(任意)。未設定時は既定の...

(QB_NEW_EN)


[grammar] ~138-~138: There might be a mistake here.
Context: ...ず末尾が /json のURLを指定してください(/xml は非対応)。 - 既定値: `https://itunes.apple.com/jp/rss/cu...

(QB_NEW_EN)


[grammar] ~139-~139: There might be a mistake here.
Context: ...URLを指定してください(/xml は非対応)。 - 既定値: https://itunes.apple.com/jp/rss/customerreviews/page=1/id=1486355943/sortBy=mostRecent/json - APPSTORE_REVIEW_STATE_GCS_URI: 既読状態を保存するGCSパス(例: `gs:///state...

(QB_NEW_EN)


[grammar] ~140-~140: There might be a mistake here.
Context: ...の都合であれば roles/storage.objectAdmin でも可。 - REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~141-~141: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - REVIEWS_TIMEZONE: タイムゾーン(例: Asia/Tokyo)。未設定時はUTC - デ...

(QB_NEW_EN)


[grammar] ~142-~142: There might be a mistake here.
Context: ...EZONE: タイムゾーン(例: Asia/Tokyo)。未設定時はUTC - デバッグ用(任意): - REVIEWS_DEBUG=1`: 取得内容...

(QB_NEW_EN)


[grammar] ~143-~143: There might be a mistake here.
Context: ...例: Asia/Tokyo)。未設定時はUTC - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - `REVIEWS_FORCE_LA...

(QB_NEW_EN)


[grammar] ~144-~144: There might be a mistake here.
Context: ... - REVIEWS_DEBUG=1: 取得内容などの詳細ログを出力 - REVIEWS_FORCE_LATEST_COUNT: 整数N。既読に関わらず最新N件を強制送信(検証用途) - `REVI...

(QB_NEW_EN)


[grammar] ~145-~145: There might be a mistake here.
Context: ...ATEST_COUNT: 整数N。既読に関わらず最新N件を強制送信(検証用途) - REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示 - **Goog...

(QB_NEW_EN)


[grammar] ~148-~148: There might be a mistake here.
Context: ...ublisher APIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL...

(QB_NEW_EN)


[grammar] ~149-~149: There might be a mistake here.
Context: ...PIから取得し、Discordへ通知します(既定: 毎時)。 - 環境変数: - DISCORD_REVIEW_WEBHOOK_URL: DiscordのWebhook URL(必須) - `GOOGLE_...

(QB_NEW_EN)


[grammar] ~150-~150: There might be a mistake here.
Context: ...EW_WEBHOOK_URL: DiscordのWebhook URL(必須) - GOOGLE_PLAY_PACKAGE_NAME: パッケージ名(既定: me.tinykitten.trainlcd`) ...

(QB_NEW_EN)


[grammar] ~151-~151: There might be a mistake here.
Context: ...E: パッケージ名(既定: me.tinykitten.trainlcd) - GOOGLEPLAY_REVIEW_STATE_GCS_URI: 既読状態を保存するGCSパス(例: gs:///state...

(QB_NEW_EN)


[grammar] ~152-~152: There might be a mistake here.
Context: ...bucket>/states/googleplay-reviews.json) - PLAY_REVIEWS_CRON_SCHEDULE: スケジュール(例: every 60 minutes`)。未設定時は毎時実...

(QB_NEW_EN)


[grammar] ~153-~153: There might be a mistake here.
Context: ... スケジュール(例: every 60 minutes)。未設定時は毎時実行 - PLAY_REVIEWS_TIMEZONE: タイムゾーン(例: Asia/Tokyo)。未設定時はUTC - 認...

(QB_NEW_EN)


[grammar] ~154-~154: There might be a mistake here.
Context: ...EZONE: タイムゾーン(例: Asia/Tokyo`)。未設定時はUTC - 認証: Cloud Functionsのサービスアカウントに「Android P...

(QB_NEW_EN)


[grammar] ~155-~155: There might be a mistake here.
Context: ...(Application Default Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ペ...

(QB_NEW_EN)


[grammar] ~156-~156: There might be a mistake here.
Context: ...fault Credentials)で認証します。 - デバッグ用(任意): - REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - `REVIEWS_FOR...

(QB_NEW_EN)


[grammar] ~157-~157: There might be a mistake here.
Context: ...- REVIEWS_DEBUG=1: 取得/ページング/保存の詳細ログを出力 - REVIEWS_FORCE_LATEST_COUNT: 整数N。既読に関わらず最新N件を強制送信(検証用途) - `REVI...

(QB_NEW_EN)


[grammar] ~158-~158: There might be a mistake here.
Context: ...ATEST_COUNT: 整数N。既読に関わらず最新N件を強制送信(検証用途) - REVIEWS_DRY_RUN=1`: Discord送信をスキップし、送信予定の項目をログ表示 ### Fir...

(QB_NEW_EN)

🔇 Additional comments (1)
functions/README.md (1)

134-147: App Store節、構成と既定値の明快さは可とす

記述は簡明で導入容易。朕は是を嘉す。

@TinyKitten TinyKitten merged commit ed04e25 into dev Sep 4, 2025
4 checks passed
@TinyKitten TinyKitten deleted the feature/store-review-notification branch September 4, 2025 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App Store/Google PlayのレビューをDiscordに通知する仕組みを作る

1 participant