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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 件',
|
||||
|
||||
@@ -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}개',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user