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 ( -