Skip to content

feat(github-app): webhook handlers, canonical reassignment, and membership initialization (#283 PR 3/7)#290

Merged
coji merged 7 commits intomainfrom
feat/issue-283-webhook-membership
Apr 8, 2026
Merged

feat(github-app): webhook handlers, canonical reassignment, and membership initialization (#283 PR 3/7)#290
coji merged 7 commits intomainfrom
feat/issue-283-webhook-membership

Conversation

@coji
Copy link
Copy Markdown
Owner

@coji coji commented Apr 7, 2026

Summary

Issue #283 の実装 stack PR 3/7 — webhook handler / canonical reassignment / membership 初期投入の中核実装。stack で最重要 PR。

設計根拠: docs/rdd/issue-283-multiple-github-accounts.md
作業計画: docs/rdd/issue-283-work-plan.md

依存: #288 (PR 1: schema), #296 (PR 2: query/octokit)

変更内容

setup callback (app/routes/api.github.setup.ts)

  • (organizationId, installationId) 単位 upsert(複合主キー対応)
  • github_account_type を保存(personal / Organization の UI 分岐用)
  • membership 初期投入: fetchInstallationRepositoriesinitializeMembershipsForInstallationmembership_initialized_at = now
  • GitHub API 失敗時は link のみ保存し、membership_initialized_at IS NULL のまま auto-repair に委譲
  • audit log: link_created / membership_initialized (success / failed)

installation webhook (app/services/github-webhook-installation.server.ts)

  • findActiveLinkByInstallationOrAccount 削除
  • すべての lookup を installation_id で行う
  • installation.deleted:
    • 該当 link のみ soft-delete
    • 最後の active link を失った時のみ integrations.method = 'token' に戻す
    • link_deleted 監査ログ
    • tenant 側で canonical reassignment を呼ぶ
  • installation.suspend / unsuspend: github_app_links.suspended_at を更新(旧 integrations.app_suspended_at から移行)
  • installation_repositories.added/removed:
    • membership upsert / soft-delete
    • canonical reassignment 呼び出し(removed 時)
    • bulk owner/repo 解決(1 query で N+1 解消)
  • installation.created: setup callback が正本のため、既存 link が無ければ no-op

canonical reassignment (app/services/github-app-membership.server.ts) 新規

  • reassignCanonicalAfterLinkLoss(orgId, lostInstallationId, source):
    • tenant DB の repository_installation_memberships を正本とする
    • 候補は active / non-suspended / membership_initialized_at IS NOT NULL の link のみ
    • 候補数で判定:
      • 1 → 自動 reassign + canonical_reassigned
      • 0 → null + canonical_cleared (or assignment_required if 未初期化 link 残存)
      • 2+ → null + assignment_required
    • 未初期化 link ガード: 未初期化 link が残っている org では、候補 0 でも canonical_cleared ではなく assignment_required に倒す
    • LEFT JOIN + bulk update で N+1 を回避
    • tenant first / shared second の cross-store 順序
  • upsertRepositoryMembership / softDeleteRepositoryMembership / initializeMembershipsForInstallation helpers

installation repos fetcher (app/services/github-installation-repos.server.ts) 新規

  • fetchInstallationRepositories(installationId): GitHub API でその installation が見える repository を全ページ取得

audit log writer (app/services/github-app-link-events.server.ts)

  • tryLogGithubAppLinkEvent best-effort wrapper を追加(呼び出し側の .catch(() => {}) ノイズを排除)

auto repair (app/services/jobs/crawl.server.ts)

  • crawl 冒頭に独立 step repair-membership:<installation_id> を追加
  • membership_initialized_at IS NULL の active link を検出 → installation_repositories を再 fetch → membership upsert → membership_initialized_at = now
  • per-link で独立 step、durably の中断・再開性を維持
  • 失敗時は次回 crawl で再試行(idempotent)

PR webhook (app/services/github-webhook-pull.server.ts)

  • owner + repo + installation_id で repository を引く
  • 移行期間中は github_installation_id IS NULL の repository も許可(PR 7 で strict 化)

tests

  • app/services/github-app-membership.server.test.ts 新規 (8 ケース):
    • 1 候補 → reassign + canonical_reassigned
    • 0 候補 → null + canonical_cleared
    • 2+ 候補 → null + assignment_required
    • 未初期化 link 残存 + 候補 0 → assignment_required (cleared じゃない)
    • suspended link は除外
    • 未初期化 link は除外
    • soft-deleted membership は除外
    • idempotency: 2 回実行しても結果が同じ
  • app/services/github-webhook.server.test.ts 既存 12 ケース更新:
    • 新 schema (suspended_at, membership_initialized_at, github_account_type, github_app_link_events) 対応
    • tenant DB mock を chain proxy に変更

満たす受入条件

Stack 位置

```text
PR 1 (#288): schema
└ PR 2 (#296): query/octokit
└ [PR 3: webhook/membership] ← this PR
└ PR 4 (UI)
└ PR 5 (repo UI)
└ PR 6 (backfill)
└ PR 7 (strict)
```

後続 PR への影響

  • PR 4: integration settings UI が getGithubAppLinks() に切替 + installation selector 追加 + assertInstallationBelongsToOrg を loader 境界で呼ぶ
  • PR 5: repository list/detail で assignment required バッジ + 個別再選択 mutation(同 helper を再利用)
  • PR 7: PR webhook の OR github_installation_id IS NULL 削除 + crawl/backfill の移行期間 fallback 削除

テスト

  • `pnpm validate` (lint / format / typecheck / build / test 全 339 tests)
  • canonical reassignment helper を 8 ケースのユニットテストでカバー (cross-store 整合性 / idempotency / 候補 0/1/2+ / 未初期化ガード / suspended 除外 / soft-deleted 除外)
  • webhook integration test (12 ケース)

🤖 Generated with Claude Code

Summary by CodeRabbit

リリースノート

  • 新機能

    • GitHub Appインストール時にリポジトリメンバーシップを自動初期化・同期する仕組みを追加
    • メンバーシップ情報に基づくリポジトリの正規割り当て(再割り当て)処理を導入
    • インストールのリポジトリ取得機能を追加
  • バグ修正

    • インストール/サスペンド/削除イベント処理のスコープと整合性を強化
    • 監査ログ書き込み失敗を影響させない安全措置を追加
  • テスト

    • 再割り当て挙動の包括的なテストスイートを追加

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

インストール処理でGitHubアカウントの type を保存し、github_app_links の upsert キーを (organizationId, installationId) に拡張。トランザクション内でリンクイベントを記録し、コミット後にリポジトリを取得してメンバーシップ初期化/修復と canonical 再割り当てを行う(失敗は吞む)。

Changes

Cohort / File(s) Summary
GitHub App セットアップルート
app/routes/api.github.setup.ts
インストール時に account.type を抽出して githubAccountType に保存。upsert の競合キーを (organizationId, installationId) に拡張。トランザクション内で link_created を記録し、コミット後にリポジトリ取得→initializeMembershipsForInstallationmembershipInitializedAt 更新→成功/失敗をログ。メンバーシップ初期化エラーは吞む。
監査ログの安全ラッパー
app/services/github-app-link-events.server.ts
logGithubAppLinkEvent を呼んで例外を吞む tryLogGithubAppLinkEvent を追加(監査ログ書込失敗を上位に伝播させない)。
メンバーシップ管理と再割り当て
app/services/github-app-membership.server.ts, app/services/github-app-membership.server.test.ts
新モジュールで ReassignmentSource 型、reassignCanonicalAfterLinkLoss(失われたインストールに紐づくリポジトリの canonical 再決定)、upsertRepositoryMembership/softDeleteRepositoryMembership/initializeMembershipsForInstallation を追加。テストで候補フィルタ、イベント発行、スコープ挙動、冪等性を網羅。
インストールリポジトリ取得
app/services/github-installation-repos.server.ts
InstallationRepoCoord 型と fetchInstallationRepositories(installationId) を追加。Octokit のページネーションで accessible リポジトリを列挙して {owner,name} 配列を返す。
Webhook ハンドラーの拡張と制約強化
app/services/github-webhook-installation.server.ts, app/services/github-webhook-pull.server.ts, app/services/github-webhook-shared.server.ts, app/services/github-webhook.server.ts, app/services/github-webhook.server.test.ts
installation 系イベントで githubAccountType を扱うよう型/解析を更新。インストール削除/サスペンド/復帰/リポジトリ変更の DB 更新を (organizationId, installationId) に限定。installation_repositories の追加/削除はトランザクション後にメンバーシップの upsert/soft-delete を実行し、必要に応じて reassignCanonicalAfterLinkLoss を呼ぶ。プル webhook のリポジトリ検索を (owner,repo) かつ githubInstallationId = installation.id OR NULL に強化。テストスキーマ/モックを更新。
ジョブ(クローラ)側の修復ステップ
app/services/jobs/crawl.server.ts
repairMembershipForLink を追加し、membershipInitializedAt === null のリンクに対して耐久タスクでリポジトリ取得→初期化→membershipInitializedAt 更新→membership_repaired イベント記録を行うように拡張。

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as "GitHub Setup API"
    participant TX as "Shared DB TX"
    participant DB as "Shared DB"
    participant Log as "Event Log Service"
    participant Fetch as "fetchInstallationRepositories"
    participant Init as "initializeMembershipsForInstallation"

    User->>API: インストール完了通知をPOST
    API->>TX: トランザクション開始(upsert githubAppLinks)
    TX->>DB: upsert (organizationId, installationId, githubAccountType...)
    DB-->>TX: upsert結果
    TX->>Log: logGithubAppLinkEvent(link_created, accountType)
    Log-->>TX: ログ書込結果
    TX->>TX: コミット
    API->>Fetch: fetchInstallationRepositories(installationId)
    Fetch-->>API: [{owner,name}, ...]
    API->>Init: initializeMembershipsForInstallation(org, installationId, repos)
    Init->>DB: tenant側で membership upsert
    DB-->>Init: マッチした repoIds
    Init-->>API: マッチ結果
    API->>DB: update githubAppLinks.membershipInitializedAt
    DB-->>API: 更新完了
    API->>Log: tryLogGithubAppLinkEvent(membership_initialized)
    Log-->>API: ログ結果
    API-->>User: 完了
Loading
sequenceDiagram
    participant Webhook as "Installation Deleted Webhook"
    participant Handler as "Installation Handler"
    participant DB as "Shared DB"
    participant Reassign as "reassignCanonicalAfterLinkLoss"
    participant TenantDB as "Tenant DB"
    participant Log as "Event Log"

    Webhook->>Handler: 削除ペイロード受信
    Handler->>DB: githubAppLinks を deleted(organizationId, installationId で更新)
    DB-->>Handler: 更新結果
    Handler->>Log: logGithubAppLinkEvent(link_deleted)
    Log-->>Handler: ログ完了
    Handler->>Reassign: reassignCanonicalAfterLinkLoss(org, lostInstallationId, source)
    Reassign->>TenantDB: 影響リポジトリと候補 membership をクエリ
    TenantDB-->>Reassign: repo rows + membership candidates
    Reassign->>TenantDB: repositories.githubInstallationId を決定値でグループ更新
    TenantDB-->>Reassign: 更新結果
    Reassign->>Log: tryLogGithubAppLinkEvent(各 repo の決定イベント)
    Log-->>Reassign: ログ完了
    Reassign-->>Handler: 完了
    Handler-->>Webhook: 処理終了
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 分

Possibly related PRs

Poem

🐇 ぴょんと来たインストールの知らせ、
型を見て名前を覚えたよ、ふわり。
リポジトリ呼んで仲間を並べ、
迷ったら再割り当てで整え、
ログにぽんと刻んで、また跳ねるよ。 ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main changes: webhook handlers, canonical reassignment, and membership initialization for GitHub App integration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-283-webhook-membership

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coji coji marked this pull request as ready for review April 7, 2026 13:42
@coji
Copy link
Copy Markdown
Owner Author

coji commented Apr 7, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@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)
app/services/jobs/crawl.server.ts (1)

21-56: 自動修復ヘルパーの実装は適切です。

repairMembershipForLink は、初期化されていないリンクのメンバーシップを修復するベストエフォート型のヘルパーとして正しく実装されています。エラーを飲み込んで監査ログに記録する設計は、自動修復の目的に適しています。

ただし、一貫性のため getErrorMessage() の使用を検討してください。

♻️ 提案する修正
+import { getErrorMessage } from '~/app/libs/error-message'
   } catch (e) {
     await tryLogGithubAppLinkEvent({
       organizationId,
       installationId,
       eventType: 'membership_repaired',
       source: 'crawl_repair',
       status: 'failed',
-      details: { error: e instanceof Error ? e.message : String(e) },
+      details: { error: getErrorMessage(e) },
     })
   }

