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

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