diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 16c9e9a4..68bbfe63 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -269,6 +269,10 @@ Rows that name something the app can already show are buttons: | MCP status | the state doubles as the button that reconnects | | Pinned (pin icon) | unpins the message | | Pinned (text) | jumps the transcript to that message | +| Linked (title) | opens the issue/PR in the browser | +| Linked (refresh) | refetches that entity's live state (cache-busting) | +| Linked (unlink) | removes the link from the session, after confirm | +| Link (section header) | opens the paste-a-URL link dialog | The goal icon reproduces the **composer target button's** colour mapping, not the goal strip's. The two disagree today — the strip paints `paused` muted and @@ -296,18 +300,31 @@ something other than "tools available". ### Linked issues and pull requests -Written by the flows that already attach a thread — the composer's issue/PR -pickers, and session creation from an issue or PR in `NewWorktreeDialog` and -`GitHubIssuePickerDialog`. There is no manual "link this" control: attaching a -thread to the work *is* the act of linking it. +Written by the flows that attach a thread — the composer's issue/PR pickers, +session creation from an issue or PR in `NewWorktreeDialog` and +`GitHubIssuePickerDialog` — **and** by the section's own Link control, which +accepts a pasted issue/PR URL (validated against the forge before recording) +and per-row Unlink. Attaching a thread and pasting a URL are the same act of +linking; the row always knows how to unlink itself. Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace `openchamber.linked_issues`), riding the same `patchSessionMetadata` channel as pinned messages. Number, title, url, author and avatar only — the body, -comments and state belong to GitHub, and mirroring them would mean owning their -staleness. The stored title can drift; that is the price of a store that never -needs refreshing. The row opens the real thread, which is where current state -lives. +comments and state belong to the forge, and mirroring them would mean owning +their staleness. + +Each row renders as a **live card** (`lib/linkedEntityLive.ts`) when the entry +resolves to a forge entity and the runtime carries that provider's API: the +current open/merged/closed state, the draft marker and the freshest title are +fetched on mount and on the row's refresh button — never on an interval. The +snapshot stays the fallback for whatever the fetch has not answered yet +(initial loading shows a spinner; a failed fetch shows a muted "live +unavailable" marker rather than silently looking stale). Fetches go through the +forge facade (`lib/forge/adapters.ts`) addressed to the session's directory, so +the repo resolves from the session's remotes; a cross-repo entity reports live +state as unavailable instead of guessing. Results are cached per entity for +60s in a module-level TTL cache, read synchronously for the initial render so +an already-resolved entity never flashes back to the snapshot. Writes happen **after** the send promise resolves and are deliberately swallowed on failure: the message went out, and a missing bookkeeping entry diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index 254b974a..d7ba437c 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -1,21 +1,167 @@ import React from 'react'; -import { useI18n } from '@/lib/i18n'; +import { toast } from '@/components/ui'; +import { Button } from '@/components/ui/button'; import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; +import { useI18n, type I18nKey } from '@/lib/i18n'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; -import { getLinkedIssues } from '@/lib/linkedIssues'; -import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues'; +import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive'; +import { setLinkedIssue } from '@/sync/session-actions'; +import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; +import { WorkStatusLinkDialog } from './WorkStatusLinkDialog'; type Props = { sessionId: string | null; directory: string | null; }; +const STATE_COLOR: Record = { + open: 'var(--pr-open)', + closed: 'var(--pr-closed)', + merged: 'var(--pr-merged)', +}; + +const STATE_LABEL_KEY: Record = { + open: 'forge.state.open', + closed: 'forge.state.closed', + merged: 'forge.state.merged', +}; + /** - * What is loaded into the agent's context: the GitHub threads this session was - * pointed at, plus how much ambient material is available. + * One linked issue/PR as a live card. + * + * When the entry resolves to a forge entity and the runtime carries the + * provider's API, the row fetches current state (open/merged/closed, draft, + * freshest title) on mount and on demand — never on an interval. The snapshot + * stays the fallback for everything the live fetch has not answered yet: + * loading keeps the snapshot row with a spinner, a failed fetch keeps it with + * a muted "live unavailable" marker instead of silently looking stale. + */ +const LinkedIssueRow: React.FC<{ + entry: LinkedIssue; + sessionId: string | null; + directory: string | null | undefined; +}> = ({ entry, sessionId, directory }) => { + const { t } = useI18n(); + const ref = React.useMemo(() => parseLinkedIssueRef(entry), [entry]); + const providerKind = entry.provider ?? ref?.provider ?? null; + const apis = getRegisteredRuntimeAPIs(); + const canLive = Boolean( + directory + && ref + && ((providerKind === 'github' && apis?.github) + || (providerKind === 'gitlab' && apis?.gitlab) + || (providerKind === 'gitea' && apis?.gitea)), + ); + const { live, loading, unavailable, refresh } = useLinkedEntityLive(entry, canLive ? directory : null); + const [unlinking, setUnlinking] = React.useState(false); + + const handleUnlink = React.useCallback(async () => { + if (!sessionId || !directory || unlinking) return; + if (!window.confirm(t('chat.workStatus.linkedIssues.unlinkConfirm'))) return; + setUnlinking(true); + try { + await setLinkedIssue(sessionId, directory, entry, false); + linkedEntityLiveInvalidate(entry.id); + } catch { + toast.error(t('chat.workStatus.linkedIssues.unlinkFailed')); + } finally { + setUnlinking(false); + } + }, [directory, entry, sessionId, t, unlinking]); + + const openInBrowser = React.useCallback(() => { + if (typeof window !== 'undefined') { + window.open(entry.url, '_blank', 'noopener,noreferrer'); + } + }, [entry.url]); + + const stateLabel = live ? t(STATE_LABEL_KEY[live.state]) : null; + + // The live fetch is the freshest word on the title; the snapshot covers + // everything the fetch has not answered yet (initial loading, failure). + const title = live?.title ?? entry.title; + + const leading = entry.authorAvatarUrl ? ( + + ) : ( + + ); + + // A plain row, not WorkStatusRow: the card carries its own controls + // (refresh, unlink) next to the number, which a full-row button cannot + // contain without nesting buttons. + return ( +
+ {leading} + + + {live ? ( + + ) : null} + {live?.draft ? {t('chat.workStatus.pr.draft')} : null} + {`#${entry.number}`} + {canLive ? ( + + ) : null} + + +
+ ); +}; + +/** + * What is loaded into the agent's context: the git-forge threads this session + * was pointed at (live state cards plus link/unlink controls), and how much + * ambient material is available. * * Agents are deliberately absent — an agent is who does the work, not material * the work is done with. Tools are absent for want of an honest source: @@ -24,12 +170,16 @@ type Props = { */ export const WorkStatusContextSection: React.FC = ({ sessionId, directory }) => { const { t } = useI18n(); + const [linkDialogOpen, setLinkDialogOpen] = React.useState(false); const session = useSession(sessionId ?? '', directory ?? undefined); const skills = useSkillsStore((state) => state.skills); const mcpStatus = useMcpStore( React.useCallback((state) => state.getStatusForDirectory(directory), [directory]), ); + // The session's server-confirmed directory is the authoritative address for + // forge lookups; the prop only covers drafts with no session yet. + const sessionDirectory = session?.directory ?? directory; // Skills were previously fetched only when the composer's slash autocomplete // opened, so this row reported whatever count happened to be cached — often @@ -85,36 +235,43 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory } } + const hasSessionContext = Boolean(sessionId && sessionDirectory); + return ( setLinkDialogOpen(true)} + aria-label={t('chat.workStatus.linkedIssues.link')} + title={t('chat.workStatus.linkedIssues.link')} + > + + {t('chat.workStatus.linkedIssues.link')} + + )} > {/* Attached threads first: they are specific to this session, while the counts below describe the workspace. */} {linked.map((entry) => ( - - ) : ( - - )} - label={entry.title} - muted - // The stored snapshot is enough to render; the live thread only ever - // exists on github.com. - onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')} - ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })} - value={{`#${entry.number}`}} + entry={entry} + sessionId={sessionId} + directory={sessionDirectory} /> ))} + {linked.length === 0 ? ( + + ) : null} + = ({ sessionId, directory label={t('chat.workStatus.breakdown.mcp')} value={{mcpCount}} /> + + ); }; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx b/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx new file mode 100644 index 00000000..49b89c59 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { toast } from '@/components/ui'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Icon } from '@/components/icon/Icon'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { buildForgeProvider } from '@/lib/forge/adapters'; +import { useI18n } from '@/lib/i18n'; +import { buildLinkedIssue, parseForgeEntityUrl } from '@/lib/linkedIssues'; +import { setLinkedIssue } from '@/sync/session-actions'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string | null; + directory: string | null; +}; + +/** + * Manual "link this issue/PR" control for the context-sources section. + * + * The URL is parsed first (`parseForgeEntityUrl`) and validated against the + * forge before the link is recorded: the live fetch proves the entity exists + * and supplies its real title, so a stale or mistyped URL surfaces as a + * `linkFailed` toast instead of a snapshot row that never resolves. Linking + * rides the same `setLinkedIssue`/session-metadata channel as the attach + * flows, so the row appears through the section's existing session read. + */ +export const WorkStatusLinkDialog: React.FC = ({ open, onOpenChange, sessionId, directory }) => { + const { t } = useI18n(); + const [url, setUrl] = React.useState(''); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const handleOpenChange = React.useCallback((next: boolean) => { + if (!next) { + setUrl(''); + setError(null); + setBusy(false); + } + onOpenChange(next); + }, [onOpenChange]); + + const handleLink = React.useCallback(async () => { + if (!sessionId || !directory || busy) return; + + const parsed = parseForgeEntityUrl(url); + if (!parsed) { + setError(t('chat.workStatus.linkedIssues.linkInvalid')); + return; + } + + setBusy(true); + setError(null); + try { + const apis = getRegisteredRuntimeAPIs(); + const provider = apis ? buildForgeProvider(parsed.provider, apis) : null; + if (!provider) { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + return; + } + + // Validate the entity exists on the forge and grab its real title. The + // facade resolves the repo from the session's remotes; an entity the + // forge no longer knows (or a repo the session cannot reach) reports no + // title and the link is refused. + let title: string | null = null; + try { + if (parsed.kind === 'pull') { + const context = await provider.getPullRequestContext(directory, parsed.number); + title = context.pr?.title ?? null; + } else { + const detail = await provider.getIssue(directory, parsed.number); + title = detail.issue?.title ?? null; + } + } catch { + title = null; + } + if (!title) { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + return; + } + + const issue = buildLinkedIssue({ + url: url.trim(), + number: parsed.number, + title, + kind: parsed.kind, + provider: parsed.provider, + repo: parsed.repo, + linkedAt: Date.now(), + }); + await setLinkedIssue(sessionId, directory, issue, true); + toast.success(t('chat.workStatus.linkedIssues.linked')); + handleOpenChange(false); + } catch { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + } finally { + setBusy(false); + } + }, [busy, directory, handleOpenChange, sessionId, t, url]); + + const canSubmit = Boolean(sessionId && directory) && !busy && url.trim().length > 0; + + return ( + + + + {t('chat.workStatus.linkedIssues.linkDialogTitle')} + + +
+ setUrl(event.target.value)} + placeholder={t('chat.workStatus.linkedIssues.linkPlaceholder')} + aria-invalid={error ? true : undefined} + disabled={busy || !sessionId || !directory} + autoFocus + onKeyDown={(event) => { + if (event.key === 'Enter' && canSubmit) void handleLink(); + }} + /> + {error ?

{error}

: null} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx index 6a36b065..0854beec 100644 --- a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx +++ b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx @@ -1,9 +1,15 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useI18n } from '@/lib/i18n'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { normalizePath } from '@/lib/pathNormalization'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { findLinkedSessionsForEntity, linkedEntityCandidateIds } from '@/lib/linkedSessionMatches'; import type { ForgeChecksResult, ForgeCommitsResult, @@ -19,6 +25,7 @@ import { ForgeCommitsSection } from './ForgeCommitsSection'; import { ForgeFilesDiffSection } from './ForgeFilesDiffSection'; import { ForgeTimelineSection } from './ForgeTimelineSection'; import { ForgeChecksSection } from './ForgeChecksSection'; +import { LinkedSessionsSection } from './LinkedSessionsSection'; import { ForgeCommentComposer, ForgeEntityActions, @@ -113,6 +120,34 @@ export const ForgeEntityDetailView: React.FC = ({ pr // under that thread card). const [replyingTo, setReplyingTo] = useState(null); + // Sessions in the same project as this view, from the same authoritative + // store the sidebar consumes. Derived client-side: no extra fetching. + const allSessions = useGlobalSessionsStore(useShallow((state) => state.activeSessions)); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + + // The repo this entity lives on, resolved from the loaded context/issue. + const repoRef = isIssue ? (issueDetail?.repo ?? null) : (pull?.context?.repo ?? null); + + const projectSessions = useMemo(() => { + const base = normalizePath(directory); + if (!base) return allSessions; + return allSessions.filter((session) => { + const sessionDirectory = resolveGlobalSessionDirectory(session); + return sessionDirectory === base || (sessionDirectory !== null && sessionDirectory.startsWith(`${base}/`)); + }); + }, [allSessions, directory]); + + const linkedSessions = useMemo(() => { + if (!repoRef) return []; + const candidateIds = linkedEntityCandidateIds(repoRef, number); + return findLinkedSessionsForEntity(projectSessions, provider.kind, candidateIds); + }, [number, projectSessions, provider.kind, repoRef]); + + const openSession = useCallback((sessionId: string) => { + useUIStore.getState().closeMainSurfaces(); + setCurrentSession(sessionId); + }, [setCurrentSession]); + const reload = useCallback(() => { setReloadToken((value) => value + 1); }, []); @@ -251,6 +286,7 @@ export const ForgeEntityDetailView: React.FC = ({ pr + = ({ pr + + {checksForPull ? (
{t('forge.section.checks')} diff --git a/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx b/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx new file mode 100644 index 00000000..3315cc31 --- /dev/null +++ b/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; +import type { LinkedSessionRow } from '@/lib/linkedSessionMatches'; + +interface LinkedSessionsSectionProps { + sessions: LinkedSessionRow[]; + /** Called with the session id when a row is clicked to open its chat. */ + onOpenSession: (sessionId: string) => void; +} + +/** + * "Chats working on this" — sessions in the current project that have this + * forge entity linked (`metadata.openchamber.linked_issues`). Purely derived + * from the already-loaded session list; rows open the session's chat. Renders + * nothing when there are no matches. + */ +export const LinkedSessionsSection = React.memo(function LinkedSessionsSection({ + sessions, + onOpenSession, +}) { + const { t } = useI18n(); + + if (sessions.length === 0) { + return null; + } + + return ( +
+
+ +

{t('forge.linkedSessions.title')}

+ + {sessions.length} + +
+
    + {sessions.map((session) => ( +
  • + +
  • + ))} +
+
+ ); +}); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index a2c43512..91dd58ad 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 041301dc..511a20e4 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9f1ab06c..5958313c 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1549,6 +1549,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 12a74755..1adf31b8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 5188b740..00185b4b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1579,6 +1579,9 @@ export const dict: Record = { '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 = { '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} 件', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 3e283154..591e2008 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1585,6 +1585,9 @@ export const dict: Record = { '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 = { '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}개', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d96b280f..d1406c2b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2066,6 +2066,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 281d4543..c835d8b4 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1549,6 +1549,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 4f290293..d408dc5e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1549,6 +1549,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 5c70d9d7..c867c749 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1549,6 +1549,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 72bfeddd..9d5d82a7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1559,6 +1559,9 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/linkedEntityLive.test.ts b/packages/ui/src/lib/linkedEntityLive.test.ts new file mode 100644 index 00000000..006d82d6 --- /dev/null +++ b/packages/ui/src/lib/linkedEntityLive.test.ts @@ -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 & { 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 => { + 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 => { + 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); + }); +}); diff --git a/packages/ui/src/lib/linkedEntityLive.ts b/packages/ui/src/lib/linkedEntityLive.ts new file mode 100644 index 00000000..31a022bb --- /dev/null +++ b/packages/ui/src/lib/linkedEntityLive.ts @@ -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(); + +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 => { + 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(() => + 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(() => + 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 }; +}; diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts index 071c736e..d781f7f6 100644 --- a/packages/ui/src/lib/linkedIssues.test.ts +++ b/packages/ui/src/lib/linkedIssues.test.ts @@ -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 => ({ id: 'owner/repo#12', @@ -16,6 +29,12 @@ const issue = (overrides: Partial = {}): 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', () => { diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts index 2e6a87d7..49ad8c32 100644 --- a/packages/ui/src/lib/linkedIssues.ts +++ b/packages/ui/src/lib/linkedIssues.ts @@ -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/` 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, }; }; diff --git a/packages/ui/src/lib/linkedSessionMatches.test.ts b/packages/ui/src/lib/linkedSessionMatches.test.ts new file mode 100644 index 00000000..8e87ed94 --- /dev/null +++ b/packages/ui/src/lib/linkedSessionMatches.test.ts @@ -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 => ({ + owner: 'owner', + repo: 'widget', + provider: 'github', + ...overrides, +}); + +const linkedIssue = (overrides: Partial = {}): 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> & { 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 }, + ]); + }); +}); diff --git a/packages/ui/src/lib/linkedSessionMatches.ts b/packages/ui/src/lib/linkedSessionMatches.ts new file mode 100644 index 00000000..0e9fee84 --- /dev/null +++ b/packages/ui/src/lib/linkedSessionMatches.ts @@ -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(); + + 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); + }); +};