As per coding guidelines: "Use getErrorMessage() from app/libs/error-message.ts to extract error messages"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/jobs/crawl.server.ts` around lines 21 - 56, The catch block in
repairMembershipForLink currently converts the error with e instanceof Error ?
e.message : String(e); replace that with a call to getErrorMessage(e) (imported
from app/libs/error-message.ts) so the standardized extractor is used for the
logged details; update the top of the file to import getErrorMessage and change
the details payload in tryLogGithubAppLinkEvent to use getErrorMessage(e).
app/routes/api.github.setup.ts (1)

164-174: getErrorMessage() の使用を検討してください。

コーディングガイドラインでは、エラーメッセージの抽出には getErrorMessage() を使用し、String(e) を避けるよう指定されています。監査ログ用途ではありますが、一貫性のため getErrorMessage() の使用を検討してください。

♻️ 提案する修正
+import { getErrorMessage } from '~/app/libs/error-message'
     await tryLogGithubAppLinkEvent({
       organizationId,
       installationId: installation.id,
       eventType: 'membership_initialized',
       source: 'setup_callback',
       status: 'failed',
-      details: { error: e instanceof Error ? e.message : String(e) },
+      details: { error: getErrorMessage(e) },
     })

As per coding guidelines: "Use getErrorMessage() from app/libs/error-message.ts to extract error messages, never use String(e)"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/api.github.setup.ts` around lines 164 - 174, Replace the inline
String(e) extraction in the catch block that calls tryLogGithubAppLinkEvent with
a call to getErrorMessage(...) from app/libs/error-message.ts; specifically, in
the catch handling around membership init (the catch that logs
'[api.github.setup] membership init failed' and calls tryLogGithubAppLinkEvent
with details.error), import getErrorMessage and pass the caught exception to it
so details.error uses getErrorMessage(e) instead of String(e).
app/services/github-webhook-installation.server.ts (5)

