diff --git a/apps/docs/content/docs/environment-variables.ja.mdx b/apps/docs/content/docs/environment-variables.ja.mdx
index f75a5b5e4..ed5f3d2ed 100644
--- a/apps/docs/content/docs/environment-variables.ja.mdx
+++ b/apps/docs/content/docs/environment-variables.ja.mdx
@@ -245,23 +245,24 @@ multica config set auto_update_check_interval 12h
## GitHub 連携
-[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの必須変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。さらに 2 つの任意変数を設定すると、インストール時点で連携先のアカウント名を取得できます。
+[GitHub PR ↔ イシュー連携](/github-integration)には、設定で Connect GitHub を有効にし受信 webhook を受け付けるために `GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` が必要です。`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は、PR カードの CI ステータスとマージ可否に**必須**です — これらがなくても連携は PR をミラーリングしイシューを自動で紐づけますが、カードには CI やマージ状態が表示されません。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | GitHub App の slug(`https://github.com/apps/` の末尾部分)。設定 → GitHub のインストールボタン URL を構成します |
| `GITHUB_WEBHOOK_SECRET` | 空 | GitHub App に設定した Webhook secret。すべての `pull_request` / `installation` delivery の HMAC-SHA256 検証に使われ、setup コールバックの state token の HMAC キーとしても使われます |
-| `GITHUB_APP_ID` | 空 | 任意。App 設定ページに表示される数値の App ID。`GITHUB_APP_PRIVATE_KEY` と併せて設定すると、setup コールバックがインストール時点で GitHub から連携先のアカウント名を取得できます |
-| `GITHUB_APP_PRIVATE_KEY` | 空 | 任意。App の RSA 秘密鍵の完全な PEM ブロック(`-----BEGIN/END-----` 行を含み、改行を保ったまま)。GitHub の App 認証 REST 呼び出しに必要な短命 JWT の発行に使われます |
+| `GITHUB_APP_ID` | 空 | App 設定ページに表示される数値の App ID。`GITHUB_APP_PRIVATE_KEY` と併せて、PR カードの CI ステータスとマージ可否に必須で、インストール時点で連携先のアカウント名を取得するためにも使われます |
+| `GITHUB_APP_PRIVATE_KEY` | 空 | App の RSA 秘密鍵の完全な PEM ブロック(`-----BEGIN/END-----` 行を含み、改行を保ったまま)。`GITHUB_APP_ID` と併せて、App として認証し、PR カードの CI ステータスとマージ可否の基になる GitHub API スナップショットを取得するために使われます |
**いずれかの必須変数が設定されていない場合の動作:**
- 設定 → GitHub の `Connect GitHub` が**無効**になり、admin に「not configured」というヒントを表示します。
- `/api/webhooks/github` エンドポイントは **`503 github webhooks not configured`** を返します — Multica はすべての署名を有効として扱うのではなく、secret なしではイベント処理を拒否します。
-**任意の `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` が設定されていない場合の動作:**
+**`GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` が設定されていない場合の動作:**
-- インストール直後、接続カードには一時的に `Connected to unknown` と表示されます。GitHub から `installation.created` webhook が届くと(通常は数秒以内)、Multica は行を実際の組織名/ユーザー名に更新し、リアルタイムブロードキャストを発行するため、開いている Settings → GitHub タブは手動更新なしで反映されます。
+- PR カードに **CI ステータスもマージ可否も表示されません** — 連携はきれいに縮退します: PR は引き続きミラーリングされ、イシューは引き続き自動で紐づき、マージ時に Done へ移動します。
+- インストール直後、接続カードには一時的に `Connected to unknown` とも表示されます。GitHub から `installation.created` webhook が届くと(通常は数秒以内)、Multica は行を実際の組織名/ユーザー名に更新し、リアルタイムブロードキャストを発行するため、開いている Settings → GitHub タブは手動更新なしで反映されます。
**注:** `GITHUB_WEBHOOK_SECRET` はインストールフローの state token の署名キーとして再利用されるため、運用者は secret を 1 つだけ管理すればよいです。これは GitHub App の *Client* secret では**ありません** — Client secret は OAuth 関連であり、この連携では使われません。完全な手順は [GitHub 連携 → セルフホストのセットアップ](/github-integration#self-host-setup)を参照してください。
diff --git a/apps/docs/content/docs/environment-variables.ko.mdx b/apps/docs/content/docs/environment-variables.ko.mdx
index 22fa580b0..0217e97fd 100644
--- a/apps/docs/content/docs/environment-variables.ko.mdx
+++ b/apps/docs/content/docs/environment-variables.ko.mdx
@@ -245,23 +245,24 @@ multica config set auto_update_check_interval 12h
## GitHub 연동
-[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 필수 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요. 추가로 두 개의 선택 변수를 설정하면 설치 시점에 연결된 계정 이름을 즉시 가져올 수 있습니다.
+[GitHub PR ↔ 이슈 연동](/github-integration)에는 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하기 위해 `GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`이 필요합니다. `GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 PR 카드의 CI 상태와 머지 가능 여부에 **필수**입니다 — 이 둘이 없어도 연동은 PR을 미러링하고 이슈를 자동 연결하지만, 카드에는 CI나 머지 상태가 표시되지 않습니다.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `GITHUB_APP_SLUG` | 비어 있음 | GitHub App의 slug (`https://github.com/apps/`의 끝부분). 설정 → GitHub 설치 버튼 URL을 구성합니다 |
| `GITHUB_WEBHOOK_SECRET` | 비어 있음 | GitHub App에 설정한 Webhook secret. 모든 `pull_request` / `installation` delivery의 HMAC-SHA256 검증에 사용되며, setup 콜백 state token의 HMAC 키로도 사용됩니다 |
-| `GITHUB_APP_ID` | 비어 있음 | 선택. App 설정 페이지에 표시되는 숫자 App ID. `GITHUB_APP_PRIVATE_KEY`와 함께 설정하면 setup 콜백이 설치 시점에 GitHub에서 연결된 계정 이름을 가져올 수 있습니다 |
-| `GITHUB_APP_PRIVATE_KEY` | 비어 있음 | 선택. App RSA 비공개 키의 전체 PEM 블록 (`-----BEGIN/END-----` 줄 포함, 줄바꿈 유지). GitHub의 App 인증 REST 호출에 필요한 단명 JWT를 발급하는 데 사용됩니다 |
+| `GITHUB_APP_ID` | 비어 있음 | App 설정 페이지에 표시되는 숫자 App ID. `GITHUB_APP_PRIVATE_KEY`와 함께, PR 카드의 CI 상태와 머지 가능 여부에 필수이며, 설치 시점에 연결된 계정 이름을 가져오는 데에도 사용됩니다 |
+| `GITHUB_APP_PRIVATE_KEY` | 비어 있음 | App RSA 비공개 키의 전체 PEM 블록 (`-----BEGIN/END-----` 줄 포함, 줄바꿈 유지). `GITHUB_APP_ID`와 함께, App으로 인증하고 PR 카드의 CI 상태와 머지 가능 여부의 기반이 되는 GitHub API 스냅샷을 가져오는 데 사용됩니다 |
**필수 변수 중 하나라도 설정하지 않았을 때의 동작:**
- 설정 → GitHub의 `Connect GitHub`가 **비활성화**되고 admin에게 "not configured" 힌트를 표시합니다.
- `/api/webhooks/github` 엔드포인트는 **`503 github webhooks not configured`**를 반환합니다 — Multica는 모든 서명을 유효한 것으로 취급하기보다, secret 없이는 이벤트 처리를 거부합니다.
-**선택 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY`가 설정되지 않았을 때의 동작:**
+**`GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY`가 설정되지 않았을 때의 동작:**
-- 설치 직후 연결 카드에 잠시 `Connected to unknown`이 표시됩니다. GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 실제 조직/사용자 이름으로 갱신하고 실시간 브로드캐스트를 보내, 열려 있는 Settings → GitHub 탭이 수동 새로고침 없이 업데이트됩니다.
+- PR 카드에 **CI 상태도 머지 가능 여부도 표시되지 않습니다** — 연동은 깔끔하게 축소됩니다: PR은 계속 미러링되고, 이슈는 계속 자동 연결되며 머지 시 Done으로 이동합니다.
+- 설치 직후 연결 카드에 잠시 `Connected to unknown`도 표시됩니다. GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 실제 조직/사용자 이름으로 갱신하고 실시간 브로드캐스트를 보내, 열려 있는 Settings → GitHub 탭이 수동 새로고침 없이 업데이트됩니다.
**참고:** `GITHUB_WEBHOOK_SECRET`은 설치 흐름 state token의 서명 키로 재사용되므로, 운영자는 secret 하나만 관리하면 됩니다. 이것은 GitHub App의 *Client* secret이 **아닙니다** — Client secret은 OAuth 관련이며 이 연동에서는 사용되지 않습니다. 전체 안내는 [GitHub 연동 → 자체 호스팅 설정](/github-integration#self-host-setup)을 참고하세요.
diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx
index 65f7d2ced..6fa608d24 100644
--- a/apps/docs/content/docs/environment-variables.mdx
+++ b/apps/docs/content/docs/environment-variables.mdx
@@ -250,23 +250,24 @@ These set a **fleet-wide default** layer of CLI flags for a backend — a conven
## GitHub integration
-The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks. Two additional variables are optional but populate the connected account name on install.
+The [GitHub PR ↔ issue integration](/github-integration) needs `GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` to enable Connect GitHub in Settings and accept incoming webhooks. `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are additionally **required** for the PR card's CI status and mergeability — without them the integration still mirrors PRs and auto-links issues, but the card shows no CI or merge status.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/`). Drives the Settings → GitHub install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
-| `GITHUB_APP_ID` | empty | Optional. Numeric App ID from the App's settings page. Combined with `GITHUB_APP_PRIVATE_KEY`, lets the setup callback fetch the connected account name from GitHub immediately on install |
-| `GITHUB_APP_PRIVATE_KEY` | empty | Optional. Full PEM block of the App's RSA private key (including `-----BEGIN/END-----` lines, newlines preserved). Used to mint the short-lived JWT GitHub requires for App-authenticated REST calls |
+| `GITHUB_APP_ID` | empty | Numeric App ID from the App's settings page. Required (with `GITHUB_APP_PRIVATE_KEY`) for the PR card's CI status and mergeability, and to fetch the connected account name on install |
+| `GITHUB_APP_PRIVATE_KEY` | empty | Full PEM block of the App's RSA private key (including `-----BEGIN/END-----` lines, newlines preserved). Required (with `GITHUB_APP_ID`) to authenticate as the App and pull the GitHub API snapshot behind the PR card's CI status and mergeability |
**Behavior when either of the required variables is unset:**
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
-**Behavior when the optional `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` are unset:**
+**Behavior when `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` are unset:**
-- The connection card briefly shows `Connected to unknown` after install. Multica refreshes the row to the real org/user name as soon as GitHub delivers the `installation.created` webhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
+- The PR card shows **no CI status or mergeability** — the integration degrades cleanly: PRs still mirror, and issues still auto-link and move to Done on merge.
+- The connection card also briefly shows `Connected to unknown` after install. Multica refreshes the row to the real org/user name as soon as GitHub delivers the `installation.created` webhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx
index 218cccb5a..2791bbbc1 100644
--- a/apps/docs/content/docs/environment-variables.zh.mdx
+++ b/apps/docs/content/docs/environment-variables.zh.mdx
@@ -250,23 +250,24 @@ multica config set auto_update_check_interval 12h
## GitHub 集成
-[GitHub PR ↔ issue 集成](/github-integration) 依赖两个必填环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。另外两个可选变量用于在安装时直接拿到关联账号名。
+[GitHub PR ↔ issue 集成](/github-integration) 依赖 `GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 来启用 Settings 里的 Connect GitHub 并接受 webhook。`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` 则是 PR 卡片 CI 状态与可合并性的**必填项**——不配这两个,集成仍会镜像 PR、自动关联 issue,但卡片不显示任何 CI 或 merge 状态。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
-| `GITHUB_APP_ID` | 空 | 可选。App 设置页上的数字 App ID。配合 `GITHUB_APP_PRIVATE_KEY` 使用,让 setup 回调在安装那一刻直接从 GitHub 取到关联账号名 |
-| `GITHUB_APP_PRIVATE_KEY` | 空 | 可选。App RSA 私钥的完整 PEM 块(包含 `-----BEGIN/END-----` 两行,保留换行)。用于签发 GitHub App 鉴权 REST 调用所需的短效 JWT |
+| `GITHUB_APP_ID` | 空 | App 设置页上的数字 App ID。配合 `GITHUB_APP_PRIVATE_KEY`,是 PR 卡片 CI 状态与可合并性的必填项,同时也用于在安装那一刻直接从 GitHub 取到关联账号名 |
+| `GITHUB_APP_PRIVATE_KEY` | 空 | App RSA 私钥的完整 PEM 块(包含 `-----BEGIN/END-----` 两行,保留换行)。配合 `GITHUB_APP_ID`,用于以 App 身份鉴权并拉取 PR 卡片 CI 状态与可合并性背后的 GitHub API 快照 |
**任一必填变量未设时:**
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
-**可选 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` 未设时:**
+**`GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` 未设时:**
-- 安装完成后,连接卡片会先短暂显示 `已连接到 unknown`。等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会把 row 刷成真实的组织/用户名,并通过 realtime 推送让正在打开的 Settings → GitHub 页面无需手动刷新即可更新。
+- PR 卡片**不显示任何 CI 状态或可合并性**——集成干净降级:PR 照常镜像,issue 照常自动关联并在 merge 时转 Done。
+- 安装完成后,连接卡片还会先短暂显示 `已连接到 unknown`。等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会把 row 刷成真实的组织/用户名,并通过 realtime 推送让正在打开的 Settings → GitHub 页面无需手动刷新即可更新。
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
diff --git a/apps/docs/content/docs/github-integration.ja.mdx b/apps/docs/content/docs/github-integration.ja.mdx
index 63783835c..14436f6b2 100644
--- a/apps/docs/content/docs/github-integration.ja.mdx
+++ b/apps/docs/content/docs/github-integration.ja.mdx
@@ -17,8 +17,20 @@ import { Callout } from "fumadocs-ui/components/callout";
| **イシューサイドバー → Pull requests** | このイシューに自動で紐づいたすべての PR が、タイトル、リポジトリ、状態(`Open` / `Draft` / `Merged` / `Closed`)、作成者とともに表示されます。行をクリックすると GitHub の該当 PR に移動します。 |
| **Webhook(バックグラウンド)** | すべての `pull_request` イベントで、Multica は PR 行を upsert し、PR からイシュー識別子をスキャンして、紐づけ行を(再)構築します。冪等性があり、同じ delivery を再送しても変化はありません。 |
| **マージ時のステータス自動変更** | PR が `merged` に遷移すると、まだ `Done` でも `Cancelled` でもない、紐づいたすべてのイシューが `Done` に移動します。ステータス変更は source `github_pr_merged` でタイムラインに記録されます。 |
+| **PR カード → CI とマージ可否** | 紐づいた各 PR について、Multica は認証済みの GitHub API スナップショットを取得し、その **CI ステータス**と**マージ可否**をカードにミラーリングします(下記の **PR カードに表示される内容** を参照)。 |
-ミラーリングされるのは PR 自体のみです。コミット、オープンな PR のないブランチ ref、CI チェックの状態はモデル化され**ません**。この連携は意図的に狭く設計されています。
+PR 自体の状態に加えて、この連携は紐づいた各 PR について、その **CI ステータス**(すべて成功、x 件が失敗(名前付き)、または x 件が実行中)と**マージ可否**(マージ可能、コンフリクトあり、blocked、behind)を、認証済みの GitHub API スナップショットから取得してミラーリングします。コミットやオープンな PR のないブランチ ref は引き続きモデル化されません。
+
+## PR カードに表示される内容
+
+紐づいた各 PR のカードには、それぞれ認証済みの GitHub API スナップショットから構築される 2 つの**独立した**要素があります。
+
+- **CI ステータス** — 次のいずれか: **すべて成功**(件数付き、例: `7/7`)、**x 件が失敗**(失敗したチェック名を表示)、または **x 件が実行中**(進行中)。チェックがまだ 1 つも設定されていない PR ではどちらも表示されません — 「チェックなし」が成功として報告されることは決してありません。
+- **マージ可否** — 次のいずれか: **マージ可能**(GitHub がマージ状態を clean、つまりコンフリクトがなく**かつ**ブランチ保護を満たしていると報告した場合のみ表示)、**コンフリクトあり**、**blocked**、または **behind**。
+
+2 つは互いを隠しません — 1 つの PR がチェックの失敗**と**マージコンフリクトを同時に表示できます。マージ済み / クローズ済みの PR ではどちらも表示されません。行の状態アイコンがすでにそれを示しています。
+
+スナップショットは GitHub API から取得され、webhook イベント(`pull_request`、`check_run`、`check_suite`、`status`)の到着時、カードを開いたとき、および PR がまだオープンで未決の間は定期的に更新されます。GitHub の一時的な障害中は、カードは空白になるのではなく、最後に判明したスナップショットを **stale** として表示し続けます。
## 識別子のマッチング方法
@@ -67,13 +79,13 @@ PR をマージ**せずに**クローズした場合は、PR カードの状態
- **連携 / 連携解除**にはワークスペースの **owner または admin** が必要です。member にはカードの説明は見えますが、Connect ボタンは見えません。
- イシューの **Pull requests** サイドバーは、そのイシューを閲覧できる誰にでも表示されます — イシュー詳細の他の部分と同じ権限です。
-- GitHub App は pull request とメタデータへの**読み取り専用**アクセスを要求します。Multica はコミット、コメント、ステータスチェックを GitHub に書き戻すことはありません。
+- GitHub App は pull request、checks、commit statuses、メタデータへの**読み取り専用**アクセスを要求します。Multica はコミット、コメント、ステータスチェックを GitHub に書き戻すことはありません — PR カードに表示される CI ステータスとマージ可否は読み取り専用で取得され、書き込まれることはありません。
## セルフホストのセットアップ
Multica Cloud で Multica を実行している場合、連携はすでに構成済みです — このセクションは飛ばしてください。
-セルフホストの場合は、GitHub App を 1 つ作成し、サーバーを指すように設定し、環境変数を 2 つ設定します。フロー全体は以下のとおりです。
+セルフホストの場合は、GitHub App を 1 つ作成し、サーバーを指すように設定し、環境変数を設定します。フロー全体は以下のとおりです。
### 1. GitHub App を作成する
@@ -94,10 +106,20 @@ Multica Cloud で Multica を実行している場合、連携はすでに構成
| **Webhook URL** | `https:///api/webhooks/github`。 |
| **Webhook secret** | 長いランダム文字列を生成してください(例: `openssl rand -hex 32`)。手順 2 で同じ値を Multica の env に貼り付けます。 |
| **Permissions → Repository → Pull requests** | **Read-only**。 |
+| **Permissions → Repository → Checks** | **Read-only** — PR カードの CI ステータスを支えます。 |
+| **Permissions → Repository → Commit statuses** | **Read-only** — CI ロールアップのうち従来の status context 側です。 |
| **Permissions → Repository → Metadata** | Read-only(必須)。 |
-| **Subscribe to events** | **Pull request** をチェックしてください。 |
+| **Subscribe to events** | **Pull request**、**Check suite**、**Check run**、**Status** をチェックしてください。 |
| **Where can this GitHub App be installed?** | お好みで。単一組織のセットアップなら `Only on this account` で十分です。 |
+
+**read だけを付与し、write は付与せず、それ以上は付けないでください。** **Checks: Read and write** は付与し**ないで**ください — write 権限を付けると、GitHub はこの App が自身の check run を報告することを期待し、その結果 check が永久に pending のままになります。読み取り専用が正しい設定です。この機能は **Actions: read** や **Contents: read** も**必要としません** — 追加しないでください。
+
+
+
+**すでにインストール済みの App にこれらの権限を追加するには承認が必要です。** 既存の App に Checks、Commit statuses、Check run、Status を追加すると、GitHub はその変更を**保留中の権限リクエスト**として保持します — 各インストールの owner が(GitHub 上のそのインストールの設定で)承認するまで有効になりません。あるインストールが承認するまで、そのリポジトリの PR カードには CI / マージ状態が表示されません。
+
+
**Create GitHub App** の後、App の詳細ページから 2 つのことを控えておいてください。
- 上部の **public link** — その末尾が slug です。`https://github.com/apps/multica-acme` → slug = `multica-acme`。
@@ -115,18 +137,19 @@ API サーバーで:
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=
-# 任意(推奨)— インストール時点で接続済みアカウント名を取得できるため、
-# 最初の webhook が届くまで待たなくて済みます:
+# PR カードの CI ステータスとマージ可否に必須。これらがなくても連携は
+# PR のミラーリングとイシューの自動紐づけ / 自動クローズを続けますが、
+# PR カードには CI やマージ状態が一切表示されません:
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
```
-`GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` は必須です。どちらかが欠けていると:
+`GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` は連携を起動するために必須です。どちらかが欠けていると:
- Settings の `Connect GitHub` が**無効**になり、「not configured」のヒントが表示されます。
- `/api/webhooks/github` エンドポイントが **`503 github webhooks not configured`** を返します — Multica は secret なしでイベントを処理することを拒否し、すべての署名を黙って有効として扱うことはありません。
-`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は**任意**です。これらを設定すると、setup コールバックが GitHub の App 認証された `/app/installations/{id}` エンドポイントを呼び出して、インストール時点で実際の組織名やユーザー名を取得できます。設定しない場合、接続カードには一時的に `Connected to unknown` と表示され、GitHub から `installation.created` webhook が届くと(通常は数秒以内に)Multica が行を更新し、リアルタイムブロードキャストを発行するため、開いている Settings タブは手動更新なしで反映されます。秘密鍵は App 設定ページの **Private keys → Generate a private key** で生成し、PEM ブロック全体(`-----BEGIN/END RSA PRIVATE KEY-----` の行を含む)を改行を保ったまま env 変数に貼り付けてください。
+`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は現在、**PR カードの CI ステータスとマージ可否に必須**です — これらにより Multica は App として認証し(App JWT → installation token)、各カードの基になる GitHub API スナップショットを取得します。秘密鍵は App 設定ページの **Private keys → Generate a private key** で生成し、PEM ブロック全体(`-----BEGIN/END RSA PRIVATE KEY-----` の行を含む)を改行を保ったまま env 変数に貼り付けてください。これらがない場合、連携は**きれいに縮退**します: PR は引き続きミラーリングされ、イシューは引き続き自動で紐づき、マージ時に Done へ移動し、接続済みアカウント名も引き続き `installation.created` webhook から解決されます — ただし PR カードには CI やマージ状態が表示されません。
`FRONTEND_ORIGIN` も設定されている必要があります(どのプロダクションのセルフホストでもすでに設定されています)。インストール後、setup コールバックがユーザーを `/settings?tab=github` に戻します。
@@ -174,12 +197,28 @@ curl -i -X POST https:///api/webhooks/github \
| `401 invalid signature` | サーバーがロードした secret が思っている値で**ありません**。 | env 変数が実行中のプロセスに反映されたか確認してください(例: `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`)。再デプロイしてください。 |
| `503 github webhooks not configured` | プロセスで `GITHUB_WEBHOOK_SECRET` が空です。 | env 変数を設定し、API を再起動してください。 |
+**App のイベントと権限を確認する。** 公開されている App メタデータのエンドポイントは、付与した内容をそのまま返します — 認証は不要です:
+
+```bash
+curl -s https://api.github.com/apps/
+```
+
+- `events` 配列には `pull_request`、`check_suite`、`check_run`、`status` が含まれているはずです。
+- `permissions` オブジェクトには `checks: read` と `statuses: read` が、`pull_requests: read` と `metadata: read` とともに含まれているはずです。
+
+いずれかが欠けている場合は、App の **Subscribe to events** / **Permissions** を修正してください — そして、すでにインストール済みの App に権限を追加しても、各インストールの owner が保留中のリクエストを承認して初めて有効になることを忘れないでください。
+
+**「PR カードに CI / マージ状態が表示されない」。** カードは PR を問題なくミラーリングするのに、チェックやマージ可否を一切表示しない場合:
+
+- 実行中の API プロセスに `GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` が設定されているか確認してください — スナップショット取得の認証はこれらが担います。
+- 各インストールが Checks と Commit statuses の**保留中の権限リクエストを承認済み**か確認してください(上記のプローブを参照)。
+
## 制限事項
現時点で知っておくべき、いくつかの粗い部分があります。
- **まだ手動の紐づけ UI はありません** — PR を紐づける唯一の方法は、ブランチ、タイトル、本文に識別子を置くことです。
-- **CI / チェック状態はありません** — PR 自体のみがミラーリングされます。ビルド状態、レビューコメント、レビュアーは Multica には表示されません。
+- **レビュースレッドやレビュアー一覧はありません** — CI ステータスとマージ可否は PR カードに表示されるようになりました(**PR カードに表示される内容** を参照)が、レビューコメント、個々のレビュアー、その承認状態は表示されません。
- マージ → Done ルールに対する**ワークスペースレベルの設定はありません** — 固定のデフォルトです(`cancelled` でない限り `merged → done`)。ワークスペースでカスタマイズできるマッピングは将来の追加予定です。
- **1 つのイシューに複数の PR が紐づく場合、マージは保守的です** — 2 つの PR がどちらも `MUL-123` を参照していて最初の 1 つがマージされると、イシューはただちに `Done` に移動します。進める前に紐づいたすべての PR が解決されるのを待つ後続の変更が進行中です。
diff --git a/apps/docs/content/docs/github-integration.ko.mdx b/apps/docs/content/docs/github-integration.ko.mdx
index 2803ac959..7a5612632 100644
--- a/apps/docs/content/docs/github-integration.ko.mdx
+++ b/apps/docs/content/docs/github-integration.ko.mdx
@@ -17,8 +17,20 @@ import { Callout } from "fumadocs-ui/components/callout";
| **이슈 사이드바 → Pull requests** | 이 이슈에 자동 연결된 모든 PR이 제목, 저장소, 상태(`Open` / `Draft` / `Merged` / `Closed`), 작성자와 함께 표시됩니다. 행을 클릭하면 GitHub의 해당 PR로 이동합니다. |
| **Webhook(백그라운드)** | 모든 `pull_request` 이벤트에서 Multica는 PR 행을 upsert하고, PR에서 이슈 식별자를 스캔한 뒤, 연결 행을 (다시) 구성합니다. 멱등성이 있어 동일 delivery를 재전송해도 변화가 없습니다. |
| **머지 시 상태 자동 변경** | PR이 `merged`로 전환되면, 아직 `Done`이나 `Cancelled`가 아닌 모든 연결된 이슈가 `Done`으로 이동합니다. 상태 변경은 source `github_pr_merged`로 타임라인에 기록됩니다. |
+| **PR 카드 → CI와 머지 가능 여부** | 연결된 각 PR에 대해 Multica는 인증된 GitHub API 스냅샷을 가져와, 그 **CI 상태**와 **머지 가능 여부**를 카드에 미러링합니다(아래 **PR 카드에 표시되는 것** 참조). |
-미러링되는 것은 PR 자체뿐입니다. 커밋, 열린 PR이 없는 브랜치 ref, CI 체크 상태는 모델링되지 **않습니다**. 이 연동은 의도적으로 좁게 설계되었습니다.
+PR 자체의 상태 외에도, 이 연동은 연결된 각 PR에 대해 그 **CI 상태**(모두 통과, x개 실패(이름 표시), 또는 x개 실행 중)와 **머지 가능 여부**(머지 가능, 충돌 있음, blocked, behind)를 인증된 GitHub API 스냅샷에서 가져와 미러링합니다. 커밋과 열린 PR이 없는 브랜치 ref는 여전히 모델링되지 않습니다.
+
+## PR 카드에 표시되는 것
+
+연결된 각 PR의 카드에는 각각 인증된 GitHub API 스냅샷에서 구성되는 두 개의 **독립적인** 요소가 있습니다.
+
+- **CI 상태** — 다음 중 하나: **모두 통과**(개수와 함께, 예: `7/7`), **x개 실패**(실패한 체크 이름 표시), 또는 **x개 실행 중**(진행 중). 아직 체크가 하나도 설정되지 않은 PR은 둘 다 표시하지 않습니다 — 「체크 없음」이 통과로 보고되는 일은 결코 없습니다.
+- **머지 가능 여부** — 다음 중 하나: **머지 가능**(GitHub가 머지 상태를 clean, 즉 충돌이 없고 **또한** 브랜치 보호를 충족한다고 보고할 때만 표시), **충돌 있음**, **blocked**, 또는 **behind**.
+
+두 요소는 서로를 가리지 않습니다 — 하나의 PR이 체크 실패**와** 머지 충돌을 동시에 표시할 수 있습니다. 머지됨 / 닫힘 PR은 둘 다 표시하지 않습니다. 행의 상태 아이콘이 이미 그것을 나타냅니다.
+
+스냅샷은 GitHub API에서 가져오며, webhook 이벤트(`pull_request`, `check_run`, `check_suite`, `status`) 도착 시, 카드를 열 때, 그리고 PR이 아직 열려 있고 미결 상태인 동안 주기적으로 새로고침됩니다. GitHub의 일시적 장애 중에는 카드가 비워지는 대신 마지막으로 알려진 스냅샷을 **stale**로 표시한 채 계속 보여줍니다.
## 식별자 매칭 방식
@@ -67,13 +79,13 @@ PR을 머지하지 **않고** 닫으면 PR 카드의 상태만 `Closed`로 업
- **연결 / 연결 해제**에는 워크스페이스 **owner 또는 admin**이 필요합니다. member에게는 카드 설명은 보이지만 Connect 버튼은 보이지 않습니다.
- 이슈의 **Pull requests** 사이드바는 해당 이슈를 읽을 수 있는 모든 사람에게 보입니다 — 이슈 상세의 나머지 부분과 동일한 권한입니다.
-- GitHub App은 pull request와 메타데이터에 대한 **읽기 전용** 액세스를 요청합니다. Multica는 커밋, 댓글, 상태 체크를 GitHub로 다시 푸시하지 않습니다.
+- GitHub App은 pull request, checks, commit statuses, 메타데이터에 대한 **읽기 전용** 액세스를 요청합니다. Multica는 커밋, 댓글, 상태 체크를 GitHub로 다시 푸시하지 않습니다 — PR 카드에 표시되는 CI 상태와 머지 가능 여부는 읽기 전용으로 가져오며, 절대 쓰지 않습니다.
## 자체 호스팅 설정
Multica Cloud에서 Multica를 실행 중이라면 연동이 이미 구성되어 있습니다 — 이 섹션은 건너뛰세요.
-자체 호스팅의 경우, GitHub App을 하나 만들고, 서버를 가리키게 한 뒤, 환경 변수 두 개를 설정합니다. 전체 흐름은 아래와 같습니다.
+자체 호스팅의 경우, GitHub App을 하나 만들고, 서버를 가리키게 한 뒤, 환경 변수를 설정합니다. 전체 흐름은 아래와 같습니다.
### 1. GitHub App 만들기
@@ -94,10 +106,20 @@ Multica Cloud에서 Multica를 실행 중이라면 연동이 이미 구성되어
| **Webhook URL** | `https:///api/webhooks/github`. |
| **Webhook secret** | 긴 무작위 문자열을 생성하세요(예: `openssl rand -hex 32`). 2단계에서 동일한 값을 Multica의 env에 붙여넣게 됩니다. |
| **Permissions → Repository → Pull requests** | **Read-only**. |
+| **Permissions → Repository → Checks** | **Read-only** — PR 카드의 CI 상태를 구동합니다. |
+| **Permissions → Repository → Commit statuses** | **Read-only** — CI 롤업 중 레거시 status context 쪽입니다. |
| **Permissions → Repository → Metadata** | Read-only(필수). |
-| **Subscribe to events** | **Pull request**를 체크하세요. |
+| **Subscribe to events** | **Pull request**, **Check suite**, **Check run**, **Status**를 체크하세요. |
| **Where can this GitHub App be installed?** | 선택 사항. 단일 조직 설정에는 `Only on this account`로 충분합니다. |
+
+**read만 부여하고, write는 부여하지 말고, 그 이상도 추가하지 마세요.** **Checks: Read and write**는 부여하지 **마세요** — write 권한을 주면 GitHub는 이 App이 자체 check run을 보고할 것으로 기대하여, 결과적으로 check가 영구히 pending 상태로 남습니다. 읽기 전용이 올바른 설정입니다. 이 기능은 **Actions: read**나 **Contents: read**도 **필요로 하지 않습니다** — 추가하지 마세요.
+
+
+
+**이미 설치된 App에 이 권한들을 추가하려면 승인이 필요합니다.** 기존 App에 Checks, Commit statuses, Check run, Status를 추가하면 GitHub는 그 변경을 **대기 중인 권한 요청**으로 보류합니다 — 각 설치의 owner가 (GitHub의 해당 설치 설정에서) 승인해야 적용됩니다. 어떤 설치가 승인하기 전까지 그 저장소의 PR 카드에는 CI / 머지 상태가 표시되지 않습니다.
+
+
**Create GitHub App** 후, App 상세 페이지에서 두 가지를 기록해 두세요.
- 상단의 **public link** — 그 꼬리가 slug입니다. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
@@ -115,18 +137,19 @@ API 서버에서:
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=
-# 선택(권장) — 설치 직후 연결된 계정 이름을 바로 확보합니다.
-# 설정하지 않으면 첫 webhook이 도착할 때까지 대기해야 합니다:
+# PR 카드의 CI 상태와 머지 가능 여부에 필수. 이 둘이 없어도 연동은
+# PR 미러링과 이슈 자동 연결 / 자동 종료를 계속하지만,
+# PR 카드에는 CI나 머지 상태가 전혀 표시되지 않습니다:
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
```
-`GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`은 필수입니다. 둘 중 하나라도 누락되면:
+`GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`은 연동을 시작하기 위한 필수 항목입니다. 둘 중 하나라도 누락되면:
- Settings의 `Connect GitHub`이 **비활성화**되고 "not configured" 힌트가 표시됩니다.
- `/api/webhooks/github` 엔드포인트가 **`503 github webhooks not configured`**를 반환합니다 — Multica는 secret 없이 이벤트를 처리하기를 거부하며, 모든 서명을 조용히 유효한 것으로 취급하지 않습니다.
-`GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 **선택 사항**입니다. 설정하면 setup 콜백이 GitHub의 App 인증 `/app/installations/{id}` 엔드포인트를 호출해 설치 직후에 실제 조직명/사용자명을 가져옵니다. 설정하지 않으면 연결 카드에 잠시 `Connected to unknown`이 표시되며, GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 갱신하고 실시간 브로드캐스트를 보내므로 열려 있는 Settings 탭이 수동 새로고침 없이 업데이트됩니다. 비공개 키는 App 설정 페이지의 **Private keys → Generate a private key**에서 생성한 뒤, PEM 블록 전체(`-----BEGIN/END RSA PRIVATE KEY-----` 줄 포함)를 줄바꿈을 유지한 채 env 값에 붙여넣으세요.
+`GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 이제 **PR 카드의 CI 상태와 머지 가능 여부에 필수**입니다 — 이 둘로 Multica가 App으로 인증하고(App JWT → installation token) 각 카드의 기반이 되는 GitHub API 스냅샷을 가져옵니다. 비공개 키는 App 설정 페이지의 **Private keys → Generate a private key**에서 생성한 뒤, PEM 블록 전체(`-----BEGIN/END RSA PRIVATE KEY-----` 줄 포함)를 줄바꿈을 유지한 채 env 값에 붙여넣으세요. 이 둘이 없으면 연동은 **깔끔하게 축소**됩니다: PR은 계속 미러링되고, 이슈는 계속 자동 연결되며 머지 시 Done으로 이동하고, 연결된 계정 이름도 계속 `installation.created` 웹훅에서 확인됩니다 — 다만 PR 카드에는 CI나 머지 상태가 표시되지 않습니다.
`FRONTEND_ORIGIN`도 설정되어 있어야 합니다(어떤 프로덕션 자체 호스팅이든 이미 설정되어 있습니다). 설치 후 setup 콜백이 사용자를 `/settings?tab=github`으로 다시 돌려보냅니다.
@@ -174,12 +197,28 @@ curl -i -X POST https:///api/webhooks/github \
| `401 invalid signature` | 서버가 로드한 secret이 생각하는 값이 **아닙니다**. | env 변수가 실행 중인 프로세스에 적용되었는지 확인하세요(예: `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`). 재배포하세요. |
| `503 github webhooks not configured` | 프로세스에서 `GITHUB_WEBHOOK_SECRET`이 비어 있습니다. | env 변수를 설정하고 API를 재시작하세요. |
+**App의 이벤트와 권한을 확인하세요.** 공개된 App 메타데이터 엔드포인트는 부여한 내용을 그대로 반환합니다 — 인증이 필요 없습니다:
+
+```bash
+curl -s https://api.github.com/apps/
+```
+
+- `events` 배열에는 `pull_request`, `check_suite`, `check_run`, `status`가 포함되어야 합니다.
+- `permissions` 객체에는 `checks: read`와 `statuses: read`가 `pull_requests: read`, `metadata: read`와 함께 포함되어야 합니다.
+
+빠진 것이 있으면 App의 **Subscribe to events** / **Permissions**를 고치세요 — 그리고 이미 설치된 App에 권한을 추가해도 각 설치의 owner가 대기 중인 요청을 승인해야 비로소 적용된다는 점을 기억하세요.
+
+**「PR 카드에 CI / 머지 상태가 표시되지 않음」.** 카드가 PR은 정상적으로 미러링하지만 체크나 머지 가능 여부를 전혀 표시하지 않는 경우:
+
+- 실행 중인 API 프로세스에 `GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`가 설정되어 있는지 확인하세요 — 스냅샷 가져오기의 인증은 이 둘이 담당합니다.
+- 각 설치가 Checks와 Commit statuses의 **대기 중인 권한 요청을 승인**했는지 확인하세요(위 프로브 참조).
+
## 제한 사항
현재 알아 둬야 할 몇 가지 거친 부분이 있습니다.
- **아직 수동 연결 UI가 없습니다** — PR을 연결하는 유일한 방법은 브랜치, 제목, 본문에 식별자를 두는 것입니다.
-- **CI / 체크 상태가 없습니다** — PR 자체만 미러링됩니다. 빌드 상태, 리뷰 댓글, 리뷰어는 Multica에 표시되지 않습니다.
+- **리뷰 스레드나 리뷰어 목록이 없습니다** — CI 상태와 머지 가능 여부는 이제 PR 카드에 표시됩니다(**PR 카드에 표시되는 것** 참조). 다만 리뷰 댓글, 개별 리뷰어, 그 승인 상태는 표시되지 않습니다.
- 머지 → 완료 규칙에 대한 **워크스페이스 수준 설정이 없습니다** — 고정된 기본값입니다(`cancelled`가 아닌 한 `merged → done`). 워크스페이스에서 커스터마이즈할 수 있는 매핑은 향후 추가될 예정입니다.
- **하나의 이슈에 여러 PR이 연결된 경우 머지가 보수적입니다** — 두 PR이 모두 `MUL-123`을 참조하고 첫 번째가 머지되면, 이슈는 즉시 `Done`으로 이동합니다. 진행하기 전에 연결된 모든 PR이 해결되기를 기다리는 후속 변경이 진행 중입니다.
diff --git a/apps/docs/content/docs/github-integration.mdx b/apps/docs/content/docs/github-integration.mdx
index 5d5b1095f..1da1048a7 100644
--- a/apps/docs/content/docs/github-integration.mdx
+++ b/apps/docs/content/docs/github-integration.mdx
@@ -17,8 +17,20 @@ There is no per-issue setup. The whole flow is identifier-driven.
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
+| **PR card → CI & mergeability** | For each linked PR, Multica fetches an authenticated GitHub API snapshot and mirrors its **CI status** and **mergeability** onto the card (see **What the PR card shows** below). |
-Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
+Alongside the PR's own state, the integration mirrors — for each linked PR — its **CI status** (all checks passed, N failed with the failing check names, or N running) and its **mergeability** (ready to merge, has conflicts, blocked, or behind), fetched from an authenticated GitHub API snapshot. Commits and branch refs without an open PR are still not modeled.
+
+## What the PR card shows
+
+Each linked PR's card carries two **independent** elements, each built from an authenticated GitHub API snapshot:
+
+- **CI status** — one of: **all checks passed** (with the count, e.g. `7/7`), **N failed** (naming the failing checks), or **N running** (still in progress). A PR with no checks configured yet shows neither — "no checks" is never reported as passed.
+- **Mergeability** — one of: **ready to merge** (shown only when GitHub reports the merge state is clean, i.e. no conflicts *and* branch protection satisfied), **has conflicts**, **blocked**, or **behind**.
+
+The two never mask each other — a PR can show failed checks *and* a merge conflict at the same time. Merged and closed PRs show neither; the row's own state icon already says so.
+
+The snapshot is fetched from the GitHub API and refreshed on webhook events (`pull_request`, `check_run`, `check_suite`, `status`), when you open the card, and periodically while the PR is still open and undecided. During a temporary GitHub outage the card keeps showing the last-known snapshot marked **stale** rather than going blank.
## Multiple workspaces
@@ -80,13 +92,13 @@ After disconnect, mirrored PR rows stay in the database so historical issue side
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
-- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
+- The GitHub App requests **read-only** access to pull requests, checks, commit statuses, and metadata. Multica never pushes commits, comments, or status checks back to GitHub — the CI status and mergeability shown on the PR card are fetched read-only, never written.
## Self-host setup
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
-For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
+For self-host, you create one GitHub App, point it at your server, and set its environment variables. The whole flow is below.
### 1. Create a GitHub App
@@ -107,10 +119,20 @@ Fill in:
| **Webhook URL** | `https:///api/webhooks/github`. |
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
| **Permissions → Repository → Pull requests** | **Read-only**. |
+| **Permissions → Repository → Checks** | **Read-only** — powers the PR card's CI status. |
+| **Permissions → Repository → Commit statuses** | **Read-only** — the legacy status-context half of the CI rollup. |
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
-| **Subscribe to events** | Tick **Pull request**. |
+| **Subscribe to events** | Tick **Pull request**, **Check suite**, **Check run**, and **Status**. |
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
+
+**Grant read, never write, and nothing more.** Do **not** grant **Checks: Read and write** — write access makes GitHub expect this App to report its own check runs, which leaves them stuck permanently pending. Read-only is correct. The feature also does **not** need **Actions: read** or **Contents: read** — don't add them.
+
+
+
+**Adding these permissions to an App that's already installed needs approval.** When you extend an existing App with Checks, Commit statuses, Check run, and Status, GitHub holds the change as a **pending permission request** — it does not take effect until each installation's owner approves it (on GitHub, under that installation's settings). Until an installation approves, the PR card stays without CI or merge status on its repos.
+
+
After **Create GitHub App**, note two things from the App's detail page:
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
@@ -128,18 +150,19 @@ On the API server:
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=
-# Optional but recommended — populates the connected account name on
-# install instead of waiting for the first webhook to refresh it:
+# Required for the PR card's CI status and mergeability. Without them the
+# integration still mirrors PRs and auto-links / auto-closes issues, but the
+# PR card shows no CI or merge status:
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
```
-`GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` are required. If either is missing:
+`GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` are required for the integration to start at all. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
-`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are **optional**. They let the setup callback call GitHub's App-authenticated `/app/installations/{id}` endpoint to fetch the real organization or user name during install. Without them, the connection card briefly shows `Connected to unknown` until GitHub delivers the `installation.created` webhook (typically within a few seconds), at which point Multica refreshes the row and broadcasts a realtime update so any open Settings tab updates without a manual refresh. Generate the private key under **Private keys → Generate a private key** on the App's settings page; paste the full PEM block (including the `-----BEGIN/END RSA PRIVATE KEY-----` lines) into the env var, preserving newlines.
+`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are now **required for the PR card's CI status and mergeability** — they let Multica authenticate as the App (App JWT → installation token) and pull the GitHub API snapshot each card is built from. Generate the key under **Private keys → Generate a private key** on the App's settings page; paste the full PEM block (including the `-----BEGIN/END RSA PRIVATE KEY-----` lines) into the env var, preserving newlines. Without them the integration **degrades cleanly**: PRs still mirror, issues still auto-link and move to Done on merge, and the connected account name still resolves from the `installation.created` webhook — but the PR card shows no CI or merge status.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `/settings?tab=github` after install.
@@ -187,12 +210,28 @@ curl -i -X POST https:///api/webhooks/github \
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
+**Confirm the App's events and permissions.** The public App-metadata endpoint reports exactly what you granted — no auth needed:
+
+```bash
+curl -s https://api.github.com/apps/
+```
+
+- The `events` array should include `pull_request`, `check_suite`, `check_run`, and `status`.
+- The `permissions` object should include `checks: read` and `statuses: read`, alongside `pull_requests: read` and `metadata: read`.
+
+If any are missing, fix the App's **Subscribe to events** / **Permissions** — and remember that adding permissions to an already-installed App only takes effect once each installation's owner approves the pending request.
+
+**"PR card shows no CI or merge status."** The card mirrors the PR fine but never shows checks or mergeability:
+
+- Confirm `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are set in the running API process — they are what authenticate the snapshot fetch.
+- Confirm each installation has **approved the pending permission request** for Checks and Commit statuses (see the probe above).
+
## Limitations
A few rough edges to be aware of today:
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
-- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
+- **No review threads or reviewer list** — CI status and mergeability are now surfaced on the PR card (see **What the PR card shows**), but review comments, individual reviewers, and their approval state are not.
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
diff --git a/apps/docs/content/docs/github-integration.zh.mdx b/apps/docs/content/docs/github-integration.zh.mdx
index 3d35ace5f..710b56e5f 100644
--- a/apps/docs/content/docs/github-integration.zh.mdx
+++ b/apps/docs/content/docs/github-integration.zh.mdx
@@ -17,8 +17,20 @@ import { Callout } from "fumadocs-ui/components/callout";
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR,含标题、仓库、状态(`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook(后台)** | 每次 `pull_request` 事件触发:upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
+| **PR 卡片 → CI 与可合并性** | 对每个关联的 PR,Multica 从 GitHub API 拉取一份经鉴权的快照,把它的 **CI 状态**和**可合并性**镜像到卡片上(见下方 **PR 卡片展示什么**)。 |
-只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
+除了 PR 自身的状态,集成还会为每个关联的 PR 镜像它的 **CI 状态**(全部通过、x 个失败并点名、或 x 个进行中)和**可合并性**(可合并、有冲突、blocked、behind),数据来自一份经鉴权的 GitHub API 快照。Commit、没开 PR 的分支仍然不入库。
+
+## PR 卡片展示什么
+
+每个关联 PR 的卡片上有两个**互相独立**的元素,各自基于一份经鉴权的 GitHub API 快照:
+
+- **CI 状态**——三态之一:**全部通过**(带计数,例如 `7/7`)、**x 个失败**(点出失败的 check 名)、或 **x 个进行中**(还在跑)。还没配任何 check 的 PR 两者都不显示——「没有 check」绝不会被当成通过。
+- **可合并性**——四态之一:**可合并**(仅当 GitHub 报告合并状态为 clean,即无冲突**且**满足分支保护时才显示)、**有冲突**、**blocked**、或 **behind**。
+
+两个元素互不遮挡——一个 PR 可以同时显示 check 失败**和**合并冲突。已合并 / 已关闭的 PR 两者都不显示;行首的状态图标已经表达了。
+
+快照从 GitHub API 拉取,并在 webhook 事件(`pull_request`、`check_run`、`check_suite`、`status`)到达时、打开卡片时、以及 PR 仍处于 open 且未决时周期性地刷新。GitHub 临时故障期间,卡片会继续显示上一次已知的快照并标记为 **stale**,而不是清空。
## 多个工作区
@@ -80,13 +92,13 @@ PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`,issue
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
-- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
+- GitHub App 申请的是 PR、Checks、Commit statuses 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check——卡片上的 CI 状态与可合并性都是只读拉取,绝不写回
## Self-Host 配置
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
-Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
+Self-Host 需要:建一个 GitHub App、指向你的 server、设好环境变量。完整流程如下。
### 1. 创建一个 GitHub App
@@ -107,10 +119,20 @@ Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境
| **Webhook URL** | `https:///api/webhooks/github` |
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
| **Permissions → Repository → Pull requests** | **Read-only** |
+| **Permissions → Repository → Checks** | **Read-only**——驱动 PR 卡片的 CI 状态 |
+| **Permissions → Repository → Commit statuses** | **Read-only**——CI 汇总里 legacy status context 的那一半 |
| **Permissions → Repository → Metadata** | Read-only(必填)|
-| **Subscribe to events** | 勾选 **Pull request** |
+| **Subscribe to events** | 勾选 **Pull request**、**Check suite**、**Check run**、**Status** |
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
+
+**只给 read,别给 write,也别多给。** **不要**授予 **Checks: Read and write**——写权限会让 GitHub 期待这个 App 自己上报 check run,结果 check 永久卡在 pending。只读才对。这个功能也**不需要** **Actions: read** 或 **Contents: read**——别加。
+
+
+
+**给一个已安装的 App 新增这些权限需要 accept。** 当你给现有 App 补上 Checks、Commit statuses、Check run、Status 时,GitHub 会把变更挂起为一条**待批准的权限请求**——直到每个 installation 的 owner 点 accept(在 GitHub 上该 installation 的设置里)它才生效。在某个 installation 批准之前,它名下仓库的 PR 卡片不会显示 CI / merge 状态。
+
+
点 **Create GitHub App** 之后,从详情页记下两件事:
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
@@ -128,17 +150,18 @@ API server 上:
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
-# 可选但建议配置——安装完成时直接拿到关联账号名,而不是等第一次 webhook 才刷新:
+# CI 状态与可合并性必填。不配这两个,集成仍会镜像 PR、自动关联 / 自动
+# 关闭 issue,但 PR 卡片不显示任何 CI 或 merge 状态:
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=<完整 PEM 块,包含 BEGIN/END 行>
```
-`GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 必填。任何一个缺失:
+`GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 是集成启动的必填项。任何一个缺失:
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
-`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` **可选**。配上之后,setup 回调可以用 App JWT 鉴权调用 GitHub `/app/installations/{id}`,安装完成那一刻就拿到真实的组织名/用户名。不配的话,连接卡片会先显示 `已连接到 unknown`,等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会刷新 row 并通过 realtime 推送让 Settings 页面无需手动刷新即可更新。私钥从 App 设置页 **Private keys → Generate a private key** 生成,把整段 PEM(含 `-----BEGIN/END RSA PRIVATE KEY-----` 两行)粘到 env 里,保留换行。
+`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` 现在是 **PR 卡片 CI 状态与可合并性的必填项**——它们让 Multica 以 App 身份鉴权(App JWT → installation token)并拉取每张卡片所依赖的 GitHub API 快照。私钥从 App 设置页 **Private keys → Generate a private key** 生成,把整段 PEM(含 `-----BEGIN/END RSA PRIVATE KEY-----` 两行)粘到 env 里,保留换行。不配这两个时集成**干净降级**:PR 照常镜像、issue 照常自动关联并在 merge 时转 Done、关联账号名也仍由 `installation.created` webhook 解析——只是 PR 卡片不显示任何 CI 或 merge 状态。
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `/settings?tab=github`。
@@ -186,12 +209,28 @@ curl -i -X POST https:///api/webhooks/github \
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env,重启 API |
+**确认 App 的事件与权限。** 公开的 App 元数据接口会如实返回你授予的内容——不需要鉴权:
+
+```bash
+curl -s https://api.github.com/apps/
+```
+
+- `events` 数组里应包含 `pull_request`、`check_suite`、`check_run`、`status`。
+- `permissions` 对象里应包含 `checks: read` 和 `statuses: read`,以及 `pull_requests: read` 和 `metadata: read`。
+
+如果缺了,去修 App 的 **Subscribe to events** / **Permissions**——并记住:给一个已安装的 App 新增权限,要等每个 installation 的 owner 批准待处理的请求后才会生效。
+
+**「PR 卡片不显示 CI / merge 状态」。** 卡片能正常镜像 PR,但从不显示 check 或可合并性:
+
+- 确认运行中的 API 进程里配了 `GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY`——快照拉取靠它们鉴权。
+- 确认每个 installation 都**批准了 Checks 与 Commit statuses 的待处理权限请求**(见上面的探针)。
+
## 已知限制
目前还没做的几个边界:
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
-- **不读 CI / check 状态**——只镜像 PR 本身,构建状态、reviewer 评论、reviewer 列表都没接进 Multica
+- **不接 review 讨论和 reviewer 列表**——CI 状态与可合并性现在会展示在 PR 卡片上(见 **PR 卡片展示什么**),但 review 评论、具体 reviewer 及其审批状态没接进 Multica
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`(cancelled 除外)。可配置映射是后续迭代
- **同 issue 多 PR 时,merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
diff --git a/packages/core/github/pull-request-status.test.ts b/packages/core/github/pull-request-status.test.ts
index b557fd43f..1ba942be2 100644
--- a/packages/core/github/pull-request-status.test.ts
+++ b/packages/core/github/pull-request-status.test.ts
@@ -1,133 +1,110 @@
import { describe, expect, it } from "vitest";
import {
- derivePullRequestStatusKind,
- derivePullRequestProgressSegments,
+ deriveChecksStatus,
+ deriveMergeStatus,
shouldShowPullRequestStats,
- type PullRequestStatusInput,
} from "./pull-request-status";
-const base: PullRequestStatusInput = { state: "open" };
-
-describe("derivePullRequestStatusKind", () => {
- it("closed beats every other signal", () => {
+describe("deriveChecksStatus", () => {
+ it("maps a `failure` rollup to failed and carries counts + names", () => {
expect(
- derivePullRequestStatusKind({
- state: "closed",
- mergeable_state: "dirty",
- checks_failed: 99,
- checks_pending: 99,
- checks_passed: 99,
+ deriveChecksStatus({
+ checks_rollup: "failure",
+ checks_total: 7,
+ checks_failed: 2,
+ failed_check_names: ["backend", "e2e"],
}),
- ).toBe("closed");
+ ).toEqual({ kind: "failed", failed: 2, total: 7, names: ["backend", "e2e"] });
});
- it("merged beats every other signal except closed", () => {
- expect(
- derivePullRequestStatusKind({
- state: "merged",
- mergeable_state: "dirty",
- checks_failed: 5,
- }),
- ).toBe("merged");
+ it("maps an `error` rollup to failed", () => {
+ expect(deriveChecksStatus({ checks_rollup: "error", checks_total: 3 }).kind).toBe("failed");
});
- it("dirty conflicts wins over check signals", () => {
- expect(
- derivePullRequestStatusKind({
- ...base,
- mergeable_state: "dirty",
- checks_passed: 3,
- }),
- ).toBe("conflicts");
+ it("treats any failed count as failed even when the rollup is absent", () => {
+ // Failure is trusted from the count so a known failure surfaces even if the
+ // rollup verdict lags.
+ expect(deriveChecksStatus({ checks_failed: 1 }).kind).toBe("failed");
});
- it("any failed check beats pending and passed", () => {
+ it("failure beats pending and passed", () => {
expect(
- derivePullRequestStatusKind({
- ...base,
+ deriveChecksStatus({
+ checks_rollup: "failure",
checks_failed: 1,
- checks_pending: 3,
+ checks_running: 3,
checks_passed: 5,
- }),
- ).toBe("checks_failed");
+ }).kind,
+ ).toBe("failed");
});
- it("pending beats passed when no failure", () => {
+ it("maps `pending` / `expected` rollups to pending with running count", () => {
expect(
- derivePullRequestStatusKind({
- ...base,
- checks_pending: 1,
+ deriveChecksStatus({
+ checks_rollup: "pending",
+ checks_total: 7,
checks_passed: 5,
+ checks_running: 2,
}),
- ).toBe("checks_pending");
+ ).toEqual({ kind: "pending", passed: 5, total: 7, running: 2 });
+ expect(deriveChecksStatus({ checks_rollup: "expected" }).kind).toBe("pending");
});
- it("all-passed is checks_passed regardless of mergeable=clean", () => {
- expect(
- derivePullRequestStatusKind({
- ...base,
- mergeable_state: "clean",
- checks_passed: 5,
- }),
- ).toBe("checks_passed");
+ it("maps a `success` rollup to passed", () => {
+ expect(deriveChecksStatus({ checks_rollup: "success", checks_total: 7 })).toEqual({
+ kind: "passed",
+ total: 7,
+ });
});
- it("clean + no suites is ready-to-merge", () => {
- expect(
- derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
- ).toBe("ready");
- });
-
- it("opaque mergeable values render as unknown", () => {
- for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
- expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
- }
+ it("renders `none` when the rollup is absent — never passed", () => {
+ // Critical: an absent snapshot must never read as a green build, even if a
+ // legacy passed count leaks through.
+ expect(deriveChecksStatus({}).kind).toBe("none");
+ expect(deriveChecksStatus({ checks_rollup: null }).kind).toBe("none");
+ expect(deriveChecksStatus({ checks_passed: 5, checks_total: 5 }).kind).toBe("none");
});
});
-describe("derivePullRequestProgressSegments", () => {
- it("returns null for terminal PRs (merged / closed)", () => {
- expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
- expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
+describe("deriveMergeStatus", () => {
+ it("maps `conflicting` to conflicting", () => {
+ expect(deriveMergeStatus({ mergeable: "conflicting" }).kind).toBe("conflicting");
});
- it("returns null when no suite has been observed", () => {
- expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
+ it("folds a `dirty` merge state into conflicting", () => {
+ expect(deriveMergeStatus({ merge_state_status: "dirty" }).kind).toBe("conflicting");
+ });
+
+ it("asserts ready ONLY from a `clean` merge state", () => {
+ expect(deriveMergeStatus({ merge_state_status: "clean" }).kind).toBe("ready");
+ });
+
+ it("never infers ready from `mergeable === mergeable` alone", () => {
+ // "No conflict" is not "ready" — required checks / branch protection live in
+ // merge_state_status, so mergeable without a clean state renders nothing.
+ expect(deriveMergeStatus({ mergeable: "mergeable" }).kind).toBe("none");
+ expect(deriveMergeStatus({ mergeable: "mergeable", merge_state_status: null }).kind).toBe("none");
+ });
+
+ it("surfaces blocked / behind / unstable / has_hooks faithfully", () => {
+ expect(deriveMergeStatus({ merge_state_status: "blocked" }).kind).toBe("blocked");
+ expect(deriveMergeStatus({ merge_state_status: "behind" }).kind).toBe("behind");
+ expect(deriveMergeStatus({ merge_state_status: "unstable" }).kind).toBe("unstable");
+ expect(deriveMergeStatus({ merge_state_status: "has_hooks" }).kind).toBe("has_hooks");
+ });
+
+ it("renders nothing when GitHub has not decided", () => {
+ // unknown / null shows neither conflict nor ready.
+ expect(deriveMergeStatus({}).kind).toBe("none");
+ expect(deriveMergeStatus({ mergeable: "unknown" }).kind).toBe("none");
+ expect(deriveMergeStatus({ mergeable: null, merge_state_status: "unknown" }).kind).toBe("none");
+ expect(deriveMergeStatus({ merge_state_status: "draft" }).kind).toBe("none");
+ });
+
+ it("conflict wins over an otherwise decisive merge state", () => {
expect(
- derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
- ).toBeNull();
- });
-
- it("orders segments failed → pending → passed (failure leftmost)", () => {
- const segs = derivePullRequestProgressSegments({
- ...base,
- checks_failed: 1,
- checks_pending: 2,
- checks_passed: 3,
- });
- expect(segs).not.toBeNull();
- expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
- });
-
- it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
- const segs = derivePullRequestProgressSegments({
- ...base,
- checks_failed: 0,
- checks_pending: 0,
- checks_passed: 4,
- });
- expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
- });
-
- it("ratios sum to ~1 across segments", () => {
- const segs = derivePullRequestProgressSegments({
- ...base,
- checks_failed: 1,
- checks_pending: 1,
- checks_passed: 2,
- })!;
- const total = segs.reduce((acc, s) => acc + s.ratio, 0);
- expect(total).toBeCloseTo(1, 6);
+ deriveMergeStatus({ mergeable: "conflicting", merge_state_status: "blocked" }).kind,
+ ).toBe("conflicting");
});
});
diff --git a/packages/core/github/pull-request-status.ts b/packages/core/github/pull-request-status.ts
index 2b80b436c..019765d6f 100644
--- a/packages/core/github/pull-request-status.ts
+++ b/packages/core/github/pull-request-status.ts
@@ -1,87 +1,123 @@
-import type { GitHubPullRequest } from "../types";
+import type {
+ GitHubPullRequestChecksRollup,
+ GitHubPullRequestMergeable,
+ GitHubPullRequestMergeStateStatus,
+} from "../types";
-// Status kinds rendered in the PR sidebar row's detail line. Order in the
-// pass-through table matters — the first matching rule wins. The order is
-// chosen so terminal PR states (closed / merged) short-circuit before any
-// transient CI/conflict signal, since those signals are no longer actionable
-// on a terminal PR.
+// The PR sidebar row surfaces TWO independent facts, each tri-state and each
+// sourced from the GitHub API snapshot:
//
-// Priority (high → low):
-// 1. closed (not merged) → status_closed
-// 2. merged → status_merged
-// 3. mergeable_state = "dirty" → status_conflicts
-// 4. any failed suite → status_checks_failed
-// 5. any pending suite → status_checks_pending
-// 6. any passed suite → status_checks_passed
-// 7. no suite + mergeable=clean → status_ready
-// 8. otherwise → status_unknown
+// 1. CI status — derived from `checks_rollup` (primary) + counts.
+// 2. Mergeability — derived from `mergeable` + `merge_state_status`.
//
-// Note: this table is the single source of truth for the sidebar PR row. The
-// older row-with-badges implementation used a separate "hide status row for
-// terminal PRs" branch — the current row renders
-// with status_closed / status_merged text, never falling through to a
-// conflicts / checks line on a terminal PR. Keep this priority order in sync
-// with the i18n keys `pull_request_card_status_*` and with the progress-strip
-// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
-// solid bar; the rest map onto the per-suite counts).
-export type PullRequestStatusKind =
- | "closed"
- | "merged"
- | "conflicts"
- | "checks_failed"
- | "checks_pending"
- | "checks_passed"
- | "ready"
- | "unknown";
+// The two are intentionally decoupled: a PR can have failing checks AND a merge
+// conflict, and both must show. Neither element is derived from the other, and
+// neither is shown for terminal PRs (merged / closed) — the row's leading state
+// icon already conveys terminal state; the caller applies that gate.
+//
+// Every input field is optional because older backends omit the snapshot
+// fields; each rule defaults defensively (`?? 0`, `?? []`, explicit `=== "..."`
+// checks) so an absent field never fabricates a positive verdict.
-export interface PullRequestStatusInput {
- state: GitHubPullRequest["state"];
- mergeable_state?: string | null;
- checks_failed?: number;
- checks_pending?: number;
+// ---------------------------------------------------------------------------
+// CI status
+// ---------------------------------------------------------------------------
+
+// Discriminated union for the CI element. `none` is a first-class state ("no
+// checks yet") that must never be rendered as passed/green.
+export type PullRequestChecksStatus =
+ | { kind: "failed"; failed: number; total: number; names: string[] }
+ | { kind: "pending"; passed: number; total: number; running: number }
+ | { kind: "passed"; total: number }
+ | { kind: "none" };
+
+export interface PullRequestChecksInput {
+ checks_rollup?: GitHubPullRequestChecksRollup | null;
+ checks_total?: number;
checks_passed?: number;
+ checks_failed?: number;
+ checks_running?: number;
+ failed_check_names?: string[];
}
-export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
- if (input.state === "closed") return "closed";
- if (input.state === "merged") return "merged";
- if (input.mergeable_state === "dirty") return "conflicts";
- if ((input.checks_failed ?? 0) > 0) return "checks_failed";
- if ((input.checks_pending ?? 0) > 0) return "checks_pending";
- if ((input.checks_passed ?? 0) > 0) return "checks_passed";
- if (input.mergeable_state === "clean") return "ready";
- return "unknown";
-}
-
-export interface PullRequestProgressSegment {
- kind: "failed" | "pending" | "passed";
- ratio: number;
-}
-
-// Segmented progress bar input. Returns null when:
-// - the PR is terminal (closed/merged) — the card paints a solid bar
-// in a state-specific color, no segmentation needed;
-// - no check_suite has been observed (total === 0) — the card hides
-// the bar entirely.
-// Otherwise emits the segments left-to-right: failed → pending → passed.
-// "Failure first" is intentional: problems should be visible before signal
-// that everything is fine.
-export function derivePullRequestProgressSegments(
- input: PullRequestStatusInput,
-): PullRequestProgressSegment[] | null {
- if (input.state === "closed" || input.state === "merged") return null;
- const failed = input.checks_failed ?? 0;
- const pending = input.checks_pending ?? 0;
+// Priority (high → low):
+// 1. rollup failure/error OR any failed count → failed
+// 2. rollup pending/expected → pending
+// 3. rollup success → passed
+// 4. otherwise (rollup null/absent) → none ("no checks yet")
+//
+// Failure trusts the count as well as the rollup so a known failure is surfaced
+// even if the rollup verdict lags; "passed" requires an explicit `success`
+// rollup so an absent snapshot is never mistaken for a green build.
+export function deriveChecksStatus(input: PullRequestChecksInput): PullRequestChecksStatus {
+ const rollup = input.checks_rollup ?? null;
+ const total = input.checks_total ?? 0;
const passed = input.checks_passed ?? 0;
- const total = failed + pending + passed;
- if (total === 0) return null;
- const segments: PullRequestProgressSegment[] = [];
- if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
- if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
- if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
- return segments;
+ const failed = input.checks_failed ?? 0;
+ const running = input.checks_running ?? 0;
+ const names = input.failed_check_names ?? [];
+
+ if (rollup === "failure" || rollup === "error" || failed > 0) {
+ return { kind: "failed", failed, total, names };
+ }
+ if (rollup === "pending" || rollup === "expected") {
+ return { kind: "pending", passed, total, running };
+ }
+ if (rollup === "success") {
+ return { kind: "passed", total };
+ }
+ return { kind: "none" };
}
+// ---------------------------------------------------------------------------
+// Mergeability
+// ---------------------------------------------------------------------------
+
+// Discriminated union for the mergeability element. `none` renders nothing:
+// when GitHub has not decided (mergeable unknown/null and no decisive
+// merge_state_status) the card asserts neither "conflict" nor "ready".
+export type PullRequestMergeStatus =
+ | { kind: "conflicting" }
+ | { kind: "ready" }
+ | { kind: "blocked" }
+ | { kind: "behind" }
+ | { kind: "unstable" }
+ | { kind: "has_hooks" }
+ | { kind: "none" };
+
+export interface PullRequestMergeInput {
+ mergeable?: GitHubPullRequestMergeable | null;
+ merge_state_status?: GitHubPullRequestMergeStateStatus | null;
+}
+
+// Priority (high → low):
+// 1. mergeable conflicting OR merge_state dirty → conflicting
+// 2. merge_state clean → ready
+// 3. merge_state blocked/behind/unstable/hooks → that faithful label
+// 4. otherwise → none (render nothing)
+//
+// `mergeable` answers only "is there a conflict"; `merge_state_status === dirty`
+// is GitHub's other view of the same fact (an unmergeable conflict), so both
+// map to `conflicting`. "Ready" is asserted ONLY from `clean` — never inferred
+// from `mergeable === "mergeable"`, which does not account for required checks
+// or branch protection.
+export function deriveMergeStatus(input: PullRequestMergeInput): PullRequestMergeStatus {
+ const mergeable = input.mergeable ?? null;
+ const mergeState = input.merge_state_status ?? null;
+
+ if (mergeable === "conflicting" || mergeState === "dirty") return { kind: "conflicting" };
+ if (mergeState === "clean") return { kind: "ready" };
+ if (mergeState === "blocked") return { kind: "blocked" };
+ if (mergeState === "behind") return { kind: "behind" };
+ if (mergeState === "unstable") return { kind: "unstable" };
+ if (mergeState === "has_hooks") return { kind: "has_hooks" };
+ return { kind: "none" };
+}
+
+// ---------------------------------------------------------------------------
+// Diff stats
+// ---------------------------------------------------------------------------
+
export interface PullRequestStatsInput {
additions?: number;
deletions?: number;
diff --git a/packages/core/types/github.ts b/packages/core/types/github.ts
index b77351f69..7e3ee1a53 100644
--- a/packages/core/types/github.ts
+++ b/packages/core/types/github.ts
@@ -2,15 +2,44 @@ export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
/** Aggregated CI status for a PR's current head SHA, computed server-side from
* the latest check_suite per app. `null` when no completed suite has been seen
- * yet (e.g. PR just opened, or repository has no CI configured). */
+ * yet (e.g. PR just opened, or repository has no CI configured).
+ *
+ * Legacy compat field kept for backend drift; the current PR card derives CI
+ * status from `checks_rollup` + counts instead. */
export type GitHubPullRequestChecksConclusion = "passed" | "failed" | "pending";
-/** Raw mirror of GitHub's `mergeable_state`. The UI only surfaces `clean` and
- * `dirty`; the other values (`blocked`, `behind`, `unstable`, `unknown`,
- * `has_hooks`, `draft`) round-trip but render as unknown to avoid asserting
- * "conflicts" for blocking reasons that aren't actual conflicts. */
+/** Raw mirror of GitHub's legacy `mergeable_state`. Superseded by the
+ * `mergeable` + `merge_state_status` snapshot pair; kept optional for backend
+ * drift only. */
export type GitHubMergeableState = string;
+/** GitHub's `mergeable` verdict — answers ONLY "is there a conflict". `unknown`
+ * is a normal transient value (GitHub computes it lazily); it must render as
+ * neither "conflict" nor "ready". */
+export type GitHubPullRequestMergeable = "mergeable" | "conflicting" | "unknown";
+
+/** GitHub's `mergeStateStatus`. "Ready to merge" is asserted ONLY from `clean`
+ * (which folds in required checks + branch protection); the other values are
+ * surfaced faithfully and never inferred into a ready/mergeable claim. */
+export type GitHubPullRequestMergeStateStatus =
+ | "clean"
+ | "dirty"
+ | "blocked"
+ | "behind"
+ | "unstable"
+ | "draft"
+ | "has_hooks"
+ | "unknown";
+
+/** GitHub's overall CI rollup verdict (`statusCheckRollup.state`). `null`/absent
+ * means NO checks have been reported yet — it must never render as passed. */
+export type GitHubPullRequestChecksRollup =
+ | "success"
+ | "failure"
+ | "pending"
+ | "error"
+ | "expected";
+
export interface GitHubInstallation {
id: string;
workspace_id: string;
@@ -44,14 +73,35 @@ export interface GitHubPullRequest {
closed_at: string | null;
pr_created_at: string;
pr_updated_at: string;
- /** Optional; older backends omit this field. */
- mergeable_state?: GitHubMergeableState | null;
- /** Optional; older backends omit this field. */
- checks_conclusion?: GitHubPullRequestChecksConclusion | null;
- /** Per-suite counts that feed the segmented progress bar. Older backends
- * omit these; treat absence as 0 (the card renders only when sum > 0). */
+ /** Conflict verdict from the GitHub API snapshot. Answers ONLY
+ * "is there a conflict"; older backends omit it. */
+ mergeable?: GitHubPullRequestMergeable | null;
+ /** GitHub's `mergeStateStatus` from the snapshot. Source of the "Ready to
+ * merge" claim (only when `clean`); older backends omit it. */
+ merge_state_status?: GitHubPullRequestMergeStateStatus | null;
+ /** GitHub's overall CI rollup verdict from the snapshot. `null`/absent means
+ * NO checks yet — never treat absence as passed. */
+ checks_rollup?: GitHubPullRequestChecksRollup | null;
+ /** Check counts from the snapshot. Older backends omit these; treat absence
+ * as 0. `checks_total` is 0 when no checks have been reported. */
+ checks_total?: number;
checks_passed?: number;
checks_failed?: number;
+ checks_running?: number;
+ /** Names of the currently failing checks, for the "…failed · a, b" summary.
+ * Older backends omit it; treat absence as an empty list. */
+ failed_check_names?: string[];
+ /** True when the shown snapshot is stale (GitHub outage / revoked key). The
+ * card greys out both status elements and shows the snapshot age. */
+ snapshot_stale?: boolean;
+ /** RFC3339 timestamp of when the snapshot was fetched, for the stale hint. */
+ snapshot_fetched_at?: string | null;
+ /** Legacy mirror of GitHub's `mergeable_state`. Optional; superseded by
+ * `mergeable` + `merge_state_status`. */
+ mergeable_state?: GitHubMergeableState | null;
+ /** Legacy aggregated CI conclusion. Optional; superseded by `checks_rollup`. */
+ checks_conclusion?: GitHubPullRequestChecksConclusion | null;
+ /** Legacy pending-suite count. Optional; superseded by `checks_running`. */
checks_pending?: number;
/** Diff stats from GitHub's `pull_request` payload. Older backends omit
* these fields; we treat 0/0/0 as "unknown" and hide the stats row. */
diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts
index 9cb492f10..0b5e122d2 100644
--- a/packages/core/types/index.ts
+++ b/packages/core/types/index.ts
@@ -130,6 +130,9 @@ export type {
GitHubMergeableState,
GitHubPullRequest,
GitHubPullRequestChecksConclusion,
+ GitHubPullRequestChecksRollup,
+ GitHubPullRequestMergeable,
+ GitHubPullRequestMergeStateStatus,
GitHubPullRequestState,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
diff --git a/packages/views/issues/components/pull-request-list.test.tsx b/packages/views/issues/components/pull-request-list.test.tsx
index f1719d3fe..0ae4c1190 100644
--- a/packages/views/issues/components/pull-request-list.test.tsx
+++ b/packages/views/issues/components/pull-request-list.test.tsx
@@ -43,11 +43,16 @@ function makePR(overrides: Partial = {}): GitHubPullRequest {
closed_at: null,
pr_created_at: "2026-01-01T00:00:00Z",
pr_updated_at: "2026-01-01T00:00:00Z",
- mergeable_state: null,
- checks_conclusion: null,
+ mergeable: null,
+ merge_state_status: null,
+ checks_rollup: null,
+ checks_total: 0,
checks_passed: 0,
checks_failed: 0,
- checks_pending: 0,
+ checks_running: 0,
+ failed_check_names: [],
+ snapshot_stale: false,
+ snapshot_fetched_at: null,
additions: 0,
deletions: 0,
changed_files: 0,
@@ -80,77 +85,176 @@ describe("PullRequestList sidebar rows", () => {
expect(row).not.toHaveClass("rounded-lg", "border", "bg-card");
});
- it("renders All-checks-passed status when only passed counts are non-zero", async () => {
- mockPRs = [makePR({ checks_passed: 3 })];
+ // --- CI status element ---------------------------------------------------
+
+ it("renders all-checks-passed only when the rollup is success", async () => {
+ mockPRs = [makePR({ checks_rollup: "success", checks_total: 7 })];
renderList();
await waitForRender();
- expect(screen.getByText("All checks passed")).toBeInTheDocument();
+ expect(screen.getByText("All checks passed (7/7)")).toBeInTheDocument();
});
- it("renders Some-checks-failed when any failed count is non-zero", async () => {
- mockPRs = [makePR({ checks_failed: 1, checks_passed: 5 })];
+ it("renders 'No checks yet' when the rollup is absent — never passed", async () => {
+ // Acceptance criterion 5: absent snapshot must not read as a green build.
+ mockPRs = [makePR({ checks_rollup: null, checks_passed: 5, checks_total: 5 })];
renderList();
await waitForRender();
- expect(screen.getByText("Some checks failed")).toBeInTheDocument();
+ expect(screen.getByText("No checks yet")).toBeInTheDocument();
+ expect(screen.queryByText(/All checks passed/)).not.toBeInTheDocument();
});
- it("renders pending status when only pending suites remain", async () => {
- mockPRs = [makePR({ checks_pending: 2, checks_passed: 1 })];
+ it("renders failed count with the first failing check names", async () => {
+ mockPRs = [
+ makePR({
+ checks_rollup: "failure",
+ checks_total: 7,
+ checks_failed: 2,
+ failed_check_names: ["backend", "e2e"],
+ }),
+ ];
renderList();
await waitForRender();
- expect(screen.getByText("Some checks haven't completed yet")).toBeInTheDocument();
+ const badge = screen.getByText(/2\/7 failed/);
+ expect(badge).toHaveTextContent("2/7 failed");
+ expect(badge).toHaveTextContent("backend, e2e");
});
- it("renders conflicts status when mergeable_state=dirty", async () => {
- mockPRs = [makePR({ mergeable_state: "dirty" })];
+ it("truncates the failing names to two and appends a +N more count", async () => {
+ mockPRs = [
+ makePR({
+ checks_rollup: "failure",
+ checks_total: 7,
+ checks_failed: 4,
+ failed_check_names: ["a", "b", "c", "d"],
+ }),
+ ];
renderList();
await waitForRender();
- expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
+ const badge = screen.getByText(/4\/7 failed/);
+ expect(badge).toHaveTextContent("4/7 failed");
+ expect(badge).toHaveTextContent("a, b, +2 more");
});
- it("renders Ready-to-merge when mergeable=clean and no suites observed", async () => {
- mockPRs = [makePR({ mergeable_state: "clean" })];
+ it("renders the running count when the rollup is pending", async () => {
+ mockPRs = [
+ makePR({ checks_rollup: "pending", checks_total: 7, checks_passed: 5, checks_running: 2 }),
+ ];
+ renderList();
+ await waitForRender();
+ const badge = screen.getByText(/2 running/);
+ expect(badge).toHaveTextContent("5/7");
+ expect(badge).toHaveTextContent("2 running");
+ });
+
+ // --- Mergeability element ------------------------------------------------
+
+ it("renders 'Ready to merge' only when the merge state is clean", async () => {
+ mockPRs = [makePR({ merge_state_status: "clean" })];
renderList();
await waitForRender();
expect(screen.getByText("Ready to merge")).toBeInTheDocument();
});
- it("renders Merged status for merged PRs, suppressing conflict/check text", async () => {
+ it("never infers 'Ready to merge' from mergeable alone", async () => {
+ // Acceptance criterion 8: mergeable without a clean state shows neither.
+ mockPRs = [makePR({ mergeable: "mergeable", merge_state_status: null })];
+ renderList();
+ await waitForRender();
+ expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
+ expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
+ });
+
+ it("renders 'Has merge conflicts' when mergeable is conflicting", async () => {
+ mockPRs = [makePR({ mergeable: "conflicting" })];
+ renderList();
+ await waitForRender();
+ expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
+ });
+
+ it("shows neither conflict nor ready when the merge verdict is unknown", async () => {
+ // Acceptance criterion 5: unknown mergeability shows neither element.
+ mockPRs = [makePR({ mergeable: "unknown", merge_state_status: "unknown" })];
+ renderList();
+ await waitForRender();
+ expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
+ expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
+ });
+
+ // --- The two elements are independent ------------------------------------
+
+ it("shows a failed CI element and a conflict element together", async () => {
+ mockPRs = [
+ makePR({
+ checks_rollup: "failure",
+ checks_total: 7,
+ checks_failed: 2,
+ failed_check_names: ["backend"],
+ mergeable: "conflicting",
+ }),
+ ];
+ renderList();
+ await waitForRender();
+ expect(screen.getByText(/2\/7 failed/)).toHaveTextContent("2/7 failed");
+ expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
+ });
+
+ // --- Terminal PRs suppress both elements ---------------------------------
+
+ it("shows neither status element for merged PRs", async () => {
mockPRs = [
makePR({
state: "merged",
- mergeable_state: "dirty",
- checks_conclusion: "failed",
+ checks_rollup: "failure",
checks_failed: 5,
+ checks_total: 5,
+ mergeable: "conflicting",
}),
];
renderList();
await waitForRender();
- expect(screen.getByText("Merged")).toBeInTheDocument();
+ expect(screen.queryByText(/failed/)).not.toBeInTheDocument();
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
- expect(screen.queryByText("Some checks failed")).not.toBeInTheDocument();
- expect(screen.queryByText("Conflicts")).not.toBeInTheDocument();
- expect(screen.queryByText("Checks failed")).not.toBeInTheDocument();
+ expect(screen.queryByText("No checks yet")).not.toBeInTheDocument();
});
- it("renders Closed-without-merging status for closed PRs, suppressing conflict/check badges", async () => {
+ it("shows neither status element for closed PRs", async () => {
mockPRs = [
makePR({
state: "closed",
- mergeable_state: "clean",
- checks_conclusion: "passed",
+ checks_rollup: "success",
checks_passed: 3,
+ checks_total: 3,
+ merge_state_status: "clean",
}),
];
renderList();
await waitForRender();
- expect(screen.getByText("Closed without merging")).toBeInTheDocument();
+ expect(screen.queryByText(/All checks passed/)).not.toBeInTheDocument();
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
- expect(screen.queryByText("All checks passed")).not.toBeInTheDocument();
- expect(screen.queryByText("No conflicts")).not.toBeInTheDocument();
- expect(screen.queryByText("Checks passed")).not.toBeInTheDocument();
+ expect(screen.queryByText("No checks yet")).not.toBeInTheDocument();
});
+ // --- Stale snapshot ------------------------------------------------------
+
+ it("greys out the status elements and annotates the age when the snapshot is stale", async () => {
+ mockPRs = [
+ makePR({
+ checks_rollup: "success",
+ checks_total: 3,
+ snapshot_stale: true,
+ snapshot_fetched_at: "2026-01-01T00:00:00Z",
+ }),
+ ];
+ renderList();
+ await waitForRender();
+ const badge = screen.getByText("All checks passed (3/3)");
+ expect(badge).toHaveClass("opacity-60");
+ expect(badge).toHaveAttribute("title");
+ expect(badge.getAttribute("title")).toBeTruthy();
+ });
+
+ // --- Diff stats ----------------------------------------------------------
+
it("hides stats row when all stats are 0 (legacy backend)", async () => {
mockPRs = [makePR()];
renderList();
@@ -175,6 +279,8 @@ describe("PullRequestList sidebar rows", () => {
expect(screen.getByText("1 file")).toBeInTheDocument();
});
+ // --- Collapse behaviour --------------------------------------------------
+
it("collapses extra PR rows past the visible limit behind Show more toggle", async () => {
mockPRs = [
makePR({ id: "a", number: 1, title: "PR-A" }),
diff --git a/packages/views/issues/components/pull-request-list.tsx b/packages/views/issues/components/pull-request-list.tsx
index e54be4fae..8f5a4a5d1 100644
--- a/packages/views/issues/components/pull-request-list.tsx
+++ b/packages/views/issues/components/pull-request-list.tsx
@@ -4,7 +4,9 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
+ Circle,
CircleDashed,
+ CircleSlash,
GitMerge,
GitPullRequest,
GitPullRequestArrow,
@@ -15,19 +17,15 @@ import {
} from "lucide-react";
import {
issuePullRequestsOptions,
- derivePullRequestStatusKind,
- derivePullRequestProgressSegments,
+ deriveChecksStatus,
+ deriveMergeStatus,
shouldShowPullRequestStats,
- type PullRequestStatusKind,
- type PullRequestProgressSegment,
+ type PullRequestChecksStatus,
+ type PullRequestMergeStatus,
} from "@multica/core/github";
-import type {
- GitHubPullRequest,
- GitHubPullRequestChecksConclusion,
- GitHubPullRequestState,
-} from "@multica/core/types";
+import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
-import { useT } from "../../i18n";
+import { useT, useTimeAgo } from "../../i18n";
type IssuesT = ReturnType>["t"];
@@ -45,15 +43,6 @@ const STATE_ICON: Record<
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
};
-const CHECKS_ICON: Record<
- GitHubPullRequestChecksConclusion,
- { icon: React.ComponentType<{ className?: string }>; className: string }
-> = {
- passed: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
- failed: { icon: XCircle, className: "text-rose-600 dark:text-rose-400" },
- pending: { icon: CircleDashed, className: "text-amber-600 dark:text-amber-400" },
-};
-
export function PullRequestList({ issueId }: { issueId: string }) {
const { t } = useT("issues");
const [expanded, setExpanded] = useState(false);
@@ -108,26 +97,7 @@ function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
const StateIcon = cfg.icon;
- const kind = derivePullRequestStatusKind({
- state: pr.state,
- mergeable_state: pr.mergeable_state,
- checks_failed: pr.checks_failed,
- checks_pending: pr.checks_pending,
- checks_passed: pr.checks_passed,
- });
- const segments = derivePullRequestProgressSegments({
- state: pr.state,
- checks_failed: pr.checks_failed,
- checks_pending: pr.checks_pending,
- checks_passed: pr.checks_passed,
- });
- const showStats = shouldShowPullRequestStats({
- additions: pr.additions,
- deletions: pr.deletions,
- changed_files: pr.changed_files,
- });
- const statusText = useStatusText(kind);
- const draftPrefix = pr.state === "draft";
+ const isDraft = pr.state === "draft";
const stateLabel = getStateLabel(pr.state, t);
return (
@@ -138,7 +108,7 @@ function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
rel="noreferrer noopener"
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
- draftPrefix ? "opacity-80" : null,
+ isDraft ? "opacity-80" : null,
)}
>
@@ -150,55 +120,45 @@ function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
{pr.author_login ? ` · @${pr.author_login}` : null}
- $.detail.pull_request_card_draft_prefix, { status: statusText })
- : statusText
- }
- statusKind={kind}
- />
+
);
}
-function PullRequestRowDetails({
- pr,
- segments,
- showStats,
- statusText,
- statusKind,
-}: {
- pr: GitHubPullRequest;
- segments: PullRequestProgressSegment[] | null;
- showStats: boolean;
- statusText: string;
- statusKind: PullRequestStatusKind;
-}) {
+function PullRequestRowDetails({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
- const checksBadge = getChecksBadge(pr, t);
- const conflictsBadge = getConflictsBadge(pr, t);
- const isTerminal = statusKind === "closed" || statusKind === "merged";
- const showChecksBadge =
- !isTerminal &&
- !!checksBadge &&
- statusKind !== "checks_failed" &&
- statusKind !== "checks_pending" &&
- statusKind !== "checks_passed";
- const showConflictsBadge =
- !isTerminal && !!conflictsBadge && statusKind !== "conflicts" && statusKind !== "ready";
+ const timeAgo = useTimeAgo();
+
+ const showStats = shouldShowPullRequestStats({
+ additions: pr.additions,
+ deletions: pr.deletions,
+ changed_files: pr.changed_files,
+ });
+
+ // Neither status element is shown for terminal PRs — the leading state icon
+ // already conveys merged / closed, and CI / mergeability are no longer
+ // actionable there.
+ const isTerminal = pr.state === "merged" || pr.state === "closed";
+ const checksBadge = isTerminal ? null : getChecksBadge(deriveChecksStatus(pr), t);
+ const mergeBadge = isTerminal ? null : getMergeBadge(deriveMergeStatus(pr), t);
+
+ // A stale snapshot (GitHub outage / revoked key) greys out both elements and
+ // annotates them with the snapshot age instead of hiding the last-known data.
+ const stale = !isTerminal && pr.snapshot_stale === true;
+ const staleTitle = stale
+ ? pr.snapshot_fetched_at
+ ? t(($) => $.detail.pull_request_snapshot_stale, { time: timeAgo(pr.snapshot_fetched_at) })
+ : t(($) => $.detail.pull_request_snapshot_stale_unknown)
+ : undefined;
+
+ if (!showStats && !checksBadge && !mergeBadge) return null;
return (
{showStats ?
: null}
-
-
{statusText}
- {showChecksBadge ?
: null}
- {showConflictsBadge ?
: null}
+ {checksBadge ?
: null}
+ {mergeBadge ?
: null}
);
}
@@ -219,89 +179,137 @@ function PullRequestStats({ pr }: { pr: GitHubPullRequest }) {
);
}
-function PullRequestProgressStrip({
- segments,
-}: {
- segments: PullRequestProgressSegment[] | null;
-}) {
- if (!segments) return null;
- return (
-
- {segments.map((seg) => (
-
- ))}
-
- );
-}
-
interface PullRequestBadgeConfig {
icon: React.ComponentType<{ className?: string }>;
label: string;
className: string;
}
-function PullRequestBadge({ badge }: { badge: PullRequestBadgeConfig }) {
+function PullRequestBadge({
+ badge,
+ stale,
+ title,
+}: {
+ badge: PullRequestBadgeConfig;
+ stale?: boolean;
+ title?: string;
+}) {
const Icon = badge.icon;
return (
-
+
{badge.label}
);
}
-function getConflictsBadge(
- pr: GitHubPullRequest,
- t: IssuesT,
-): PullRequestBadgeConfig | null {
- const mergeable = pr.mergeable_state ?? null;
- return mergeable === "dirty"
- ? {
- icon: TriangleAlert,
- label: t(($) => $.detail.pull_request_conflicts_dirty),
+// CI element. Always returns a config for non-terminal PRs — including the
+// "no checks yet" state, which renders muted and MUST NOT read as passed/green.
+function getChecksBadge(status: PullRequestChecksStatus, t: IssuesT): PullRequestBadgeConfig {
+ switch (status.kind) {
+ case "failed":
+ return {
+ icon: XCircle,
className: "text-rose-600 dark:text-rose-400",
- }
- : mergeable === "clean"
- ? {
- icon: CheckCircle2,
- label: t(($) => $.detail.pull_request_conflicts_clean),
- className: "text-emerald-600 dark:text-emerald-400",
- }
- : null;
+ label: checksFailedLabel(status, t),
+ };
+ case "pending":
+ return {
+ icon: CircleDashed,
+ className: "text-amber-600 dark:text-amber-400",
+ label: t(($) => $.detail.pull_request_checks_running, {
+ passed: status.passed,
+ total: status.total,
+ running: status.running,
+ }),
+ };
+ case "passed":
+ return {
+ icon: CheckCircle2,
+ className: "text-emerald-600 dark:text-emerald-400",
+ label: t(($) => $.detail.pull_request_checks_all_passed, { total: status.total }),
+ };
+ case "none":
+ return {
+ icon: Circle,
+ className: "text-muted-foreground",
+ label: t(($) => $.detail.pull_request_checks_none),
+ };
+ }
}
-function getChecksBadge(
- pr: GitHubPullRequest,
- t: IssuesT,
-): PullRequestBadgeConfig | null {
- const checks = pr.checks_conclusion ?? null;
- return checks && CHECKS_ICON[checks]
- ? {
- icon: CHECKS_ICON[checks].icon,
- className: CHECKS_ICON[checks].className,
- label:
- checks === "passed"
- ? t(($) => $.detail.pull_request_checks_passed)
- : checks === "failed"
- ? t(($) => $.detail.pull_request_checks_failed)
- : t(($) => $.detail.pull_request_checks_pending),
- }
- : null;
-}
-
-function getStateLabel(
- state: GitHubPullRequestState,
+function checksFailedLabel(
+ status: Extract,
t: IssuesT,
): string {
+ const shown = status.names.slice(0, 2);
+ if (shown.length === 0) {
+ return t(($) => $.detail.pull_request_checks_failed_count, {
+ failed: status.failed,
+ total: status.total,
+ });
+ }
+ const remaining = status.names.length - shown.length;
+ const parts = [...shown];
+ if (remaining > 0) {
+ parts.push(t(($) => $.detail.pull_request_checks_more, { count: remaining }));
+ }
+ return t(($) => $.detail.pull_request_checks_failed_named, {
+ failed: status.failed,
+ total: status.total,
+ names: parts.join(", "),
+ });
+}
+
+// Mergeability element. Returns null for the "none" state — when GitHub has not
+// decided, the card asserts neither "conflict" nor "ready".
+function getMergeBadge(status: PullRequestMergeStatus, t: IssuesT): PullRequestBadgeConfig | null {
+ switch (status.kind) {
+ case "conflicting":
+ return {
+ icon: TriangleAlert,
+ className: "text-amber-600 dark:text-amber-400",
+ label: t(($) => $.detail.pull_request_merge_conflicting),
+ };
+ case "ready":
+ return {
+ icon: CheckCircle2,
+ className: "text-emerald-600 dark:text-emerald-400",
+ label: t(($) => $.detail.pull_request_merge_ready),
+ };
+ case "blocked":
+ return {
+ icon: CircleSlash,
+ className: "text-muted-foreground",
+ label: t(($) => $.detail.pull_request_merge_blocked),
+ };
+ case "behind":
+ return {
+ icon: CircleSlash,
+ className: "text-muted-foreground",
+ label: t(($) => $.detail.pull_request_merge_behind),
+ };
+ case "unstable":
+ return {
+ icon: CircleSlash,
+ className: "text-muted-foreground",
+ label: t(($) => $.detail.pull_request_merge_unstable),
+ };
+ case "has_hooks":
+ return {
+ icon: CircleSlash,
+ className: "text-muted-foreground",
+ label: t(($) => $.detail.pull_request_merge_has_hooks),
+ };
+ case "none":
+ return null;
+ }
+}
+
+function getStateLabel(state: GitHubPullRequestState, t: IssuesT): string {
return state === "open"
? t(($) => $.detail.pull_request_state_open)
: state === "draft"
@@ -312,25 +320,3 @@ function getStateLabel(
? t(($) => $.detail.pull_request_state_closed)
: state;
}
-
-function useStatusText(kind: PullRequestStatusKind): string {
- const { t } = useT("issues");
- switch (kind) {
- case "closed":
- return t(($) => $.detail.pull_request_card_status_closed);
- case "merged":
- return t(($) => $.detail.pull_request_card_status_merged);
- case "conflicts":
- return t(($) => $.detail.pull_request_card_status_conflicts);
- case "checks_failed":
- return t(($) => $.detail.pull_request_card_status_checks_failed);
- case "checks_pending":
- return t(($) => $.detail.pull_request_card_status_checks_pending);
- case "checks_passed":
- return t(($) => $.detail.pull_request_card_status_checks_passed);
- case "ready":
- return t(($) => $.detail.pull_request_card_status_ready);
- case "unknown":
- return t(($) => $.detail.pull_request_card_status_unknown);
- }
-}
diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json
index a134dac31..d893bcd79 100644
--- a/packages/views/locales/en/issues.json
+++ b/packages/views/locales/en/issues.json
@@ -252,20 +252,20 @@
"pull_request_state_draft": "Draft",
"pull_request_state_merged": "Merged",
"pull_request_state_closed": "Closed",
- "pull_request_checks_passed": "Checks passed",
- "pull_request_checks_failed": "Checks failed",
- "pull_request_checks_pending": "Checks pending",
- "pull_request_conflicts_clean": "No conflicts",
- "pull_request_conflicts_dirty": "Conflicts",
- "pull_request_card_status_closed": "Closed without merging",
- "pull_request_card_status_merged": "Merged",
- "pull_request_card_status_conflicts": "Has merge conflicts",
- "pull_request_card_status_checks_failed": "Some checks failed",
- "pull_request_card_status_checks_pending": "Some checks haven't completed yet",
- "pull_request_card_status_checks_passed": "All checks passed",
- "pull_request_card_status_ready": "Ready to merge",
- "pull_request_card_status_unknown": "Checks haven't reported yet",
- "pull_request_card_draft_prefix": "Draft · {{status}}",
+ "pull_request_checks_none": "No checks yet",
+ "pull_request_checks_all_passed": "All checks passed ({{total}}/{{total}})",
+ "pull_request_checks_running": "{{passed}}/{{total}} · {{running}} running",
+ "pull_request_checks_failed_count": "{{failed}}/{{total}} failed",
+ "pull_request_checks_failed_named": "{{failed}}/{{total}} failed · {{names}}",
+ "pull_request_checks_more": "+{{count}} more",
+ "pull_request_merge_conflicting": "Has merge conflicts",
+ "pull_request_merge_ready": "Ready to merge",
+ "pull_request_merge_blocked": "Blocked",
+ "pull_request_merge_behind": "Behind base",
+ "pull_request_merge_unstable": "Unstable",
+ "pull_request_merge_has_hooks": "Has hooks",
+ "pull_request_snapshot_stale": "Updated {{time}}",
+ "pull_request_snapshot_stale_unknown": "Snapshot may be outdated",
"pull_request_card_files_count_one": "{{count}} file",
"pull_request_card_files_count_other": "{{count}} files",
"pull_request_card_show_more": "Show {{count}} more",
diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json
index ee6db3517..a1271039b 100644
--- a/packages/views/locales/ja/issues.json
+++ b/packages/views/locales/ja/issues.json
@@ -250,20 +250,20 @@
"pull_request_state_draft": "ドラフト",
"pull_request_state_merged": "マージ済み",
"pull_request_state_closed": "クローズ",
- "pull_request_checks_passed": "チェック成功",
- "pull_request_checks_failed": "チェック失敗",
- "pull_request_checks_pending": "チェック待機中",
- "pull_request_conflicts_clean": "コンフリクトなし",
- "pull_request_conflicts_dirty": "コンフリクトあり",
- "pull_request_card_status_closed": "マージせずにクローズ",
- "pull_request_card_status_merged": "マージ済み",
- "pull_request_card_status_conflicts": "マージコンフリクトあり",
- "pull_request_card_status_checks_failed": "一部のチェックが失敗しました",
- "pull_request_card_status_checks_pending": "一部のチェックがまだ完了していません",
- "pull_request_card_status_checks_passed": "すべてのチェックに成功しました",
- "pull_request_card_status_ready": "マージ可能",
- "pull_request_card_status_unknown": "まだチェック結果がありません",
- "pull_request_card_draft_prefix": "ドラフト · {{status}}",
+ "pull_request_checks_none": "チェックはまだありません",
+ "pull_request_checks_all_passed": "すべてのチェックに成功({{total}}/{{total}})",
+ "pull_request_checks_running": "{{passed}}/{{total}} · {{running}} 件実行中",
+ "pull_request_checks_failed_count": "{{failed}}/{{total}} 失敗",
+ "pull_request_checks_failed_named": "{{failed}}/{{total}} 失敗 · {{names}}",
+ "pull_request_checks_more": "他 +{{count}} 件",
+ "pull_request_merge_conflicting": "マージコンフリクトあり",
+ "pull_request_merge_ready": "マージ可能",
+ "pull_request_merge_blocked": "ブロック中",
+ "pull_request_merge_behind": "ベースブランチより遅れています",
+ "pull_request_merge_unstable": "不安定",
+ "pull_request_merge_has_hooks": "フックあり",
+ "pull_request_snapshot_stale": "{{time}}に更新",
+ "pull_request_snapshot_stale_unknown": "スナップショットが古い可能性があります",
"pull_request_card_files_count_other": "{{count}} ファイル",
"pull_request_card_show_more": "他 {{count}} 件を表示",
"pull_request_card_show_less": "折りたたむ",
diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json
index 0f02cf480..3f81e75da 100644
--- a/packages/views/locales/ko/issues.json
+++ b/packages/views/locales/ko/issues.json
@@ -250,20 +250,20 @@
"pull_request_state_draft": "초안",
"pull_request_state_merged": "병합됨",
"pull_request_state_closed": "닫힘",
- "pull_request_checks_passed": "검사 통과",
- "pull_request_checks_failed": "검사 실패",
- "pull_request_checks_pending": "검사 대기 중",
- "pull_request_conflicts_clean": "충돌 없음",
- "pull_request_conflicts_dirty": "충돌 있음",
- "pull_request_card_status_closed": "병합 없이 닫힘",
- "pull_request_card_status_merged": "병합됨",
- "pull_request_card_status_conflicts": "병합 충돌 있음",
- "pull_request_card_status_checks_failed": "일부 검사 실패",
- "pull_request_card_status_checks_pending": "일부 검사가 아직 끝나지 않았습니다",
- "pull_request_card_status_checks_passed": "모든 검사 통과",
- "pull_request_card_status_ready": "병합 가능",
- "pull_request_card_status_unknown": "아직 검사 결과가 없습니다",
- "pull_request_card_draft_prefix": "초안 · {{status}}",
+ "pull_request_checks_none": "아직 검사 없음",
+ "pull_request_checks_all_passed": "모든 검사 통과 ({{total}}/{{total}})",
+ "pull_request_checks_running": "{{passed}}/{{total}} · {{running}}개 실행 중",
+ "pull_request_checks_failed_count": "{{failed}}/{{total}} 실패",
+ "pull_request_checks_failed_named": "{{failed}}/{{total}} 실패 · {{names}}",
+ "pull_request_checks_more": "+{{count}}개",
+ "pull_request_merge_conflicting": "병합 충돌 있음",
+ "pull_request_merge_ready": "병합 가능",
+ "pull_request_merge_blocked": "차단됨",
+ "pull_request_merge_behind": "기준 브랜치보다 뒤처짐",
+ "pull_request_merge_unstable": "불안정",
+ "pull_request_merge_has_hooks": "훅 있음",
+ "pull_request_snapshot_stale": "{{time}} 업데이트됨",
+ "pull_request_snapshot_stale_unknown": "스냅샷이 오래되었을 수 있습니다",
"pull_request_card_files_count_other": "파일 {{count}}개",
"pull_request_card_show_more": "{{count}}개 더 보기",
"pull_request_card_show_less": "간단히 보기",
diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json
index 24f5b8470..641ab2814 100644
--- a/packages/views/locales/zh-Hans/issues.json
+++ b/packages/views/locales/zh-Hans/issues.json
@@ -250,20 +250,20 @@
"pull_request_state_draft": "Draft",
"pull_request_state_merged": "Merged",
"pull_request_state_closed": "Closed",
- "pull_request_checks_passed": "Checks 通过",
- "pull_request_checks_failed": "Checks 失败",
- "pull_request_checks_pending": "Checks 运行中",
- "pull_request_conflicts_clean": "无冲突",
- "pull_request_conflicts_dirty": "存在冲突",
- "pull_request_card_status_closed": "已关闭,未合入",
- "pull_request_card_status_merged": "已合入",
- "pull_request_card_status_conflicts": "存在合并冲突",
- "pull_request_card_status_checks_failed": "部分检查失败",
- "pull_request_card_status_checks_pending": "部分检查仍在运行",
- "pull_request_card_status_checks_passed": "全部检查通过",
- "pull_request_card_status_ready": "可以合入",
- "pull_request_card_status_unknown": "暂无检查信息",
- "pull_request_card_draft_prefix": "Draft · {{status}}",
+ "pull_request_checks_none": "暂无检查",
+ "pull_request_checks_all_passed": "全部检查通过({{total}}/{{total}})",
+ "pull_request_checks_running": "{{passed}}/{{total}} · {{running}} 个进行中",
+ "pull_request_checks_failed_count": "{{failed}}/{{total}} 失败",
+ "pull_request_checks_failed_named": "{{failed}}/{{total}} 失败 · {{names}}",
+ "pull_request_checks_more": "+{{count}} 个",
+ "pull_request_merge_conflicting": "存在合并冲突",
+ "pull_request_merge_ready": "可以合入",
+ "pull_request_merge_blocked": "被阻止",
+ "pull_request_merge_behind": "落后于基础分支",
+ "pull_request_merge_unstable": "不稳定",
+ "pull_request_merge_has_hooks": "存在钩子",
+ "pull_request_snapshot_stale": "更新于 {{time}}",
+ "pull_request_snapshot_stale_unknown": "快照可能已过期",
"pull_request_card_files_count_other": "{{count}} 个文件",
"pull_request_card_show_more": "展开剩余 {{count}} 个",
"pull_request_card_show_less": "收起",
diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go
index 2ca5e4cb9..19c49c8aa 100644
--- a/server/cmd/server/main.go
+++ b/server/cmd/server/main.go
@@ -428,6 +428,9 @@ func main() {
if h.WebhookDeliveryWorker != nil {
go h.WebhookDeliveryWorker.Run(sweepCtx)
}
+ // GitHub PR-card API snapshot pipeline (MUL-5265): worker pool + TTL sweeper.
+ // No-op when unconfigured (no App private key).
+ h.PRRefresh.Start(sweepCtx)
// Channel inbound supervisor (MUL-3620): holds the §4.4 WS lease per
// installation and drives each channel.Channel. It is built
diff --git a/server/internal/handler/github.go b/server/internal/handler/github.go
index e67d50ea2..a26274756 100644
--- a/server/internal/handler/github.go
+++ b/server/internal/handler/github.go
@@ -75,20 +75,47 @@ type GitHubPullRequestResponse struct {
ClosedAt *string `json:"closed_at"`
PRCreatedAt string `json:"pr_created_at"`
PRUpdatedAt string `json:"pr_updated_at"`
- // Mergeable state mirrors GitHub's `mergeable_state` field. We only
- // surface `clean`/`dirty` in the UI today; other values (`blocked`,
- // `behind`, `unstable`, `unknown`) round-trip but render as unknown.
+ // Mergeable state mirrors GitHub's REST `mergeable_state` field, retained
+ // for compatibility. The card now reads the richer GraphQL fields below.
MergeableState *string `json:"mergeable_state"`
- // ChecksConclusion is the aggregated state of the latest CI check
- // suites for the PR's current head SHA. One of "passed", "failed",
- // "pending", or nil when no completed suite has been observed.
+ // ── GitHub API snapshot (MUL-5265, Plan C) ──────────────────────────────
+ // These come from an authenticated GraphQL query, the single source of
+ // truth. All are null / empty / 0 when no snapshot has landed (or the
+ // GitHub App private key is unconfigured), so the card hides the CI / merge
+ // region and degrades cleanly.
+ //
+ // Mergeable answers ONLY "is there a conflict": "mergeable" | "conflicting"
+ // | "unknown" | null. A false "conflicting" must never be reported as
+ // "not mergeable" — that verdict is MergeStateStatus's job.
+ Mergeable *string `json:"mergeable"`
+ // MergeStateStatus is GitHub's merge-state verdict, lowercased: "clean" |
+ // "dirty" | "blocked" | "behind" | "unstable" | "draft" | "has_hooks" |
+ // "unknown" | null. "Ready to merge" is derived ONLY from "clean".
+ MergeStateStatus *string `json:"merge_state_status"`
+ // ChecksRollup is GitHub's overall CI verdict, lowercased: "success" |
+ // "failure" | "pending" | "error" | "expected" | null. null means
+ // statusCheckRollup was null (no checks yet) and must NEVER render as
+ // passed.
+ ChecksRollup *string `json:"checks_rollup"`
+ // ChecksConclusion is a coarse compat alias derived from the snapshot:
+ // "passed" | "failed" | "pending" | null.
ChecksConclusion *string `json:"checks_conclusion"`
- // Per-suite counts that drive the card's segmented progress bar.
- // Always present on list rows; bare upsert broadcasts default to 0
- // and the frontend hides the bar when total == 0.
+ // Run-level counts for the PR's snapshot head. ChecksPending mirrors
+ // ChecksRunning for older clients that still read the old key.
+ ChecksTotal int64 `json:"checks_total"`
ChecksPassed int64 `json:"checks_passed"`
ChecksFailed int64 `json:"checks_failed"`
+ ChecksRunning int64 `json:"checks_running"`
ChecksPending int64 `json:"checks_pending"`
+ // FailedCheckNames names the failing checks so the card can point at them
+ // (e.g. "✗ 2/7 · backend, e2e").
+ FailedCheckNames []string `json:"failed_check_names"`
+ // SnapshotStale is true when an open PR's last successful fetch is older
+ // than the stale threshold (GitHub outage / revoked key): the card shows
+ // last-known data greyed out rather than blank.
+ SnapshotStale bool `json:"snapshot_stale"`
+ // SnapshotFetchedAt is when the snapshot was last fetched (RFC3339), or null.
+ SnapshotFetchedAt *string `json:"snapshot_fetched_at"`
// Diff stats (lines added/removed and file count) sourced from the
// `pull_request` webhook payload. Legacy rows that pre-date this
// field default to 0; the frontend treats total == 0 as "unknown"
@@ -148,51 +175,76 @@ func githubPullRequestToResponse(p db.GithubPullRequest) GitHubPullRequestRespon
PRCreatedAt: timestampToString(p.PrCreatedAt),
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
MergeableState: textToPtr(p.MergeableState),
- // A bare PR row has no aggregated check counts — webhook
- // broadcasts of a single PR fall through here and the frontend
- // re-queries the list for fresh counts.
+ // A bare PR row has no aggregated check counts — webhook broadcasts of a
+ // single PR fall through here and the frontend re-queries the list for
+ // the full snapshot (mergeable / rollup / counts).
ChecksConclusion: nil,
+ FailedCheckNames: []string{},
Additions: p.Additions,
Deletions: p.Deletions,
ChangedFiles: p.ChangedFiles,
}
}
+// prSnapshotStaleThreshold is how old an open PR's last successful fetch may be
+// before the card greys it out as stale. Healthy pipelines refresh open PRs at
+// least every sweep interval (~10m), so crossing 30m means refreshes are not
+// landing (GitHub outage, revoked key) and the shown data is last-known.
+const prSnapshotStaleThreshold = 30 * time.Minute
+
func issuePullRequestRowToResponse(p db.ListPullRequestsByIssueRow) GitHubPullRequestResponse {
+ stale := false
+ if p.SnapshotFetchedAt.Valid && (p.State == "open" || p.State == "draft") {
+ stale = time.Since(p.SnapshotFetchedAt.Time) > prSnapshotStaleThreshold
+ }
+ failedNames := p.FailedCheckNames
+ if failedNames == nil {
+ failedNames = []string{}
+ }
return GitHubPullRequestResponse{
- ID: uuidToString(p.ID),
- Provider: "github",
- WorkspaceID: uuidToString(p.WorkspaceID),
- RepoOwner: p.RepoOwner,
- RepoName: p.RepoName,
- Number: p.PrNumber,
- Title: p.Title,
- State: p.State,
- HtmlURL: p.HtmlUrl,
- Branch: textToPtr(p.Branch),
- AuthorLogin: textToPtr(p.AuthorLogin),
- AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
- MergedAt: timestampToPtr(p.MergedAt),
- ClosedAt: timestampToPtr(p.ClosedAt),
- PRCreatedAt: timestampToString(p.PrCreatedAt),
- PRUpdatedAt: timestampToString(p.PrUpdatedAt),
- MergeableState: textToPtr(p.MergeableState),
- ChecksConclusion: aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal),
- ChecksPassed: p.ChecksPassed,
- ChecksFailed: p.ChecksFailed,
- ChecksPending: p.ChecksPending,
- Additions: p.Additions,
- Deletions: p.Deletions,
- ChangedFiles: p.ChangedFiles,
+ ID: uuidToString(p.ID),
+ Provider: "github",
+ WorkspaceID: uuidToString(p.WorkspaceID),
+ RepoOwner: p.RepoOwner,
+ RepoName: p.RepoName,
+ Number: p.PrNumber,
+ Title: p.Title,
+ State: p.State,
+ HtmlURL: p.HtmlUrl,
+ Branch: textToPtr(p.Branch),
+ AuthorLogin: textToPtr(p.AuthorLogin),
+ AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
+ MergedAt: timestampToPtr(p.MergedAt),
+ ClosedAt: timestampToPtr(p.ClosedAt),
+ PRCreatedAt: timestampToString(p.PrCreatedAt),
+ PRUpdatedAt: timestampToString(p.PrUpdatedAt),
+ MergeableState: textToPtr(p.MergeableState),
+ Mergeable: lowerTextPtr(p.ApiMergeable),
+ MergeStateStatus: lowerTextPtr(p.ApiMergeStateStatus),
+ ChecksRollup: lowerTextPtr(p.ChecksRollupState),
+ ChecksConclusion: rollupToConclusion(p.ChecksRollupState, p.ChecksFailed, p.ChecksRunning, p.ChecksPassed),
+ ChecksTotal: p.ChecksTotal,
+ ChecksPassed: p.ChecksPassed,
+ ChecksFailed: p.ChecksFailed,
+ ChecksRunning: p.ChecksRunning,
+ ChecksPending: p.ChecksRunning,
+ FailedCheckNames: failedNames,
+ SnapshotStale: stale,
+ SnapshotFetchedAt: timestampToPtr(p.SnapshotFetchedAt),
+ Additions: p.Additions,
+ Deletions: p.Deletions,
+ ChangedFiles: p.ChangedFiles,
}
}
-// aggregateChecksConclusion collapses the per-PR check_suite counts into a
-// single status surfaced to the UI:
-// - any failed-class suite wins ("failed");
-// - any not-yet-completed suite makes the PR "pending";
-// - all completed and in the passed-class is "passed";
-// - no observed suite at all is nil (rendered as "no checks" / hidden).
+// aggregateChecksConclusion collapses per-PR commit-status counts into a
+// single coarse status. Still used by the self-hosted VCS provider path
+// (Forgejo / Gitea / GitLab), which mirrors commit statuses via webhook rather
+// than fetching a GitHub-style API snapshot:
+// - any failed status wins ("failed");
+// - any not-yet-completed status makes the PR "pending";
+// - all completed and passed is "passed";
+// - no observed status at all is nil (rendered as "no checks" / hidden).
func aggregateChecksConclusion(failed, passed, pending, total int64) *string {
if total == 0 {
return nil
@@ -211,6 +263,47 @@ func aggregateChecksConclusion(failed, passed, pending, total int64) *string {
return &v
}
+// lowerTextPtr returns a lowercased *string for a non-empty pgtype.Text, else
+// nil. Used to expose GraphQL enums (MERGEABLE / CLEAN / SUCCESS …) to the API
+// in the project's lowercase convention.
+func lowerTextPtr(t pgtype.Text) *string {
+ if !t.Valid || t.String == "" {
+ return nil
+ }
+ v := strings.ToLower(t.String)
+ return &v
+}
+
+// rollupToConclusion derives the coarse compat "checks_conclusion" from the
+// GraphQL rollup, falling back to the run counts when the rollup enum is
+// unfamiliar. A null/empty rollup means "no checks yet" → nil (never "passed").
+func rollupToConclusion(rollup pgtype.Text, failed, running, passed int64) *string {
+ if !rollup.Valid || rollup.String == "" {
+ return nil
+ }
+ var v string
+ switch strings.ToUpper(rollup.String) {
+ case "FAILURE", "ERROR":
+ v = "failed"
+ case "PENDING", "EXPECTED":
+ v = "pending"
+ case "SUCCESS":
+ v = "passed"
+ default:
+ switch {
+ case failed > 0:
+ v = "failed"
+ case running > 0:
+ v = "pending"
+ case passed > 0:
+ v = "passed"
+ default:
+ return nil
+ }
+ }
+ return &v
+}
+
// ── Connect / state token ───────────────────────────────────────────────────
// githubAppSlug returns the GitHub App slug used to build the install URL.
@@ -577,6 +670,14 @@ func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Reques
out := make([]GitHubPullRequestResponse, 0, len(rows))
for _, row := range rows {
out = append(out, issuePullRequestRowToResponse(row))
+ // Page-visit trigger (MUL-5265): if this card's snapshot is missing or
+ // older than the view TTL, kick an async refresh. Non-blocking — the
+ // current (possibly stale) response is returned immediately and the
+ // fresh snapshot arrives via the pull_request:updated realtime event.
+ h.PRRefresh.MaybeEnqueueOnView(
+ row.InstallationID, row.RepoOwner, row.RepoName, row.PrNumber,
+ row.SnapshotFetchedAt.Time, row.SnapshotFetchedAt.Valid,
+ )
}
// PRs from token-based providers (Forgejo / Gitea / GitLab) share the same
// card list. They live in their own provider-tagged tables, so they merge
@@ -596,6 +697,29 @@ func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, map[string]any{"pull_requests": out})
}
+// broadcastPRSnapshotApplied is the ghsnapshot pipeline's onApplied callback:
+// once an API snapshot is written to a PR row, re-broadcast the PR so every
+// open issue detail page re-queries its PR list and picks up the fresh CI /
+// mergeability state. Runs on a background pipeline goroutine.
+func (h *Handler) broadcastPRSnapshotApplied(ctx context.Context, prID pgtype.UUID) {
+ pr, err := h.Queries.GetGitHubPullRequestByID(ctx, prID)
+ if err != nil {
+ return
+ }
+ issueIDs, err := h.Queries.ListIssueIDsForPullRequest(ctx, prID)
+ if err != nil {
+ return
+ }
+ linked := make([]string, 0, len(issueIDs))
+ for _, id := range issueIDs {
+ linked = append(linked, uuidToString(id))
+ }
+ h.publish(protocol.EventPullRequestUpdated, uuidToString(pr.WorkspaceID), "system", "", map[string]any{
+ "pull_request": githubPullRequestToResponse(pr),
+ "linked_issue_ids": linked,
+ })
+}
+
// ── Webhook ─────────────────────────────────────────────────────────────────
// identifierRe extracts identifiers like "MUL-1510" from text. Case-insensitive
@@ -650,8 +774,11 @@ func (h *Handler) HandleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
h.handleInstallationEvent(ctx, body)
case "pull_request":
h.handlePullRequestEvent(ctx, body)
- case "check_suite":
- h.handleCheckSuiteEvent(ctx, body)
+ case "check_suite", "check_run", "status":
+ // CI events are pure triggers under Plan C (MUL-5265): their payload is
+ // never read for display. Each just asks the API pipeline to re-fetch
+ // the authoritative snapshot for the PR(s) it concerns.
+ h.triggerPRRefreshFromCIEvent(ctx, body)
default:
// Acknowledge every event so GitHub doesn't mark the endpoint failing,
// but ignore types we don't model.
@@ -850,6 +977,102 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
for _, inst := range insts {
h.mirrorPullRequestForWorkspace(ctx, inst.WorkspaceID, inst.InstallationID, &p)
}
+ // The PR row(s) now carry the new head; ask the API pipeline for the
+ // authoritative CI + mergeability snapshot for that head. The webhook is
+ // only the doorbell — its own mergeable/checks payload is not used for
+ // display anymore (MUL-5265).
+ h.PRRefresh.Enqueue(p.Installation.ID, p.Repository.Owner.Login, p.Repository.Name, p.PullRequest.Number)
+}
+
+// ghCIEventPayload captures the shared shape of the check_suite / check_run /
+// status webhooks — enough to resolve which PR to refresh. These events are
+// pure triggers under Plan C: their payload data is never read for display.
+type ghCIEventPayload struct {
+ Installation struct {
+ ID int64 `json:"id"`
+ } `json:"installation"`
+ Repository struct {
+ Name string `json:"name"`
+ Owner struct {
+ Login string `json:"login"`
+ } `json:"owner"`
+ } `json:"repository"`
+ // status events: top-level commit SHA, no PR number.
+ SHA string `json:"sha"`
+ CheckSuite struct {
+ HeadSHA string `json:"head_sha"`
+ PullRequests []struct {
+ Number int32 `json:"number"`
+ } `json:"pull_requests"`
+ } `json:"check_suite"`
+ CheckRun struct {
+ PullRequests []struct {
+ Number int32 `json:"number"`
+ } `json:"pull_requests"`
+ CheckSuite struct {
+ HeadSHA string `json:"head_sha"`
+ } `json:"check_suite"`
+ } `json:"check_run"`
+}
+
+// triggerPRRefreshFromCIEvent enqueues an API refresh for the PR(s) a
+// check_suite / check_run / status webhook concerns. check_suite/check_run
+// carry the PR numbers directly; status events carry only a commit SHA, so we
+// map it back to the mirrored head_sha to find the PR(s).
+func (h *Handler) triggerPRRefreshFromCIEvent(ctx context.Context, body []byte) {
+ if !h.PRRefresh.Enabled() {
+ return
+ }
+ var p ghCIEventPayload
+ if err := json.Unmarshal(body, &p); err != nil {
+ return
+ }
+ if p.Installation.ID == 0 || p.Repository.Name == "" {
+ return
+ }
+ owner, repo := p.Repository.Owner.Login, p.Repository.Name
+
+ seen := map[int32]struct{}{}
+ enqueue := func(number int32) {
+ if number == 0 {
+ return
+ }
+ if _, ok := seen[number]; ok {
+ return
+ }
+ seen[number] = struct{}{}
+ h.PRRefresh.Enqueue(p.Installation.ID, owner, repo, number)
+ }
+ for _, pr := range p.CheckSuite.PullRequests {
+ enqueue(pr.Number)
+ }
+ for _, pr := range p.CheckRun.PullRequests {
+ enqueue(pr.Number)
+ }
+ if len(seen) > 0 {
+ return
+ }
+ // No PR number in the payload (status event, or a check event whose
+ // pull_requests array was empty) — resolve by head SHA.
+ sha := p.SHA
+ if sha == "" {
+ sha = coalesce(p.CheckSuite.HeadSHA, p.CheckRun.CheckSuite.HeadSHA)
+ }
+ if sha == "" {
+ return
+ }
+ numbers, err := h.Queries.ListGitHubPRNumbersByHeadSHA(ctx, db.ListGitHubPRNumbersByHeadSHAParams{
+ InstallationID: p.Installation.ID,
+ RepoOwner: owner,
+ RepoName: repo,
+ HeadSha: sha,
+ })
+ if err != nil {
+ return
+ }
+ for _, number := range numbers {
+ enqueue(number)
+ }
}
// mirrorPullRequestForWorkspace mirrors a pull_request webhook into a single
@@ -889,13 +1112,6 @@ func (h *Handler) mirrorPullRequestForWorkspace(ctx context.Context, wsID pgtype
return
}
- // Drain any check_suite events that arrived before this PR row was
- // mirrored (out-of-order webhook delivery). Each drained row is
- // replayed through the same upsert path used by live check_suite
- // events; the DrainPending… query removes them atomically so a
- // concurrent PR upsert can't double-apply.
- h.replayPendingCheckSuitesForPR(ctx, pr, wsID)
-
workspaceID := uuidToString(wsID)
resp := githubPullRequestToResponse(pr)
@@ -1021,198 +1237,6 @@ func (h *Handler) mirrorPullRequestForWorkspace(ctx context.Context, wsID pgtype
})
}
-// ── check_suite webhook ────────────────────────────────────────────────────
-
-type ghCheckSuitePayload struct {
- Action string `json:"action"`
- CheckSuite struct {
- ID int64 `json:"id"`
- HeadSHA string `json:"head_sha"`
- Status string `json:"status"`
- Conclusion string `json:"conclusion"`
- UpdatedAt string `json:"updated_at"`
- App struct {
- ID int64 `json:"id"`
- } `json:"app"`
- PullRequests []struct {
- Number int32 `json:"number"`
- } `json:"pull_requests"`
- } `json:"check_suite"`
- Repository struct {
- Name string `json:"name"`
- Owner struct {
- Login string `json:"login"`
- } `json:"owner"`
- } `json:"repository"`
- Installation struct {
- ID int64 `json:"id"`
- } `json:"installation"`
-}
-
-// handleCheckSuiteEvent records the CI suite state for each PR the suite
-// references. We persist all non-terminal actions (`requested`, `rerequested`)
-// as well as `completed`: a `requested`/`rerequested` event has status
-// `queued`/`in_progress` and an empty conclusion, which the aggregation query
-// counts as pending. Without persisting them, the per-PR `checks_pending`
-// count stays at 0 while CI is mid-run and the PR card falls through to
-// "checks not reported yet" until the first suite finishes.
-//
-// The suite payload may reference multiple PRs (e.g. the same head SHA is
-// open against several base branches), so we iterate. A reference whose PR
-// hasn't been mirrored locally is stashed in `github_pending_check_suite`
-// and replayed when the matching `pull_request` event upserts the PR row.
-func (h *Handler) handleCheckSuiteEvent(ctx context.Context, body []byte) {
- var p ghCheckSuitePayload
- if err := json.Unmarshal(body, &p); err != nil {
- slog.Warn("github: bad check_suite payload", "err", err)
- return
- }
- if p.Installation.ID == 0 {
- return
- }
- insts, err := h.Queries.ListGitHubInstallationsByInstallationID(ctx, p.Installation.ID)
- if err != nil {
- slog.Warn("github: lookup installation failed", "err", err)
- return
- }
- if len(insts) == 0 {
- return
- }
- if len(p.CheckSuite.PullRequests) == 0 {
- // Forks emit suites whose `pull_requests` array is empty for
- // the upstream repo. We have no way to attribute the result
- // without polling, so drop with a hint.
- slog.Info("github: check_suite has no associated PRs", "suite_id", p.CheckSuite.ID)
- return
- }
- updatedAt := parseGHTimeRequired(p.CheckSuite.UpdatedAt)
-
- // Fan out to every workspace bound to this installation: each records the
- // suite against its own mirror of the PR (see handlePullRequestEvent /
- // MUL-4343).
- for _, inst := range insts {
- h.recordCheckSuiteForWorkspace(ctx, inst.WorkspaceID, &p, updatedAt)
- }
-}
-
-// recordCheckSuiteForWorkspace records a check_suite webhook against one
-// workspace's mirror of each referenced PR. A reference whose PR hasn't been
-// mirrored in this workspace yet is stashed and replayed when the matching
-// pull_request event upserts the row. Invoked once per workspace bound to the
-// delivering installation.
-func (h *Handler) recordCheckSuiteForWorkspace(ctx context.Context, wsID pgtype.UUID, p *ghCheckSuitePayload, updatedAt pgtype.Timestamptz) {
- affectedIssues := map[string]struct{}{}
- recorded := false
- for _, prRef := range p.CheckSuite.PullRequests {
- // Scope the lookup to the repo's workspace. The (workspace_id,
- // repo_owner, repo_name, pr_number) tuple is the real uniqueness key:
- // a bare (owner, repo, number) lookup could return a row from a
- // different workspace that also tracks this repo and land the suite
- // on the wrong PR.
- pr, err := h.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
- WorkspaceID: wsID,
- RepoOwner: p.Repository.Owner.Login,
- RepoName: p.Repository.Name,
- PrNumber: prRef.Number,
- })
- if err != nil {
- if !errors.Is(err, pgx.ErrNoRows) {
- slog.Warn("github: lookup pr for check_suite failed", "err", err)
- continue
- }
- // Out-of-order delivery: the suite reached us before the
- // `pull_request` webhook that mirrors the PR row. Stash the
- // event keyed by (workspace, repo, pr_number, suite_id); the
- // PR upsert path will drain and replay it.
- if err := h.Queries.UpsertPendingCheckSuite(ctx, db.UpsertPendingCheckSuiteParams{
- WorkspaceID: wsID,
- InstallationID: p.Installation.ID,
- RepoOwner: p.Repository.Owner.Login,
- RepoName: p.Repository.Name,
- PrNumber: prRef.Number,
- SuiteID: p.CheckSuite.ID,
- HeadSha: p.CheckSuite.HeadSHA,
- AppID: p.CheckSuite.App.ID,
- Conclusion: strToText(p.CheckSuite.Conclusion),
- Status: p.CheckSuite.Status,
- SuiteUpdatedAt: updatedAt,
- }); err != nil {
- slog.Warn("github: stash pending check_suite failed",
- "err", err, "suite_id", p.CheckSuite.ID)
- }
- continue
- }
- if err := h.Queries.UpsertPullRequestCheckSuite(ctx, db.UpsertPullRequestCheckSuiteParams{
- PrID: pr.ID,
- SuiteID: p.CheckSuite.ID,
- HeadSha: p.CheckSuite.HeadSHA,
- AppID: p.CheckSuite.App.ID,
- Conclusion: strToText(p.CheckSuite.Conclusion),
- Status: p.CheckSuite.Status,
- UpdatedAt: updatedAt,
- }); err != nil {
- slog.Warn("github: upsert check_suite failed", "err", err, "suite_id", p.CheckSuite.ID)
- continue
- }
- recorded = true
- issues, err := h.Queries.ListIssueIDsForPullRequest(ctx, pr.ID)
- if err == nil {
- for _, id := range issues {
- affectedIssues[uuidToString(id)] = struct{}{}
- }
- }
- }
-
- if !recorded {
- return
- }
- // Broadcast on the existing event so the issue page just re-queries the PR
- // list. We don't pass a single pull_request payload here because a suite can
- // touch several and the listener already invalidates by issue.
- linked := make([]string, 0, len(affectedIssues))
- for id := range affectedIssues {
- linked = append(linked, id)
- }
- h.publish(protocol.EventPullRequestUpdated, uuidToString(wsID), "system", "", map[string]any{
- "linked_issue_ids": linked,
- })
-}
-
-// replayPendingCheckSuitesForPR drains the stash table for one PR (any
-// rows left there by a check_suite event that arrived before the PR row
-// was mirrored) and re-applies each event through the normal upsert
-// path. Safe to call on every PR upsert: the drain is a single
-// DELETE … RETURNING, so when there is nothing to replay the helper is
-// a no-op round-trip.
-func (h *Handler) replayPendingCheckSuitesForPR(ctx context.Context, pr db.GithubPullRequest, workspaceID pgtype.UUID) {
- pending, err := h.Queries.DrainPendingCheckSuitesForPR(ctx, db.DrainPendingCheckSuitesForPRParams{
- WorkspaceID: workspaceID,
- RepoOwner: pr.RepoOwner,
- RepoName: pr.RepoName,
- PrNumber: pr.PrNumber,
- })
- if err != nil {
- slog.Warn("github: drain pending check_suites failed",
- "err", err, "pr_id", uuidToString(pr.ID))
- return
- }
- for _, row := range pending {
- if err := h.Queries.UpsertPullRequestCheckSuite(ctx, db.UpsertPullRequestCheckSuiteParams{
- PrID: pr.ID,
- SuiteID: row.SuiteID,
- HeadSha: row.HeadSha,
- AppID: row.AppID,
- Conclusion: row.Conclusion,
- Status: row.Status,
- UpdatedAt: row.SuiteUpdatedAt,
- }); err != nil {
- slog.Warn("github: replay pending check_suite failed",
- "err", err, "pr_id", uuidToString(pr.ID),
- "suite_id", row.SuiteID)
- }
- }
-}
-
// derivePRMergeableState resolves the upsert behaviour for the PR row's
// mergeable_state column on a `pull_request` webhook. It returns three
// states encoded as (value, clear):
diff --git a/server/internal/handler/github_test.go b/server/internal/handler/github_test.go
index 071ce4e7f..34ee67060 100644
--- a/server/internal/handler/github_test.go
+++ b/server/internal/handler/github_test.go
@@ -1463,35 +1463,6 @@ func TestDerivePRMergeableState(t *testing.T) {
}
}
-func TestAggregateChecksConclusion(t *testing.T) {
- str := func(p *string) string {
- if p == nil {
- return ""
- }
- return *p
- }
- cases := []struct {
- name string
- failed, passed, pending, total int64
- want string
- }{
- {"no_suites_nil", 0, 0, 0, 0, ""},
- {"any_failure_wins", 1, 5, 0, 6, "failed"},
- {"failure_beats_pending", 1, 0, 3, 4, "failed"},
- {"pending_when_no_failure", 0, 1, 2, 3, "pending"},
- {"all_passed", 0, 3, 0, 3, "passed"},
- {"counts_zero_but_total_nonzero_returns_nil", 0, 0, 0, 1, ""},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- got := aggregateChecksConclusion(tc.failed, tc.passed, tc.pending, tc.total)
- if str(got) != tc.want {
- t.Errorf("aggregateChecksConclusion = %s, want %s", str(got), tc.want)
- }
- })
- }
-}
-
// firePullRequestWebhookWithHead is like firePullRequestWebhook but lets the
// caller control the head SHA and mergeable_state on the payload. The CI
// tests need both knobs to exercise head-change semantics.
@@ -1537,54 +1508,6 @@ func firePullRequestWebhookWithHead(t *testing.T, secret, identifier string, ins
}
}
-func fireCheckSuiteWebhook(t *testing.T, secret string, installationID int64, repo string, prNumbers []int32, suiteID, appID int64, headSHA, conclusion, updatedAt string) {
- t.Helper()
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, repo, prNumbers,
- suiteID, appID, headSHA, "completed", "completed", conclusion, updatedAt)
-}
-
-// fireCheckSuiteWebhookWithStatus is the parametric form of
-// fireCheckSuiteWebhook. Tests covering the `requested`/`rerequested` and
-// `queued`/`in_progress` matrix use it directly; the legacy completed-only
-// helper above wraps it for existing call sites.
-func fireCheckSuiteWebhookWithStatus(t *testing.T, secret string, installationID int64, repo string, prNumbers []int32, suiteID, appID int64, headSHA, action, status, conclusion, updatedAt string) {
- t.Helper()
- prRefs := make([]map[string]any, 0, len(prNumbers))
- for _, n := range prNumbers {
- prRefs = append(prRefs, map[string]any{"number": n})
- }
- payload := map[string]any{
- "action": action,
- "check_suite": map[string]any{
- "id": suiteID,
- "head_sha": headSHA,
- "status": status,
- "conclusion": conclusion,
- "updated_at": updatedAt,
- "app": map[string]any{"id": appID},
- "pull_requests": prRefs,
- },
- "repository": map[string]any{
- "name": repo,
- "owner": map[string]any{"login": "acme"},
- },
- "installation": map[string]any{"id": installationID},
- }
- raw, _ := json.Marshal(payload)
- mac := hmac.New(sha256.New, []byte(secret))
- mac.Write(raw)
- sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
-
- rec := httptest.NewRecorder()
- hookReq := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
- hookReq.Header.Set("X-GitHub-Event", "check_suite")
- hookReq.Header.Set("X-Hub-Signature-256", sig)
- testHandler.HandleGitHubWebhook(rec, hookReq)
- if rec.Code != http.StatusAccepted {
- t.Fatalf("check_suite webhook: expected 202, got %d (%s)", rec.Code, rec.Body.String())
- }
-}
-
func setupPRTestIssue(t *testing.T, ctx context.Context, secret string) (IssueResponse, int64) {
t.Helper()
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
@@ -1621,259 +1544,6 @@ func setupPRTestIssue(t *testing.T, ctx context.Context, secret string) (IssueRe
return created, installationID
}
-// TestWebhook_CheckSuite_AggregatesAcrossApps ensures the list query reports
-// "failed" when one app's latest suite is a failure and another app's is a
-// success on the same head. Without per-app aggregation, the last-completed
-// suite would silently flip the verdict.
-func TestWebhook_CheckSuite_AggregatesAcrossApps(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-aggregate-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- head := "abc1234567890"
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-a", 11, "opened", head, "")
- // App A → success, App B → failure. The list query must report failed.
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-a", []int32{11}, 1001, 7001, head, "success", "2026-05-01T00:00:00Z")
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-a", []int32{11}, 1002, 7002, head, "failure", "2026-05-01T00:01:00Z")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if len(rows) != 1 {
- t.Fatalf("expected 1 PR row, got %d", len(rows))
- }
- got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- if got == nil || *got != "failed" {
- t.Errorf("expected aggregate failed, got %v (counts: failed=%d passed=%d pending=%d total=%d)",
- got, rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- }
-}
-
-// TestWebhook_CheckSuite_OldHeadIgnored asserts that a late-arriving
-// check_suite for a stale head SHA doesn't contaminate the current head's
-// pending view. Without the head_sha filter in the aggregation query, the
-// new head would inherit the old head's "passed" verdict.
-func TestWebhook_CheckSuite_OldHeadIgnored(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-oldhead-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- oldHead := "old1111111111"
- newHead := "new2222222222"
-
- // First: open the PR at old head, run a passing suite.
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-b", 22, "opened", oldHead, "")
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-b", []int32{22}, 2001, 8001, oldHead, "success", "2026-05-01T00:00:00Z")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- if got == nil || *got != "passed" {
- t.Fatalf("setup: expected passed on old head, got %v", got)
- }
-
- // Then: synchronize to new head — no new suite yet. Then a late suite
- // for the OLD head fires (e.g. a delayed delivery). The current aggregate
- // must be nil (no suite for the new head).
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-b", 22, "synchronize", newHead, "")
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-b", []int32{22}, 2002, 8001, oldHead, "success", "2026-05-01T00:05:00Z")
-
- rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- got = aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- if got != nil {
- t.Errorf("expected no aggregate (nil) after head change, got %v", got)
- }
-}
-
-// TestWebhook_CheckSuite_LateOlderEventIgnored guards the single-row ordering
-// rule: for the same (pr_id, suite_id) the upsert must not let a later-
-// delivered older event overwrite the latest one. We send the newer state
-// (failure) first and then the older (success) and assert the row still
-// reads failure.
-func TestWebhook_CheckSuite_LateOlderEventIgnored(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-ordering-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- head := "ord1234567890"
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-c", 33, "opened", head, "")
- // Latest event first.
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-c", []int32{33}, 3001, 9001, head, "failure", "2026-05-01T01:00:00Z")
- // Late-arriving older event for the same suite.
- fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-c", []int32{33}, 3001, 9001, head, "success", "2026-05-01T00:00:00Z")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- if got == nil || *got != "failed" {
- t.Errorf("expected failure to win against later-delivered older success, got %v", got)
- }
-}
-
-// TestWebhook_CheckSuite_QueuedCountsAsPending covers the "CI 跑到一半" path:
-// GitHub fires `check_suite.requested` with status `queued` and an empty
-// conclusion while CI is still spinning up. The handler must persist these
-// non-terminal events so the per-PR `checks_pending` count reflects work in
-// progress; otherwise the frontend falls through to the "checks not
-// reported yet" placeholder until the first completed suite arrives.
-func TestWebhook_CheckSuite_QueuedCountsAsPending(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-pending-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- head := "pending1234567"
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-pending", 55, "opened", head, "")
- // CI just kicked off — `requested` action, status=queued, no conclusion.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-pending", []int32{55}, 4001, 6001, head, "requested", "queued", "", "2026-05-01T00:00:00Z")
- // A second app's suite starts a moment later with status=in_progress.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-pending", []int32{55}, 4002, 6002, head, "requested", "in_progress", "", "2026-05-01T00:00:30Z")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if len(rows) != 1 {
- t.Fatalf("expected 1 PR row, got %d", len(rows))
- }
- if rows[0].ChecksPending != 2 || rows[0].ChecksTotal != 2 ||
- rows[0].ChecksFailed != 0 || rows[0].ChecksPassed != 0 {
- t.Fatalf("expected pending=2 total=2 failed=0 passed=0, got pending=%d total=%d failed=%d passed=%d",
- rows[0].ChecksPending, rows[0].ChecksTotal, rows[0].ChecksFailed, rows[0].ChecksPassed)
- }
- got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- if got == nil || *got != "pending" {
- t.Errorf("expected aggregate pending while CI is running, got %v", got)
- }
-
- // Now one app completes successfully — pending count drops to 1 and the
- // aggregate stays pending until the second app finishes.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-pending", []int32{55}, 4001, 6001, head, "completed", "completed", "success", "2026-05-01T00:05:00Z")
- rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if rows[0].ChecksPending != 1 || rows[0].ChecksPassed != 1 || rows[0].ChecksTotal != 2 {
- t.Fatalf("expected pending=1 passed=1 total=2 after one suite completes, got pending=%d passed=%d total=%d",
- rows[0].ChecksPending, rows[0].ChecksPassed, rows[0].ChecksTotal)
- }
-}
-
-// TestWebhook_CheckSuite_OutOfOrderReplaysOnPRUpsert covers the out-of-order
-// path: a `check_suite` event arrives before the matching `pull_request`
-// row has been mirrored locally (e.g. webhook reordering, or the PR was
-// linked to an installation that was suspended/resumed). The handler must
-// stash the suite and replay it when the PR upsert arrives, otherwise the
-// PR's first observed suite is silently lost and `checks_pending` stays at
-// 0 until the next suite ships.
-func TestWebhook_CheckSuite_OutOfOrderReplaysOnPRUpsert(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-oooreplay-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- head := "oo01234567890"
- // Suite event lands FIRST — the PR row does not exist yet.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-ooo", []int32{66}, 5001, 7501, head, "requested", "in_progress", "", "2026-05-01T00:00:00Z")
-
- // Verify nothing landed on the PR table yet (no PR row to land on).
- if rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID)); err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- } else if len(rows) != 0 {
- t.Fatalf("expected 0 PR rows before PR webhook, got %d", len(rows))
- }
-
- // Now the pull_request webhook arrives. The handler must drain the
- // pending stash and replay it onto this PR.
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-ooo", 66, "opened", head, "")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if len(rows) != 1 {
- t.Fatalf("expected 1 PR row after PR webhook, got %d", len(rows))
- }
- if rows[0].ChecksPending != 1 || rows[0].ChecksTotal != 1 {
- t.Fatalf("expected pending=1 total=1 after replay, got pending=%d total=%d",
- rows[0].ChecksPending, rows[0].ChecksTotal)
- }
-
- // The next PR upsert (a no-op metadata edit) must NOT re-apply or fail
- // — the drain is one-shot, so the second pull_request webhook drains
- // an empty pending list.
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-ooo", 66, "edited", head, "")
- rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if rows[0].ChecksPending != 1 || rows[0].ChecksTotal != 1 {
- t.Fatalf("expected pending=1 total=1 after no-op edit, got pending=%d total=%d",
- rows[0].ChecksPending, rows[0].ChecksTotal)
- }
-}
-
-// TestWebhook_CheckSuite_OutOfOrderStashKeepsNewer guards the pending
-// stash against the same out-of-order trap the live table already
-// handles: while the PR row is still missing, an older event for the
-// same suite_id must not overwrite a newer payload that was stashed
-// first. Without the suite_updated_at guard on UpsertPendingCheckSuite,
-// a late `requested/in_progress` arriving after `completed/success`
-// would roll the stash back to pending; the subsequent PR upsert would
-// then replay the stale state and the PR card would stay stuck on
-// "pending" until the next suite shipped.
-func TestWebhook_CheckSuite_OutOfOrderStashKeepsNewer(t *testing.T) {
- if testHandler == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- const secret = "ci-stash-order-secret"
- created, installationID := setupPRTestIssue(t, ctx, secret)
-
- head := "stash01234567"
- // Newer event lands FIRST while the PR row does not exist yet.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-stash", []int32{77}, 6001, 8001, head, "completed", "completed", "success", "2026-05-01T00:05:00Z")
- // Older event for the SAME suite arrives later (webhook reorder). The
- // pending stash must keep the newer payload.
- fireCheckSuiteWebhookWithStatus(t, secret, installationID, "ci-repo-stash", []int32{77}, 6001, 8001, head, "requested", "in_progress", "", "2026-05-01T00:00:00Z")
-
- // PR webhook arrives — drain replays the (still newer) stash.
- firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-stash", 77, "opened", head, "")
-
- rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
- if err != nil {
- t.Fatalf("ListPullRequestsByIssue: %v", err)
- }
- if len(rows) != 1 {
- t.Fatalf("expected 1 PR row after PR webhook, got %d", len(rows))
- }
- if rows[0].ChecksPassed != 1 || rows[0].ChecksPending != 0 || rows[0].ChecksTotal != 1 {
- t.Fatalf("expected passed=1 pending=0 total=1 (newer stash preserved), got passed=%d pending=%d total=%d",
- rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
- }
-}
-
// TestWebhook_PullRequest_SynchronizeClearsMergeable verifies that
// `synchronize` sets mergeable_state to NULL even when the payload still
// carries the previous "clean" verdict — the old answer no longer applies
@@ -2935,207 +2605,6 @@ func TestWebhook_PullRequest_FansOutToBoundWorkspaces(t *testing.T) {
}
}
-// TestWebhook_CheckSuite_FansOutToBoundWorkspaces mirrors the PR fan-out for CI:
-// a check_suite event must be recorded against every bound workspace's copy of
-// the referenced PR, not just one.
-func TestWebhook_CheckSuite_FansOutToBoundWorkspaces(t *testing.T) {
- if testHandler == nil || testPool == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- secret := "fanout-cs-secret"
- t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
-
- const repo = "fanout-ci-repo"
- const prNumber int32 = 4344
- const installationID int64 = 778899102
- const suiteID int64 = 90019001
- head := "fanoutsha123456"
-
- testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ci-ws-a")
- testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ci-ws-b")
- wsA, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
- Name: "fanout-ci-ws-a", Slug: "fanout-ci-ws-a", IssuePrefix: "FCA",
- })
- if err != nil {
- t.Fatalf("CreateWorkspace A: %v", err)
- }
- wsB, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
- Name: "fanout-ci-ws-b", Slug: "fanout-ci-ws-b", IssuePrefix: "FCB",
- })
- if err != nil {
- t.Fatalf("CreateWorkspace B: %v", err)
- }
-
- if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
- WorkspaceID: wsA.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
- }); err != nil {
- t.Fatalf("CreateGitHubInstallation A: %v", err)
- }
- if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
- WorkspaceID: wsB.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
- }); err != nil {
- t.Fatalf("CreateGitHubInstallation B: %v", err)
- }
-
- t.Cleanup(func() {
- testPool.Exec(ctx, `DELETE FROM github_pull_request_check_suite WHERE pr_id IN (SELECT id FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1)`, repo)
- testPool.Exec(ctx, `DELETE FROM github_pending_check_suite WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
- testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
- testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsA.ID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsB.ID)
- })
-
- // Mirror the PR into both workspaces (no issue needed), then fire CI on the
- // same head SHA.
- firePullRequestWebhookWithHead(t, secret, "FCX-1", installationID, repo, prNumber, "opened", head, "")
- fireCheckSuiteWebhook(t, secret, installationID, repo, []int32{prNumber}, suiteID, 7100, head, "failure", "2026-05-01T00:00:00Z")
-
- // The suite must be recorded against BOTH workspaces' PR rows.
- assertRecorded := func(label string, prID any) {
- var n int
- if err := testPool.QueryRow(ctx,
- `SELECT count(*) FROM github_pull_request_check_suite WHERE pr_id = $1 AND suite_id = $2`,
- prID, suiteID).Scan(&n); err != nil {
- t.Fatalf("workspace %s: count check suites: %v", label, err)
- }
- if n != 1 {
- t.Fatalf("workspace %s: expected 1 recorded check_suite, got %d", label, n)
- }
- }
-
- prA, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
- WorkspaceID: wsA.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
- })
- if err != nil {
- t.Fatalf("workspace A: expected PR mirrored: %v", err)
- }
- prB, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
- WorkspaceID: wsB.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
- })
- if err != nil {
- t.Fatalf("workspace B: expected PR mirrored: %v", err)
- }
- assertRecorded("A", prA.ID)
- assertRecorded("B", prB.ID)
-}
-
-// TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces covers the most
-// error-prone multi-workspace path: a check_suite that arrives BEFORE the PR is
-// mirrored. Each bound workspace must stash its own pending row, and when the PR
-// event fans out, each workspace must drain its own pending row and record the
-// suite — one workspace's stash/drain must not stand in for another's.
-func TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces(t *testing.T) {
- if testHandler == nil || testPool == nil {
- t.Skip("handler test fixture not initialized (no DB?)")
- }
- ctx := context.Background()
- secret := "fanout-cs-ooo-secret"
- t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
-
- const repo = "fanout-ooo-repo"
- const prNumber int32 = 4345
- const installationID int64 = 778899103
- const suiteID int64 = 90019002
- head := "ooosha7654321"
-
- testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ooo-ws-a")
- testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ooo-ws-b")
- wsA, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
- Name: "fanout-ooo-ws-a", Slug: "fanout-ooo-ws-a", IssuePrefix: "OOA",
- })
- if err != nil {
- t.Fatalf("CreateWorkspace A: %v", err)
- }
- wsB, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
- Name: "fanout-ooo-ws-b", Slug: "fanout-ooo-ws-b", IssuePrefix: "OOB",
- })
- if err != nil {
- t.Fatalf("CreateWorkspace B: %v", err)
- }
-
- if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
- WorkspaceID: wsA.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
- }); err != nil {
- t.Fatalf("CreateGitHubInstallation A: %v", err)
- }
- if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
- WorkspaceID: wsB.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
- }); err != nil {
- t.Fatalf("CreateGitHubInstallation B: %v", err)
- }
-
- t.Cleanup(func() {
- testPool.Exec(ctx, `DELETE FROM github_pull_request_check_suite WHERE pr_id IN (SELECT id FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1)`, repo)
- testPool.Exec(ctx, `DELETE FROM github_pending_check_suite WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
- testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
- testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsA.ID)
- testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsB.ID)
- })
-
- pendingCount := func(wsID any) int {
- var n int
- if err := testPool.QueryRow(ctx,
- `SELECT count(*) FROM github_pending_check_suite WHERE workspace_id = $1 AND repo_owner = 'acme' AND repo_name = $2 AND pr_number = $3 AND suite_id = $4`,
- wsID, repo, prNumber, suiteID).Scan(&n); err != nil {
- t.Fatalf("count pending: %v", err)
- }
- return n
- }
- suiteCount := func(prID any) int {
- var n int
- if err := testPool.QueryRow(ctx,
- `SELECT count(*) FROM github_pull_request_check_suite WHERE pr_id = $1 AND suite_id = $2`,
- prID, suiteID).Scan(&n); err != nil {
- t.Fatalf("count suites: %v", err)
- }
- return n
- }
-
- // 1. check_suite arrives BEFORE any PR mirror: each bound workspace stashes
- // its own pending row.
- fireCheckSuiteWebhook(t, secret, installationID, repo, []int32{prNumber}, suiteID, 7200, head, "failure", "2026-05-02T00:00:00Z")
- if got := pendingCount(wsA.ID); got != 1 {
- t.Fatalf("workspace A: expected 1 pending check_suite, got %d", got)
- }
- if got := pendingCount(wsB.ID); got != 1 {
- t.Fatalf("workspace B: expected 1 pending check_suite, got %d", got)
- }
-
- // 2. The PR arrives and fans out: each workspace drains its own pending row
- // and records the suite against its own PR mirror.
- firePullRequestWebhookWithHead(t, secret, "OOX-1", installationID, repo, prNumber, "opened", head, "")
-
- prA, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
- WorkspaceID: wsA.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
- })
- if err != nil {
- t.Fatalf("workspace A: expected PR mirrored: %v", err)
- }
- prB, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
- WorkspaceID: wsB.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
- })
- if err != nil {
- t.Fatalf("workspace B: expected PR mirrored: %v", err)
- }
- if got := suiteCount(prA.ID); got != 1 {
- t.Fatalf("workspace A: expected 1 recorded check_suite after drain, got %d", got)
- }
- if got := suiteCount(prB.ID); got != 1 {
- t.Fatalf("workspace B: expected 1 recorded check_suite after drain, got %d", got)
- }
- if got := pendingCount(wsA.ID); got != 0 {
- t.Fatalf("workspace A: expected pending drained to 0, got %d", got)
- }
- if got := pendingCount(wsB.ID); got != 0 {
- t.Fatalf("workspace B: expected pending drained to 0, got %d", got)
- }
-}
-
// TestSecondWorkspaceBindDoesNotUnbindFirst is the #4823 regression: binding
// the same GitHub App installation in a second workspace must NOT overwrite the
// first workspace's binding. Both bindings coexist, and re-binding an existing
diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go
index 4a87c901a..97a06e4b9 100644
--- a/server/internal/handler/handler.go
+++ b/server/internal/handler/handler.go
@@ -23,6 +23,7 @@ import (
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
composio "github.com/multica-ai/multica/server/internal/integrations/composio"
+ "github.com/multica-ai/multica/server/internal/integrations/ghsnapshot"
"github.com/multica-ai/multica/server/internal/integrations/lark"
"github.com/multica-ai/multica/server/internal/integrations/slack"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
@@ -246,7 +247,14 @@ type Handler struct {
// error rather than silently storing plaintext. Wired in
// cmd/server/router.go after New.
VCSSecretBox *secretbox.Box
- cfg Config
+ // PRRefresh drives the GitHub API snapshot pipeline for PR cards (MUL-5265):
+ // webhook / page-visit / TTL triggers → authenticated GraphQL fetch →
+ // head-SHA-guarded atomic snapshot write. Always non-nil, but inert (every
+ // trigger is a no-op) when GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY are unset,
+ // so the feature degrades cleanly on deployments without a private key.
+ // Wired in cmd/server/router.go after New.
+ PRRefresh *ghsnapshot.Manager
+ cfg Config
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {
@@ -318,6 +326,18 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
cfg: cfg,
}
h.WebhookDeliveryWorker = NewWebhookDeliveryWorker(h)
+
+ // GitHub API snapshot pipeline for PR cards (MUL-5265). Built
+ // unconditionally but inert (every trigger no-ops) when the App private key
+ // is unconfigured, so the feature degrades cleanly. main.go calls
+ // h.PRRefresh.Start(ctx) to launch its worker pool + TTL sweeper.
+ ghClient, err := ghsnapshot.NewClientFromEnv()
+ if err != nil {
+ // Malformed key is operator-actionable; the pipeline stays disabled.
+ slog.Warn("github: PR snapshot pipeline disabled (invalid App private key)", "err", err)
+ }
+ h.PRRefresh = ghsnapshot.NewManager(ghClient, queries, txStarter, h.broadcastPRSnapshotApplied)
+
return h
}
diff --git a/server/internal/handler/vcs_webhook.go b/server/internal/handler/vcs_webhook.go
index ca357fc70..132c64bc9 100644
--- a/server/internal/handler/vcs_webhook.go
+++ b/server/internal/handler/vcs_webhook.go
@@ -68,9 +68,12 @@ func vcsPullRequestRowToResponse(p db.ListVCSPullRequestsByIssueRow) GitHubPullR
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
MergeableState: nil,
ChecksConclusion: aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal),
+ ChecksTotal: p.ChecksTotal,
ChecksPassed: p.ChecksPassed,
ChecksFailed: p.ChecksFailed,
ChecksPending: p.ChecksPending,
+ ChecksRunning: p.ChecksPending,
+ FailedCheckNames: []string{},
Additions: p.Additions,
Deletions: p.Deletions,
ChangedFiles: p.ChangedFiles,
diff --git a/server/internal/integrations/ghsnapshot/client.go b/server/internal/integrations/ghsnapshot/client.go
new file mode 100644
index 000000000..94ff4f6b5
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/client.go
@@ -0,0 +1,267 @@
+// Package ghsnapshot fetches a GitHub pull request's CI + mergeability state
+// from the GitHub API and treats that response as the single source of truth
+// for the PR card (MUL-5265, Plan C). Webhooks and page visits only trigger a
+// refresh; nothing here infers state incrementally from webhook payloads.
+//
+// The package has three layers:
+// - Client (this file): GitHub App auth — App JWT → installation access
+// token (cached per installation, refreshed early) → GraphQL/REST calls.
+// - snapshot.go: the single GraphQL query, contexts pagination, and
+// normalization into a flat per-check snapshot.
+// - refresh.go: the outbound work queue (dedup, single in-flight per PR,
+// bounded concurrency, Retry-After backoff), the head-SHA-guarded atomic
+// write, and the trigger surfaces (webhook / page visit / TTL sweep).
+//
+// Credential hygiene (acceptance criterion 6): the App private key and every
+// installation token are treated as opaque secrets and are NEVER written to a
+// log or embedded in an error message.
+package ghsnapshot
+
+import (
+ "bytes"
+ "context"
+ "crypto/rsa"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const (
+ defaultAPIBase = "https://api.github.com"
+ // Renew an installation token this long before it actually expires so an
+ // in-flight request never races the expiry boundary. GitHub tokens live
+ // one hour.
+ tokenRenewSkew = 5 * time.Minute
+)
+
+// RateLimitError signals that GitHub asked us to back off. RetryAfter is how
+// long to wait before retrying; it is derived from the Retry-After header, or
+// the X-RateLimit-Reset header, or a conservative default.
+type RateLimitError struct {
+ RetryAfter time.Duration
+}
+
+func (e *RateLimitError) Error() string {
+ return fmt.Sprintf("github rate limited, retry after %s", e.RetryAfter)
+}
+
+// Client is an installation-token-authenticated GitHub API client. A nil
+// *Client is a valid "feature disabled" value — every method the refresh
+// pipeline calls tolerates it — so a deployment without a GitHub App private
+// key degrades cleanly (acceptance criterion 4).
+type Client struct {
+ appID string
+ privateKey *rsa.PrivateKey
+ apiBase string
+ httpClient *http.Client
+ now func() time.Time
+
+ mu sync.Mutex
+ tokens map[int64]cachedToken
+}
+
+type cachedToken struct {
+ token string
+ expiry time.Time
+}
+
+// NewClientFromEnv builds a Client from GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.
+//
+// - Both unset → (nil, nil): the App API is simply not configured for this
+// deployment; the caller degrades the whole feature off.
+// - Key present but malformed → (nil, err): operator-actionable, surface it.
+func NewClientFromEnv() (*Client, error) {
+ appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
+ pemKey := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
+ if appID == "" || pemKey == "" {
+ return nil, nil
+ }
+ key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(pemKey))
+ if err != nil {
+ // Deliberately do not include the key material in the error.
+ return nil, fmt.Errorf("parse GITHUB_APP_PRIVATE_KEY: %w", err)
+ }
+ return &Client{
+ appID: appID,
+ privateKey: key,
+ apiBase: defaultAPIBase,
+ httpClient: &http.Client{Timeout: 20 * time.Second},
+ now: time.Now,
+ tokens: map[int64]cachedToken{},
+ }, nil
+}
+
+// Enabled reports whether the App API is configured. A nil client is disabled.
+func (c *Client) Enabled() bool { return c != nil && c.privateKey != nil }
+
+// signAppJWT mints the short-lived RS256 JWT GitHub requires for
+// App-authenticated calls. iat is back-dated 60s to absorb clock skew and exp
+// is capped at 9 minutes (GitHub's ceiling is 10).
+func (c *Client) signAppJWT(now time.Time) (string, error) {
+ claims := jwt.MapClaims{
+ "iat": now.Add(-60 * time.Second).Unix(),
+ "exp": now.Add(9 * time.Minute).Unix(),
+ "iss": c.appID,
+ }
+ tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ signed, err := tok.SignedString(c.privateKey)
+ if err != nil {
+ return "", errors.New("sign App JWT failed")
+ }
+ return signed, nil
+}
+
+// installationToken returns a cached installation access token, minting a new
+// one via POST /app/installations/{id}/access_tokens when the cache is empty or
+// within the renew skew of expiry.
+func (c *Client) installationToken(ctx context.Context, installationID int64) (string, error) {
+ now := c.now()
+ c.mu.Lock()
+ if t, ok := c.tokens[installationID]; ok && now.Add(tokenRenewSkew).Before(t.expiry) {
+ tok := t.token
+ c.mu.Unlock()
+ return tok, nil
+ }
+ c.mu.Unlock()
+
+ appJWT, err := c.signAppJWT(now)
+ if err != nil {
+ return "", err
+ }
+ endpoint := fmt.Sprintf("%s/app/installations/%d/access_tokens", strings.TrimRight(c.apiBase, "/"), installationID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Authorization", "Bearer "+appJWT)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
+ return "", rateLimitFromResponse(resp, c.now())
+ }
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ // Never echo the body — a token-mint failure body can contain sensitive
+ // hints; the status code is enough to diagnose.
+ return "", fmt.Errorf("github installation token: unexpected status %d", resp.StatusCode)
+ }
+ var parsed struct {
+ Token string `json:"token"`
+ ExpiresAt string `json:"expires_at"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return "", errors.New("github installation token: malformed response")
+ }
+ if parsed.Token == "" {
+ return "", errors.New("github installation token: empty token")
+ }
+ expiry := now.Add(time.Hour)
+ if parsed.ExpiresAt != "" {
+ if t, err := time.Parse(time.RFC3339, parsed.ExpiresAt); err == nil {
+ expiry = t
+ }
+ }
+ c.mu.Lock()
+ c.tokens[installationID] = cachedToken{token: parsed.Token, expiry: expiry}
+ c.mu.Unlock()
+ return parsed.Token, nil
+}
+
+// graphQL runs a single GraphQL query as the given installation and returns the
+// raw `data` object. GitHub returns HTTP 200 even for query-level errors, so we
+// inspect the `errors` array too, mapping a RATE_LIMITED error type to a
+// RateLimitError.
+func (c *Client) graphQL(ctx context.Context, installationID int64, query string, variables map[string]any) (json.RawMessage, error) {
+ token, err := c.installationToken(ctx, installationID)
+ if err != nil {
+ return nil, err
+ }
+ payload, err := json.Marshal(map[string]any{"query": query, "variables": variables})
+ if err != nil {
+ return nil, err
+ }
+ endpoint := strings.TrimRight(c.apiBase, "/") + "/graphql"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
+ return nil, rateLimitFromResponse(resp, c.now())
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("github graphql: unexpected status %d", resp.StatusCode)
+ }
+ var envelope struct {
+ Data json.RawMessage `json:"data"`
+ Errors []struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ } `json:"errors"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ return nil, errors.New("github graphql: malformed response")
+ }
+ if len(envelope.Errors) > 0 {
+ for _, e := range envelope.Errors {
+ if e.Type == "RATE_LIMITED" {
+ return nil, &RateLimitError{RetryAfter: time.Minute}
+ }
+ }
+ // Surface the message but nothing else; GraphQL error messages do not
+ // contain credentials.
+ return nil, fmt.Errorf("github graphql error: %s", envelope.Errors[0].Message)
+ }
+ if len(envelope.Data) == 0 {
+ return nil, errors.New("github graphql: empty data")
+ }
+ return envelope.Data, nil
+}
+
+// rateLimitFromResponse builds a RateLimitError from GitHub's throttling
+// headers. Retry-After (seconds) wins; then X-RateLimit-Reset (unix seconds);
+// otherwise a conservative 60s. The wait is clamped to [1s, 5m].
+func rateLimitFromResponse(resp *http.Response, now time.Time) *RateLimitError {
+ wait := time.Minute
+ if v := resp.Header.Get("Retry-After"); v != "" {
+ if secs, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && secs >= 0 {
+ wait = time.Duration(secs) * time.Second
+ }
+ } else if v := resp.Header.Get("X-RateLimit-Reset"); v != "" {
+ if unix, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
+ if d := time.Unix(unix, 0).Sub(now); d > 0 {
+ wait = d
+ }
+ }
+ }
+ if wait < time.Second {
+ wait = time.Second
+ }
+ if wait > 5*time.Minute {
+ wait = 5 * time.Minute
+ }
+ return &RateLimitError{RetryAfter: wait}
+}
diff --git a/server/internal/integrations/ghsnapshot/client_test.go b/server/internal/integrations/ghsnapshot/client_test.go
new file mode 100644
index 000000000..555662431
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/client_test.go
@@ -0,0 +1,156 @@
+package ghsnapshot
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func newTestClient(t *testing.T, apiBase string) *Client {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &Client{
+ appID: "123",
+ privateKey: key,
+ apiBase: apiBase,
+ httpClient: &http.Client{Timeout: 5 * time.Second},
+ now: time.Now,
+ tokens: map[int64]cachedToken{},
+ }
+}
+
+// TestInstallationTokenCaches proves the token is minted once and reused within
+// the renew skew — the cache the whole pipeline relies on to stay under
+// GitHub's App-JWT budget.
+func TestInstallationTokenCaches(t *testing.T) {
+ var mints int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access_tokens") {
+ atomic.AddInt32(&mints, 1)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
+ time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ c := newTestClient(t, srv.URL)
+ ctx := context.Background()
+ for i := 0; i < 3; i++ {
+ tok, err := c.installationToken(ctx, 42)
+ if err != nil {
+ t.Fatalf("installationToken: %v", err)
+ }
+ if tok != "ghs_secret" {
+ t.Fatalf("token = %q", tok)
+ }
+ }
+ if got := atomic.LoadInt32(&mints); got != 1 {
+ t.Fatalf("minted %d times, want 1 (cache miss)", got)
+ }
+}
+
+// TestGraphQLRateLimited maps a 403 with Retry-After to a *RateLimitError so the
+// refresh manager can back off (acceptance criterion 3).
+func TestGraphQLRateLimited(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access_tokens") {
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
+ time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
+ return
+ }
+ w.Header().Set("Retry-After", "42")
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer srv.Close()
+
+ c := newTestClient(t, srv.URL)
+ _, err := c.graphQL(context.Background(), 1, "query{}", nil)
+ rl, ok := err.(*RateLimitError)
+ if !ok {
+ t.Fatalf("err = %T (%v), want *RateLimitError", err, err)
+ }
+ if rl.RetryAfter != 42*time.Second {
+ t.Fatalf("RetryAfter = %s, want 42s", rl.RetryAfter)
+ }
+}
+
+func TestRateLimitFromResponse(t *testing.T) {
+ now := time.Unix(1000, 0)
+ cases := []struct {
+ name string
+ headers map[string]string
+ want time.Duration
+ }{
+ {"retry-after wins", map[string]string{"Retry-After": "30", "X-RateLimit-Reset": "5000"}, 30 * time.Second},
+ {"reset fallback", map[string]string{"X-RateLimit-Reset": "1090"}, 90 * time.Second},
+ {"default", map[string]string{}, time.Minute},
+ {"clamped to 5m", map[string]string{"Retry-After": "99999"}, 5 * time.Minute},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := &http.Response{Header: http.Header{}}
+ for k, v := range tc.headers {
+ resp.Header.Set(k, v)
+ }
+ if got := rateLimitFromResponse(resp, now).RetryAfter; got != tc.want {
+ t.Fatalf("wait = %s, want %s", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestNewClientFromEnv covers the three configuration outcomes, including the
+// clean-degradation case (acceptance criterion 4): no key → nil client, no error.
+func TestNewClientFromEnv(t *testing.T) {
+ t.Run("unconfigured yields nil client no error", func(t *testing.T) {
+ t.Setenv("GITHUB_APP_ID", "")
+ t.Setenv("GITHUB_APP_PRIVATE_KEY", "")
+ c, err := NewClientFromEnv()
+ if err != nil || c != nil {
+ t.Fatalf("got (%v, %v), want (nil, nil)", c, err)
+ }
+ if c.Enabled() {
+ t.Fatal("nil client must report disabled")
+ }
+ })
+ t.Run("malformed key is an error", func(t *testing.T) {
+ t.Setenv("GITHUB_APP_ID", "1")
+ t.Setenv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\nnope\n-----END RSA PRIVATE KEY-----")
+ c, err := NewClientFromEnv()
+ if err == nil {
+ t.Fatal("want error for malformed key")
+ }
+ // The error must not echo the key material.
+ if strings.Contains(err.Error(), "nope") {
+ t.Fatal("error leaked key material")
+ }
+ if c != nil {
+ t.Fatal("want nil client on error")
+ }
+ })
+ t.Run("valid key enables the client", func(t *testing.T) {
+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
+ pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+ t.Setenv("GITHUB_APP_ID", "1")
+ t.Setenv("GITHUB_APP_PRIVATE_KEY", string(pemBytes))
+ c, err := NewClientFromEnv()
+ if err != nil || !c.Enabled() {
+ t.Fatalf("got (%v, %v), want enabled client", c, err)
+ }
+ })
+}
diff --git a/server/internal/integrations/ghsnapshot/refresh.go b/server/internal/integrations/ghsnapshot/refresh.go
new file mode 100644
index 000000000..2a0e1c7f0
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/refresh.go
@@ -0,0 +1,411 @@
+package ghsnapshot
+
+import (
+ "context"
+ "log/slog"
+ "math/rand"
+ "sync"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+// TxBeginner is the subset of a pgx pool the manager needs to open the
+// snapshot-write transaction. *pgxpool.Pool satisfies it.
+type TxBeginner interface {
+ Begin(ctx context.Context) (pgx.Tx, error)
+}
+
+// address is the refresh unit and the dedup / single-in-flight key
+// (acceptance criterion 3): one (installation, owner, repo, number) tuple, which
+// may fan out to multiple github_pull_request rows across workspaces.
+type address struct {
+ InstallationID int64
+ Owner string
+ Repo string
+ Number int32
+}
+
+// Default tuning. The chase-window backoff climbs 30s → 1m → 2m → 5m and holds
+// at 5m; a chase stops when the snapshot is decided, the PR closes, or
+// maxChaseAttempts is reached — never unbounded (acceptance criterion 7). The
+// TTL sweep and page-visit refresh recover anything a stopped chase misses.
+var (
+ defaultChaseBackoff = []time.Duration{30 * time.Second, time.Minute, 2 * time.Minute, 5 * time.Minute}
+ defaultConcurrency = 12
+ defaultViewTTL = 60 * time.Second
+ defaultSweepTTL = 10 * time.Minute
+ defaultSweepInterval = 10 * time.Minute
+ defaultSweepMaxRows = int32(200)
+ maxChaseAttempts = 12
+ queueBuffer = 2048
+)
+
+// Manager owns the outbound GitHub API refresh pipeline. A Manager whose client
+// is nil/disabled is inert: every trigger method is a no-op, so a deployment
+// without a GitHub App private key degrades the feature off without touching
+// PR linking, merge→Done, or any other existing behavior (acceptance
+// criterion 4).
+type Manager struct {
+ client *Client
+ queries *db.Queries
+ pool TxBeginner
+ onApplied func(ctx context.Context, prID pgtype.UUID)
+
+ concurrency int
+ viewTTL time.Duration
+ sweepTTL time.Duration
+ sweepInterval time.Duration
+ sweepMaxRows int32
+ chaseBackoff []time.Duration
+ now func() time.Time
+ jitter func() time.Duration
+ // fetch is the snapshot fetcher, a seam so tests can drive the queue /
+ // backoff without a live GitHub. Defaults to FetchPRSnapshot.
+ fetch func(ctx context.Context, c *Client, installationID int64, owner, repo string, number int32) (*PRSnapshot, error)
+
+ queue chan address
+
+ mu sync.Mutex
+ active map[address]bool // queued OR in-flight → coalesce (single in-flight per PR)
+ attempts map[address]int // chase attempts for the current undecided window
+ rateUntil time.Time // global pause after a secondary rate limit
+
+ ctx context.Context
+ started bool
+}
+
+// NewManager wires the pipeline. onApplied is called once per PR row whose
+// snapshot was actually written (guard passed), so the handler can broadcast a
+// realtime PR update. Passing a nil client yields a disabled (no-op) manager.
+func NewManager(client *Client, queries *db.Queries, pool TxBeginner, onApplied func(ctx context.Context, prID pgtype.UUID)) *Manager {
+ return &Manager{
+ client: client,
+ queries: queries,
+ pool: pool,
+ onApplied: onApplied,
+ concurrency: defaultConcurrency,
+ viewTTL: defaultViewTTL,
+ sweepTTL: defaultSweepTTL,
+ sweepInterval: defaultSweepInterval,
+ sweepMaxRows: defaultSweepMaxRows,
+ chaseBackoff: defaultChaseBackoff,
+ now: time.Now,
+ jitter: func() time.Duration { return time.Duration(rand.Int63n(int64(250 * time.Millisecond))) },
+ fetch: FetchPRSnapshot,
+ queue: make(chan address, queueBuffer),
+ active: map[address]bool{},
+ attempts: map[address]int{},
+ }
+}
+
+// Enabled reports whether the pipeline will actually do anything.
+func (m *Manager) Enabled() bool { return m != nil && m.client.Enabled() }
+
+// Start launches the worker pool and the TTL sweeper under ctx. No-op (and
+// safe) when the manager is disabled.
+func (m *Manager) Start(ctx context.Context) {
+ if !m.Enabled() {
+ return
+ }
+ m.mu.Lock()
+ if m.started {
+ m.mu.Unlock()
+ return
+ }
+ m.started = true
+ m.ctx = ctx
+ m.mu.Unlock()
+
+ for i := 0; i < m.concurrency; i++ {
+ go m.worker(ctx)
+ }
+ go m.sweepLoop(ctx)
+}
+
+// Enqueue schedules a refresh for a PR address. Coalesces: if the address is
+// already queued or in flight, this is a no-op (single in-flight per PR). Never
+// blocks the caller.
+func (m *Manager) Enqueue(installationID int64, owner, repo string, number int32) {
+ if !m.Enabled() {
+ return
+ }
+ addr := address{InstallationID: installationID, Owner: owner, Repo: repo, Number: number}
+ m.mu.Lock()
+ if m.active[addr] {
+ m.mu.Unlock()
+ return
+ }
+ m.active[addr] = true
+ m.mu.Unlock()
+
+ select {
+ case m.queue <- addr:
+ default:
+ // Queue is saturated; drop and let the TTL sweep / next event recover
+ // rather than block a webhook handler. Unmark so it can be re-enqueued.
+ m.mu.Lock()
+ delete(m.active, addr)
+ m.mu.Unlock()
+ slog.Warn("ghsnapshot: refresh queue full, dropping enqueue")
+ }
+}
+
+// MaybeEnqueueOnView is the page-visit trigger: refresh only when the snapshot
+// is missing or older than the view TTL, so opening a card that already has
+// fresh data costs nothing.
+func (m *Manager) MaybeEnqueueOnView(installationID int64, owner, repo string, number int32, fetchedAt time.Time, hasFetched bool) {
+ if !m.Enabled() {
+ return
+ }
+ if hasFetched && m.now().Sub(fetchedAt) < m.viewTTL {
+ return
+ }
+ m.Enqueue(installationID, owner, repo, number)
+}
+
+func (m *Manager) worker(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case addr := <-m.queue:
+ m.process(ctx, addr)
+ m.mu.Lock()
+ delete(m.active, addr)
+ m.mu.Unlock()
+ }
+ }
+}
+
+func (m *Manager) process(ctx context.Context, addr address) {
+ // Global secondary-rate-limit pause + per-request jitter to smooth bursts.
+ m.mu.Lock()
+ pause := m.rateUntil.Sub(m.now())
+ m.mu.Unlock()
+ if pause > 0 {
+ if !sleepCtx(ctx, pause) {
+ return
+ }
+ }
+ if j := m.jitter(); j > 0 {
+ if !sleepCtx(ctx, j) {
+ return
+ }
+ }
+
+ snap, err := m.fetch(ctx, m.client, addr.InstallationID, addr.Owner, addr.Repo, addr.Number)
+ if err != nil {
+ var rl *RateLimitError
+ if asRateLimit(err, &rl) {
+ m.mu.Lock()
+ m.rateUntil = m.now().Add(rl.RetryAfter)
+ m.mu.Unlock()
+ m.scheduleRetry(addr, rl.RetryAfter)
+ return
+ }
+ // Transient/GitHub failure: keep the last-known snapshot (the row is
+ // untouched, so the card shows stale data, never wrong data). No secret
+ // is ever logged. The next trigger or the TTL sweep retries.
+ slog.Warn("ghsnapshot: fetch failed", "owner", addr.Owner, "repo", addr.Repo, "number", addr.Number, "err", err.Error())
+ return
+ }
+
+ rows, err := m.queries.ListGitHubPRRowsByAddress(ctx, db.ListGitHubPRRowsByAddressParams{
+ InstallationID: addr.InstallationID,
+ RepoOwner: addr.Owner,
+ RepoName: addr.Repo,
+ PrNumber: addr.Number,
+ })
+ if err != nil {
+ slog.Warn("ghsnapshot: list rows failed", "err", err.Error())
+ return
+ }
+
+ anyApplied := false
+ anyOpenApplied := false
+ for _, row := range rows {
+ applied, err := m.applySnapshot(ctx, row.ID, snap)
+ if err != nil {
+ slog.Warn("ghsnapshot: apply snapshot failed", "err", err.Error())
+ continue
+ }
+ if !applied {
+ continue
+ }
+ anyApplied = true
+ if row.State == "open" || row.State == "draft" {
+ anyOpenApplied = true
+ }
+ if m.onApplied != nil {
+ m.onApplied(ctx, row.ID)
+ }
+ }
+
+ // Chase decision. Chase only while the snapshot is undecided AND we still
+ // have an open PR row on this head. If nothing applied (head advanced past
+ // this response, or the PR is gone), the webhook that moved the head has
+ // already enqueued the fresh head, so we stop here.
+ if anyApplied && anyOpenApplied && !snap.Decided() {
+ m.scheduleChase(addr)
+ } else {
+ m.mu.Lock()
+ delete(m.attempts, addr)
+ m.mu.Unlock()
+ }
+}
+
+// applySnapshot performs the head-SHA-guarded atomic batch replace for one PR
+// row: guarded UPDATE of the snapshot columns, then a full DELETE + INSERT of
+// the per-check rows — all in one transaction. Returns applied=false (and
+// writes nothing) when the row's head has advanced past the snapshot's head
+// (acceptance criterion 1).
+func (m *Manager) applySnapshot(ctx context.Context, prID pgtype.UUID, snap *PRSnapshot) (bool, error) {
+ tx, err := m.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer tx.Rollback(ctx)
+ q := m.queries.WithTx(tx)
+
+ rollup := pgtype.Text{}
+ if snap.HasChecks {
+ rollup = textOrNull(snap.RollupState)
+ }
+ n, err := q.UpdateGitHubPRSnapshot(ctx, db.UpdateGitHubPRSnapshotParams{
+ ApiMergeable: textOrNull(snap.Mergeable),
+ ApiMergeStateStatus: textOrNull(snap.MergeStateStatus),
+ ChecksRollupState: rollup,
+ HeadSha: snap.HeadSHA,
+ FetchedAt: tsFromTime(m.now()),
+ PrID: prID,
+ })
+ if err != nil {
+ return false, err
+ }
+ if n == 0 {
+ // Head advanced — discard the entire response, including the per-check
+ // rows. Nothing is written.
+ return false, nil
+ }
+ if err := q.DeleteGitHubPRCheckRuns(ctx, prID); err != nil {
+ return false, err
+ }
+ for i, c := range snap.Contexts {
+ if err := q.InsertGitHubPRCheckRun(ctx, db.InsertGitHubPRCheckRunParams{
+ PrID: prID,
+ HeadSha: snap.HeadSHA,
+ Ordinal: int32(i),
+ Name: c.Name,
+ Status: c.Status,
+ Conclusion: textOrNull(c.Conclusion),
+ DetailsUrl: textOrNull(c.DetailsURL),
+ IsStatusContext: c.IsStatusContext,
+ }); err != nil {
+ return false, err
+ }
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// scheduleChase re-enqueues the address after the current backoff step. Bounded
+// by maxChaseAttempts so an endlessly-pending CI or a wedged mergeability
+// verdict can never spin forever (acceptance criterion 7).
+func (m *Manager) scheduleChase(addr address) {
+ m.mu.Lock()
+ attempt := m.attempts[addr]
+ if attempt >= maxChaseAttempts {
+ delete(m.attempts, addr)
+ m.mu.Unlock()
+ return
+ }
+ m.attempts[addr] = attempt + 1
+ idx := attempt
+ if idx >= len(m.chaseBackoff) {
+ idx = len(m.chaseBackoff) - 1
+ }
+ delay := m.chaseBackoff[idx]
+ m.mu.Unlock()
+ m.scheduleRetry(addr, delay)
+}
+
+// scheduleRetry re-enqueues the address after delay, unless the manager is
+// shutting down.
+func (m *Manager) scheduleRetry(addr address, delay time.Duration) {
+ ctx := m.ctx
+ time.AfterFunc(delay, func() {
+ if ctx != nil && ctx.Err() != nil {
+ return
+ }
+ m.Enqueue(addr.InstallationID, addr.Owner, addr.Repo, addr.Number)
+ })
+}
+
+func (m *Manager) sweepLoop(ctx context.Context) {
+ ticker := time.NewTicker(m.sweepInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ m.sweepOnce(ctx)
+ }
+ }
+}
+
+// sweepOnce enqueues a refresh for every open PR whose snapshot is missing or
+// older than the sweep TTL. Bounded by sweepMaxRows. This is the safety net for
+// base-branch changes that produce no pull_request webhook on this PR, and for
+// any webhook that was dropped during a deploy.
+func (m *Manager) sweepOnce(ctx context.Context) {
+ rows, err := m.queries.ListStaleOpenGitHubPRs(ctx, db.ListStaleOpenGitHubPRsParams{
+ OlderThan: tsFromTime(m.now().Add(-m.sweepTTL)),
+ MaxRows: m.sweepMaxRows,
+ })
+ if err != nil {
+ slog.Warn("ghsnapshot: sweep query failed", "err", err.Error())
+ return
+ }
+ for _, r := range rows {
+ m.Enqueue(r.InstallationID, r.RepoOwner, r.RepoName, r.PrNumber)
+ }
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+func asRateLimit(err error, target **RateLimitError) bool {
+ if rl, ok := err.(*RateLimitError); ok {
+ *target = rl
+ return true
+ }
+ return false
+}
+
+func textOrNull(s string) pgtype.Text {
+ if s == "" {
+ return pgtype.Text{}
+ }
+ return pgtype.Text{String: s, Valid: true}
+}
+
+func tsFromTime(t time.Time) pgtype.Timestamptz {
+ return pgtype.Timestamptz{Time: t, Valid: true}
+}
+
+// sleepCtx sleeps for d or until ctx is cancelled; returns false if cancelled.
+func sleepCtx(ctx context.Context, d time.Duration) bool {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-t.C:
+ return true
+ }
+}
diff --git a/server/internal/integrations/ghsnapshot/refresh_db_test.go b/server/internal/integrations/ghsnapshot/refresh_db_test.go
new file mode 100644
index 000000000..aeea0e459
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/refresh_db_test.go
@@ -0,0 +1,203 @@
+package ghsnapshot
+
+import (
+ "context"
+ "crypto/rand"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+func testDBPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ dbURL := os.Getenv("DATABASE_URL")
+ if dbURL == "" {
+ dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
+ }
+ pool, err := pgxpool.New(context.Background(), dbURL)
+ if err != nil {
+ t.Skipf("skipping DB test: %v", err)
+ }
+ if err := pool.Ping(context.Background()); err != nil {
+ pool.Close()
+ t.Skipf("skipping DB test: database not reachable: %v", err)
+ }
+ return pool
+}
+
+// seedWorkspace inserts a minimal workspace and registers its cleanup.
+// github_pull_request carries a workspace_id foreign key (it predates the
+// no-FK convention), so the row must reference a real workspace.
+func seedWorkspace(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
+ t.Helper()
+ slug := "ghsnap-" + randHex(t)
+ var wsID pgtype.UUID
+ if err := pool.QueryRow(context.Background(),
+ `INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ($1,$2,$3,$4) RETURNING id`,
+ "ghsnap test", slug, "ghsnap test workspace", "GHS").Scan(&wsID); err != nil {
+ t.Fatalf("seed workspace: %v", err)
+ }
+ t.Cleanup(func() {
+ _, _ = pool.Exec(context.Background(), `DELETE FROM workspace WHERE id=$1`, wsID)
+ })
+ return wsID
+}
+
+func randHex(t *testing.T) string {
+ t.Helper()
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ t.Fatal(err)
+ }
+ const hexdigits = "0123456789abcdef"
+ out := make([]byte, 16)
+ for i, v := range b {
+ out[i*2] = hexdigits[v>>4]
+ out[i*2+1] = hexdigits[v&0x0f]
+ }
+ return string(out)
+}
+
+func seedPR(t *testing.T, pool *pgxpool.Pool, q *db.Queries, headSHA string) db.GithubPullRequest {
+ t.Helper()
+ ts := pgtype.Timestamptz{Time: time.Unix(1_700_000_000, 0), Valid: true}
+ pr, err := q.UpsertGitHubPullRequest(context.Background(), db.UpsertGitHubPullRequestParams{
+ WorkspaceID: seedWorkspace(t, pool),
+ InstallationID: 987654,
+ RepoOwner: "o",
+ RepoName: "r",
+ PrNumber: 4242,
+ Title: "t",
+ State: "open",
+ HtmlUrl: "http://x",
+ HeadSha: headSHA,
+ PrCreatedAt: ts,
+ PrUpdatedAt: ts,
+ })
+ if err != nil {
+ t.Fatalf("seed PR: %v", err)
+ }
+ return pr
+}
+
+func checkRunCount(t *testing.T, pool *pgxpool.Pool, prID pgtype.UUID) int {
+ t.Helper()
+ var n int
+ if err := pool.QueryRow(context.Background(),
+ `SELECT count(*) FROM github_pull_request_check_run WHERE pr_id=$1`, prID).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// TestApplySnapshotHeadSHAGuard is the acceptance-criterion-1 regression: a slow
+// response for an old head must never overwrite a newer head's snapshot.
+func TestApplySnapshotHeadSHAGuard(t *testing.T) {
+ pool := testDBPool(t)
+ defer pool.Close()
+ q := db.New(pool)
+ ctx := context.Background()
+ now := time.Unix(1_700_000_100, 0)
+
+ pr := seedPR(t, pool, q, "B")
+ t.Cleanup(func() {
+ _, _ = pool.Exec(context.Background(), `DELETE FROM github_pull_request_check_run WHERE pr_id=$1`, pr.ID)
+ _, _ = pool.Exec(context.Background(), `DELETE FROM github_pull_request WHERE id=$1`, pr.ID)
+ })
+
+ m := &Manager{queries: q, pool: pool, now: func() time.Time { return now }}
+
+ // 1. A response for head "A" while the row is at "B" → discarded, nothing written.
+ applied, err := m.applySnapshot(ctx, pr.ID, &PRSnapshot{HeadSHA: "A", Mergeable: "CONFLICTING", MergeStateStatus: "DIRTY"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if applied {
+ t.Fatal("mismatched-head snapshot must be discarded")
+ }
+ got, _ := q.GetGitHubPullRequestByID(ctx, pr.ID)
+ if got.SnapshotHeadSha != "" || got.ApiMergeable.Valid {
+ t.Fatalf("discarded write leaked into row: %+v", got)
+ }
+
+ // 2. Matching head "B" → applied; snapshot columns + per-check rows written.
+ applied, err = m.applySnapshot(ctx, pr.ID, &PRSnapshot{
+ HeadSHA: "B", Mergeable: "MERGEABLE", MergeStateStatus: "CLEAN", HasChecks: true, RollupState: "FAILURE",
+ Contexts: []CheckContext{
+ {Name: "backend", Status: "completed", Conclusion: "failure"},
+ {Name: "vercel", Status: "completed", Conclusion: "success", IsStatusContext: true},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !applied {
+ t.Fatal("matching-head snapshot must apply")
+ }
+ got, _ = q.GetGitHubPullRequestByID(ctx, pr.ID)
+ if got.SnapshotHeadSha != "B" || got.ApiMergeable.String != "MERGEABLE" || got.ChecksRollupState.String != "FAILURE" {
+ t.Fatalf("snapshot not written: %+v", got)
+ }
+ if n := checkRunCount(t, pool, pr.ID); n != 2 {
+ t.Fatalf("check runs = %d, want 2", n)
+ }
+
+ // 3. Head advances to "C" (a new push mirrored by the pull_request webhook);
+ // a late in-flight response for the old head "B" must be discarded and must
+ // NOT overwrite the stored snapshot.
+ if _, err := pool.Exec(ctx, `UPDATE github_pull_request SET head_sha='C' WHERE id=$1`, pr.ID); err != nil {
+ t.Fatal(err)
+ }
+ applied, err = m.applySnapshot(ctx, pr.ID, &PRSnapshot{HeadSHA: "B", Mergeable: "CONFLICTING", MergeStateStatus: "DIRTY"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if applied {
+ t.Fatal("late response for the old head must be discarded once head advanced")
+ }
+ got, _ = q.GetGitHubPullRequestByID(ctx, pr.ID)
+ if got.SnapshotHeadSha != "B" || got.ApiMergeable.String != "MERGEABLE" {
+ t.Fatalf("stale late write corrupted the snapshot: %+v", got)
+ }
+ if n := checkRunCount(t, pool, pr.ID); n != 2 {
+ t.Fatalf("check runs after stale late write = %d, want 2 (unchanged)", n)
+ }
+}
+
+// TestApplySnapshotReplacesRuns proves each successful apply is an atomic batch
+// replace, not an accumulation.
+func TestApplySnapshotReplacesRuns(t *testing.T) {
+ pool := testDBPool(t)
+ defer pool.Close()
+ q := db.New(pool)
+ ctx := context.Background()
+ now := time.Unix(1_700_000_200, 0)
+
+ pr := seedPR(t, pool, q, "H")
+ t.Cleanup(func() {
+ _, _ = pool.Exec(context.Background(), `DELETE FROM github_pull_request_check_run WHERE pr_id=$1`, pr.ID)
+ _, _ = pool.Exec(context.Background(), `DELETE FROM github_pull_request WHERE id=$1`, pr.ID)
+ })
+ m := &Manager{queries: q, pool: pool, now: func() time.Time { return now }}
+
+ three := &PRSnapshot{HeadSHA: "H", Mergeable: "MERGEABLE", MergeStateStatus: "CLEAN", HasChecks: true, RollupState: "PENDING",
+ Contexts: []CheckContext{{Name: "a", Status: "in_progress"}, {Name: "b", Status: "in_progress"}, {Name: "c", Status: "in_progress"}}}
+ if _, err := m.applySnapshot(ctx, pr.ID, three); err != nil {
+ t.Fatal(err)
+ }
+ if n := checkRunCount(t, pool, pr.ID); n != 3 {
+ t.Fatalf("after first apply: %d runs, want 3", n)
+ }
+ one := &PRSnapshot{HeadSHA: "H", Mergeable: "MERGEABLE", MergeStateStatus: "CLEAN", HasChecks: true, RollupState: "SUCCESS",
+ Contexts: []CheckContext{{Name: "a", Status: "completed", Conclusion: "success"}}}
+ if _, err := m.applySnapshot(ctx, pr.ID, one); err != nil {
+ t.Fatal(err)
+ }
+ if n := checkRunCount(t, pool, pr.ID); n != 1 {
+ t.Fatalf("after replace: %d runs, want 1 (old runs deleted)", n)
+ }
+}
diff --git a/server/internal/integrations/ghsnapshot/refresh_test.go b/server/internal/integrations/ghsnapshot/refresh_test.go
new file mode 100644
index 000000000..7aa0f12a8
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/refresh_test.go
@@ -0,0 +1,115 @@
+package ghsnapshot
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "testing"
+ "time"
+)
+
+func enabledClient(t *testing.T) *Client {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &Client{appID: "1", privateKey: key, tokens: map[int64]cachedToken{}, now: time.Now}
+}
+
+// TestManagerDisabledNoOps is the clean-degradation guarantee (acceptance
+// criterion 4): with no App key the manager touches nothing.
+func TestManagerDisabledNoOps(t *testing.T) {
+ m := NewManager(nil, nil, nil, nil)
+ if m.Enabled() {
+ t.Fatal("nil-client manager must be disabled")
+ }
+ m.Enqueue(1, "o", "r", 2)
+ m.MaybeEnqueueOnView(1, "o", "r", 2, time.Time{}, false)
+ if len(m.queue) != 0 {
+ t.Fatalf("disabled manager enqueued %d items, want 0", len(m.queue))
+ }
+ // Start must be a safe no-op (no workers, no panic).
+ m.Start(context.Background())
+}
+
+// TestEnqueueCoalesces proves the dedup / single-in-flight key (acceptance
+// criterion 3): the same PR address enqueued repeatedly is coalesced to one
+// pending item; distinct addresses are not.
+func TestEnqueueCoalesces(t *testing.T) {
+ m := NewManager(enabledClient(t), nil, nil, nil)
+ // Workers are NOT started, so items accumulate in the queue for inspection.
+ m.Enqueue(1, "o", "r", 7)
+ m.Enqueue(1, "o", "r", 7)
+ m.Enqueue(1, "o", "r", 7)
+ if len(m.queue) != 1 {
+ t.Fatalf("same address enqueued 3× produced %d queued items, want 1", len(m.queue))
+ }
+ m.Enqueue(1, "o", "r", 8) // different PR
+ m.Enqueue(1, "o", "other", 7)
+ if len(m.queue) != 3 {
+ t.Fatalf("queue length = %d, want 3 distinct", len(m.queue))
+ }
+}
+
+// TestMaybeEnqueueOnViewRespectsTTL: a fresh snapshot is not refreshed on view;
+// a stale or missing one is.
+func TestMaybeEnqueueOnViewRespectsTTL(t *testing.T) {
+ m := NewManager(enabledClient(t), nil, nil, nil)
+ now := time.Unix(10000, 0)
+ m.now = func() time.Time { return now }
+
+ m.MaybeEnqueueOnView(1, "o", "r", 1, now.Add(-10*time.Second), true) // fresh (<60s)
+ if len(m.queue) != 0 {
+ t.Fatal("fresh snapshot should not refresh on view")
+ }
+ m.MaybeEnqueueOnView(1, "o", "r", 2, now.Add(-5*time.Minute), true) // stale
+ m.MaybeEnqueueOnView(1, "o", "r", 3, time.Time{}, false) // never fetched
+ if len(m.queue) != 2 {
+ t.Fatalf("stale/missing snapshots enqueued %d, want 2", len(m.queue))
+ }
+}
+
+// TestProcessRateLimitedSetsPause proves a rate-limited fetch pauses the whole
+// pipeline until Retry-After elapses (acceptance criterion 3) and writes nothing.
+func TestProcessRateLimitedSetsPause(t *testing.T) {
+ m := NewManager(enabledClient(t), nil, nil, nil)
+ now := time.Unix(20000, 0)
+ m.now = func() time.Time { return now }
+ m.jitter = func() time.Duration { return 0 }
+ // A cancelled ctx keeps the rescheduled retry from lingering after the test.
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ m.ctx = ctx
+ m.fetch = func(context.Context, *Client, int64, string, string, int32) (*PRSnapshot, error) {
+ return nil, &RateLimitError{RetryAfter: 90 * time.Second}
+ }
+ // queries/pool are nil; the fetch errors before any DB access, proving the
+ // rate-limit path never touches storage.
+ m.process(ctx, address{InstallationID: 1, Owner: "o", Repo: "r", Number: 1})
+
+ if got := m.rateUntil; !got.Equal(now.Add(90 * time.Second)) {
+ t.Fatalf("rateUntil = %v, want %v", got, now.Add(90*time.Second))
+ }
+}
+
+// TestScheduleChaseBounded proves the chase window terminates (acceptance
+// criterion 7): after maxChaseAttempts the address stops being rescheduled.
+func TestScheduleChaseBounded(t *testing.T) {
+ m := NewManager(enabledClient(t), nil, nil, nil)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ m.ctx = ctx
+ addr := address{InstallationID: 1, Owner: "o", Repo: "r", Number: 1}
+ for i := 0; i < maxChaseAttempts; i++ {
+ m.scheduleChase(addr)
+ if m.attempts[addr] != i+1 {
+ t.Fatalf("attempt %d recorded as %d", i+1, m.attempts[addr])
+ }
+ }
+ // One more chase past the cap clears tracking and does not reschedule.
+ m.scheduleChase(addr)
+ if _, ok := m.attempts[addr]; ok {
+ t.Fatal("chase past the cap must stop and clear attempts")
+ }
+}
diff --git a/server/internal/integrations/ghsnapshot/snapshot.go b/server/internal/integrations/ghsnapshot/snapshot.go
new file mode 100644
index 000000000..baad3d2d3
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/snapshot.go
@@ -0,0 +1,273 @@
+package ghsnapshot
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+)
+
+// prSnapshotQuery is the single GraphQL query behind the whole feature. It
+// returns, in one round trip (Elon measured cost=1):
+// - headRefOid: the head the snapshot describes (pins the anti-stale write);
+// - mergeable: MERGEABLE / CONFLICTING / UNKNOWN — answers "is there a
+// conflict" only;
+// - mergeStateStatus: CLEAN / DIRTY / BLOCKED / BEHIND / UNSTABLE / ... —
+// "Ready to merge" is derived ONLY from CLEAN;
+// - statusCheckRollup: the overall CI verdict plus every check/status context.
+//
+// $cursor paginates statusCheckRollup.contexts; the caller loops until
+// hasNextPage is false (acceptance criterion 2 — never assume <100 contexts).
+const prSnapshotQuery = `query($owner:String!,$repo:String!,$number:Int!,$cursor:String){
+ repository(owner:$owner,name:$repo){
+ pullRequest(number:$number){
+ headRefOid
+ mergeable
+ mergeStateStatus
+ commits(last:1){nodes{commit{
+ statusCheckRollup{
+ state
+ contexts(first:100,after:$cursor){
+ pageInfo{hasNextPage endCursor}
+ nodes{
+ __typename
+ ... on CheckRun{name status conclusion detailsUrl}
+ ... on StatusContext{context state targetUrl}
+ }
+ }
+ }
+ }}}
+ }
+ }
+}`
+
+// CheckContext is one normalized check for a PR head. Both GraphQL CheckRun and
+// StatusContext contexts are flattened into this shape (see normalizeNode), so
+// downstream storage and aggregation are uniform.
+type CheckContext struct {
+ Name string
+ // Status is the normalized lifecycle: "queued", "in_progress", or
+ // "completed".
+ Status string
+ // Conclusion is the normalized result: "success", "failure", "neutral",
+ // "cancelled", "skipped", "timed_out", "action_required", "startup_failure",
+ // "stale", or "error"; empty while the check is still running.
+ Conclusion string
+ DetailsURL string
+ IsStatusContext bool
+}
+
+// PRSnapshot is the atomic unit written per fetch. It mirrors exactly what the
+// API returned — no incremental inference.
+type PRSnapshot struct {
+ HeadSHA string
+ Mergeable string // MERGEABLE / CONFLICTING / UNKNOWN (raw enum)
+ MergeStateStatus string // CLEAN / DIRTY / BLOCKED / BEHIND / UNSTABLE / ... (raw enum)
+ // RollupState is statusCheckRollup.state (SUCCESS/FAILURE/PENDING/ERROR/
+ // EXPECTED). Empty ONLY when HasChecks is false.
+ RollupState string
+ // HasChecks is false when statusCheckRollup was null — GitHub reports "no
+ // checks have been created for this commit yet". This must NEVER be rendered
+ // as passed (acceptance criterion 5).
+ HasChecks bool
+ Contexts []CheckContext
+}
+
+// Decided reports whether the snapshot has settled: no check is still running
+// and mergeability is known. An undecided snapshot on an open PR drives the
+// bounded chase-window re-fetch (refresh.go).
+func (s *PRSnapshot) Decided() bool {
+ if s.Mergeable == "UNKNOWN" || s.Mergeable == "" {
+ return false
+ }
+ if s.HasChecks {
+ switch s.RollupState {
+ case "PENDING", "EXPECTED", "":
+ return false
+ }
+ }
+ for _, c := range s.Contexts {
+ if c.Status != "completed" {
+ return false
+ }
+ }
+ return true
+}
+
+type graphqlRollup struct {
+ State string `json:"state"`
+ Contexts struct {
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ Nodes []json.RawMessage `json:"nodes"`
+ } `json:"contexts"`
+}
+
+type graphqlPullRequest struct {
+ HeadRefOid string `json:"headRefOid"`
+ Mergeable string `json:"mergeable"`
+ MergeStateStatus string `json:"mergeStateStatus"`
+ Commits struct {
+ Nodes []struct {
+ Commit struct {
+ StatusCheckRollup *graphqlRollup `json:"statusCheckRollup"`
+ } `json:"commit"`
+ } `json:"nodes"`
+ } `json:"commits"`
+}
+
+func (pr *graphqlPullRequest) rollup() *graphqlRollup {
+ if len(pr.Commits.Nodes) == 0 {
+ return nil
+ }
+ return pr.Commits.Nodes[0].Commit.StatusCheckRollup
+}
+
+type graphqlPRData struct {
+ Repository struct {
+ PullRequest *graphqlPullRequest `json:"pullRequest"`
+ } `json:"repository"`
+}
+
+// FetchPRSnapshot runs prSnapshotQuery, paginating statusCheckRollup.contexts
+// to completion, and returns the normalized snapshot. A nil rollup yields
+// HasChecks=false with no contexts.
+func FetchPRSnapshot(ctx context.Context, c *Client, installationID int64, owner, repo string, number int32) (*PRSnapshot, error) {
+ if !c.Enabled() {
+ return nil, errors.New("ghsnapshot: client not configured")
+ }
+ snap := &PRSnapshot{}
+ cursor := ""
+ // Guard against a pathological cursor loop; 100 pages = 10k contexts, far
+ // beyond any real PR.
+ for page := 0; page < 100; page++ {
+ vars := map[string]any{"owner": owner, "repo": repo, "number": number}
+ if cursor != "" {
+ vars["cursor"] = cursor
+ } else {
+ vars["cursor"] = nil
+ }
+ data, err := c.graphQL(ctx, installationID, prSnapshotQuery, vars)
+ if err != nil {
+ return nil, err
+ }
+ var parsed graphqlPRData
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ return nil, errors.New("ghsnapshot: malformed pull request data")
+ }
+ pr := parsed.Repository.PullRequest
+ if pr == nil {
+ return nil, errors.New("ghsnapshot: pull request not found")
+ }
+ if page == 0 {
+ snap.HeadSHA = pr.HeadRefOid
+ snap.Mergeable = pr.Mergeable
+ snap.MergeStateStatus = pr.MergeStateStatus
+ }
+ rollup := pr.rollup()
+ if rollup == nil {
+ // statusCheckRollup is null → no checks yet. Nothing to paginate.
+ break
+ }
+ snap.HasChecks = true
+ snap.RollupState = rollup.State
+ for _, raw := range rollup.Contexts.Nodes {
+ if cc, ok := normalizeNode(raw); ok {
+ snap.Contexts = append(snap.Contexts, cc)
+ }
+ }
+ if !rollup.Contexts.PageInfo.HasNextPage || rollup.Contexts.PageInfo.EndCursor == "" {
+ break
+ }
+ cursor = rollup.Contexts.PageInfo.EndCursor
+ }
+ return snap, nil
+}
+
+// normalizeNode flattens one GraphQL union node (CheckRun or StatusContext)
+// into a CheckContext. CheckRun statuses/conclusions map to lowercase; legacy
+// StatusContext states map onto the same lifecycle so aggregation is uniform:
+//
+// SUCCESS → completed / success
+// FAILURE → completed / failure
+// ERROR → completed / error
+// PENDING → in_progress / (running)
+// EXPECTED → queued / (running)
+func normalizeNode(raw json.RawMessage) (CheckContext, bool) {
+ var probe struct {
+ Typename string `json:"__typename"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return CheckContext{}, false
+ }
+ switch probe.Typename {
+ case "CheckRun":
+ var n struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Conclusion string `json:"conclusion"`
+ DetailsURL string `json:"detailsUrl"`
+ }
+ if err := json.Unmarshal(raw, &n); err != nil {
+ return CheckContext{}, false
+ }
+ return CheckContext{
+ Name: n.Name,
+ Status: normalizeRunStatus(n.Status),
+ Conclusion: strings.ToLower(n.Conclusion),
+ DetailsURL: n.DetailsURL,
+ }, true
+ case "StatusContext":
+ var n struct {
+ Context string `json:"context"`
+ State string `json:"state"`
+ TargetURL string `json:"targetUrl"`
+ }
+ if err := json.Unmarshal(raw, &n); err != nil {
+ return CheckContext{}, false
+ }
+ status, conclusion := normalizeStatusState(n.State)
+ return CheckContext{
+ Name: n.Context,
+ Status: status,
+ Conclusion: conclusion,
+ DetailsURL: n.TargetURL,
+ IsStatusContext: true,
+ }, true
+ default:
+ return CheckContext{}, false
+ }
+}
+
+// normalizeRunStatus maps a GraphQL CheckRun.status enum to our lifecycle.
+// Only COMPLETED is terminal; QUEUED/IN_PROGRESS/WAITING/PENDING/REQUESTED are
+// all still running.
+func normalizeRunStatus(s string) string {
+ switch strings.ToUpper(s) {
+ case "COMPLETED":
+ return "completed"
+ case "IN_PROGRESS":
+ return "in_progress"
+ default:
+ return "queued"
+ }
+}
+
+// normalizeStatusState maps a legacy StatusContext.state onto (status,
+// conclusion). Empty conclusion means still running.
+func normalizeStatusState(s string) (status, conclusion string) {
+ switch strings.ToUpper(s) {
+ case "SUCCESS":
+ return "completed", "success"
+ case "FAILURE":
+ return "completed", "failure"
+ case "ERROR":
+ return "completed", "error"
+ case "PENDING":
+ return "in_progress", ""
+ default: // EXPECTED and any unknown state
+ return "queued", ""
+ }
+}
diff --git a/server/internal/integrations/ghsnapshot/snapshot_test.go b/server/internal/integrations/ghsnapshot/snapshot_test.go
new file mode 100644
index 000000000..65105a767
--- /dev/null
+++ b/server/internal/integrations/ghsnapshot/snapshot_test.go
@@ -0,0 +1,139 @@
+package ghsnapshot
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// graphqlServer stands in for GitHub's GraphQL endpoint. tokenHandler mints a
+// token; the queryFn produces the `data` payload for each GraphQL request,
+// receiving the request variables so it can page.
+func graphqlServer(t *testing.T, queryFn func(vars map[string]any) string) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access_tokens") {
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
+ time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ var req struct {
+ Variables map[string]any `json:"variables"`
+ }
+ _ = json.Unmarshal(body, &req)
+ _, _ = w.Write([]byte(`{"data":` + queryFn(req.Variables) + `}`))
+ }))
+}
+
+// TestFetchPRSnapshotPaginatesContexts is the acceptance-criterion-2 test:
+// contexts spanning two pages must all be collected — never assume <100.
+func TestFetchPRSnapshotPaginatesContexts(t *testing.T) {
+ srv := graphqlServer(t, func(vars map[string]any) string {
+ cursor, _ := vars["cursor"].(string)
+ if cursor == "" {
+ // Page 1: two contexts, more to come.
+ return `{"repository":{"pullRequest":{
+ "headRefOid":"sha1","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN",
+ "commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE","contexts":{
+ "pageInfo":{"hasNextPage":true,"endCursor":"CUR2"},
+ "nodes":[
+ {"__typename":"CheckRun","name":"backend","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"u1"},
+ {"__typename":"CheckRun","name":"frontend","status":"COMPLETED","conclusion":"SUCCESS","detailsUrl":"u2"}
+ ]}}}}]}}}}`
+ }
+ // Page 2 (cursor==CUR2): one more context, plus a legacy StatusContext.
+ return `{"repository":{"pullRequest":{
+ "headRefOid":"sha1","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN",
+ "commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE","contexts":{
+ "pageInfo":{"hasNextPage":false,"endCursor":""},
+ "nodes":[
+ {"__typename":"CheckRun","name":"e2e","status":"IN_PROGRESS","conclusion":null,"detailsUrl":"u3"},
+ {"__typename":"StatusContext","context":"vercel","state":"SUCCESS","targetUrl":"u4"}
+ ]}}}}]}}}}`
+ })
+ defer srv.Close()
+
+ c := newTestClient(t, srv.URL)
+ snap, err := FetchPRSnapshot(context.Background(), c, 1, "o", "r", 5)
+ if err != nil {
+ t.Fatalf("FetchPRSnapshot: %v", err)
+ }
+ if snap.HeadSHA != "sha1" || snap.Mergeable != "MERGEABLE" || snap.MergeStateStatus != "CLEAN" {
+ t.Fatalf("head/merge = %+v", snap)
+ }
+ if !snap.HasChecks || snap.RollupState != "FAILURE" {
+ t.Fatalf("rollup = %q hasChecks=%v", snap.RollupState, snap.HasChecks)
+ }
+ if len(snap.Contexts) != 4 {
+ t.Fatalf("collected %d contexts across pages, want 4", len(snap.Contexts))
+ }
+ // StatusContext normalization: SUCCESS → completed/success, is_status_context.
+ last := snap.Contexts[3]
+ if last.Name != "vercel" || last.Status != "completed" || last.Conclusion != "success" || !last.IsStatusContext {
+ t.Fatalf("status context normalized wrong: %+v", last)
+ }
+ // In-progress CheckRun stays running.
+ if snap.Contexts[2].Status != "in_progress" || snap.Contexts[2].Conclusion != "" {
+ t.Fatalf("in-progress run normalized wrong: %+v", snap.Contexts[2])
+ }
+ if snap.Decided() {
+ t.Fatal("snapshot with an in-progress run must be undecided")
+ }
+}
+
+// TestFetchPRSnapshotNullRollup is the acceptance-criterion-5 test: a null
+// statusCheckRollup means "no checks yet" (HasChecks=false), never passed.
+func TestFetchPRSnapshotNullRollup(t *testing.T) {
+ srv := graphqlServer(t, func(vars map[string]any) string {
+ return `{"repository":{"pullRequest":{
+ "headRefOid":"sha9","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN",
+ "commits":{"nodes":[{"commit":{"statusCheckRollup":null}}]}}}}`
+ })
+ defer srv.Close()
+
+ c := newTestClient(t, srv.URL)
+ snap, err := FetchPRSnapshot(context.Background(), c, 1, "o", "r", 5)
+ if err != nil {
+ t.Fatalf("FetchPRSnapshot: %v", err)
+ }
+ if snap.HasChecks {
+ t.Fatal("null rollup must yield HasChecks=false")
+ }
+ if snap.RollupState != "" || len(snap.Contexts) != 0 {
+ t.Fatalf("null rollup must have empty rollup/contexts, got %+v", snap)
+ }
+ if snap.Decided() {
+ t.Fatal("UNKNOWN mergeable must be undecided")
+ }
+}
+
+func TestSnapshotDecided(t *testing.T) {
+ cases := []struct {
+ name string
+ snap PRSnapshot
+ want bool
+ }{
+ {"clean passed", PRSnapshot{Mergeable: "MERGEABLE", HasChecks: true, RollupState: "SUCCESS",
+ Contexts: []CheckContext{{Status: "completed", Conclusion: "success"}}}, true},
+ {"conflicting decided", PRSnapshot{Mergeable: "CONFLICTING", HasChecks: false}, true},
+ {"mergeable unknown", PRSnapshot{Mergeable: "UNKNOWN", HasChecks: true, RollupState: "SUCCESS"}, false},
+ {"rollup pending", PRSnapshot{Mergeable: "MERGEABLE", HasChecks: true, RollupState: "PENDING"}, false},
+ {"running context", PRSnapshot{Mergeable: "MERGEABLE", HasChecks: true, RollupState: "SUCCESS",
+ Contexts: []CheckContext{{Status: "in_progress"}}}, false},
+ {"no checks but mergeable", PRSnapshot{Mergeable: "MERGEABLE", HasChecks: false}, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.snap.Decided(); got != tc.want {
+ t.Fatalf("Decided() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/server/migrations/219_github_pr_api_snapshot.down.sql b/server/migrations/219_github_pr_api_snapshot.down.sql
new file mode 100644
index 000000000..a8a3f4ee1
--- /dev/null
+++ b/server/migrations/219_github_pr_api_snapshot.down.sql
@@ -0,0 +1,8 @@
+DROP TABLE IF EXISTS github_pull_request_check_run;
+
+ALTER TABLE github_pull_request
+ DROP COLUMN IF EXISTS api_mergeable,
+ DROP COLUMN IF EXISTS api_merge_state_status,
+ DROP COLUMN IF EXISTS checks_rollup_state,
+ DROP COLUMN IF EXISTS snapshot_head_sha,
+ DROP COLUMN IF EXISTS snapshot_fetched_at;
diff --git a/server/migrations/219_github_pr_api_snapshot.up.sql b/server/migrations/219_github_pr_api_snapshot.up.sql
new file mode 100644
index 000000000..1d354d255
--- /dev/null
+++ b/server/migrations/219_github_pr_api_snapshot.up.sql
@@ -0,0 +1,55 @@
+-- MUL-5265: GitHub API snapshot for PR cards (Plan C).
+--
+-- The PR card's CI status and mergeability are now sourced from an
+-- authenticated GitHub API snapshot (GraphQL pullRequest query) rather than
+-- inferred from webhook check_suite events. Webhooks and page visits only
+-- trigger a refresh; the API response is the single source of truth and is
+-- written as one atomic batch replace per PR.
+--
+-- All new columns are nullable / defaulted so rows that pre-date the snapshot
+-- (or deployments without a GitHub App private key, where the feature degrades
+-- off) keep working: the card simply hides the CI / merge region until a
+-- snapshot lands.
+
+ALTER TABLE github_pull_request
+ -- GraphQL `mergeable`: MERGEABLE / CONFLICTING / UNKNOWN. Answers only
+ -- "is there a merge conflict". NULL until first snapshot.
+ ADD COLUMN api_mergeable TEXT,
+ -- GraphQL `mergeStateStatus`: CLEAN / DIRTY / BLOCKED / BEHIND / UNSTABLE /
+ -- DRAFT / HAS_HOOKS / UNKNOWN. "Ready to merge" is derived ONLY from CLEAN.
+ ADD COLUMN api_merge_state_status TEXT,
+ -- GraphQL statusCheckRollup.state: SUCCESS / FAILURE / PENDING / ERROR /
+ -- EXPECTED. NULL means statusCheckRollup was null → "no checks yet", which
+ -- must never be rendered as passed.
+ ADD COLUMN checks_rollup_state TEXT,
+ -- The head SHA the snapshot was fetched for. Pinned so a slow response for
+ -- an old head cannot overwrite a newer head's snapshot (head-SHA anti-stale
+ -- write). Empty until first snapshot.
+ ADD COLUMN snapshot_head_sha TEXT NOT NULL DEFAULT '',
+ -- When the snapshot was fetched. Drives the TTL / page-visit refresh and the
+ -- stale visual marker. NULL until first snapshot.
+ ADD COLUMN snapshot_fetched_at TIMESTAMPTZ;
+
+-- Per-check snapshot rows for a PR's current head. Replaced atomically (delete
+-- all + insert) on every successful API fetch — no incremental inference. Both
+-- GraphQL CheckRun and StatusContext contexts are normalized into this shape at
+-- write time (see ghsnapshot.normalizeContext). Keyed by (pr_id, ordinal)
+-- because two checks can share a name (matrix jobs, re-runs), so name is not
+-- unique.
+CREATE TABLE github_pull_request_check_run (
+ pr_id UUID NOT NULL,
+ head_sha TEXT NOT NULL,
+ ordinal INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ -- Normalized lifecycle: 'queued' / 'in_progress' / 'completed'.
+ status TEXT NOT NULL,
+ -- Normalized conclusion: 'success' / 'failure' / 'neutral' / 'cancelled' /
+ -- 'skipped' / 'timed_out' / 'action_required' / 'error' / ... ; NULL while
+ -- the check is still running.
+ conclusion TEXT,
+ details_url TEXT,
+ -- TRUE for legacy commit-status contexts (GraphQL StatusContext), FALSE for
+ -- Checks API runs (GraphQL CheckRun). Kept for display / debugging.
+ is_status_context BOOLEAN NOT NULL DEFAULT FALSE,
+ PRIMARY KEY (pr_id, ordinal)
+);
diff --git a/server/pkg/db/generated/github.sql.go b/server/pkg/db/generated/github.sql.go
index 9f5e7793b..61da01227 100644
--- a/server/pkg/db/generated/github.sql.go
+++ b/server/pkg/db/generated/github.sql.go
@@ -115,67 +115,6 @@ func (q *Queries) DeletePendingGitHubInstallation(ctx context.Context, installat
return err
}
-const drainPendingCheckSuitesForPR = `-- name: DrainPendingCheckSuitesForPR :many
-DELETE FROM github_pending_check_suite
-WHERE workspace_id = $1
- AND repo_owner = $2
- AND repo_name = $3
- AND pr_number = $4
-RETURNING suite_id, head_sha, app_id, conclusion, status, suite_updated_at
-`
-
-type DrainPendingCheckSuitesForPRParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- RepoOwner string `json:"repo_owner"`
- RepoName string `json:"repo_name"`
- PrNumber int32 `json:"pr_number"`
-}
-
-type DrainPendingCheckSuitesForPRRow struct {
- SuiteID int64 `json:"suite_id"`
- HeadSha string `json:"head_sha"`
- AppID int64 `json:"app_id"`
- Conclusion pgtype.Text `json:"conclusion"`
- Status string `json:"status"`
- SuiteUpdatedAt pgtype.Timestamptz `json:"suite_updated_at"`
-}
-
-// Atomically reads + deletes all pending suites for the given PR address.
-// Caller replays each row through UpsertPullRequestCheckSuite. RETURNING
-// gives us the payloads we need without a separate SELECT, so two parallel
-// handlers racing on the same PR can't double-apply the same row.
-func (q *Queries) DrainPendingCheckSuitesForPR(ctx context.Context, arg DrainPendingCheckSuitesForPRParams) ([]DrainPendingCheckSuitesForPRRow, error) {
- rows, err := q.db.Query(ctx, drainPendingCheckSuitesForPR,
- arg.WorkspaceID,
- arg.RepoOwner,
- arg.RepoName,
- arg.PrNumber,
- )
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []DrainPendingCheckSuitesForPRRow{}
- for rows.Next() {
- var i DrainPendingCheckSuitesForPRRow
- if err := rows.Scan(
- &i.SuiteID,
- &i.HeadSha,
- &i.AppID,
- &i.Conclusion,
- &i.Status,
- &i.SuiteUpdatedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
const getGitHubInstallationByID = `-- name: GetGitHubInstallationByID :one
SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation
WHERE id = $1
@@ -199,7 +138,7 @@ func (q *Queries) GetGitHubInstallationByID(ctx context.Context, id pgtype.UUID)
}
const getGitHubPullRequest = `-- name: GetGitHubPullRequest :one
-SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files FROM github_pull_request
+SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files, api_mergeable, api_merge_state_status, checks_rollup_state, snapshot_head_sha, snapshot_fetched_at FROM github_pull_request
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4
`
@@ -242,6 +181,11 @@ func (q *Queries) GetGitHubPullRequest(ctx context.Context, arg GetGitHubPullReq
&i.Additions,
&i.Deletions,
&i.ChangedFiles,
+ &i.ApiMergeable,
+ &i.ApiMergeStateStatus,
+ &i.ChecksRollupState,
+ &i.SnapshotHeadSha,
+ &i.SnapshotFetchedAt,
)
return i, err
}
@@ -500,33 +444,32 @@ func (q *Queries) ListIssueIDsForPullRequest(ctx context.Context, pullRequestID
const listPullRequestsByIssue = `-- name: ListPullRequestsByIssue :many
WITH issue_prs AS (
- SELECT pr.id, pr.head_sha
+ SELECT pr.id, pr.snapshot_head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
),
-per_app_latest AS (
- SELECT DISTINCT ON (cs.pr_id, cs.app_id)
- cs.pr_id, cs.app_id, cs.conclusion, cs.status
- FROM github_pull_request_check_suite cs
- JOIN issue_prs ip ON ip.id = cs.pr_id
- WHERE cs.head_sha = ip.head_sha AND ip.head_sha <> ''
- ORDER BY cs.pr_id, cs.app_id, cs.updated_at DESC
-),
checks AS (
SELECT
- pr_id,
+ cr.pr_id,
COUNT(*)::bigint AS total,
- SUM(CASE WHEN status = 'completed' AND conclusion IN
- ('failure','cancelled','timed_out','action_required','startup_failure','stale')
+ SUM(CASE WHEN cr.status = 'completed' AND cr.conclusion IN
+ ('failure','cancelled','timed_out','action_required','startup_failure','stale','error')
THEN 1 ELSE 0 END)::bigint AS failed,
- SUM(CASE WHEN status = 'completed' AND conclusion IN
+ SUM(CASE WHEN cr.status = 'completed' AND cr.conclusion IN
('success','neutral','skipped')
THEN 1 ELSE 0 END)::bigint AS passed,
- SUM(CASE WHEN status <> 'completed' OR conclusion IS NULL
- THEN 1 ELSE 0 END)::bigint AS pending
- FROM per_app_latest
- GROUP BY pr_id
+ SUM(CASE WHEN cr.status <> 'completed' OR cr.conclusion IS NULL
+ THEN 1 ELSE 0 END)::bigint AS running,
+ COALESCE(
+ array_agg(cr.name) FILTER (WHERE cr.status = 'completed' AND cr.conclusion IN
+ ('failure','cancelled','timed_out','action_required','startup_failure','stale','error')),
+ '{}'
+ )::text[] AS failed_names
+ FROM github_pull_request_check_run cr
+ JOIN issue_prs ip ON ip.id = cr.pr_id
+ WHERE cr.head_sha = ip.snapshot_head_sha AND ip.snapshot_head_sha <> ''
+ GROUP BY cr.pr_id
)
SELECT
pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name,
@@ -534,11 +477,14 @@ SELECT
pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at,
pr.pr_updated_at, pr.head_sha, pr.mergeable_state,
pr.additions, pr.deletions, pr.changed_files,
+ pr.api_mergeable, pr.api_merge_state_status, pr.checks_rollup_state,
+ pr.snapshot_head_sha, pr.snapshot_fetched_at,
pr.created_at, pr.updated_at,
COALESCE(c.total, 0)::bigint AS checks_total,
COALESCE(c.passed, 0)::bigint AS checks_passed,
COALESCE(c.failed, 0)::bigint AS checks_failed,
- COALESCE(c.pending, 0)::bigint AS checks_pending
+ COALESCE(c.running, 0)::bigint AS checks_running,
+ COALESCE(c.failed_names, '{}')::text[] AS failed_check_names
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
@@ -547,46 +493,51 @@ ORDER BY pr.pr_created_at DESC
`
type ListPullRequestsByIssueRow struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- InstallationID int64 `json:"installation_id"`
- RepoOwner string `json:"repo_owner"`
- RepoName string `json:"repo_name"`
- PrNumber int32 `json:"pr_number"`
- Title string `json:"title"`
- State string `json:"state"`
- HtmlUrl string `json:"html_url"`
- Branch pgtype.Text `json:"branch"`
- AuthorLogin pgtype.Text `json:"author_login"`
- AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
- MergedAt pgtype.Timestamptz `json:"merged_at"`
- ClosedAt pgtype.Timestamptz `json:"closed_at"`
- PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
- PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
- HeadSha string `json:"head_sha"`
- MergeableState pgtype.Text `json:"mergeable_state"`
- Additions int32 `json:"additions"`
- Deletions int32 `json:"deletions"`
- ChangedFiles int32 `json:"changed_files"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- ChecksTotal int64 `json:"checks_total"`
- ChecksPassed int64 `json:"checks_passed"`
- ChecksFailed int64 `json:"checks_failed"`
- ChecksPending int64 `json:"checks_pending"`
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ InstallationID int64 `json:"installation_id"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ PrNumber int32 `json:"pr_number"`
+ Title string `json:"title"`
+ State string `json:"state"`
+ HtmlUrl string `json:"html_url"`
+ Branch pgtype.Text `json:"branch"`
+ AuthorLogin pgtype.Text `json:"author_login"`
+ AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
+ MergedAt pgtype.Timestamptz `json:"merged_at"`
+ ClosedAt pgtype.Timestamptz `json:"closed_at"`
+ PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
+ PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
+ HeadSha string `json:"head_sha"`
+ MergeableState pgtype.Text `json:"mergeable_state"`
+ Additions int32 `json:"additions"`
+ Deletions int32 `json:"deletions"`
+ ChangedFiles int32 `json:"changed_files"`
+ ApiMergeable pgtype.Text `json:"api_mergeable"`
+ ApiMergeStateStatus pgtype.Text `json:"api_merge_state_status"`
+ ChecksRollupState pgtype.Text `json:"checks_rollup_state"`
+ SnapshotHeadSha string `json:"snapshot_head_sha"`
+ SnapshotFetchedAt pgtype.Timestamptz `json:"snapshot_fetched_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ ChecksTotal int64 `json:"checks_total"`
+ ChecksPassed int64 `json:"checks_passed"`
+ ChecksFailed int64 `json:"checks_failed"`
+ ChecksRunning int64 `json:"checks_running"`
+ FailedCheckNames []string `json:"failed_check_names"`
}
-// Returns the issue's linked PRs with the aggregated check-suite counts for
-// the PR's CURRENT head SHA. The `issue_prs` CTE narrows to this issue's PR
-// ids first so the per-app aggregation only touches suite rows for those
-// PRs — without that scoping the planner has to scan/aggregate every PR's
-// suites in the workspace before joining on issue. Per-app latest suite is
-// selected so a single app firing multiple suites on the same head doesn't
-// get counted N times. Late-arriving suites for an OLD head are stored but
-// excluded by the head_sha filter, so they can't override the new head's
-// pending view. reference_only links (a PR that merely mentions the issue
-// identifier in its body, with no closing keyword and no title/branch
-// reference) are filtered out — they are not working PRs for this issue.
+// Returns the issue's linked PRs with the GitHub API snapshot (MUL-5265): the
+// mergeability verdict, the CI rollup, and per-check counts for the PR's
+// CURRENT snapshot head SHA. Checks are aggregated from
+// github_pull_request_check_run — the run-level snapshot written by the API
+// refresh pipeline — NOT the legacy suite-level webhook aggregation, which is
+// removed. The `issue_prs` CTE narrows to this issue's PR ids first so the
+// aggregation only touches check rows for those PRs. Rows for an OLD head are
+// excluded by the snapshot_head_sha filter. reference_only links (a PR that
+// merely mentions the issue identifier in its body, with no closing keyword and
+// no title/branch reference) are filtered out — they are not working PRs.
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]ListPullRequestsByIssueRow, error) {
rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID)
if err != nil {
@@ -618,12 +569,18 @@ func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UU
&i.Additions,
&i.Deletions,
&i.ChangedFiles,
+ &i.ApiMergeable,
+ &i.ApiMergeStateStatus,
+ &i.ChecksRollupState,
+ &i.SnapshotHeadSha,
+ &i.SnapshotFetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChecksTotal,
&i.ChecksPassed,
&i.ChecksFailed,
- &i.ChecksPending,
+ &i.ChecksRunning,
+ &i.FailedCheckNames,
); err != nil {
return nil, err
}
@@ -741,7 +698,7 @@ ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
deletions = EXCLUDED.deletions,
changed_files = EXCLUDED.changed_files,
updated_at = now()
-RETURNING id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files
+RETURNING id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files, api_mergeable, api_merge_state_status, checks_rollup_state, snapshot_head_sha, snapshot_fetched_at
`
type UpsertGitHubPullRequestParams struct {
@@ -830,71 +787,15 @@ func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubP
&i.Additions,
&i.Deletions,
&i.ChangedFiles,
+ &i.ApiMergeable,
+ &i.ApiMergeStateStatus,
+ &i.ChecksRollupState,
+ &i.SnapshotHeadSha,
+ &i.SnapshotFetchedAt,
)
return i, err
}
-const upsertPendingCheckSuite = `-- name: UpsertPendingCheckSuite :exec
-
-INSERT INTO github_pending_check_suite (
- workspace_id, installation_id, repo_owner, repo_name, pr_number,
- suite_id, head_sha, app_id, conclusion, status, suite_updated_at
-) VALUES (
- $1, $2, $3, $4, $5,
- $6, $7, $8, $11, $9, $10
-)
-ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number, suite_id) DO UPDATE SET
- installation_id = EXCLUDED.installation_id,
- head_sha = EXCLUDED.head_sha,
- app_id = EXCLUDED.app_id,
- conclusion = EXCLUDED.conclusion,
- status = EXCLUDED.status,
- suite_updated_at = EXCLUDED.suite_updated_at,
- received_at = now()
-WHERE EXCLUDED.suite_updated_at >= github_pending_check_suite.suite_updated_at
-`
-
-type UpsertPendingCheckSuiteParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- InstallationID int64 `json:"installation_id"`
- RepoOwner string `json:"repo_owner"`
- RepoName string `json:"repo_name"`
- PrNumber int32 `json:"pr_number"`
- SuiteID int64 `json:"suite_id"`
- HeadSha string `json:"head_sha"`
- AppID int64 `json:"app_id"`
- Status string `json:"status"`
- SuiteUpdatedAt pgtype.Timestamptz `json:"suite_updated_at"`
- Conclusion pgtype.Text `json:"conclusion"`
-}
-
-// =====================
-// GitHub pending check_suite (out-of-order arrival stash)
-// =====================
-// Stashes a check_suite event whose PR row is not yet mirrored. Replayed
-// (and deleted) by DrainPendingCheckSuitesForPR once the matching
-// `pull_request` webhook lands. ON CONFLICT keeps the newest payload
-// for the same (workspace, repo, pr_number, suite_id) — repeated
-// deliveries while the PR is still missing are idempotent. The
-// suite_updated_at guard mirrors UpsertPullRequestCheckSuite so an older
-// event arriving after a newer one cannot overwrite the newer payload.
-func (q *Queries) UpsertPendingCheckSuite(ctx context.Context, arg UpsertPendingCheckSuiteParams) error {
- _, err := q.db.Exec(ctx, upsertPendingCheckSuite,
- arg.WorkspaceID,
- arg.InstallationID,
- arg.RepoOwner,
- arg.RepoName,
- arg.PrNumber,
- arg.SuiteID,
- arg.HeadSha,
- arg.AppID,
- arg.Status,
- arg.SuiteUpdatedAt,
- arg.Conclusion,
- )
- return err
-}
-
const upsertPendingGitHubInstallation = `-- name: UpsertPendingGitHubInstallation :one
INSERT INTO github_pending_installation (
installation_id, account_login, account_type, account_avatar_url
@@ -934,51 +835,3 @@ func (q *Queries) UpsertPendingGitHubInstallation(ctx context.Context, arg Upser
)
return i, err
}
-
-const upsertPullRequestCheckSuite = `-- name: UpsertPullRequestCheckSuite :exec
-
-INSERT INTO github_pull_request_check_suite (
- pr_id, suite_id, head_sha, app_id, conclusion, status, updated_at
-) VALUES (
- $1, $2, $3, $4, $7, $5, $6
-)
-ON CONFLICT (pr_id, suite_id) DO UPDATE SET
- head_sha = EXCLUDED.head_sha,
- app_id = EXCLUDED.app_id,
- conclusion = EXCLUDED.conclusion,
- status = EXCLUDED.status,
- updated_at = EXCLUDED.updated_at
-WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at
-`
-
-type UpsertPullRequestCheckSuiteParams struct {
- PrID pgtype.UUID `json:"pr_id"`
- SuiteID int64 `json:"suite_id"`
- HeadSha string `json:"head_sha"`
- AppID int64 `json:"app_id"`
- Status string `json:"status"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- Conclusion pgtype.Text `json:"conclusion"`
-}
-
-// =====================
-// GitHub PR check suite
-// =====================
-// Upserts a single check_suite row keyed by (pr_id, suite_id). The WHERE
-// clause on the DO UPDATE branch prevents a late-arriving older event from
-// overwriting a newer one — same-PR/same-suite ordering protection. Late
-// events targeting an old head still land here (their head_sha is stored
-// on the row); the head_sha filter in ListPullRequestsByIssue keeps them
-// out of the current aggregate.
-func (q *Queries) UpsertPullRequestCheckSuite(ctx context.Context, arg UpsertPullRequestCheckSuiteParams) error {
- _, err := q.db.Exec(ctx, upsertPullRequestCheckSuite,
- arg.PrID,
- arg.SuiteID,
- arg.HeadSha,
- arg.AppID,
- arg.Status,
- arg.UpdatedAt,
- arg.Conclusion,
- )
- return err
-}
diff --git a/server/pkg/db/generated/github_snapshot.sql.go b/server/pkg/db/generated/github_snapshot.sql.go
new file mode 100644
index 000000000..036b6a6d1
--- /dev/null
+++ b/server/pkg/db/generated/github_snapshot.sql.go
@@ -0,0 +1,292 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: github_snapshot.sql
+
+package db
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const deleteGitHubPRCheckRuns = `-- name: DeleteGitHubPRCheckRuns :exec
+DELETE FROM github_pull_request_check_run WHERE pr_id = $1
+`
+
+// First half of the atomic per-check replace. Runs inside the same transaction
+// as UpdateGitHubPRSnapshot and the inserts below.
+func (q *Queries) DeleteGitHubPRCheckRuns(ctx context.Context, prID pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, deleteGitHubPRCheckRuns, prID)
+ return err
+}
+
+const getGitHubPullRequestByID = `-- name: GetGitHubPullRequestByID :one
+SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files, api_mergeable, api_merge_state_status, checks_rollup_state, snapshot_head_sha, snapshot_fetched_at FROM github_pull_request WHERE id = $1
+`
+
+func (q *Queries) GetGitHubPullRequestByID(ctx context.Context, id pgtype.UUID) (GithubPullRequest, error) {
+ row := q.db.QueryRow(ctx, getGitHubPullRequestByID, id)
+ var i GithubPullRequest
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.InstallationID,
+ &i.RepoOwner,
+ &i.RepoName,
+ &i.PrNumber,
+ &i.Title,
+ &i.State,
+ &i.HtmlUrl,
+ &i.Branch,
+ &i.AuthorLogin,
+ &i.AuthorAvatarUrl,
+ &i.MergedAt,
+ &i.ClosedAt,
+ &i.PrCreatedAt,
+ &i.PrUpdatedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.HeadSha,
+ &i.MergeableState,
+ &i.Additions,
+ &i.Deletions,
+ &i.ChangedFiles,
+ &i.ApiMergeable,
+ &i.ApiMergeStateStatus,
+ &i.ChecksRollupState,
+ &i.SnapshotHeadSha,
+ &i.SnapshotFetchedAt,
+ )
+ return i, err
+}
+
+const insertGitHubPRCheckRun = `-- name: InsertGitHubPRCheckRun :exec
+INSERT INTO github_pull_request_check_run (
+ pr_id, head_sha, ordinal, name, status, conclusion, details_url, is_status_context
+) VALUES (
+ $1, $2, $3, $4, $5, $7, $8, $6
+)
+`
+
+type InsertGitHubPRCheckRunParams struct {
+ PrID pgtype.UUID `json:"pr_id"`
+ HeadSha string `json:"head_sha"`
+ Ordinal int32 `json:"ordinal"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ IsStatusContext bool `json:"is_status_context"`
+ Conclusion pgtype.Text `json:"conclusion"`
+ DetailsUrl pgtype.Text `json:"details_url"`
+}
+
+func (q *Queries) InsertGitHubPRCheckRun(ctx context.Context, arg InsertGitHubPRCheckRunParams) error {
+ _, err := q.db.Exec(ctx, insertGitHubPRCheckRun,
+ arg.PrID,
+ arg.HeadSha,
+ arg.Ordinal,
+ arg.Name,
+ arg.Status,
+ arg.IsStatusContext,
+ arg.Conclusion,
+ arg.DetailsUrl,
+ )
+ return err
+}
+
+const listGitHubPRNumbersByHeadSHA = `-- name: ListGitHubPRNumbersByHeadSHA :many
+SELECT DISTINCT pr_number
+FROM github_pull_request
+WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND head_sha = $4
+`
+
+type ListGitHubPRNumbersByHeadSHAParams struct {
+ InstallationID int64 `json:"installation_id"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ HeadSha string `json:"head_sha"`
+}
+
+// Resolves a commit SHA to the PR numbers whose head it is. `status` webhook
+// events (legacy commit statuses) carry a SHA + repo but no PR number, so we
+// map back through the mirrored head_sha to find which PR(s) to refresh.
+func (q *Queries) ListGitHubPRNumbersByHeadSHA(ctx context.Context, arg ListGitHubPRNumbersByHeadSHAParams) ([]int32, error) {
+ rows, err := q.db.Query(ctx, listGitHubPRNumbersByHeadSHA,
+ arg.InstallationID,
+ arg.RepoOwner,
+ arg.RepoName,
+ arg.HeadSha,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []int32{}
+ for rows.Next() {
+ var pr_number int32
+ if err := rows.Scan(&pr_number); err != nil {
+ return nil, err
+ }
+ items = append(items, pr_number)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listGitHubPRRowsByAddress = `-- name: ListGitHubPRRowsByAddress :many
+
+SELECT id, workspace_id, head_sha, state
+FROM github_pull_request
+WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4
+`
+
+type ListGitHubPRRowsByAddressParams struct {
+ InstallationID int64 `json:"installation_id"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ PrNumber int32 `json:"pr_number"`
+}
+
+type ListGitHubPRRowsByAddressRow struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ HeadSha string `json:"head_sha"`
+ State string `json:"state"`
+}
+
+// =====================
+// GitHub API snapshot (MUL-5265, Plan C)
+//
+// These queries back the API-snapshot refresh pipeline. The GitHub GraphQL
+// response is the single source of truth; each successful fetch is written as
+// one atomic batch replace (guarded update of the PR row + full replace of the
+// per-check rows) inside a single transaction.
+// =====================
+// One (installation, owner, repo, number) address can map to several
+// github_pull_request rows — the same installation can be bound to multiple
+// workspaces (#4823/#4855), each mirroring its own row. A single API fetch is
+// applied to every matching row (each guarded by its own head_sha).
+func (q *Queries) ListGitHubPRRowsByAddress(ctx context.Context, arg ListGitHubPRRowsByAddressParams) ([]ListGitHubPRRowsByAddressRow, error) {
+ rows, err := q.db.Query(ctx, listGitHubPRRowsByAddress,
+ arg.InstallationID,
+ arg.RepoOwner,
+ arg.RepoName,
+ arg.PrNumber,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListGitHubPRRowsByAddressRow{}
+ for rows.Next() {
+ var i ListGitHubPRRowsByAddressRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.HeadSha,
+ &i.State,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listStaleOpenGitHubPRs = `-- name: ListStaleOpenGitHubPRs :many
+SELECT DISTINCT installation_id, repo_owner, repo_name, pr_number
+FROM github_pull_request
+WHERE state IN ('open', 'draft')
+ AND (snapshot_fetched_at IS NULL OR snapshot_fetched_at < $1)
+ORDER BY installation_id, repo_owner, repo_name, pr_number
+LIMIT $2
+`
+
+type ListStaleOpenGitHubPRsParams struct {
+ OlderThan pgtype.Timestamptz `json:"older_than"`
+ MaxRows int32 `json:"max_rows"`
+}
+
+type ListStaleOpenGitHubPRsRow struct {
+ InstallationID int64 `json:"installation_id"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ PrNumber int32 `json:"pr_number"`
+}
+
+// TTL / safety-net sweep source. Returns distinct addresses of open/draft PRs
+// whose snapshot is missing or older than the TTL cutoff. Bounded by LIMIT so
+// one sweep can never fan out unbounded. Open PRs whose base branch advanced
+// (a conflict-producing event that emits NO pull_request webhook on this PR)
+// are recovered here without needing Contents:read. Merged/closed PRs are
+// excluded — a settled PR leaves the refresh set.
+func (q *Queries) ListStaleOpenGitHubPRs(ctx context.Context, arg ListStaleOpenGitHubPRsParams) ([]ListStaleOpenGitHubPRsRow, error) {
+ rows, err := q.db.Query(ctx, listStaleOpenGitHubPRs, arg.OlderThan, arg.MaxRows)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListStaleOpenGitHubPRsRow{}
+ for rows.Next() {
+ var i ListStaleOpenGitHubPRsRow
+ if err := rows.Scan(
+ &i.InstallationID,
+ &i.RepoOwner,
+ &i.RepoName,
+ &i.PrNumber,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateGitHubPRSnapshot = `-- name: UpdateGitHubPRSnapshot :execrows
+UPDATE github_pull_request
+SET api_mergeable = $1,
+ api_merge_state_status = $2,
+ checks_rollup_state = $3,
+ snapshot_head_sha = $4,
+ snapshot_fetched_at = $5,
+ updated_at = now()
+WHERE id = $6 AND head_sha = $4
+`
+
+type UpdateGitHubPRSnapshotParams struct {
+ ApiMergeable pgtype.Text `json:"api_mergeable"`
+ ApiMergeStateStatus pgtype.Text `json:"api_merge_state_status"`
+ ChecksRollupState pgtype.Text `json:"checks_rollup_state"`
+ HeadSha string `json:"head_sha"`
+ FetchedAt pgtype.Timestamptz `json:"fetched_at"`
+ PrID pgtype.UUID `json:"pr_id"`
+}
+
+// Head-SHA anti-stale write (acceptance criterion 1): the snapshot is written
+// only when the row's current head_sha still equals the head the snapshot was
+// fetched for. If the head advanced (a newer push landed while this request was
+// in flight, mirrored by the pull_request webhook), 0 rows are updated and the
+// caller discards the whole response — the per-check replace is skipped too.
+func (q *Queries) UpdateGitHubPRSnapshot(ctx context.Context, arg UpdateGitHubPRSnapshotParams) (int64, error) {
+ result, err := q.db.Exec(ctx, updateGitHubPRSnapshot,
+ arg.ApiMergeable,
+ arg.ApiMergeStateStatus,
+ arg.ChecksRollupState,
+ arg.HeadSha,
+ arg.FetchedAt,
+ arg.PrID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go
index 17caa06eb..df8b92007 100644
--- a/server/pkg/db/generated/models.go
+++ b/server/pkg/db/generated/models.go
@@ -521,29 +521,45 @@ type GithubPendingInstallation struct {
}
type GithubPullRequest struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- InstallationID int64 `json:"installation_id"`
- RepoOwner string `json:"repo_owner"`
- RepoName string `json:"repo_name"`
- PrNumber int32 `json:"pr_number"`
- Title string `json:"title"`
- State string `json:"state"`
- HtmlUrl string `json:"html_url"`
- Branch pgtype.Text `json:"branch"`
- AuthorLogin pgtype.Text `json:"author_login"`
- AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
- MergedAt pgtype.Timestamptz `json:"merged_at"`
- ClosedAt pgtype.Timestamptz `json:"closed_at"`
- PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
- PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- HeadSha string `json:"head_sha"`
- MergeableState pgtype.Text `json:"mergeable_state"`
- Additions int32 `json:"additions"`
- Deletions int32 `json:"deletions"`
- ChangedFiles int32 `json:"changed_files"`
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ InstallationID int64 `json:"installation_id"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ PrNumber int32 `json:"pr_number"`
+ Title string `json:"title"`
+ State string `json:"state"`
+ HtmlUrl string `json:"html_url"`
+ Branch pgtype.Text `json:"branch"`
+ AuthorLogin pgtype.Text `json:"author_login"`
+ AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
+ MergedAt pgtype.Timestamptz `json:"merged_at"`
+ ClosedAt pgtype.Timestamptz `json:"closed_at"`
+ PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
+ PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ HeadSha string `json:"head_sha"`
+ MergeableState pgtype.Text `json:"mergeable_state"`
+ Additions int32 `json:"additions"`
+ Deletions int32 `json:"deletions"`
+ ChangedFiles int32 `json:"changed_files"`
+ ApiMergeable pgtype.Text `json:"api_mergeable"`
+ ApiMergeStateStatus pgtype.Text `json:"api_merge_state_status"`
+ ChecksRollupState pgtype.Text `json:"checks_rollup_state"`
+ SnapshotHeadSha string `json:"snapshot_head_sha"`
+ SnapshotFetchedAt pgtype.Timestamptz `json:"snapshot_fetched_at"`
+}
+
+type GithubPullRequestCheckRun struct {
+ PrID pgtype.UUID `json:"pr_id"`
+ HeadSha string `json:"head_sha"`
+ Ordinal int32 `json:"ordinal"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Conclusion pgtype.Text `json:"conclusion"`
+ DetailsUrl pgtype.Text `json:"details_url"`
+ IsStatusContext bool `json:"is_status_context"`
}
type GithubPullRequestCheckSuite struct {
diff --git a/server/pkg/db/queries/github.sql b/server/pkg/db/queries/github.sql
index 91c0fa1a1..145e8c164 100644
--- a/server/pkg/db/queries/github.sql
+++ b/server/pkg/db/queries/github.sql
@@ -130,45 +130,43 @@ SELECT * FROM github_pull_request
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4;
-- name: ListPullRequestsByIssue :many
--- Returns the issue's linked PRs with the aggregated check-suite counts for
--- the PR's CURRENT head SHA. The `issue_prs` CTE narrows to this issue's PR
--- ids first so the per-app aggregation only touches suite rows for those
--- PRs — without that scoping the planner has to scan/aggregate every PR's
--- suites in the workspace before joining on issue. Per-app latest suite is
--- selected so a single app firing multiple suites on the same head doesn't
--- get counted N times. Late-arriving suites for an OLD head are stored but
--- excluded by the head_sha filter, so they can't override the new head's
--- pending view. reference_only links (a PR that merely mentions the issue
--- identifier in its body, with no closing keyword and no title/branch
--- reference) are filtered out — they are not working PRs for this issue.
+-- Returns the issue's linked PRs with the GitHub API snapshot (MUL-5265): the
+-- mergeability verdict, the CI rollup, and per-check counts for the PR's
+-- CURRENT snapshot head SHA. Checks are aggregated from
+-- github_pull_request_check_run — the run-level snapshot written by the API
+-- refresh pipeline — NOT the legacy suite-level webhook aggregation, which is
+-- removed. The `issue_prs` CTE narrows to this issue's PR ids first so the
+-- aggregation only touches check rows for those PRs. Rows for an OLD head are
+-- excluded by the snapshot_head_sha filter. reference_only links (a PR that
+-- merely mentions the issue identifier in its body, with no closing keyword and
+-- no title/branch reference) are filtered out — they are not working PRs.
WITH issue_prs AS (
- SELECT pr.id, pr.head_sha
+ SELECT pr.id, pr.snapshot_head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id') AND NOT ipr.reference_only
),
-per_app_latest AS (
- SELECT DISTINCT ON (cs.pr_id, cs.app_id)
- cs.pr_id, cs.app_id, cs.conclusion, cs.status
- FROM github_pull_request_check_suite cs
- JOIN issue_prs ip ON ip.id = cs.pr_id
- WHERE cs.head_sha = ip.head_sha AND ip.head_sha <> ''
- ORDER BY cs.pr_id, cs.app_id, cs.updated_at DESC
-),
checks AS (
SELECT
- pr_id,
+ cr.pr_id,
COUNT(*)::bigint AS total,
- SUM(CASE WHEN status = 'completed' AND conclusion IN
- ('failure','cancelled','timed_out','action_required','startup_failure','stale')
+ SUM(CASE WHEN cr.status = 'completed' AND cr.conclusion IN
+ ('failure','cancelled','timed_out','action_required','startup_failure','stale','error')
THEN 1 ELSE 0 END)::bigint AS failed,
- SUM(CASE WHEN status = 'completed' AND conclusion IN
+ SUM(CASE WHEN cr.status = 'completed' AND cr.conclusion IN
('success','neutral','skipped')
THEN 1 ELSE 0 END)::bigint AS passed,
- SUM(CASE WHEN status <> 'completed' OR conclusion IS NULL
- THEN 1 ELSE 0 END)::bigint AS pending
- FROM per_app_latest
- GROUP BY pr_id
+ SUM(CASE WHEN cr.status <> 'completed' OR cr.conclusion IS NULL
+ THEN 1 ELSE 0 END)::bigint AS running,
+ COALESCE(
+ array_agg(cr.name) FILTER (WHERE cr.status = 'completed' AND cr.conclusion IN
+ ('failure','cancelled','timed_out','action_required','startup_failure','stale','error')),
+ '{}'
+ )::text[] AS failed_names
+ FROM github_pull_request_check_run cr
+ JOIN issue_prs ip ON ip.id = cr.pr_id
+ WHERE cr.head_sha = ip.snapshot_head_sha AND ip.snapshot_head_sha <> ''
+ GROUP BY cr.pr_id
)
SELECT
pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name,
@@ -176,11 +174,14 @@ SELECT
pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at,
pr.pr_updated_at, pr.head_sha, pr.mergeable_state,
pr.additions, pr.deletions, pr.changed_files,
+ pr.api_mergeable, pr.api_merge_state_status, pr.checks_rollup_state,
+ pr.snapshot_head_sha, pr.snapshot_fetched_at,
pr.created_at, pr.updated_at,
COALESCE(c.total, 0)::bigint AS checks_total,
COALESCE(c.passed, 0)::bigint AS checks_passed,
COALESCE(c.failed, 0)::bigint AS checks_failed,
- COALESCE(c.pending, 0)::bigint AS checks_pending
+ COALESCE(c.running, 0)::bigint AS checks_running,
+ COALESCE(c.failed_names, '{}')::text[] AS failed_check_names
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
@@ -246,71 +247,6 @@ FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only;
--- =====================
--- GitHub PR check suite
--- =====================
-
--- name: UpsertPullRequestCheckSuite :exec
--- Upserts a single check_suite row keyed by (pr_id, suite_id). The WHERE
--- clause on the DO UPDATE branch prevents a late-arriving older event from
--- overwriting a newer one — same-PR/same-suite ordering protection. Late
--- events targeting an old head still land here (their head_sha is stored
--- on the row); the head_sha filter in ListPullRequestsByIssue keeps them
--- out of the current aggregate.
-INSERT INTO github_pull_request_check_suite (
- pr_id, suite_id, head_sha, app_id, conclusion, status, updated_at
-) VALUES (
- $1, $2, $3, $4, sqlc.narg('conclusion'), $5, $6
-)
-ON CONFLICT (pr_id, suite_id) DO UPDATE SET
- head_sha = EXCLUDED.head_sha,
- app_id = EXCLUDED.app_id,
- conclusion = EXCLUDED.conclusion,
- status = EXCLUDED.status,
- updated_at = EXCLUDED.updated_at
-WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at;
-
--- =====================
--- GitHub pending check_suite (out-of-order arrival stash)
--- =====================
-
--- name: UpsertPendingCheckSuite :exec
--- Stashes a check_suite event whose PR row is not yet mirrored. Replayed
--- (and deleted) by DrainPendingCheckSuitesForPR once the matching
--- `pull_request` webhook lands. ON CONFLICT keeps the newest payload
--- for the same (workspace, repo, pr_number, suite_id) — repeated
--- deliveries while the PR is still missing are idempotent. The
--- suite_updated_at guard mirrors UpsertPullRequestCheckSuite so an older
--- event arriving after a newer one cannot overwrite the newer payload.
-INSERT INTO github_pending_check_suite (
- workspace_id, installation_id, repo_owner, repo_name, pr_number,
- suite_id, head_sha, app_id, conclusion, status, suite_updated_at
-) VALUES (
- $1, $2, $3, $4, $5,
- $6, $7, $8, sqlc.narg('conclusion'), $9, $10
-)
-ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number, suite_id) DO UPDATE SET
- installation_id = EXCLUDED.installation_id,
- head_sha = EXCLUDED.head_sha,
- app_id = EXCLUDED.app_id,
- conclusion = EXCLUDED.conclusion,
- status = EXCLUDED.status,
- suite_updated_at = EXCLUDED.suite_updated_at,
- received_at = now()
-WHERE EXCLUDED.suite_updated_at >= github_pending_check_suite.suite_updated_at;
-
--- name: DrainPendingCheckSuitesForPR :many
--- Atomically reads + deletes all pending suites for the given PR address.
--- Caller replays each row through UpsertPullRequestCheckSuite. RETURNING
--- gives us the payloads we need without a separate SELECT, so two parallel
--- handlers racing on the same PR can't double-apply the same row.
-DELETE FROM github_pending_check_suite
-WHERE workspace_id = $1
- AND repo_owner = $2
- AND repo_name = $3
- AND pr_number = $4
-RETURNING suite_id, head_sha, app_id, conclusion, status, suite_updated_at;
-
-- =====================
-- Issue ↔ Pull Request link
-- =====================
diff --git a/server/pkg/db/queries/github_snapshot.sql b/server/pkg/db/queries/github_snapshot.sql
new file mode 100644
index 000000000..aaaa65605
--- /dev/null
+++ b/server/pkg/db/queries/github_snapshot.sql
@@ -0,0 +1,69 @@
+-- =====================
+-- GitHub API snapshot (MUL-5265, Plan C)
+--
+-- These queries back the API-snapshot refresh pipeline. The GitHub GraphQL
+-- response is the single source of truth; each successful fetch is written as
+-- one atomic batch replace (guarded update of the PR row + full replace of the
+-- per-check rows) inside a single transaction.
+-- =====================
+
+-- name: ListGitHubPRRowsByAddress :many
+-- One (installation, owner, repo, number) address can map to several
+-- github_pull_request rows — the same installation can be bound to multiple
+-- workspaces (#4823/#4855), each mirroring its own row. A single API fetch is
+-- applied to every matching row (each guarded by its own head_sha).
+SELECT id, workspace_id, head_sha, state
+FROM github_pull_request
+WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4;
+
+-- name: UpdateGitHubPRSnapshot :execrows
+-- Head-SHA anti-stale write (acceptance criterion 1): the snapshot is written
+-- only when the row's current head_sha still equals the head the snapshot was
+-- fetched for. If the head advanced (a newer push landed while this request was
+-- in flight, mirrored by the pull_request webhook), 0 rows are updated and the
+-- caller discards the whole response — the per-check replace is skipped too.
+UPDATE github_pull_request
+SET api_mergeable = sqlc.narg('api_mergeable'),
+ api_merge_state_status = sqlc.narg('api_merge_state_status'),
+ checks_rollup_state = sqlc.narg('checks_rollup_state'),
+ snapshot_head_sha = sqlc.arg('head_sha'),
+ snapshot_fetched_at = sqlc.arg('fetched_at'),
+ updated_at = now()
+WHERE id = sqlc.arg('pr_id') AND head_sha = sqlc.arg('head_sha');
+
+-- name: DeleteGitHubPRCheckRuns :exec
+-- First half of the atomic per-check replace. Runs inside the same transaction
+-- as UpdateGitHubPRSnapshot and the inserts below.
+DELETE FROM github_pull_request_check_run WHERE pr_id = $1;
+
+-- name: InsertGitHubPRCheckRun :exec
+INSERT INTO github_pull_request_check_run (
+ pr_id, head_sha, ordinal, name, status, conclusion, details_url, is_status_context
+) VALUES (
+ $1, $2, $3, $4, $5, sqlc.narg('conclusion'), sqlc.narg('details_url'), $6
+);
+
+-- name: ListStaleOpenGitHubPRs :many
+-- TTL / safety-net sweep source. Returns distinct addresses of open/draft PRs
+-- whose snapshot is missing or older than the TTL cutoff. Bounded by LIMIT so
+-- one sweep can never fan out unbounded. Open PRs whose base branch advanced
+-- (a conflict-producing event that emits NO pull_request webhook on this PR)
+-- are recovered here without needing Contents:read. Merged/closed PRs are
+-- excluded — a settled PR leaves the refresh set.
+SELECT DISTINCT installation_id, repo_owner, repo_name, pr_number
+FROM github_pull_request
+WHERE state IN ('open', 'draft')
+ AND (snapshot_fetched_at IS NULL OR snapshot_fetched_at < sqlc.arg('older_than'))
+ORDER BY installation_id, repo_owner, repo_name, pr_number
+LIMIT sqlc.arg('max_rows');
+
+-- name: ListGitHubPRNumbersByHeadSHA :many
+-- Resolves a commit SHA to the PR numbers whose head it is. `status` webhook
+-- events (legacy commit statuses) carry a SHA + repo but no PR number, so we
+-- map back through the mirrored head_sha to find which PR(s) to refresh.
+SELECT DISTINCT pr_number
+FROM github_pull_request
+WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND head_sha = $4;
+
+-- name: GetGitHubPullRequestByID :one
+SELECT * FROM github_pull_request WHERE id = $1;