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