67-76: 共有DBの更新に organizationId 条件を追加することを推奨

installationId のみで更新していますが、コーディングガイドラインでは共有DBの org-scoped テーブルへの UPDATE には WHERE organizationId = ? を含めることが求められています。link.organizationId が利用可能なので、防御的コーディングとして追加することを検討してください。

♻️ 推奨される修正
   await trx
     .updateTable('githubAppLinks')
     .set({
       githubOrg: login,
       githubAccountType: accountType,
       appRepositorySelection: selectionFromInstallation(installation),
       updatedAt: now,
     })
+    .where('organizationId', '=', link.organizationId)
     .where('installationId', '=', installation.id)
     .execute()

As per coding guidelines: "Every UPDATE/DELETE on shared database org-scoped tables must include WHERE organizationId = ? with server-derived organization ID"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-installation.server.ts` around lines 67 - 76, The
UPDATE against the shared org-scoped table githubAppLinks uses only
installationId; add a server-derived organizationId guard to the WHERE clause to
satisfy the guideline. Modify the
trx.updateTable('githubAppLinks')...where('installationId','=',
installation.id).execute() chain to also include .where('organizationId', '=',
link.organizationId) (using the existing link.organizationId value) so the
update scopes to the correct org; ensure link.organizationId is used (not
client-provided data) and keep both WHERE conditions in the same query.

88-93: 同様に organizationId 条件を追加することを推奨

handleInstallationDeleted でも link.organizationId が利用可能なので、UPDATE に追加することを推奨します。

♻️ 推奨される修正
   await trx
     .updateTable('githubAppLinks')
     .set({ deletedAt: now, updatedAt: now })
+    .where('organizationId', '=', link.organizationId)
     .where('installationId', '=', installation.id)
     .execute()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-installation.server.ts` around lines 88 - 93, The
