feat(ui): chat-entity linking — live linked-entity cards, manual link/unlink, session cross-listing

- linkedIssues: provider/repo/host fields (backward-compatible), gitea id generation fix, parseLinkedIssueRef/parseForgeEntityUrl
- work-status panel: live state cards (TTL-cached), link-by-URL dialog, per-row unlink
- forge entity view: 'Chats working on this' section derived from the session store
This commit is contained in:
2026-08-16 16:29:25 +00:00
parent 8f5cfdcd62
commit 1f28b61c5a
22 changed files with 2010 additions and 48 deletions
+12 -1
View File
@@ -1330,6 +1330,9 @@ export const dict = {
'forge.files.empty': 'Keine Dateien geändert',
'forge.files.noDiff': 'Kein Diff verfügbar',
'forge.loading': 'Wird geladen...',
'forge.linkedSessions.count': 'Mit diesem Element verknüpfte Chats: {count}',
'forge.linkedSessions.open': 'Sitzung „{title}“ öffnen',
'forge.linkedSessions.title': 'Chats, die daran arbeiten',
'forge.notConnected': 'Ihr Git-Forge ist nicht verbunden',
'forge.section.checks': 'Prüfungen',
'forge.section.commits': 'Commits',
@@ -3407,8 +3410,16 @@ export const dict = {
'chat.workStatus.linkedIssues.open': '#{number} auf GitHub öffnen',
'chat.workStatus.linkedIssues.unlink': 'Verknüpfung entfernen',
'chat.workStatus.linkedIssues.unlinkFailed': 'Verknüpfung konnte nicht entfernt werden',
'chat.workStatus.linkedIssues.link': 'Mit Sitzung verknüpfen',
'chat.workStatus.linkedIssues.link': 'Verknüpfen',
'chat.workStatus.linkedIssues.linkFailed': 'Verknüpfen fehlgeschlagen',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Issue oder Pull Request verknüpfen',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Issue- oder PR-URL einfügen…',
'chat.workStatus.linkedIssues.linkInvalid': 'Das sieht nicht wie eine unterstützte Issue-/PR-URL aus',
'chat.workStatus.linkedIssues.linked': 'Verknüpft',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Dieses Issue/PR von der Sitzung trennen?',
'chat.workStatus.linkedIssues.liveRefresh': 'Status aktualisieren',
'chat.workStatus.linkedIssues.liveUnavailable': 'Live-Status nicht verfügbar',
'chat.workStatus.linkedIssues.empty': 'Keine Issues oder Pull Requests verknüpft',
'chat.workStatus.breakdown.issueCountSingle': '{count} Issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} Issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1582,6 +1582,9 @@ export const dict = {
'forge.files.empty': 'No files changed',
'forge.files.noDiff': 'No diff available',
'forge.loading': 'Loading...',
'forge.linkedSessions.count': 'Sessions linked to this entity: {count}',
'forge.linkedSessions.open': 'Open session "{title}"',
'forge.linkedSessions.title': 'Chats working on this',
'forge.notConnected': 'Your git forge is not connected',
'forge.section.checks': 'Checks',
'forge.section.commits': 'Commits',
@@ -3408,8 +3411,16 @@ export const dict = {
'chat.workStatus.linkedIssues.open': 'Open #{number} on GitHub',
'chat.workStatus.linkedIssues.unlink': 'Remove link',
'chat.workStatus.linkedIssues.unlinkFailed': 'Could not remove the link',
'chat.workStatus.linkedIssues.link': 'Link to session',
'chat.workStatus.linkedIssues.link': 'Link',
'chat.workStatus.linkedIssues.linkFailed': 'Could not link',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Link issue or pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Paste an issue or PR URL…',
'chat.workStatus.linkedIssues.linkInvalid': 'That doesn\'t look like a supported issue/PR URL',
'chat.workStatus.linkedIssues.linked': 'Linked',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Unlink this issue/PR from the session?',
'chat.workStatus.linkedIssues.liveRefresh': 'Refresh status',
'chat.workStatus.linkedIssues.liveUnavailable': 'Live status unavailable',
'chat.workStatus.linkedIssues.empty': 'No linked issues or pull requests',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1549,6 +1549,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'No hay archivos modificados',
'forge.files.noDiff': 'No hay diff disponible',
'forge.loading': 'Cargando...',
'forge.linkedSessions.count': 'Sesiones vinculadas a este elemento: {count}',
'forge.linkedSessions.open': 'Abrir sesión «{title}»',
'forge.linkedSessions.title': 'Chats trabajando en esto',
'forge.notConnected': 'Tu forge de git no está conectado',
'forge.section.checks': 'Comprobaciones',
'forge.section.commits': 'Commits',
@@ -3410,8 +3413,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'Abrir #{number} en GitHub',
'chat.workStatus.linkedIssues.unlink': 'Quitar vínculo',
'chat.workStatus.linkedIssues.unlinkFailed': 'No se pudo quitar el vínculo',
'chat.workStatus.linkedIssues.link': 'Vincular a la sesión',
'chat.workStatus.linkedIssues.link': 'Vincular',
'chat.workStatus.linkedIssues.linkFailed': 'No se pudo vincular',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Vincular issue o pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Pega una URL de issue o PR…',
'chat.workStatus.linkedIssues.linkInvalid': 'Eso no parece una URL de issue/PR admitida',
'chat.workStatus.linkedIssues.linked': 'Vinculado',
'chat.workStatus.linkedIssues.unlinkConfirm': '¿Quitar este issue/PR de la sesión?',
'chat.workStatus.linkedIssues.liveRefresh': 'Actualizar estado',
'chat.workStatus.linkedIssues.liveUnavailable': 'Estado en vivo no disponible',
'chat.workStatus.linkedIssues.empty': 'Sin issues ni pull requests vinculados',
'chat.workStatus.breakdown.issueCountSingle': '{count} incidencia',
'chat.workStatus.breakdown.issueCountPlural': '{count} incidencias',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -3255,6 +3255,9 @@ export const dict = {
'forge.files.empty': 'Aucun fichier modifié',
'forge.files.noDiff': 'Aucun diff disponible',
'forge.loading': 'Chargement...',
'forge.linkedSessions.count': 'Conversations liées à cet élément : {count}',
'forge.linkedSessions.open': 'Ouvrir la conversation « {title} »',
'forge.linkedSessions.title': 'Conversations en cours sur cet élément',
'forge.notConnected': 'Votre forge git n\'est pas connectée',
'forge.section.checks': 'Vérifications',
'forge.section.commits': 'Commits',
@@ -3407,8 +3410,16 @@ export const dict = {
'chat.workStatus.linkedIssues.open': 'Ouvrir #{number} sur GitHub',
'chat.workStatus.linkedIssues.unlink': 'Retirer le lien',
'chat.workStatus.linkedIssues.unlinkFailed': 'Impossible de retirer le lien',
'chat.workStatus.linkedIssues.link': 'Lier à la session',
'chat.workStatus.linkedIssues.link': 'Lier',
'chat.workStatus.linkedIssues.linkFailed': 'Impossible de lier',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Lier un issue ou une pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Collez une URL d\'issue ou de PR…',
'chat.workStatus.linkedIssues.linkInvalid': 'Cela ne ressemble pas à une URL d\'issue/PR prise en charge',
'chat.workStatus.linkedIssues.linked': 'Lié',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Retirer cet issue/PR de la session ?',
'chat.workStatus.linkedIssues.liveRefresh': 'Actualiser le statut',
'chat.workStatus.linkedIssues.liveUnavailable': 'Statut en direct indisponible',
'chat.workStatus.linkedIssues.empty': 'Aucun issue ni pull request lié',
'chat.workStatus.breakdown.issueCountSingle': '{count} ticket',
'chat.workStatus.breakdown.issueCountPlural': '{count} tickets',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1579,6 +1579,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '変更されたファイルはありません',
'forge.files.noDiff': '差分はありません',
'forge.loading': '読み込み中...',
'forge.linkedSessions.count': 'この項目にリンクされているセッション: {count}',
'forge.linkedSessions.open': 'セッション「{title}」を開く',
'forge.linkedSessions.title': 'この項目に取り組んでいるチャット',
'forge.notConnected': 'Gitフォージに接続されていません',
'forge.section.checks': 'チェック',
'forge.section.commits': 'コミット',
@@ -3409,8 +3412,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'GitHub で #{number} を開く',
'chat.workStatus.linkedIssues.unlink': 'リンクを解除',
'chat.workStatus.linkedIssues.unlinkFailed': 'リンクを解除できませんでした',
'chat.workStatus.linkedIssues.link': 'セッションにリンク',
'chat.workStatus.linkedIssues.link': 'リンク',
'chat.workStatus.linkedIssues.linkFailed': 'リンクできませんでした',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Issue または Pull Request をリンク',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Issue または PR の URL を貼り付け…',
'chat.workStatus.linkedIssues.linkInvalid': 'サポートされている issue/PR の URL には見えません',
'chat.workStatus.linkedIssues.linked': 'リンクしました',
'chat.workStatus.linkedIssues.unlinkConfirm': 'この issue/PR をセッションからリンク解除しますか?',
'chat.workStatus.linkedIssues.liveRefresh': 'ステータスを更新',
'chat.workStatus.linkedIssues.liveUnavailable': 'ライブステータスを取得できません',
'chat.workStatus.linkedIssues.empty': 'リンクされた issue や Pull Request はありません',
'chat.workStatus.breakdown.issueCountSingle': 'Issue {count} 件',
'chat.workStatus.breakdown.issueCountPlural': 'Issue {count} 件',
'chat.workStatus.breakdown.prCountSingle': 'PR {count} 件',
+12 -1
View File
@@ -1585,6 +1585,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '변경된 파일이 없습니다',
'forge.files.noDiff': 'diff를 사용할 수 없습니다',
'forge.loading': '불러오는 중...',
'forge.linkedSessions.count': '이 항목에 연결된 세션: {count}',
'forge.linkedSessions.open': '세션 "{title}" 열기',
'forge.linkedSessions.title': '이 항목에서 작업 중인 채팅',
'forge.notConnected': 'Git 포지에 연결되어 있지 않습니다',
'forge.section.checks': '확인',
'forge.section.commits': '커밋',
@@ -3409,8 +3412,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'GitHub에서 #{number} 열기',
'chat.workStatus.linkedIssues.unlink': '연결 해제',
'chat.workStatus.linkedIssues.unlinkFailed': '연결을 해제하지 못했습니다',
'chat.workStatus.linkedIssues.link': '세션에 연결',
'chat.workStatus.linkedIssues.link': '연결',
'chat.workStatus.linkedIssues.linkFailed': '연결하지 못했습니다',
'chat.workStatus.linkedIssues.linkDialogTitle': '이슈 또는 풀 리퀘스트 연결',
'chat.workStatus.linkedIssues.linkPlaceholder': '이슈 또는 PR URL 붙여넣기…',
'chat.workStatus.linkedIssues.linkInvalid': '지원되는 이슈/PR URL로 보이지 않습니다',
'chat.workStatus.linkedIssues.linked': '연결됨',
'chat.workStatus.linkedIssues.unlinkConfirm': '이 이슈/PR을 세션에서 연결 해제할까요?',
'chat.workStatus.linkedIssues.liveRefresh': '상태 새로고침',
'chat.workStatus.linkedIssues.liveUnavailable': '실시간 상태를 사용할 수 없음',
'chat.workStatus.linkedIssues.empty': '연결된 이슈 또는 풀 리퀘스트 없음',
'chat.workStatus.breakdown.issueCountSingle': '이슈 {count}개',
'chat.workStatus.breakdown.issueCountPlural': '이슈 {count}개',
'chat.workStatus.breakdown.prCountSingle': 'PR {count}개',
+12 -1
View File
@@ -2066,6 +2066,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Brak zmienionych plików',
'forge.files.noDiff': 'Brak dostępnego diff',
'forge.loading': 'Wczytywanie...',
'forge.linkedSessions.count': 'Sesje powiązane z tym elementem: {count}',
'forge.linkedSessions.open': 'Otwórz sesję „{title}“',
'forge.linkedSessions.title': 'Czaty pracujące nad tym',
'forge.notConnected': 'Twoja platforma git nie jest połączona',
'forge.section.checks': 'Kontrole',
'forge.section.commits': 'Commity',
@@ -3426,8 +3429,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'Otwórz #{number} w GitHub',
'chat.workStatus.linkedIssues.unlink': 'Usuń powiązanie',
'chat.workStatus.linkedIssues.unlinkFailed': 'Nie udało się usunąć powiązania',
'chat.workStatus.linkedIssues.link': 'Powiąż z sesją',
'chat.workStatus.linkedIssues.link': 'Powiąż',
'chat.workStatus.linkedIssues.linkFailed': 'Nie udało się powiązać',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Powiąż issue lub pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Wklej URL issue lub PR…',
'chat.workStatus.linkedIssues.linkInvalid': 'To nie wygląda na obsługiwany URL issue/PR',
'chat.workStatus.linkedIssues.linked': 'Powiązano',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Usunąć powiązanie tego issue/PR z sesją?',
'chat.workStatus.linkedIssues.liveRefresh': 'Odśwież status',
'chat.workStatus.linkedIssues.liveUnavailable': 'Status na żywo niedostępny',
'chat.workStatus.linkedIssues.empty': 'Brak powiązanych issues lub pull requestów',
'chat.workStatus.breakdown.issueCountSingle': '{count} zgłoszenie',
'chat.workStatus.breakdown.issueCountPlural': '{count} zgłoszeń',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1549,6 +1549,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Nenhum arquivo alterado',
'forge.files.noDiff': 'Nenhum diff disponível',
'forge.loading': 'Carregando...',
'forge.linkedSessions.count': 'Sessões vinculadas a este item: {count}',
'forge.linkedSessions.open': 'Abrir sessão "{title}"',
'forge.linkedSessions.title': 'Chats trabalhando nisso',
'forge.notConnected': 'Sua plataforma git não está conectada',
'forge.section.checks': 'Verificações',
'forge.section.commits': 'Commits',
@@ -3410,8 +3413,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'Abrir #{number} no GitHub',
'chat.workStatus.linkedIssues.unlink': 'Remover vínculo',
'chat.workStatus.linkedIssues.unlinkFailed': 'Não foi possível remover o vínculo',
'chat.workStatus.linkedIssues.link': 'Vincular à sessão',
'chat.workStatus.linkedIssues.link': 'Vincular',
'chat.workStatus.linkedIssues.linkFailed': 'Não foi possível vincular',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Vincular issue ou pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Cole uma URL de issue ou PR…',
'chat.workStatus.linkedIssues.linkInvalid': 'Isso não parece uma URL de issue/PR compatível',
'chat.workStatus.linkedIssues.linked': 'Vinculado',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Remover este issue/PR da sessão?',
'chat.workStatus.linkedIssues.liveRefresh': 'Atualizar status',
'chat.workStatus.linkedIssues.liveUnavailable': 'Status ao vivo indisponível',
'chat.workStatus.linkedIssues.empty': 'Nenhum issue ou pull request vinculado',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1549,6 +1549,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Змінених файлів немає',
'forge.files.noDiff': 'Diff недоступний',
'forge.loading': 'Завантаження...',
'forge.linkedSessions.count': 'Сеанси, пов\'язані з цим елементом: {count}',
'forge.linkedSessions.open': 'Відкрити сеанс «{title}»',
'forge.linkedSessions.title': 'Чати, що працюють над цим',
'forge.notConnected': 'Вашу Git-платформу не підключено',
'forge.section.checks': 'Перевірки',
'forge.section.commits': 'Комміти',
@@ -3410,8 +3413,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': 'Відкрити #{number} на GitHub',
'chat.workStatus.linkedIssues.unlink': 'Прибрати лінк',
'chat.workStatus.linkedIssues.unlinkFailed': 'Не вдалося прибрати лінк',
'chat.workStatus.linkedIssues.link': 'Прилінкувати до сесії',
'chat.workStatus.linkedIssues.link': 'Прилінкувати',
'chat.workStatus.linkedIssues.linkFailed': 'Не вдалося прилінкувати',
'chat.workStatus.linkedIssues.linkDialogTitle': 'Прилінкувати issue або pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': 'Вставте URL issue або PR…',
'chat.workStatus.linkedIssues.linkInvalid': 'Це не схоже на підтримуваний URL issue/PR',
'chat.workStatus.linkedIssues.linked': 'Прилінковано',
'chat.workStatus.linkedIssues.unlinkConfirm': 'Прибрати цей issue/PR із сесії?',
'chat.workStatus.linkedIssues.liveRefresh': 'Оновити статус',
'chat.workStatus.linkedIssues.liveUnavailable': 'Живий статус недоступний',
'chat.workStatus.linkedIssues.empty': 'Немає прилінкованих issues або pull requestів',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
+12 -1
View File
@@ -1549,6 +1549,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '无文件更改',
'forge.files.noDiff': '无可用差异',
'forge.loading': '加载中...',
'forge.linkedSessions.count': '与此事项关联的会话:{count}',
'forge.linkedSessions.open': '打开会话「{title}」',
'forge.linkedSessions.title': '正在处理此事项的聊天',
'forge.notConnected': '你的 Git 平台未连接',
'forge.section.checks': '检查',
'forge.section.commits': '提交',
@@ -3410,8 +3413,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': '在 GitHub 上打开 #{number}',
'chat.workStatus.linkedIssues.unlink': '移除关联',
'chat.workStatus.linkedIssues.unlinkFailed': '无法移除关联',
'chat.workStatus.linkedIssues.link': '关联到会话',
'chat.workStatus.linkedIssues.link': '关联',
'chat.workStatus.linkedIssues.linkFailed': '无法关联',
'chat.workStatus.linkedIssues.linkDialogTitle': '关联问题或拉取请求',
'chat.workStatus.linkedIssues.linkPlaceholder': '粘贴 issue 或 PR 的 URL…',
'chat.workStatus.linkedIssues.linkInvalid': '这看起来不是受支持的 issue/PR URL',
'chat.workStatus.linkedIssues.linked': '已关联',
'chat.workStatus.linkedIssues.unlinkConfirm': '从会话中移除这个 issue/PR',
'chat.workStatus.linkedIssues.liveRefresh': '刷新状态',
'chat.workStatus.linkedIssues.liveUnavailable': '实时状态不可用',
'chat.workStatus.linkedIssues.empty': '没有已关联的问题或拉取请求',
'chat.workStatus.breakdown.issueCountSingle': '{count} 个 issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} 个 issue',
'chat.workStatus.breakdown.prCountSingle': '{count} 个 PR',
+12 -1
View File
@@ -1559,6 +1559,9 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '沒有檔案變更',
'forge.files.noDiff': '沒有可用的差異',
'forge.loading': '載入中...',
'forge.linkedSessions.count': '與此項目關聯的會話:{count}',
'forge.linkedSessions.open': '開啟會話「{title}」',
'forge.linkedSessions.title': '正在處理此項目的聊天',
'forge.notConnected': '你的 Git 平台尚未連線',
'forge.section.checks': '檢查',
'forge.section.commits': '提交',
@@ -3409,8 +3412,16 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.linkedIssues.open': '在 GitHub 上開啟 #{number}',
'chat.workStatus.linkedIssues.unlink': '移除關聯',
'chat.workStatus.linkedIssues.unlinkFailed': '無法移除關聯',
'chat.workStatus.linkedIssues.link': '關聯到工作階段',
'chat.workStatus.linkedIssues.link': '關聯',
'chat.workStatus.linkedIssues.linkFailed': '無法關聯',
'chat.workStatus.linkedIssues.linkDialogTitle': '關聯 issue 或 pull request',
'chat.workStatus.linkedIssues.linkPlaceholder': '貼上 issue 或 PR 的 URL…',
'chat.workStatus.linkedIssues.linkInvalid': '這看起來不是受支援的 issue/PR URL',
'chat.workStatus.linkedIssues.linked': '已關聯',
'chat.workStatus.linkedIssues.unlinkConfirm': '從工作階段中移除這個 issue/PR',
'chat.workStatus.linkedIssues.liveRefresh': '重新整理狀態',
'chat.workStatus.linkedIssues.liveUnavailable': '即時狀態無法使用',
'chat.workStatus.linkedIssues.empty': '沒有已關聯的 issue 或 pull request',
'chat.workStatus.breakdown.issueCountSingle': '{count} 個 issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} 個 issue',
'chat.workStatus.breakdown.prCountSingle': '{count} 個 PR',
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type {
GitHubAPI,
GitHubIssueGetResult,
GitHubPullRequestContextResult,
} from '@/lib/api/types';
import type { ForgeIssue, ForgePullRequest } from '@/lib/forge/types';
// GitHub issues have no merged state on the wire; `ForgeIssue` is broader.
type LiveIssue = Omit<ForgeIssue, 'state'> & { state: 'open' | 'closed' };
const fetchCalls: Array<{ kind: string; directory: string; number: number }> = [];
let issueResult: { connected: boolean; issue: LiveIssue | null } = { connected: true, issue: null };
let pullResult: { connected: boolean; pr: ForgePullRequest | null } = { connected: true, pr: null };
let fetchFailure: Error | null = null;
let apiPresent = true;
let registryAvailable = true;
// Minimal GitHub API double. `resolveLinkedEntityLive` goes through the real
// github adapter (`createGithubForgeProvider`), whose issue/PR lookups touch
// only `issueGet` and `prContext` on this surface — everything else is never
// reached. The module registry (`@/lib/forge/adapters`) is intentionally left
// untouched so the real provider factory is exercised exactly like `forge.test.ts`.
const fakeGithubApi = {
issueGet: async (directory: string, number: number): Promise<GitHubIssueGetResult> => {
fetchCalls.push({ kind: 'issue', directory, number });
if (fetchFailure) throw fetchFailure;
return {
connected: issueResult.connected,
repo: null,
issue: issueResult.issue
? {
number: issueResult.issue.number,
title: issueResult.issue.title,
url: '',
state: issueResult.issue.state,
}
: null,
};
},
prContext: async (directory: string, number: number): Promise<GitHubPullRequestContextResult> => {
fetchCalls.push({ kind: 'pull', directory, number });
if (fetchFailure) throw fetchFailure;
return {
connected: pullResult.connected,
repo: null,
pr: pullResult.pr
? {
number: pullResult.pr.number,
title: pullResult.pr.title,
url: '',
state: pullResult.pr.state,
draft: pullResult.pr.draft,
base: pullResult.pr.base?.ref ?? '',
head: pullResult.pr.head?.ref ?? '',
}
: null,
issueComments: [],
};
},
} as GitHubAPI;
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => {
if (!registryAvailable) return null;
return apiPresent ? { github: fakeGithubApi } : {};
},
}));
const { linkedEntityLiveInvalidate, resolveLinkedEntityLive } = await import('./linkedEntityLive');
const githubEntry = () => ({
id: 'owner/repo#12',
number: 12,
title: 'Rail badge count',
url: 'https://github.com/owner/repo/issues/12',
kind: 'issue' as const,
provider: 'github' as const,
linkedAt: 1,
});
const pullEntry = () => ({
...githubEntry(),
id: 'owner/repo#7',
number: 7,
title: 'Fix',
url: 'https://github.com/owner/repo/pull/7',
kind: 'pull' as const,
});
// GitHub issues have no merged state on the wire; `ForgeIssue` is broader.
const issue = (state: 'open' | 'closed', title = 'Rail badge count'): LiveIssue => ({
number: 12, title, state, labels: [], assignees: [],
});
const pr = (state: ForgePullRequest['state'], draft = false, title = 'Fix'): ForgePullRequest => ({
number: 7, title, state, draft,
base: { ref: 'main' }, head: { ref: 'feature' },
labels: [], assignees: [],
});
describe('resolveLinkedEntityLive', () => {
beforeEach(() => {
fetchCalls.length = 0;
issueResult = { connected: true, issue: null };
pullResult = { connected: true, pr: null };
fetchFailure = null;
apiPresent = true;
registryAvailable = true;
});
afterEach(() => {
linkedEntityLiveInvalidate(githubEntry().id);
linkedEntityLiveInvalidate(pullEntry().id);
});
test('resolves an issue to its live state and title', async () => {
issueResult.issue = issue('open', 'Fresher title');
const result = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(result?.state).toBe('open');
expect(result?.draft).toBe(false);
expect(result?.title).toBe('Fresher title');
expect(typeof result?.fetchedAt).toBe('number');
expect(fetchCalls).toEqual([{ kind: 'issue', directory: '/repo', number: 12 }]);
});
test('resolves a pull request including its draft marker', async () => {
pullResult.pr = pr('open', true, 'Draft: fix things');
const result = await resolveLinkedEntityLive(pullEntry(), '/repo');
expect(result?.state).toBe('open');
expect(result?.draft).toBe(true);
expect(result?.title).toBe('Draft: fix things');
expect(typeof result?.fetchedAt).toBe('number');
expect(fetchCalls).toEqual([{ kind: 'pull', directory: '/repo', number: 7 }]);
});
test('returns null without calling the facade when the entry has no resolvable id', async () => {
const result = await resolveLinkedEntityLive(
{ ...githubEntry(), id: 'no-number' },
'/repo',
);
expect(result).toBeNull();
expect(fetchCalls).toHaveLength(0);
});
test('returns null without calling the facade when the provider API is absent', async () => {
registryAvailable = false;
const result = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(result).toBeNull();
expect(fetchCalls).toHaveLength(0);
});
test('returns null when buildForgeProvider yields no adapter', async () => {
apiPresent = false;
const result = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(result).toBeNull();
expect(fetchCalls).toHaveLength(0);
});
test('returns null when the entity no longer resolves', async () => {
issueResult.issue = null;
const result = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(result).toBeNull();
expect(fetchCalls).toHaveLength(1);
});
test('never throws: a wire failure returns null and is cached', async () => {
fetchFailure = new Error('boom');
const first = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(first).toBeNull();
fetchFailure = null;
issueResult.issue = issue('open');
// The failed result is cached within the TTL, so no re-request happens.
const second = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(second).toBeNull();
expect(fetchCalls).toHaveLength(1);
});
test('serves the second call from cache within the TTL window', async () => {
issueResult.issue = issue('closed');
const first = await resolveLinkedEntityLive(githubEntry(), '/repo');
const second = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(first?.state).toBe('closed');
expect(second?.state).toBe('closed');
expect(fetchCalls).toHaveLength(1);
});
test('linkedEntityLiveInvalidate forces a refetch', async () => {
issueResult.issue = issue('open');
await resolveLinkedEntityLive(githubEntry(), '/repo');
linkedEntityLiveInvalidate(githubEntry().id);
issueResult.issue = issue('closed');
const after = await resolveLinkedEntityLive(githubEntry(), '/repo');
expect(after?.state).toBe('closed');
expect(fetchCalls).toHaveLength(2);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { buildForgeProvider } from '@/lib/forge/adapters';
import { parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
/**
* Live state of a linked issue/PR, fetched through the forge facade.
*
* The linked-issues snapshot stores title and identity only state, draft
* and the freshest title belong to the forge. This module resolves them on
* demand for the work-status cards, mirroring the `gitlabMrStatus.ts` pattern:
* a module-level TTL cache shared across every surface that mounts the same
* entity, a synchronous cache read for the initial render (so an
* already-resolved entity never flashes back to the stale snapshot), and
* `null` for "no authoritative live data" never a guessed value.
*
* Fetches are mount-driven and refresh-driven only; nothing here polls.
*/
export type LinkedEntityLive = {
state: 'open' | 'closed' | 'merged';
draft: boolean;
title: string;
fetchedAt: number;
};
const CACHE_TTL_MS = 60_000;
const liveCache = new Map<string, { at: number; result: LinkedEntityLive | null }>();
const cacheKeyFor = (id: string): string => id;
const readCachedLive = (id: string): LinkedEntityLive | null | undefined =>
liveCache.get(cacheKeyFor(id))?.result;
/** Drop the cached live state for one linked entity, e.g. after it was rewritten. */
export const linkedEntityLiveInvalidate = (id: string): void => {
liveCache.delete(cacheKeyFor(id));
};
/**
* Resolve the live state of one linked entity, or null when it cannot be
* resolved: an unparseable id, a runtime without the provider's API, a fetch
* that fails, or an entity the forge no longer knows.
*
* The fetch is addressed to `directory` and lets the provider resolve the
* repository from the session's remotes, exactly like the facade's other
* directory-addressed calls. Cross-repo entities therefore resolve only when
* the session's repo is the entity's repo; anything else reports null
* ("live unavailable") rather than guessing.
*
* Pull-request context is heavier than a status card needs the facade's
* only PR lookup fetches comments/files/diff too but it is the single
* facade path available, the wire call is cached server-side, and the
* snapshot row survives regardless, so the extra weight is acceptable.
*/
export const resolveLinkedEntityLive = async (
entry: LinkedIssue,
directory: string,
): Promise<LinkedEntityLive | null> => {
const ref = parseLinkedIssueRef(entry);
if (!ref || !directory) return null;
const apis = getRegisteredRuntimeAPIs();
const provider = apis ? buildForgeProvider(entry.provider ?? ref.provider, apis) : null;
if (!provider) return null;
const key = cacheKeyFor(entry.id);
const cached = liveCache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
return cached.result;
}
let result: LinkedEntityLive | null = null;
try {
if (entry.kind === 'pull') {
const context = await provider.getPullRequestContext(directory, ref.number);
const pr = context.pr;
if (pr) {
result = { state: pr.state, draft: pr.draft, title: pr.title, fetchedAt: Date.now() };
}
} else {
const detail = await provider.getIssue(directory, ref.number);
const issue = detail.issue;
if (issue) {
result = { state: issue.state, draft: false, title: issue.title, fetchedAt: Date.now() };
}
}
} catch {
result = null;
}
liveCache.set(key, { at: Date.now(), result });
return result;
};
/**
* Subscribe to one linked entity's live state. Reads the TTL cache
* synchronously for the initial render; a cache miss shows the loading state
* rather than a result from a previous fetch. `unavailable` means a fetch ran
* and resolved to nothing never a not-yet-fetched state.
*
* `refresh` invalidates the cache and refetches this one entry.
*/
export const useLinkedEntityLive = (
entry: LinkedIssue,
directory: string | null | undefined,
): { live: LinkedEntityLive | null; loading: boolean; unavailable: boolean; refresh: () => void } => {
const [live, setLive] = useState<LinkedEntityLive | null>(() =>
directory ? (readCachedLive(entry.id) ?? null) : null,
);
const [loading, setLoading] = useState(false);
// Whether the first fetch for this entity has settled — the difference
// between "not fetched yet" and "fetched and found nothing". Without it a
// cache-miss mount would report `unavailable` for one frame before the
// effect marks the fetch as loading.
const [settled, setSettled] = useState<boolean>(() =>
directory ? readCachedLive(entry.id) !== undefined : false,
);
const [tick, setTick] = useState(0);
useEffect(() => {
if (!directory) {
setLive(null);
setLoading(false);
setSettled(false);
return;
}
let mounted = true;
const cached = readCachedLive(entry.id);
const cacheEntry = liveCache.get(cacheKeyFor(entry.id));
const fresh = cacheEntry !== undefined && Date.now() - cacheEntry.at < CACHE_TTL_MS;
if (fresh) {
setLive(cached ?? null);
setLoading(false);
setSettled(true);
return;
}
// A stale entry stays on screen while it refreshes; a missing one shows
// the loading state rather than a result from a previous entity.
setLive(cached ?? null);
setLoading(true);
setSettled(false);
void resolveLinkedEntityLive(entry, directory).then((resolved) => {
if (mounted) {
setLive(resolved);
setLoading(false);
setSettled(true);
}
});
return () => {
mounted = false;
};
}, [entry, directory, tick]);
const refresh = useCallback(() => {
linkedEntityLiveInvalidate(entry.id);
setTick((current) => current + 1);
}, [entry.id]);
return { live, loading, unavailable: settled && !loading && live === null, refresh };
};
+529 -2
View File
@@ -1,6 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { beforeEach, describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
import {
buildLinkedIssue,
buildLinkedIssueId,
deriveLinkedIssueProvider,
deriveLinkedIssueRepo,
getLinkedIssues,
parseForgeEntityUrl,
parseLinkedIssueRef,
withLinkedIssue,
type LinkedIssue,
} from './linkedIssues';
const issue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
id: 'owner/repo#12',
@@ -16,6 +29,12 @@ const issue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
const sessionWith = (linked: unknown): Session =>
({ metadata: { openchamber: { linked_issues: linked } } } as unknown as Session);
const resetStores = () => {
useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } });
useGiteaAuthStore.setState({ status: null });
useGitLabAuthStore.setState({ status: null });
};
describe('buildLinkedIssueId', () => {
test('is stable per repository and number', () => {
expect(buildLinkedIssueId('owner', 'repo', 12)).toBe('owner/repo#12');
@@ -23,6 +42,8 @@ describe('buildLinkedIssueId', () => {
});
describe('buildLinkedIssue', () => {
beforeEach(resetStores);
test('derives the id from the thread url', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/12',
@@ -82,6 +103,50 @@ describe('buildLinkedIssue', () => {
expect(legacy.id).toBe('owner/repo#9');
});
test('builds a stable gitea id for pull and issue urls (regression fix)', () => {
// Gitea/Forgejo urls are `https://host/owner/repo/pulls/N` or
// `/issues/N` with no `/-/` segment. Before the fix, the `/pulls/` form
// fell through to the raw-url fallback id and could never be matched.
const pulls = buildLinkedIssue({
url: 'https://git.example.com/owner/repo/pulls/5',
number: 5,
title: 'Gitea PR',
kind: 'pull',
linkedAt: 5,
});
expect(pulls.id).toBe('owner/repo#5');
const issues = buildLinkedIssue({
url: 'https://git.example.com/owner/repo/issues/5',
number: 5,
title: 'Gitea issue',
kind: 'issue',
linkedAt: 5,
});
expect(issues.id).toBe('owner/repo#5');
// Forgejo shares the flat owner/repo + /pulls/ shape.
const forgejo = buildLinkedIssue({
url: 'https://codeberg.example/org/repo/pulls/7',
number: 7,
title: 'Forgejo PR',
kind: 'pull',
linkedAt: 5,
});
expect(forgejo.id).toBe('org/repo#7');
});
test('keeps a github.com /pulls/ url on the github id shape', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/pulls/5',
number: 5,
title: 'Plural pulls',
kind: 'pull',
linkedAt: 5,
});
expect(built.id).toBe('owner/repo#5');
});
test('falls back to a url-based id for an unparseable url', () => {
const built = buildLinkedIssue({
url: 'https://ghe.internal/x',
@@ -105,6 +170,455 @@ describe('buildLinkedIssue', () => {
expect(built.author).toBe(undefined);
expect(built.authorAvatarUrl).toBe(undefined);
});
test('records provider, repo and host for a github.com link', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/12',
number: 12,
title: 'Rail badge count',
kind: 'issue',
linkedAt: 5,
});
expect(built.provider).toBe('github');
expect(built.repo).toBe('owner/repo');
expect(built.host).toBe('github.com');
});
test('records provider, repo and host for a gitlab.com link', () => {
const built = buildLinkedIssue({
url: 'https://gitlab.com/group/project/-/issues/5',
number: 5,
title: 'Group issue',
kind: 'issue',
linkedAt: 5,
});
expect(built.provider).toBe('gitlab');
expect(built.repo).toBe('group/project');
expect(built.host).toBe('gitlab.com');
});
test('records provider, repo and host for a gitea.com link', () => {
const built = buildLinkedIssue({
url: 'https://gitea.com/owner/repo/pulls/5',
number: 5,
title: 'Gitea PR',
kind: 'pull',
linkedAt: 5,
});
expect(built.provider).toBe('gitea');
expect(built.repo).toBe('owner/repo');
expect(built.host).toBe('gitea.com');
});
test('derives provider and repo for a self-hosted gitea link from the domains store', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: [], gitea: ['git.example.com'] },
});
const built = buildLinkedIssue({
url: 'https://git.example.com/owner/repo/pulls/5',
number: 5,
title: 'Gitea PR',
kind: 'pull',
linkedAt: 5,
});
expect(built.provider).toBe('gitea');
expect(built.repo).toBe('owner/repo');
expect(built.host).toBe('git.example.com');
});
test('omits identity fields when nothing can be derived', () => {
const built = buildLinkedIssue({
url: 'https://ghe.internal/x',
number: 3,
title: 'Internal',
kind: 'issue',
linkedAt: 5,
});
expect(built.provider).toBe(undefined);
expect(built.repo).toBe(undefined);
expect(built.host).toBe('ghe.internal');
});
test('respects explicit provider, repo and host overrides', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/5',
number: 5,
title: 'Overridden',
kind: 'issue',
linkedAt: 5,
provider: 'gitlab',
repo: 'custom/path',
host: 'mirror.example.com',
});
// The id stays url-driven; only the identity fields are overridden.
expect(built.id).toBe('owner/repo#5');
expect(built.provider).toBe('gitlab');
expect(built.repo).toBe('custom/path');
expect(built.host).toBe('mirror.example.com');
});
});
describe('deriveLinkedIssueProvider', () => {
beforeEach(resetStores);
test('recognizes the well-known hosts', () => {
expect(deriveLinkedIssueProvider('https://github.com/owner/repo/issues/1')).toBe('github');
expect(deriveLinkedIssueProvider('https://gitlab.com/a/b/project/-/issues/2')).toBe('gitlab');
expect(deriveLinkedIssueProvider('https://gitea.com/owner/repo/pulls/3')).toBe('gitea');
});
test('derives a self-hosted gitea host from the domains store', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: [], gitea: ['git.example.com'] },
});
expect(deriveLinkedIssueProvider('https://git.example.com/owner/repo/pulls/5')).toBe('gitea');
});
test('derives a self-hosted gitlab host from the domains store', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: ['gitlab.example.com'], gitea: [] },
});
expect(deriveLinkedIssueProvider('https://gitlab.example.com/group/project/-/issues/5')).toBe('gitlab');
});
test('derives a self-hosted gitea host from an auth account base url', () => {
useGiteaAuthStore.setState({
status: {
connected: true,
accounts: [
{ id: '1', user: { username: 'someone' }, baseUrl: 'https://gitea.example.com', current: true },
],
},
});
expect(deriveLinkedIssueProvider('https://gitea.example.com/org/repo/pulls/3')).toBe('gitea');
});
test('derives a self-hosted gitlab host from an auth account base url', () => {
useGitLabAuthStore.setState({
status: {
connected: true,
accounts: [
{ id: '1', user: { username: 'someone' }, baseUrl: 'https://gitlab.example.com', current: true },
],
defaultBaseUrl: 'https://gitlab.example.com',
},
});
expect(deriveLinkedIssueProvider('https://gitlab.example.com/a/b/project/-/issues/5')).toBe('gitlab');
});
test('github wins over a configured gitea host (github.com never becomes gitea)', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: [], gitea: ['github.com', 'gitea.example.com'] },
});
expect(deriveLinkedIssueProvider('https://github.com/owner/repo/issues/1')).toBe('github');
});
test('returns null for an unknown host without configuration', () => {
expect(deriveLinkedIssueProvider('https://internal.example/owner/repo/issues/3')).toBeNull();
expect(deriveLinkedIssueProvider('not a url')).toBeNull();
});
});
describe('deriveLinkedIssueRepo', () => {
test('returns the project path portion of the stable id', () => {
expect(deriveLinkedIssueRepo('https://github.com/owner/repo/issues/12', 12)).toBe('owner/repo');
expect(deriveLinkedIssueRepo('https://gitlab.com/a/b/project/-/issues/5', 5)).toBe('a/b/project');
expect(deriveLinkedIssueRepo('https://git.example.com/owner/repo/pulls/5', 5)).toBe('owner/repo');
expect(deriveLinkedIssueRepo('https://gitea.com/owner/repo/issues/3', 3)).toBe('owner/repo');
});
test('returns null for an unparseable url', () => {
expect(deriveLinkedIssueRepo('https://ghe.internal/x', 3)).toBeNull();
});
});
describe('parseLinkedIssueRef', () => {
beforeEach(resetStores);
test('parses a github entry', () => {
const ref = parseLinkedIssueRef(issue());
expect(ref).toEqual({ provider: 'github', owner: 'owner', repo: 'repo', number: 12 });
});
test('parses a github pull entry', () => {
const ref = parseLinkedIssueRef({
id: 'owner/repo#7',
number: 7,
title: 'Fix',
url: 'https://github.com/owner/repo/pull/7',
kind: 'pull',
provider: 'github',
linkedAt: 1,
});
expect(ref).toEqual({ provider: 'github', owner: 'owner', repo: 'repo', number: 7 });
});
test('parses a gitlab entry with a multi-segment namespace via the provider field', () => {
const ref = parseLinkedIssueRef({
id: 'a/b/project#5',
number: 5,
title: 'Nested',
url: 'https://gitlab.example.com/a/b/project/-/issues/5',
kind: 'issue',
provider: 'gitlab',
linkedAt: 1,
});
expect(ref).toEqual({
provider: 'gitlab',
owner: 'a',
namespace: 'a/b',
repo: 'project',
number: 5,
});
});
test('parses a gitlab entry with a flat namespace', () => {
const ref = parseLinkedIssueRef({
id: 'group/project#9',
number: 9,
title: 'Flat',
url: 'https://gitlab.com/group/project/-/issues/9',
kind: 'issue',
provider: 'gitlab',
linkedAt: 1,
});
expect(ref).toEqual({
provider: 'gitlab',
owner: 'group',
namespace: 'group',
repo: 'project',
number: 9,
});
});
test('parses a gitea entry', () => {
const ref = parseLinkedIssueRef({
id: 'owner/repo#5',
number: 5,
title: 'Gitea PR',
url: 'https://git.example.com/owner/repo/pulls/5',
kind: 'pull',
provider: 'gitea',
linkedAt: 1,
});
expect(ref).toEqual({ provider: 'gitea', owner: 'owner', repo: 'repo', number: 5 });
});
test('derives github for a legacy snapshot without a provider field', () => {
const ref = parseLinkedIssueRef({
id: 'owner/repo#7',
number: 7,
title: 'Legacy',
url: 'https://github.com/owner/repo/pull/7',
kind: 'pull',
linkedAt: 1,
});
expect(ref).toEqual({ provider: 'github', owner: 'owner', repo: 'repo', number: 7 });
});
test('derives gitlab for a legacy snapshot with a nested id on a known host', () => {
const ref = parseLinkedIssueRef({
id: 'a/b/project#5',
number: 5,
title: 'Nested legacy',
url: 'https://gitlab.com/a/b/project/-/issues/5',
kind: 'issue',
linkedAt: 1,
});
expect(ref).toEqual({
provider: 'gitlab',
owner: 'a',
namespace: 'a/b',
repo: 'project',
number: 5,
});
});
test('infers gitlab-style from a multi-segment id when no provider is derivable', () => {
const ref = parseLinkedIssueRef({
id: 'a/b/c#5',
number: 5,
title: 'Unknown host',
url: 'https://unknown.example/a/b/c/issues/5',
kind: 'issue',
linkedAt: 1,
});
expect(ref).toEqual({ provider: 'gitlab', owner: 'a', namespace: 'a/b', repo: 'c', number: 5 });
});
test('infers github-style from a flat id when no provider is derivable', () => {
const ref = parseLinkedIssueRef({
id: 'owner/repo#5',
number: 5,
title: 'Unknown host',
url: 'https://unknown.example/owner/repo/issues/5',
kind: 'issue',
linkedAt: 1,
});
expect(ref).toEqual({ provider: 'github', owner: 'owner', repo: 'repo', number: 5 });
});
test('round-trips a github link through buildLinkedIssue', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/12',
number: 12,
title: 'Rail badge count',
kind: 'issue',
linkedAt: 5,
});
expect(parseLinkedIssueRef(built)).toEqual({
provider: 'github',
owner: 'owner',
repo: 'repo',
number: 12,
});
});
test('round-trips a gitlab link through buildLinkedIssue', () => {
const built = buildLinkedIssue({
url: 'https://gitlab.com/a/b/project/-/issues/5',
number: 5,
title: 'Nested',
kind: 'issue',
linkedAt: 5,
});
expect(parseLinkedIssueRef(built)).toEqual({
provider: 'gitlab',
owner: 'a',
namespace: 'a/b',
repo: 'project',
number: 5,
});
});
test('round-trips a gitea link through buildLinkedIssue', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: [], gitea: ['git.example.com'] },
});
const built = buildLinkedIssue({
url: 'https://git.example.com/owner/repo/pulls/5',
number: 5,
title: 'Gitea PR',
kind: 'pull',
linkedAt: 5,
});
expect(parseLinkedIssueRef(built)).toEqual({
provider: 'gitea',
owner: 'owner',
repo: 'repo',
number: 5,
});
});
test('returns null for malformed ids', () => {
const base = {
number: 1,
title: 'x',
url: 'https://github.com/o/r/issues/1',
kind: 'issue' as const,
linkedAt: 1,
};
expect(parseLinkedIssueRef({ ...base, id: 'no-number' })).toBeNull();
expect(parseLinkedIssueRef({ ...base, id: 'owner/repo#' })).toBeNull();
expect(parseLinkedIssueRef({ ...base, id: 'repo#1' })).toBeNull();
expect(parseLinkedIssueRef({ ...base, id: 'owner#notanumber' })).toBeNull();
// Fallback ids embed the full url and cannot be resolved.
expect(parseLinkedIssueRef({ ...base, id: 'https://ghe.internal/x#3' })).toBeNull();
});
});
describe('parseForgeEntityUrl', () => {
beforeEach(resetStores);
test('parses a github issue url', () => {
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues/12')).toEqual({
provider: 'github',
repo: 'owner/repo',
number: 12,
kind: 'issue',
});
});
test('parses github pull urls in singular and plural', () => {
expect(parseForgeEntityUrl('https://github.com/owner/repo/pull/7')).toEqual({
provider: 'github', repo: 'owner/repo', number: 7, kind: 'pull',
});
expect(parseForgeEntityUrl('https://github.com/owner/repo/pulls/7')).toEqual({
provider: 'github', repo: 'owner/repo', number: 7, kind: 'pull',
});
});
test('parses a gitlab issue url with nested namespaces and the -/ segment', () => {
expect(parseForgeEntityUrl('https://gitlab.com/a/b/project/-/issues/5')).toEqual({
provider: 'gitlab',
repo: 'a/b/project',
number: 5,
kind: 'issue',
});
});
test('parses a gitlab merge request url, including the legacy non-/- path', () => {
expect(parseForgeEntityUrl('https://gitlab.com/group/project/-/merge_requests/9')).toEqual({
provider: 'gitlab', repo: 'group/project', number: 9, kind: 'pull',
});
expect(parseForgeEntityUrl('https://gitlab.com/group/project/merge_requests/9')).toEqual({
provider: 'gitlab', repo: 'group/project', number: 9, kind: 'pull',
});
});
test('parses a gitea pulls url on a configured host', () => {
useGitProviderDomainsStore.setState({
domains: { github: [], gitlab: [], gitea: ['git.example.com'] },
});
expect(parseForgeEntityUrl('https://git.example.com/owner/repo/pulls/5')).toEqual({
provider: 'gitea', repo: 'owner/repo', number: 5, kind: 'pull',
});
});
test('ignores query strings, fragments and trailing slashes', () => {
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues/12?ref=main#comments')).toEqual({
provider: 'github', repo: 'owner/repo', number: 12, kind: 'issue',
});
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues/12/')).toEqual({
provider: 'github', repo: 'owner/repo', number: 12, kind: 'issue',
});
});
test('round-trips through buildLinkedIssue', () => {
const parsed = parseForgeEntityUrl('https://github.com/owner/repo/pull/7');
expect(parsed).not.toBeNull();
if (!parsed) return;
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/pull/7',
number: parsed.number,
title: 'Fix',
kind: parsed.kind,
provider: parsed.provider,
repo: parsed.repo,
linkedAt: 5,
});
expect(built.id).toBe('owner/repo#7');
expect(parseLinkedIssueRef(built)).toEqual({
provider: 'github', owner: 'owner', repo: 'repo', number: 7,
});
});
test('returns null for urls without an entity segment or number', () => {
expect(parseForgeEntityUrl('https://github.com/owner/repo')).toBeNull();
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues')).toBeNull();
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues/')).toBeNull();
expect(parseForgeEntityUrl('https://github.com/owner/repo/issues/abc')).toBeNull();
});
test('returns null for a host the provider cannot be derived from', () => {
expect(parseForgeEntityUrl('https://internal.example/owner/repo/issues/3')).toBeNull();
});
test('returns null for non-URLs and incomplete paths', () => {
expect(parseForgeEntityUrl('not a url')).toBeNull();
expect(parseForgeEntityUrl('https://github.com/repo/issues/3')).toBeNull();
expect(parseForgeEntityUrl('')).toBeNull();
});
});
describe('getLinkedIssues', () => {
@@ -129,6 +643,19 @@ describe('getLinkedIssues', () => {
test('survives a non-array payload', () => {
expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]);
});
test('keeps old snapshots with the new optional identity fields', () => {
// A snapshot recorded after the upgrade carries provider/repo/host; the
// previous shape (without them) must stay valid too.
const oldStyle = issue({ id: 'owner/repo#12' });
const newStyle = {
...issue({ id: 'owner/repo#13', number: 13 }),
provider: 'github',
repo: 'owner/repo',
host: 'github.com',
};
expect(getLinkedIssues(sessionWith([oldStyle, newStyle]))).toEqual([oldStyle, newStyle]);
});
});
describe('withLinkedIssue', () => {
+213 -6
View File
@@ -1,14 +1,25 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { ForgeProviderKind } from '@/lib/forge/types';
import { parseGitHost } from '@/lib/gitHost';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { normalizeProviderDomain, useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
/**
* GitHub issues and pull requests a user has linked to a session.
* Git-forge issues and pull requests a user has linked to a session.
*
* Stored as a **snapshot**, not a reference: number, title, author and avatar
* only. Enough to render a row and open the thing, and nothing more the body,
* comments and state of an issue belong to GitHub, and mirroring them here
* would mean owning their staleness. The stored title can drift from the real
* one; that is the accepted cost of a storage that never needs refreshing.
* Stored as a **snapshot**, not a reference: number, title, author and avatar,
* plus the entity's identity (provider, repo, host) enough to render a row,
* open the thing, and look the entity up for live status. The body, comments
* and state of an issue belong to the forge, and mirroring them here would
* mean owning their staleness. The stored title can drift from the real one;
* that is the accepted cost of a storage that never needs refreshing.
*
* `provider`, `repo` and `host` are derived from the link url (and, for
* self-hosted instances, the connected auth accounts and configured domains)
* the moment the link is recorded. They are still a snapshot of the entity's
* identity never live data.
*
* Rides the same session-metadata channel as pinned messages
* (`contextObligatoryMessages`), so it inherits their persistence and sync for
@@ -24,6 +35,12 @@ export type LinkedIssue = {
kind: 'issue' | 'pull';
author?: string;
authorAvatarUrl?: string;
/** 'github' | 'gitlab' | 'gitea' — derived from the URL when not supplied. */
provider?: ForgeProviderKind;
/** Project path from the URL: 'owner/repo' (github/gitea) or 'namespace/project' (gitlab). */
repo?: string;
/** Bare hostname the entity lives on (e.g. 'github.com', 'git.example.com'). */
host?: string;
linkedAt: number;
};
@@ -59,6 +76,12 @@ const GITHUB_URL_RE = /github\.com\/([^/]+)\/([^/]+)\//;
// before the `/-/issues|/merge_requests/` segment on any host. The legacy
// non-`/-/` issue/merge-request URLs are accepted too.
const GITLAB_URL_RE = /^https?:\/\/[^/]+\/(.+?)\/(?:-\/)?(?:issues|merge_requests)\/\d+/;
// Gitea and Forgejo use a flat `owner/repo` path with no `/-/` segment:
// `https://host/owner/repo/pulls/N` or `/issues/N`. GitHub is tried first and
// GitLab second (whose legacy `issues|merge_requests` branch also catches
// gitea `/issues/` urls), so by the time this runs, a remaining
// `/owner/repo/(pulls|issues)/N` url is a gitea-style one.
const GITEA_URL_RE = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/(?:pulls|issues)\/\d+/;
const buildStableIssueId = (url: string, number: number): string => {
const githubMatch = GITHUB_URL_RE.exec(url);
@@ -71,9 +94,186 @@ const buildStableIssueId = (url: string, number: number): string => {
return `${gitlabMatch[1]}#${number}`;
}
const giteaMatch = GITEA_URL_RE.exec(url);
if (giteaMatch) {
return buildLinkedIssueId(giteaMatch[1], giteaMatch[2], number);
}
return `${url}#${number}`;
};
/**
* Bare hostname of a link url. `parseGitHost` handles the git-remote forms;
* a direct URL parse covers any residue it rejects.
*/
const getIssueUrlHost = (url: string): string | null => {
const fromGitHost = parseGitHost(url);
if (fromGitHost) return fromGitHost;
try {
return new URL(url).hostname || null;
} catch {
return null;
}
};
/**
* Which forge a link url belongs to. Well-known hosts resolve without any
* state; self-hosted hosts resolve through the connected auth accounts' base
* urls and the user-configured domains, in precedence order github -> gitlab ->
* gitea. Returns null when nothing is known never a guess, so github-branded
* UI is not offered for an unknown host. github.com is matched first so it can
* never be mistaken for a gitea host.
*/
export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null => {
const host = getIssueUrlHost(url);
if (!host) return null;
if (host === 'github.com') return 'github';
if (host === 'gitlab.com') return 'gitlab';
if (host === 'gitea.com') return 'gitea';
const giteaAccountHosts = (useGiteaAuthStore.getState().status?.accounts ?? [])
.map((account) => normalizeProviderDomain(account.baseUrl))
.filter((candidate): candidate is string => candidate !== null);
if (giteaAccountHosts.includes(host)) return 'gitea';
const gitlabAccountHosts = (useGitLabAuthStore.getState().status?.accounts ?? [])
.map((account) => normalizeProviderDomain(account.baseUrl))
.filter((candidate): candidate is string => candidate !== null);
if (gitlabAccountHosts.includes(host)) return 'gitlab';
// GitHub accounts carry no base URL (they are github.com-only, which the
// built-in match above already handles), so there is no host to consult.
const { domains } = useGitProviderDomainsStore.getState();
if (domains.github.includes(host)) return 'github';
if (domains.gitlab.includes(host)) return 'gitlab';
if (domains.gitea.includes(host)) return 'gitea';
return null;
};
/**
* The project path portion of a link url's stable id: `owner/repo` for
* github/gitea, the full `namespace/project` for gitlab. Returns null when the
* url does not parse into a forge id.
*/
export const deriveLinkedIssueRepo = (url: string, number: number): string | null => {
const id = buildStableIssueId(url, number);
const hashIndex = id.lastIndexOf('#');
if (hashIndex <= 0) return null;
const path = id.slice(0, hashIndex);
// A url that failed to parse yields the url itself as the id path; that is
// not a repo, so report nothing rather than a nonsense value.
if (path === url || path.length === 0) return null;
return path;
};
// A pasted forge issue/PR URL, split into its identity pieces. The path must
// end in `issues|pull|pulls|merge_requests/<number>` with an owner/repo (or,
// for gitlab, nested namespace) path before the entity segment; the optional
// `-/` covers GitLab's modern `/-/issues` route. Strict on purpose: the Link
// dialog's paste box has no other signal to guess from, so a non-matching URL
// is invalid rather than best-effort.
const FORGE_ENTITY_URL_RE = /^(?:https?:\/\/)?[^/\s]+\/(.+?)\/(?:-\/)?(issues|pull|pulls|merge_requests)\/(\d+)\/?$/;
/**
* Parse a forge issue/PR URL pasted into the Link control into the pieces the
* facade needs. `provider` resolves through `deriveLinkedIssueProvider`
* (well-known hosts, connected auth accounts, configured domains) so an
* unknown host returns null instead of a guess; `repo` is the project path
* (`owner/repo`, or the full gitlab namespace path); `kind` comes from the
* URL's entity segment; `number` from its trailing digits.
*/
export const parseForgeEntityUrl = (url: string): {
provider: ForgeProviderKind;
repo: string;
number: number;
kind: 'issue' | 'pull';
} | null => {
const trimmed = url.trim();
const provider = deriveLinkedIssueProvider(trimmed);
if (!provider) return null;
// Query strings, fragments and trailing slashes are URL noise, not part of
// the entity identity.
const cleaned = trimmed.split(/[?#]/, 1)[0].replace(/\/+$/, '');
const match = FORGE_ENTITY_URL_RE.exec(cleaned);
if (!match) return null;
const path = match[1].split('/').filter((segment) => segment.length > 0);
if (path.length < 2) return null;
const number = Number(match[3]);
if (!Number.isFinite(number) || number < 1) return null;
return {
provider,
repo: path.join('/'),
number,
kind: match[2] === 'issues' ? 'issue' : 'pull',
};
};
/**
* A linked issue broken into the pieces the forge APIs need to look it up:
* provider, top-level namespace (owner), gitlab multi-segment namespace, repo
* and number. Built from the stored id (`path#number`); the provider comes
* from the entry when recorded, else is derived from the url, else inferred
* from the id shape (multi-segment paths are gitlab-style nested namespaces).
*/
export type LinkedIssueRef = {
provider: ForgeProviderKind;
/** Top-level namespace (github/gitea owner; gitlab top-level namespace). */
owner: string;
/** GitLab multi-segment namespace path (e.g. 'a/b'); absent for github/gitea. */
namespace?: string;
repo: string;
number: number;
};
export const parseLinkedIssueRef = (entry: LinkedIssue): LinkedIssueRef | null => {
const hashIndex = entry.id.lastIndexOf('#');
if (hashIndex <= 0) return null;
const path = entry.id.slice(0, hashIndex);
const rawNumber = entry.id.slice(hashIndex + 1);
if (rawNumber.length === 0) return null;
const number = Number(rawNumber);
if (!Number.isFinite(number)) return null;
// Fallback ids embed the whole url (`${url}#${number}`) and cannot be
// resolved to a forge entity.
if (path === entry.url || path.includes('://')) return null;
const segments = path.split('/').filter((segment) => segment.length > 0);
if (segments.length < 2) return null;
const explicitProvider = entry.provider;
const provider = explicitProvider === 'github' || explicitProvider === 'gitlab' || explicitProvider === 'gitea'
? explicitProvider
: deriveLinkedIssueProvider(entry.url);
if (provider === 'gitlab') {
const repo = segments[segments.length - 1];
const namespace = segments.slice(0, -1).join('/');
return { provider, owner: segments[0], namespace, repo, number };
}
if (provider === 'github' || provider === 'gitea') {
return { provider, owner: segments[0], repo: segments[1], number };
}
// No provider derivable: infer from the id shape. Multi-segment paths are
// gitlab-style (nested namespaces), flat paths github-style.
if (segments.length >= 3) {
const repo = segments[segments.length - 1];
const namespace = segments.slice(0, -1).join('/');
return { provider: 'gitlab', owner: segments[0], namespace, repo, number };
}
return { provider: 'github', owner: segments[0], repo: segments[1], number };
};
export const buildLinkedIssue = (input: {
url: string;
number: number;
@@ -81,6 +281,10 @@ export const buildLinkedIssue = (input: {
kind: 'issue' | 'pull';
author?: { login?: string; avatarUrl?: string } | null;
linkedAt: number;
/** Explicit overrides; when absent, provider/repo/host are derived from the url. */
provider?: ForgeProviderKind;
repo?: string;
host?: string;
}): LinkedIssue => {
const id = buildStableIssueId(input.url, input.number);
@@ -93,6 +297,9 @@ export const buildLinkedIssue = (input: {
author: input.author?.login ?? undefined,
authorAvatarUrl: input.author?.avatarUrl ?? undefined,
linkedAt: input.linkedAt,
provider: input.provider ?? deriveLinkedIssueProvider(input.url) ?? undefined,
repo: input.repo ?? deriveLinkedIssueRepo(input.url, input.number) ?? undefined,
host: input.host ?? parseGitHost(input.url) ?? undefined,
};
};
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
import type { ForgeRepoRef } from '@/lib/forge/types';
import type { LinkedIssue } from '@/lib/linkedIssues';
import {
findLinkedSessionsForEntity,
linkedEntityCandidateIds,
type LinkedSessionCandidate,
} from './linkedSessionMatches';
const resetStores = () => {
useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } });
useGiteaAuthStore.setState({ status: null });
useGitLabAuthStore.setState({ status: null });
};
const repoRef = (overrides: Partial<ForgeRepoRef> = {}): ForgeRepoRef => ({
owner: 'owner',
repo: 'widget',
provider: 'github',
...overrides,
});
const linkedIssue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
id: 'owner/widget#42',
number: 42,
title: 'Rail badge count',
url: 'https://github.com/owner/widget/pull/42',
kind: 'pull',
author: 'someone',
linkedAt: 100,
...overrides,
});
const session = (
overrides: Partial<Pick<LinkedSessionCandidate, 'id' | 'title'>> & { linked?: unknown } = {},
): LinkedSessionCandidate => ({
id: overrides.id ?? 'ses_1',
title: overrides.title ?? 'Fix the rail',
metadata: { openchamber: { linked_issues: overrides.linked ?? [] } },
});
describe('linkedEntityCandidateIds', () => {
test('github uses the flat owner/repo id', () => {
expect(linkedEntityCandidateIds(repoRef({ provider: 'github' }), 42)).toEqual([
'owner/widget#42',
]);
});
test('gitea uses the flat owner/repo id', () => {
expect(linkedEntityCandidateIds(repoRef({ provider: 'gitea' }), 42)).toEqual([
'owner/widget#42',
]);
});
test('gitlab with a single-segment namespace uses the flat id only', () => {
expect(linkedEntityCandidateIds(repoRef({ provider: 'gitlab', owner: 'acme', repo: 'proj' }), 9)).toEqual([
'acme/proj#9',
]);
});
test('gitlab with a multi-segment namespace also includes the full project path', () => {
expect(
linkedEntityCandidateIds(repoRef({ provider: 'gitlab', owner: 'acme', namespace: 'group/sub', repo: 'proj' }), 9),
).toEqual(['acme/proj#9', 'group/sub/proj#9']);
});
test('gitlab where the namespace equals the owner produces one candidate', () => {
expect(
linkedEntityCandidateIds(repoRef({ provider: 'gitlab', owner: 'group', namespace: 'group', repo: 'proj' }), 9),
).toEqual(['group/proj#9']);
});
});
describe('findLinkedSessionsForEntity', () => {
beforeEach(resetStores);
test('returns an empty list when nothing matches', () => {
const sessions = [
session({ id: 'ses_a', linked: [linkedIssue({ id: 'other/repo#7', number: 7 })] }),
session({ id: 'ses_b' }),
];
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42'])).toEqual([]);
});
test('matches a session whose stored entry id is the entity', () => {
const sessions = [session({ id: 'ses_a', linked: [linkedIssue()] })];
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42'])).toEqual([
{ sessionId: 'ses_a', title: 'Fix the rail', linkedAt: 100 },
]);
});
test('matches a legacy entry without a provider field by deriving it from the url', () => {
const legacy = linkedIssue({
id: 'owner/widget#42',
url: 'https://github.com/owner/widget/pull/42',
provider: undefined,
});
const sessions = [session({ id: 'ses_a', linked: [legacy] })];
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42'])).toEqual([
{ sessionId: 'ses_a', title: 'Fix the rail', linkedAt: 100 },
]);
});
test('rejects an entry whose provider differs from the viewed entity', () => {
const gitlabEntry = linkedIssue({
id: 'owner/widget#42',
url: 'https://gitlab.com/owner/widget/-/issues/42',
provider: 'gitlab',
});
const sessions = [session({ id: 'ses_a', linked: [gitlabEntry] })];
// Same id, but the session linked a gitlab entity while this view is github.
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42'])).toEqual([]);
});
test('matches by id alone when no provider can be derived', () => {
// Fallback ids embed the full url and cannot be resolved to a provider.
const unknown = linkedIssue({
id: 'https://ghe.internal/x#3',
number: 3,
url: 'https://ghe.internal/x',
});
const sessions = [session({ id: 'ses_a', linked: [unknown] })];
expect(findLinkedSessionsForEntity(sessions, 'github', ['https://ghe.internal/x#3'])).toEqual([
{ sessionId: 'ses_a', title: 'Fix the rail', linkedAt: 100 },
]);
});
test('matches the gitlab namespace candidate when the stored id uses the full project path', () => {
const entry = linkedIssue({
id: 'group/sub/proj#9',
number: 9,
url: 'https://gitlab.com/group/sub/proj/-/issues/9',
provider: 'gitlab',
});
const sessions = [session({ id: 'ses_a', linked: [entry] })];
const candidates = linkedEntityCandidateIds(
repoRef({ provider: 'gitlab', owner: 'acme', namespace: 'group/sub', repo: 'proj' }),
9,
);
expect(candidates).toEqual(['acme/proj#9', 'group/sub/proj#9']);
expect(findLinkedSessionsForEntity(sessions, 'gitlab', candidates)).toEqual([
{ sessionId: 'ses_a', title: 'Fix the rail', linkedAt: 100 },
]);
});
test('dedupes a session that holds both the flat and the namespaced candidate, keeping the newest linkedAt', () => {
const entries = [
linkedIssue({
id: 'acme/proj#9',
number: 9,
url: 'https://gitlab.com/acme/proj/-/issues/9',
provider: 'gitlab',
linkedAt: 10,
}),
linkedIssue({
id: 'group/sub/proj#9',
number: 9,
url: 'https://gitlab.com/group/sub/proj/-/issues/9',
provider: 'gitlab',
linkedAt: 40,
}),
];
const sessions = [session({ id: 'ses_a', linked: entries })];
const candidates = linkedEntityCandidateIds(
repoRef({ provider: 'gitlab', owner: 'acme', namespace: 'group/sub', repo: 'proj' }),
9,
);
expect(findLinkedSessionsForEntity(sessions, 'gitlab', candidates)).toEqual([
{ sessionId: 'ses_a', title: 'Fix the rail', linkedAt: 40 },
]);
});
test('sorts by linkedAt descending, then by title', () => {
const sessions = [
session({ id: 'ses_old', title: 'Old chat', linked: [linkedIssue({ linkedAt: 10 })] }),
session({ id: 'ses_new', title: 'New chat', linked: [linkedIssue({ linkedAt: 50 })] }),
// Same linkedAt as ses_old; title decides the order.
session({ id: 'ses_tie', title: 'A tie', linked: [linkedIssue({ linkedAt: 10 })] }),
];
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42']).map((row) => row.sessionId))
.toEqual(['ses_new', 'ses_tie', 'ses_old']);
});
test('skips sessions with no metadata and malformed entries', () => {
const sessions = [
session({ id: 'ses_empty' }),
session({ id: 'ses_malformed', linked: [{ id: 'owner/widget#42' }] }),
{ id: 'ses_no_title', metadata: { openchamber: { linked_issues: [linkedIssue()] } } },
];
expect(findLinkedSessionsForEntity(sessions, 'github', ['owner/widget#42'])).toEqual([
{ sessionId: 'ses_no_title', title: '', linkedAt: 100 },
]);
});
});
+106
View File
@@ -0,0 +1,106 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { ForgeProviderKind, ForgeRepoRef } from '@/lib/forge/types';
import {
buildLinkedIssueId,
getLinkedIssues,
parseLinkedIssueRef,
type LinkedIssue,
} from '@/lib/linkedIssues';
/**
* Which sessions in a project are "working on" a forge entity, derived
* client-side from the already-loaded session list.
*
* The matching key is the stored `metadata.openchamber.linked_issues` entry id
* (`owner/repo#number`, or `namespace/repo#number` for gitlab). A session
* matches when one of its entries has the entity's id AND the entry's provider
* (recorded, or derived from its url via `parseLinkedIssueRef`) is the
* provider of the entity being viewed. When no provider can be derived the id
* match alone is trusted a link recorded against an unrecognized host is
* still this entity.
*
* Pure and side-effect free apart from reading session metadata unit-testable
* without a store.
*/
export type LinkedSessionRow = {
sessionId: string;
title: string;
/** Epoch ms the entity was last linked in this session. */
linkedAt?: number;
};
/**
* Loose session shape from the store's session list. `getLinkedIssues` only
* reads `metadata`, so this is enough to run the match.
*/
export type LinkedSessionCandidate = {
id: string;
title?: string | null;
metadata?: unknown;
};
/**
* The stored LinkedIssue ids this entity can be recorded under.
*
* GitHub and Gitea are flat `owner/repo`. GitLab records the full project path,
* so a multi-segment namespace (`group/sub/proj`) adds its own candidate
* (`group/sub/proj#number`) alongside the flat `owner/repo#number` form in case
* the stored snapshot was written from the flat form.
*/
export const linkedEntityCandidateIds = (repo: ForgeRepoRef, number: number): string[] => {
const candidates = [buildLinkedIssueId(repo.owner, repo.repo, number)];
if (repo.namespace && repo.namespace !== repo.owner) {
candidates.push(buildLinkedIssueId(repo.namespace, repo.repo, number));
}
return candidates;
};
const entryProviderKind = (entry: LinkedIssue): ForgeProviderKind | null => {
if (entry.provider === 'github' || entry.provider === 'gitlab' || entry.provider === 'gitea') {
return entry.provider;
}
return parseLinkedIssueRef(entry)?.provider ?? null;
};
/**
* Sessions whose stored linked_issues contain this entity.
*
* Provider is matched when it can be known (recorded field or derived from the
* entry url/shape); an entry whose provider cannot be derived matches by id
* alone. Results are deduped by session id (a session can hold both the flat
* and the namespaced candidate for the same entity) and sorted by most recent
* `linkedAt` first, then title.
*/
export const findLinkedSessionsForEntity = (
sessions: LinkedSessionCandidate[],
providerKind: ForgeProviderKind,
candidateIds: string[],
): LinkedSessionRow[] => {
const candidateSet = new Set(candidateIds);
const rowsBySessionId = new Map<string, LinkedSessionRow>();
for (const session of sessions) {
if (!session.id) continue;
const entries = getLinkedIssues(session as Session);
let linkedAt: number | undefined;
for (const entry of entries) {
if (!candidateSet.has(entry.id)) continue;
const provider = entryProviderKind(entry);
if (provider !== null && provider !== providerKind) continue;
linkedAt = linkedAt === undefined ? entry.linkedAt : Math.max(linkedAt, entry.linkedAt);
}
if (linkedAt === undefined) continue;
rowsBySessionId.set(session.id, {
sessionId: session.id,
title: session.title ?? '',
linkedAt,
});
}
return Array.from(rowsBySessionId.values()).sort((a, b) => {
const byLinkedAt = (b.linkedAt ?? 0) - (a.linkedAt ?? 0);
if (byLinkedAt !== 0) return byLinkedAt;
return a.title.localeCompare(b.title);
});
};