UPDATE currently only filters by installationId; add the organizationId filter
to avoid touching other orgs' rows by extending the query on
trx.updateTable('githubAppLinks').set(...) to include a .where('organizationId',
'=', link.organizationId) (using the existing link.organizationId available in
handleInstallationDeleted) so the update matches both installationId and
organizationId.

189-198: イベントタイプ membership_repaired は意味的に不正確かもしれません

この関数は installation_repositories webhook の add/remove を処理していますが、membership_repaired は自動修復ジョブで使用されるイベントタイプのように見えます。repositories_added / repositories_removed または membership_synced のような、より正確なイベントタイプを検討してください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-installation.server.ts` around lines 189 - 198,
The eventType currently passed to logGithubAppLinkEvent ('membership_repaired')
is semantically incorrect for handling installation_repositories webhook
add/remove events; update the call in the installation_repositories_webhook
handling code to emit a more precise eventType such as 'repositories_added' or
'repositories_removed' (or 'membership_synced' if this block is doing a full
sync) based on the webhook action, and ensure you use the link.organizationId
and installation.id values as before; also update any downstream consumers/tests
that expect 'membership_repaired' to the new event name.

180-187: handleInstallationRepositoriesEvent でも同様の修正を推奨

一貫性のため、link.organizationId を WHERE 条件に追加してください。

♻️ 推奨される修正
   await trx
     .updateTable('githubAppLinks')
     .set({
       appRepositorySelection: selectionFromInstallation(installation),
       updatedAt: now,
     })
+    .where('organizationId', '=', link.organizationId)
     .where('installationId', '=', installation.id)
     .execute()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-installation.server.ts` around lines 180 - 187,
The update on githubAppLinks only filters by installationId; add the
organizationId condition for consistency by modifying the
trx.updateTable('githubAppLinks')...where(...) clause to include
.where('organizationId', '=', link.organizationId) (or an AND-equivalent chained
where) so the update targets the specific organization—use the existing link
variable from the surrounding function (same pattern as
handleInstallationRepositoriesEvent).

139-146: handleInstallationSuspend でも同様の修正を推奨

一貫性のため、link.organizationId を WHERE 条件に追加してください。

♻️ 推奨される修正
   await trx
     .updateTable('githubAppLinks')
     .set({
       suspendedAt: suspend ? now : null,
       updatedAt: now,
     })
+    .where('organizationId', '=', link.organizationId)
     .where('installationId', '=', installation.id)
     .execute()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-installation.server.ts` around lines 139 - 146,
この更新クエリは installationId のみで行を絞っていますが、一貫性のため handleInstallationSuspend
と同様に組織でスコープを狭める必要があります。trx.updateTable('githubAppLinks') の
.where('installationId', '=', installation.id) に加えて link.organizationId を参照する
WHERE 条件(例: .where('organizationId', '=', link.organizationId
相当))を追加して、更新対象を該当組織のリンクに限定してください。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/services/github-webhook-pull.server.ts`:
- Around line 53-66: The current single query with OR on githubInstallationId
can return the NULL-backed row first; change to a two-step lookup using
tenantDb: first query repositories where owner=coords.owner, repo=coords.name
and githubInstallationId = installation.id (use executeTakeFirst()), and if that
returns null run a second query matching githubInstallationId IS NULL as a
fallback; return the id from the first non-null result so installation-matching
rows are always preferred.

---

Nitpick comments:
In `@app/routes/api.github.setup.ts`:
- Around line 164-174: Replace the inline String(e) extraction in the catch
block that calls tryLogGithubAppLinkEvent with a call to getErrorMessage(...)
from app/libs/error-message.ts; specifically, in the catch handling around
membership init (the catch that logs '[api.github.setup] membership init failed'
and calls tryLogGithubAppLinkEvent with details.error), import getErrorMessage
and pass the caught exception to it so details.error uses getErrorMessage(e)
instead of String(e).

In `@app/services/github-webhook-installation.server.ts`:
- Around line 67-76: The UPDATE against the shared org-scoped table
githubAppLinks uses only installationId; add a server-derived organizationId
guard to the WHERE clause to satisfy the guideline. Modify the
trx.updateTable('githubAppLinks')...where('installationId','=',
installation.id).execute() chain to also include .where('organizationId', '=',
link.organizationId) (using the existing link.organizationId value) so the
update scopes to the correct org; ensure link.organizationId is used (not
client-provided data) and keep both WHERE conditions in the same query.
- Around line 88-93: The UPDATE currently only filters by installationId; add
the organizationId filter to avoid touching other orgs' rows by extending the
query on trx.updateTable('githubAppLinks').set(...) to include a
.where('organizationId', '=', link.organizationId) (using the existing
link.organizationId available in handleInstallationDeleted) so the update
matches both installationId and organizationId.
- Around line 189-198: The eventType currently passed to logGithubAppLinkEvent
('membership_repaired') is semantically incorrect for handling
installation_repositories webhook add/remove events; update the call in the
installation_repositories_webhook handling code to emit a more precise eventType
such as 'repositories_added' or 'repositories_removed' (or 'membership_synced'
if this block is doing a full sync) based on the webhook action, and ensure you
use the link.organizationId and installation.id values as before; also update
any downstream consumers/tests that expect 'membership_repaired' to the new
event name.
- Around line 180-187: The update on githubAppLinks only filters by
installationId; add the organizationId condition for consistency by modifying
the trx.updateTable('githubAppLinks')...where(...) clause to include
.where('organizationId', '=', link.organizationId) (or an AND-equivalent chained
where) so the update targets the specific organization—use the existing link
variable from the surrounding function (same pattern as
handleInstallationRepositoriesEvent).
- Around line 139-146: この更新クエリは installationId のみで行を絞っていますが、一貫性のため
handleInstallationSuspend
と同様に組織でスコープを狭める必要があります。trx.updateTable('githubAppLinks') の
.where('installationId', '=', installation.id) に加えて link.organizationId を参照する
WHERE 条件(例: .where('organizationId', '=', link.organizationId
相当))を追加して、更新対象を該当組織のリンクに限定してください。

In `@app/services/jobs/crawl.server.ts`:
- Around line 21-56: The catch block in repairMembershipForLink currently
converts the error with e instanceof Error ? e.message : String(e); replace that
with a call to getErrorMessage(e) (imported from app/libs/error-message.ts) so
the standardized extractor is used for the logged details; update the top of the
file to import getErrorMessage and change the details payload in
tryLogGithubAppLinkEvent to use getErrorMessage(e).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 25950167-87bf-4c58-91ba-643aafd11726

📥 Commits

Reviewing files that changed from the base of the PR and between 744462f and 6768429.

📒 Files selected for processing (11)
  • app/routes/api.github.setup.ts
  • app/services/github-app-link-events.server.ts
  • app/services/github-app-membership.server.test.ts
  • app/services/github-app-membership.server.ts
  • app/services/github-installation-repos.server.ts
  • app/services/github-webhook-installation.server.ts
  • app/services/github-webhook-pull.server.ts
  • app/services/github-webhook-shared.server.ts
  • app/services/github-webhook.server.test.ts
  • app/services/github-webhook.server.ts
  • app/services/jobs/crawl.server.ts

Comment on lines +53 to +66
// Match by `(owner, repo)` *and* the installation that delivered the
// webhook. `github_installation_id IS NULL` is still accepted while
// existing rows are unbackfilled; the strict variant drops the OR clause.
const repo = await tenantDb
.selectFrom('repositories')
.select('id')
.where('owner', '=', coords.owner)
.where('repo', '=', coords.name)
.where((eb) =>
eb.or([
eb('githubInstallationId', '=', installation.id),
eb('githubInstallationId', 'is', null),
]),
)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 7, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

インストール一致を優先していないため、誤った repositoryId を選ぶ可能性があります。

OR githubInstallationId IS NULLexecuteTakeFirst() の組み合わせだと、同一 (owner, repo) に一致行と NULL 行が共存する移行期に NULL 側を返し得ます。フォールバックは残しつつ、まず installation 一致を確定で取り、その後に NULL を試す2段階にしてください。

💡 提案差分(installation一致を優先し、NULLをフォールバックにする)
-  const repo = await tenantDb
-    .selectFrom('repositories')
-    .select('id')
-    .where('owner', '=', coords.owner)
-    .where('repo', '=', coords.name)
-    .where((eb) =>
-      eb.or([
-        eb('githubInstallationId', '=', installation.id),
-        eb('githubInstallationId', 'is', null),
-      ]),
-    )
-    .executeTakeFirst()
+  const baseRepoQuery = tenantDb
+    .selectFrom('repositories')
+    .select('id')
+    .where('owner', '=', coords.owner)
+    .where('repo', '=', coords.name)
+
+  const repo =
+    (await baseRepoQuery
+      .where('githubInstallationId', '=', installation.id)
+      .executeTakeFirst()) ??
+    (await baseRepoQuery
+      .where('githubInstallationId', 'is', null)
+      .executeTakeFirst())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-webhook-pull.server.ts` around lines 53 - 66, The current
single query with OR on githubInstallationId can return the NULL-backed row
first; change to a two-step lookup using tenantDb: first query repositories
where owner=coords.owner, repo=coords.name and githubInstallationId =
installation.id (use executeTakeFirst()), and if that returns null run a second
query matching githubInstallationId IS NULL as a fallback; return the id from
the first non-null result so installation-matching rows are always preferred.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

スキーマを確認しました。repositories テーブルは (integration_id, owner, repo) で UNIQUE で、1 テナント = 1 integration なので、同一 (owner, repo) に一致行と NULL 行が共存することは構造的に発生しません。executeTakeFirst() も高々 1 行しか返せない前提です。

加えて、この fallback は PR #294 (strict) で完全に削除されるので、ここでの 2 段階化は見送ります。

@coji
Copy link
Copy Markdown
Owner Author

coji commented Apr 8, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coji coji force-pushed the feat/issue-283-query-octokit branch from 9d945da to 25d8948 Compare April 8, 2026 11:02
@coji coji force-pushed the feat/issue-283-webhook-membership branch from 1c62eb5 to e40a7c8 Compare April 8, 2026 11:04
Base automatically changed from feat/issue-283-query-octokit to main April 8, 2026 11:11
@coji
Copy link
Copy Markdown
Owner Author

coji commented Apr 8, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/services/github-webhook-installation.server.ts`:
- Around line 193-210: The code logs a 'membership_repaired' event via
logGithubAppLinkEvent before the actual membership changes are applied
(applyMembershipChangesAfterCommit) which can cause log/state divergence if the
post-commit operation fails; fix by either (A) moving the logGithubAppLinkEvent
call to after applyMembershipChangesAfterCommit completes successfully (ensuring
you run it outside the transaction/after commit and still pass installation.id,
link.organizationId and readRepositoryCoords(payload).added/removed), or (B)
change the eventType from 'membership_repaired' to a provisional name like
'selection_updated' to reflect that changes are only scheduled, not yet applied.
Ensure you update the call site that currently uses trx so the final log runs in
the correct (post-commit) context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cc6e466b-e85c-4286-9f64-60f8ab93735c

📥 Commits

Reviewing files that changed from the base of the PR and between 6768429 and e40a7c8.

📒 Files selected for processing (18)
  • app/routes/api.github.setup.ts
  • app/services/github-app-link-events.server.ts
  • app/services/github-app-membership.server.test.ts
  • app/services/github-app-membership.server.ts
  • app/services/github-app-mutations.server.ts
  • app/services/github-installation-repos.server.ts
  • app/services/github-integration-queries.server.ts
  • app/services/github-octokit.server.test.ts
  • app/services/github-octokit.server.ts
  • app/services/github-webhook-installation.server.ts
  • app/services/github-webhook-pull.server.ts
  • app/services/github-webhook-shared.server.ts
  • app/services/github-webhook.server.test.ts
  • app/services/github-webhook.server.ts
  • app/services/jobs/backfill.server.ts
  • app/services/jobs/crawl.server.ts
  • batch/db/queries.ts
  • tsconfig.json
✅ Files skipped from review due to trivial changes (3)
  • tsconfig.json
  • app/services/github-webhook-pull.server.ts
  • app/services/github-webhook-shared.server.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/services/github-webhook.server.ts
  • app/routes/api.github.setup.ts
  • app/services/github-installation-repos.server.ts
  • app/services/github-app-link-events.server.ts
  • app/services/github-app-membership.server.test.ts

coji and others added 2 commits April 8, 2026 22:20
…rship initialization (#283 PR 3/7)

setup callback (api.github.setup.ts):
- (organizationId, installationId) 単位 upsert + github_account_type 保存
- membership 初期投入 (fetchInstallationRepositories → initializeMembershipsForInstallation)
- 成功時に membership_initialized_at をセット、失敗時は link のみ保存
- audit log: link_created / membership_initialized

installation webhook (github-webhook-installation.server.ts):
- findActiveLinkByInstallationOrAccount 削除、installation_id のみで lookup
- installation.deleted: 該当 link のみ soft-delete + 最後の active を失った時のみ method=token 復帰
- installation.suspend/unsuspend: github_app_links.suspended_at を更新
- installation_repositories.added/removed: membership upsert/soft-delete + canonical reassignment 呼び出し
- 全 event を audit log に記録
- bulk lookup: 1 query で owner/repo 解決し N+1 を解消

PR webhook (github-webhook-pull.server.ts):
- owner + repo + installation_id で repository を引く(移行期間は github_installation_id IS NULL も許可)

canonical reassignment helper (github-app-membership.server.ts) 新規:
- reassignCanonicalAfterLinkLoss: 候補 0/1/2+ で reassign / cleared / assignment_required を判定
- 候補は repository_installation_memberships を正本とし、active / non-suspended / membership_initialized_at IS NOT NULL の link のみ
- 未初期化 link 残存時は assignment_required に倒す保守ガード
- LEFT JOIN + bulk update で N+1 を回避
- tenant first / shared second の cross-store 順序
- audit log: canonical_reassigned / canonical_cleared / assignment_required
- upsertRepositoryMembership / softDeleteRepositoryMembership / initializeMembershipsForInstallation helpers

installation repos fetcher (github-installation-repos.server.ts) 新規:
- fetchInstallationRepositories: GitHub API で installation の見える repository を全ページ取得

audit log (github-app-link-events.server.ts):
- tryLogGithubAppLinkEvent best-effort wrapper を追加

auto repair (jobs/crawl.server.ts):
- crawl 冒頭に repair-membership:<id> step を追加
- membership_initialized_at IS NULL の active link を検出し installation_repositories を再 fetch して埋める
- per-link で独立 step、durably の再開性を維持

tests:
- github-app-membership.server.test.ts: reassignCanonicalAfterLinkLoss の 8 ケース (1/0/2+/未初期化ガード/suspended 除外/未初期化除外/soft-deleted membership/idempotency)
- github-webhook.server.test.ts: 新 schema (suspended_at, membership_initialized_at, github_account_type, github_app_link_events) + 既存 12 ケース更新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the `membership_repaired` event was logged inside the
transaction before `applyMembershipChangesAfterCommit` executed the
actual membership mutations. If the post-commit step failed, the
audit log would claim success while the state diverged.

Move the log emission to run only after the post-commit membership
changes succeed, so the audit trail reflects actual state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coji coji force-pushed the feat/issue-283-webhook-membership branch from e40a7c8 to 763a5c9 Compare April 8, 2026 13:21
Copy link
Copy Markdown

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/services/github-app-membership.server.ts`:
- Around line 148-160: The loop calling logGithubAppLinkEvent for each decision
can throw and abort the webhook after tenant updates; replace those calls with
the non-throwing variant tryLogGithubAppLinkEvent (or wrap logGithubAppLinkEvent
in a try/catch) inside the same for (const decision of decisions) loop so audit
logging failures are swallowed/handled and do not cause a 500; keep the same
payload (organizationId, installationId: lostInstallationId, eventType:
decision.eventType, source, status: 'success', details: { repositoryId:
decision.repositoryId, nextCanonical: decision.nextCanonical, candidateCount:
decision.candidateCount }) and ensure any caught errors are logged but not
rethrown.

In `@app/services/github-webhook.server.test.ts`:
- Around line 182-185: The shared DB mutations in the test are unscoped: make
the delete on githubAppLinkEvents
(db.deleteFrom('githubAppLinkEvents').execute()) and the update invoked around
mockTenantUpdateTable/mockTenantSelectFrom include a WHERE organizationId =
<server-derived-org-id> condition; locate where the test sets up the fixture
organization (e.g., org or serverOrganization) and use that ID in the delete and
the update so both operations are limited to that org only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6ab76844-332e-4468-971b-23b420109f7e

📥 Commits

Reviewing files that changed from the base of the PR and between e40a7c8 and 763a5c9.

📒 Files selected for processing (11)
  • app/routes/api.github.setup.ts
  • app/services/github-app-link-events.server.ts
  • app/services/github-app-membership.server.test.ts
  • app/services/github-app-membership.server.ts
  • app/services/github-installation-repos.server.ts
  • app/services/github-webhook-installation.server.ts
  • app/services/github-webhook-pull.server.ts
  • app/services/github-webhook-shared.server.ts
  • app/services/github-webhook.server.test.ts
  • app/services/github-webhook.server.ts
  • app/services/jobs/crawl.server.ts
✅ Files skipped from review due to trivial changes (3)
  • app/services/github-webhook.server.ts
  • app/services/github-webhook-shared.server.ts
  • app/services/github-installation-repos.server.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/services/github-webhook-pull.server.ts
  • app/services/github-app-membership.server.test.ts
  • app/services/github-webhook-installation.server.ts

After reassignCanonicalAfterLinkLoss completes the tenant mutation,
logGithubAppLinkEvent failures would surface to the webhook caller
as 500 errors even though the canonical was already reassigned.
Same for the installation_repositories post-commit log.

Switch both post-commit log calls to tryLogGithubAppLinkEvent so
audit-log infrastructure failures never block the webhook response.

Also scope two test fixtures to organizationId='o1' so they don't
clobber other orgs if additional fixtures are added later.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/services/github-webhook-installation.server.ts`:
- Around line 354-360: The call to reassignCanonicalAfterLinkLoss in the
installation_repositories.removed handler currently treats the installation as
fully lost (removing lostInstallationId from all repos) which is incorrect for
partial removals; change the call so it only reassigns canonical for the
repositories that were actually removed (i.e. pass the list of removed
repository IDs from pending.removed into reassignCanonicalAfterLinkLoss), or add
a flag like {partialRemoval: true, removedRepoIds: [...] } and update
reassignCanonicalAfterLinkLoss to restrict its lostInstallationId exclusion to
those specific repo candidates; update call sites and the
reassignCanonicalAfterLinkLoss logic accordingly (see
applyMembershipChangesAfterCommit and softDeleteRepositoryMembership for the
flow).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 203d3a5f-ffe6-4e8e-b879-a54a84b9a8fd

📥 Commits

Reviewing files that changed from the base of the PR and between 763a5c9 and bd37ff1.

📒 Files selected for processing (3)
  • app/services/github-app-membership.server.ts
  • app/services/github-webhook-installation.server.ts
  • app/services/github-webhook.server.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/services/github-webhook.server.test.ts

installation_repositories.removed previously invoked
reassignCanonicalAfterLinkLoss without scoping, so repositories that
were still legitimately bound to the installation would be
reassigned (or orphaned) alongside the actually-removed ones.

- reassignCanonicalAfterLinkLoss takes an optional repositoryIds
  parameter that narrows the scan to the given repository IDs.
- applyMembershipChangesAfterCommit returns the removed repository IDs.
- The removed handler passes that list through as the scope.
- installation.deleted keeps the default (whole-installation) behavior.

Tests: two new cases cover the scoped path and the empty-array no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@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.

🧹 Nitpick comments (2)
app/services/github-app-membership.server.test.ts (1)

25-88: テストセットアップがモジュールロード時に実行されています。

テンポラリディレクトリの作成、DBスキーマの初期化、vi.stubEnv がモジュールレベルで実行されています。これは UPFLOW_DATA_DIR に依存するモジュールのインポート前に環境を整えるための意図的な設計と理解しますが、テスト終了後にテンポラリディレクトリが残ります。

CI環境では問題になりませんが、ローカル開発では不要なファイルが蓄積する可能性があります。

♻️ クリーンアップの追加案
+import { rmSync } from 'node:fs'
 import { mkdirSync, writeFileSync } from 'node:fs'
 ...
   afterAll(async () => {
     await closeAllTenantDbs()
     await closeDb()
+    try {
+      rmSync(testDir, { recursive: true, force: true })
+    } catch {
+      // ignore cleanup errors
+    }
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-app-membership.server.test.ts` around lines 25 - 88, The
test currently creates testDir, writes DB files, initializes SQLite schemas and
calls vi.stubEnv at module load (testDir, sharedDbPath, tenantDbPath,
vi.stubEnv); move this setup into a beforeAll (or beforeEach if isolation
needed) so it runs during test lifecycle, and add an afterAll that removes the
temp directory (use fs.rmSync/testDir deletion with recursive+force) to clean up
created files and avoid leaving artifacts; ensure the beforeAll sets
vi.stubEnv(testDir) before importing modules that depend on UPFLOW_DATA_DIR and
the afterAll undoes/stubs any env changes if necessary.
app/services/github-app-membership.server.ts (1)

154-160: 変数名 repositoryIds がシャドウイングされています。

ループ変数 repositoryIds が入力パラメータ repositoryIds(Line 51, 53)をシャドウイングしています。機能的には正しいですが、可読性のために別名を検討してください。

♻️ 修正案
-  for (const [nextCanonical, repositoryIds] of reassignBuckets) {
+  for (const [nextCanonical, repoIds] of reassignBuckets) {
     await tenantDb
       .updateTable('repositories')
       .set({ githubInstallationId: nextCanonical })
-      .where('id', 'in', repositoryIds)
+      .where('id', 'in', repoIds)
       .execute()
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/github-app-membership.server.ts` around lines 154 - 160, The
for-loop declares a loop binding named repositoryIds which shadows the function
parameter repositoryIds; rename the loop variable to a non-conflicting
identifier (e.g., bucketRepositoryIds or repoIds) in the destructuring
expression for reassignBuckets (for (const [nextCanonical, <newName>] of
reassignBuckets)) and update the usage in the
tenantDb.updateTable('repositories').set(...).where('id', 'in',
<newName>).execute() call to use the new name so the parameter and loop variable
are no longer shadowing each other.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/services/github-app-membership.server.test.ts`:
- Around line 25-88: The test currently creates testDir, writes DB files,
initializes SQLite schemas and calls vi.stubEnv at module load (testDir,
sharedDbPath, tenantDbPath, vi.stubEnv); move this setup into a beforeAll (or
beforeEach if isolation needed) so it runs during test lifecycle, and add an
afterAll that removes the temp directory (use fs.rmSync/testDir deletion with
recursive+force) to clean up created files and avoid leaving artifacts; ensure
the beforeAll sets vi.stubEnv(testDir) before importing modules that depend on
UPFLOW_DATA_DIR and the afterAll undoes/stubs any env changes if necessary.

In `@app/services/github-app-membership.server.ts`:
- Around line 154-160: The for-loop declares a loop binding named repositoryIds
which shadows the function parameter repositoryIds; rename the loop variable to
a non-conflicting identifier (e.g., bucketRepositoryIds or repoIds) in the
destructuring expression for reassignBuckets (for (const [nextCanonical,
<newName>] of reassignBuckets)) and update the usage in the
tenantDb.updateTable('repositories').set(...).where('id', 'in',
<newName>).execute() call to use the new name so the parameter and loop variable
are no longer shadowing each other.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0330c9c1-0df5-4129-a739-62069753461e

📥 Commits

Reviewing files that changed from the base of the PR and between bd37ff1 and 2c91399.

📒 Files selected for processing (3)
  • app/services/github-app-membership.server.test.ts
  • app/services/github-app-membership.server.ts
  • app/services/github-webhook-installation.server.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/services/github-webhook-installation.server.ts

coji and others added 3 commits April 9, 2026 00:13
- Merge duplicated JSDoc blocks on reassignCanonicalAfterLinkLoss into
  a single block that documents both the default and scoped modes.
- Drop the redundant callee-side empty-array guard; the sole caller
  already short-circuits on removedRepositoryIds.length === 0.
- Hoist `orgId` to a local in the installation_repositories handler
  so the OrganizationId cast isn't repeated three times.
- Trim narrating WHAT comments from the new scoped-reassignment test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ip_synced

The installation_repositories webhook handler was emitting
`membership_repaired`, reusing the event type designed for the
crawl auto-repair path. That conflated two operationally distinct
concerns in the audit log:

- `membership_repaired` (crawl auto-repair): recovery for links
  whose setup-time membership initialization failed. Rare, and
  repeated occurrences are an alert signal.
- `membership_synced` (installation_repositories webhook): routine
  sync triggered by the user changing repository selection via
  GitHub. Frequent, benign, driven by user action.

Split them so the audit log distinguishes chronic setup failures
from normal user activity.

RDD event_type list updated to mirror the new type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ories

The existing installation_repositories test only verified the
app_repository_selection column was updated; it never checked that
the audit log received an event. As a result the rename to
membership_synced was technically unverified. Extend the assertion
to read back the github_app_link_events row and expect exactly
`membership_synced` / `installation_repositories_webhook`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Owner Author

coji commented Apr 8, 2026

Merge activity

  • Apr 8, 3:41 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Apr 8, 3:41 PM UTC: @coji merged this pull request with Graphite.

@coji coji merged commit fe8ba1a into main Apr 8, 2026
7 checks passed
@coji coji deleted the feat/issue-283-webhook-membership branch April 8, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant