diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index f0c681e629903..0083bf2bc0dc5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -19,6 +19,7 @@ const CONTEXT_CHIP_PRESENTATIONS = { "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, skill: { accent: "#b261be", symbol: "cube" }, "linear-issue": { accent: "#617de6", symbol: "ticket" }, + "github-issue": { accent: "#009f6e", symbol: "smallcircle.filled.circle" }, repository: { accent: "#009c9c", symbol: "folder" }, } as const; @@ -46,6 +47,12 @@ const PULL_REQUEST_CHIP_PRESENTATIONS = { closed: { accent: "#d55665", symbol: "git-pull-request" }, } as const; +/** A GitHub issue chip is green while open and purple once closed, the way GitHub colours it. */ +const CLOSED_GITHUB_ISSUE_CHIP_PRESENTATION = { + accent: "#8a70dd", + symbol: "smallcircle.filled.circle", +} as const; + export function contextChipPresentation( kind: string, record?: { @@ -53,6 +60,7 @@ export function contextChipPresentation( readonly name?: string; readonly mimeType?: string; readonly sectionId?: string; + readonly state?: string; readonly pullRequest?: { readonly state?: string; readonly isDraft?: boolean; @@ -84,6 +92,9 @@ export function contextChipPresentation( return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; } } + if (presentationKind === "github-issue" && record?.state === "closed") { + return CLOSED_GITHUB_ISSUE_CHIP_PRESENTATION; + } return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] : CONTEXT_CHIP_PRESENTATIONS.file; diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index f638405ee2104..f189879bbf0c0 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -8,6 +8,11 @@ import { ControlPillMenu } from "./ControlPill"; const PHOTOS_ACTION: MenuAction = { id: "photos", title: "Photo Library", image: "photo" }; const FILES_ACTION: MenuAction = { id: "files", title: "Choose Files", image: "folder" }; const LINEAR_ACTION: MenuAction = { id: "linear", title: "Linear issue", image: "ticket" }; +const GITHUB_ISSUE_ACTION: MenuAction = { + id: "github-issue", + title: "GitHub issue", + image: "smallcircle.filled.circle", +}; const REPOSITORY_ACTION: MenuAction = { id: "repository", title: "Repository", @@ -21,6 +26,8 @@ export function ComposerAttachmentButton(props: { readonly onPickFiles: () => Promise; /** Omit to hide the Linear action. See `useLinearIssuePicker`. */ readonly onPickLinearIssue?: () => void; + /** Omit to hide the GitHub issue action. See `useGitHubIssuePicker`. */ + readonly onPickGitHubIssue?: () => void; /** Omit to hide the repository action. See `useRepositoryPicker`. */ readonly onPickRepository?: () => void; }) { @@ -29,6 +36,7 @@ export function ComposerAttachmentButton(props: { PHOTOS_ACTION, ...(props.supportsFiles ? [FILES_ACTION] : []), ...(props.onPickLinearIssue ? [LINEAR_ACTION] : []), + ...(props.onPickGitHubIssue ? [GITHUB_ISSUE_ACTION] : []), ...(props.onPickRepository ? [REPOSITORY_ACTION] : []), ]; const usesMenu = !props.disabled && actions.length > 1; @@ -68,6 +76,8 @@ export function ComposerAttachmentButton(props: { void props.onPickFiles(); } else if (nativeEvent.event === "linear") { props.onPickLinearIssue?.(); + } else if (nativeEvent.event === "github-issue") { + props.onPickGitHubIssue?.(); } else if (nativeEvent.event === "repository") { props.onPickRepository?.(); } diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index fea911a32cf9c..8b52fe9f0eff1 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -373,6 +373,36 @@ export function ComposerContextSheet(props: { ) : null} + {record.kind === "github-issue" ? ( + + + + {record.title} + + + {record.repository}#{record.number} ·{" "} + {record.state === "open" ? "Open" : "Closed"} + + + {/^https?:\/\//i.test(record.url) ? ( + { + void tryOpenExternalUrl(record.url, "github-issue").then((opened) => { + if (!opened) Alert.alert("Could not open GitHub", "Try again later."); + }); + }} + className="rounded-xl bg-subtle p-4" + > + Open on GitHub + + ) : null} + {/* Plain text like the other captured context here: exactly what the agent received. */} + + {record.markdown} + + + ) : null} {record.kind === "thread-tab" ? ( diff --git a/apps/mobile/src/components/GitHubIssuePickerSheet.tsx b/apps/mobile/src/components/GitHubIssuePickerSheet.tsx new file mode 100644 index 0000000000000..8245e9ffe59c5 --- /dev/null +++ b/apps/mobile/src/components/GitHubIssuePickerSheet.tsx @@ -0,0 +1,278 @@ +import type { + EnvironmentId, + GitHubIssueStateFilter, + GitHubIssueSummary, + OrchestrationMessageContext, + ProjectId, +} from "@t3tools/contracts"; +import { + gitHubIssueContextRecord, + gitHubIssueLabel, + threadsForGitHubIssue, +} from "@t3tools/client-runtime/state/github-issues"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useState, type ReactNode } from "react"; +import { ActivityIndicator, Alert, FlatList, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useThreadShells } from "../state/entities"; +import { gitHubIssueEnvironment } from "../state/githubIssues"; +import { useDebouncedValue } from "../state/queries"; +import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + captureComposerDraftInsertion, + insertComposerDraftContext, + type ComposerDraftInsertion, +} from "../state/use-composer-drafts"; +import { confirmOpenExistingThread } from "./confirmOpenExistingThread"; +import { AppText as Text, AppTextInput } from "./AppText"; +import { PickerSheet } from "./PickerSheet"; +import { SegmentedControl } from "./SegmentedControl"; + +/** Each search runs `gh` on the host, so typing settles before it queries. */ +const SEARCH_DEBOUNCE_MS = 300; + +const STATE_OPTIONS = [ + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + { value: "all", label: "All" }, +] as const satisfies ReadonlyArray<{ value: GitHubIssueStateFilter; label: string }>; + +/** GitHub's failures carry a reason; the host's `gh` setup deserves a clearer hint than its detail. */ +function gitHubIssueErrorMessage(error: unknown, fallback: string): string { + if (typeof error === "object" && error !== null && "reason" in error) { + if (error.reason === "rate-limited") return "GitHub is busy. Try again shortly."; + if (error.reason === "unavailable") { + return "The GitHub CLI (gh) is not installed on the computer running T3 Code."; + } + if (error.reason === "not-authenticated") { + return "The GitHub CLI is not signed in. Run `gh auth login` on the computer running T3 Code."; + } + } + return error instanceof Error && error.message.trim() ? error.message : fallback; +} + +/** + * Attach inserts the issue into a composer draft. `cwd` is the checkout whose repository lists + * issues. With `startFrom` it starts a new thread from the issue: it first offers threads already + * linked to it, and the thread the draft creates is linked once it sends. + */ +export interface GitHubIssuePickerTarget { + readonly environmentId: EnvironmentId; + readonly draftKey: string; + readonly cwd: string; + readonly startFrom?: { readonly projectId: ProjectId }; +} + +type OpenedGitHubIssuePicker = GitHubIssuePickerTarget & { + readonly insertion: ComposerDraftInsertion; +}; + +/** Issues picked to start a draft's thread from, by draft key, until the draft sends. */ +const startFromIssueUrls = new Map(); + +/** + * The issue URL the draft was started from, if its chip is still in the draft. Clears it: the + * caller links the thread the draft just created. + */ +export function takeStartFromGitHubIssue( + draftKey: string, + context: OrchestrationMessageContext | undefined, +): string | null { + const url = startFromIssueUrls.get(draftKey); + startFromIssueUrls.delete(draftKey); + if (url === undefined) return null; + const attached = context?.records.some( + (record) => record.kind === "github-issue" && "url" in record && record.url === url, + ); + return attached ? url : null; +} + +/** + * A GitHub issue picker. The owner renders `sheet` somewhere that outlives composer focus: + * opening the sheet blurs the editor, which can unmount a focus-dependent toolbar. + */ +export function useGitHubIssuePicker(target: GitHubIssuePickerTarget | null): { + readonly open: () => void; + readonly sheet: ReactNode; +} { + const [opened, setOpened] = useState(null); + return { + open: () => { + if (target) { + setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) }); + } + }, + sheet: opened ? setOpened(null)} /> : null, + }; +} + +/** Attaches a GitHub issue at the caret the composer had when the sheet opened. */ +function GitHubIssuePickerSheet(props: OpenedGitHubIssuePicker & { readonly onClose: () => void }) { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { startFrom } = props; + const [query, setQuery] = useState(""); + const [state, setState] = useState("open"); + const trimmed = query.trim(); + const settled = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS); + const result = useAtomValue( + gitHubIssueEnvironment.issues({ + environmentId: props.environmentId, + input: { cwd: props.cwd, state, ...(settled ? { query: settled } : {}) }, + }), + ); + const threadLinks = useEnvironmentQuery( + gitHubIssueEnvironment.threadLinks({ environmentId: props.environmentId, input: {} }), + ).data; + // Starting from an issue offers the project's threads; attaching only marks issues in use. + const candidateThreads = useThreadShells().filter( + (thread) => + thread.environmentId === props.environmentId && + (!startFrom || thread.projectId === startFrom.projectId), + ); + const getIssue = useAtomCommand(gitHubIssueEnvironment.getIssue, { + label: "github issue fetch", + reportFailure: false, + }); + const [attaching, setAttaching] = useState(null); + const issues = Option.getOrNull(AsyncResult.value(result))?.issues ?? []; + const pending = settled !== trimmed || result.waiting; + const error = + result._tag === "Failure" + ? gitHubIssueErrorMessage(Cause.squash(result.cause), "Could not load GitHub issues.") + : null; + + const pick = async (issue: GitHubIssueSummary) => { + if (attaching) return; + if (startFrom) { + const choice = await confirmOpenExistingThread( + gitHubIssueLabel(issue), + threadsForGitHubIssue(candidateThreads, threadLinks, issue.url), + ); + if (choice === "cancel") return; + if (choice !== "start") { + props.onClose(); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(choice.environmentId), + threadId: String(choice.id), + }), + ); + return; + } + } + await attach(issue); + }; + + const attach = async (issue: GitHubIssueSummary) => { + setAttaching(issue.url); + try { + const fetched = await getIssue({ + environmentId: props.environmentId, + input: { url: issue.url }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach issue", + gitHubIssueErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = gitHubIssueContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + if (startFrom) startFromIssueUrls.set(props.draftKey, issue.url); + props.onClose(); + } finally { + setAttaching(null); + } + }; + + return ( + + + + + + issue.url} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }} + ListEmptyComponent={ + + {pending ? ( + + ) : ( + + {error ?? (trimmed ? "No matching issues." : "No issues.")} + + )} + + } + renderItem={({ item }) => { + const inUse = threadsForGitHubIssue(candidateThreads, threadLinks, item.url).length > 0; + return ( + void pick(item)} + className="flex-row items-center gap-3 px-4 py-3 active:bg-subtle disabled:opacity-60" + > + + + {item.title} + + + {gitHubIssueLabel(item)} · {item.state === "open" ? "Open" : "Closed"} + {inUse ? " · In use" : ""} + + + {attaching === item.url ? : null} + + ); + }} + /> + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1d0896e632176..141c0b1dc9658 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -55,8 +55,13 @@ import { takeStartFromLinearIssue, useLinearIssuePicker, } from "../../components/LinearIssuePickerSheet"; +import { + takeStartFromGitHubIssue, + useGitHubIssuePicker, +} from "../../components/GitHubIssuePickerSheet"; import { useRepositoryPicker } from "../../components/RepositoryPickerSheet"; import { ControlPillMenu } from "../../components/ControlPillMenu"; +import { gitHubIssueEnvironment } from "../../state/githubIssues"; import { linearEnvironment } from "../../state/linear"; import { useStartFromPullRequestPicker } from "./StartFromPullRequestSheet"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; @@ -994,6 +999,26 @@ export function NewTaskDraftScreen(props: { } : null, ); + // Issues belong to the repository, so the project's own checkout lists them in either mode. + const gitHubIssuePicker = useGitHubIssuePicker( + flow.draftKey && selectedProject + ? { + environmentId: selectedProject.environmentId, + draftKey: flow.draftKey, + cwd: selectedProject.workspaceRoot, + } + : null, + ); + const startFromGitHubIssuePicker = useGitHubIssuePicker( + flow.draftKey && selectedProject + ? { + environmentId: selectedProject.environmentId, + draftKey: flow.draftKey, + cwd: selectedProject.workspaceRoot, + startFrom: { projectId: selectedProject.id }, + } + : null, + ); const startFromPullRequestPicker = useStartFromPullRequestPicker( flow.draftKey && selectedProject ? { @@ -1027,6 +1052,10 @@ export function NewTaskDraftScreen(props: { label: "linear thread link", reportFailure: false, }); + const linkThreadToGitHubIssue = useAtomCommand(gitHubIssueEnvironment.linkThread, { + label: "github issue thread link", + reportFailure: false, + }); const selectedEnvironmentLabel = flow.environments.find( (environment) => environment.environmentId === flow.selectedEnvironmentId, @@ -1363,6 +1392,13 @@ export function NewTaskDraftScreen(props: { input: { threadId: message.threadId, issueId: startedFromIssueId }, }); } + const startedFromGitHubIssueUrl = takeStartFromGitHubIssue(draftKey, draftSnapshot.context); + if (startedFromGitHubIssueUrl) { + void linkThreadToGitHubIssue({ + environmentId: message.environmentId, + input: { threadId: message.threadId, url: startedFromGitHubIssueUrl }, + }); + } if (editingPendingTask) { flow.finishEditingPendingTask(); } else { @@ -1621,10 +1657,12 @@ export function NewTaskDraftScreen(props: { actions={[ { id: "pull-request", title: "Pull request", image: "arrow.triangle.pull" }, { id: "issue", title: "Linear issue", image: "ticket" }, + { id: "github-issue", title: "GitHub issue", image: "smallcircle.filled.circle" }, ]} onPressAction={({ nativeEvent }) => { if (nativeEvent.event === "pull-request") startFromPullRequestPicker.open(); else if (nativeEvent.event === "issue") startFromIssuePicker.open(); + else if (nativeEvent.event === "github-issue") startFromGitHubIssuePicker.open(); }} > @@ -1865,6 +1906,8 @@ export function NewTaskDraftScreen(props: { {linearIssuePicker.sheet} {startFromIssuePicker.sheet} + {gitHubIssuePicker.sheet} + {startFromGitHubIssuePicker.sheet} {startFromPullRequestPicker.sheet} {repositoryPicker.sheet} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 3139e114a54f3..0760dd4ddd421 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -65,6 +65,7 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { useLinearIssuePicker } from "../../components/LinearIssuePickerSheet"; +import { useGitHubIssuePicker } from "../../components/GitHubIssuePickerSheet"; import { useRepositoryPicker } from "../../components/RepositoryPickerSheet"; import { ComposerAttachmentStrip, @@ -339,10 +340,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentId: props.environmentId, draftKey: composerOwnerKey, }); + const workspaceCwd = props.selectedThread.worktreePath ?? props.projectCwd; + const gitHubIssuePicker = useGitHubIssuePicker( + workspaceCwd + ? { environmentId: props.environmentId, draftKey: composerOwnerKey, cwd: workspaceCwd } + : null, + ); const repositoryPicker = useRepositoryPicker({ environmentId: props.environmentId, draftKey: composerOwnerKey, - workspaceCwd: props.selectedThread.worktreePath ?? props.projectCwd, + workspaceCwd, }); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { Keyboard.dismiss(); @@ -731,6 +738,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickMedia={props.onPickDraftMedia} onPickFiles={props.onPickDraftFiles} onPickLinearIssue={linearIssuePicker.open} + onPickGitHubIssue={workspaceCwd ? gitHubIssuePicker.open : undefined} onPickRepository={repositoryPicker.open} /> ) : null} @@ -984,6 +992,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickMedia={props.onPickDraftMedia} onPickFiles={props.onPickDraftFiles} onPickLinearIssue={linearIssuePicker.open} + onPickGitHubIssue={workspaceCwd ? gitHubIssuePicker.open : undefined} onPickRepository={repositoryPicker.open} /> @@ -1035,6 +1044,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {linearIssuePicker.sheet} + {gitHubIssuePicker.sheet} {repositoryPicker.sheet} ); diff --git a/apps/mobile/src/features/threads/ThreadGitHubIssueLink.tsx b/apps/mobile/src/features/threads/ThreadGitHubIssueLink.tsx new file mode 100644 index 0000000000000..414f278b72982 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadGitHubIssueLink.tsx @@ -0,0 +1,104 @@ +import { gitHubIssueLinkForThread } from "@t3tools/client-runtime/state/github-issues"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, GitHubIssueThreadLink, ThreadId } from "@t3tools/contracts"; +import { Alert, Pressable, Text, View } from "react-native"; + +import { ControlPillMenu } from "../../components/ControlPillMenu"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { gitHubIssueEnvironment } from "../../state/githubIssues"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; + +const OPEN_ACTION = "github-issue:open"; +const UNLINK_ACTION = "github-issue:unlink"; + +/** Green open, purple closed, the way GitHub colours issues; matches the composer chip. */ +const STATE_COLORS = { open: "#009f6e", closed: "#8a70dd" } as const; + +/** The GitHub issue linked to the thread's tab group, if any. Older servers have no links. */ +export function useThreadGitHubIssueLink( + environmentId: EnvironmentId, + threadId: ThreadId, +): GitHubIssueThreadLink | null { + const links = useEnvironmentQuery( + gitHubIssueEnvironment.threadLinks({ environmentId, input: {} }), + ).data; + return gitHubIssueLinkForThread(links, threadId); +} + +/** + * The linked issue's number and live state. State is read from GitHub when the chip mounts and + * cached for a minute; the link's stored number and title stand in when it is unavailable. + */ +export function ThreadGitHubIssueLinkChip(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly link: GitHubIssueThreadLink; +}) { + const { environmentId, threadId, link } = props; + const summary = useEnvironmentQuery( + gitHubIssueEnvironment.issueSummary({ environmentId, input: { url: link.url } }), + ).data; + const unlinkThread = useAtomCommand(gitHubIssueEnvironment.unlinkThread, { + label: "github issue thread unlink", + reportFailure: false, + }); + const title = summary?.title ?? link.title; + const label = `${link.repository}#${link.number}`; + + const unlink = async () => { + const result = await unlinkThread({ environmentId, input: { threadId } }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not unlink issue", + error instanceof Error && error.message.trim() ? error.message : "Try again.", + ); + } + }; + const onMenuAction = (id: string) => { + if (id === OPEN_ACTION) { + void tryOpenExternalUrl(link.url, "github-issue").then((opened) => { + if (!opened) Alert.alert("Could not open GitHub", "Try again later."); + }); + } else if (id === UNLINK_ACTION) void unlink(); + }; + + return ( + onMenuAction(nativeEvent.event)} + > + + #{link.number} + {summary ? ( + <> + + + {summary.state === "open" ? "Open" : "Closed"} + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadTabs.tsx b/apps/mobile/src/features/threads/ThreadTabs.tsx index 9c14d0af549b6..1dc2498f0cf4f 100644 --- a/apps/mobile/src/features/threads/ThreadTabs.tsx +++ b/apps/mobile/src/features/threads/ThreadTabs.tsx @@ -38,6 +38,7 @@ import { insertComposerDraftContext, } from "../../state/use-composer-drafts"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { ThreadGitHubIssueLinkChip, useThreadGitHubIssueLink } from "./ThreadGitHubIssueLink"; import { ThreadLinearLinkButton, ThreadLinearLinkChip, @@ -62,8 +63,8 @@ export function clearSelectedThreadTabSources(threadId: ThreadId): void { /** * Switches, opens, and closes a thread's chat tabs; empty tabs also pick sibling context. A * started chat can hand off to any model: a new tab on that model whose draft starts with a - * summary of this chat, followed by this chat's unsent draft. The group's linked Linear issue - * sits beside the switcher. + * summary of this chat, followed by this chat's unsent draft. The group's linked Linear and + * GitHub issues sit beside the switcher. */ export function ThreadTabs({ environmentId, @@ -90,6 +91,7 @@ export function ThreadTabs({ const [busy, setBusy] = useState(false); const linear = useThreadLinearLink(environmentId, threadId); const linearPicker = useLinearIssuePicker({ mode: "link", environmentId, threadId }); + const gitHubIssueLink = useThreadGitHubIssueLink(environmentId, threadId); const serverConfig = useEnvironmentServerConfig(environmentId); const handOffModels = useMemo( () => @@ -335,6 +337,13 @@ export function ThreadTabs({ ) : linear.canLink && group.tabs.length <= 1 ? ( ) : null} + {gitHubIssueLink ? ( + + ) : null} {linearPicker.sheet} {empty && group.tabs.length > 1 ? ( diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index d934a306aa01d..540cf73c4eebe 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -1174,3 +1174,17 @@ describe("pull request chip status", () => { ).toBe("#7079e4"); }); }); + +describe("GitHub issue chip status", () => { + it("colours an issue green while open and purple once closed, keeping one glyph", async () => { + const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); + const open = contextChipPresentation("github-issue", { kind: "github-issue", state: "open" }); + const closed = contextChipPresentation("github-issue", { + kind: "github-issue", + state: "closed", + }); + expect(open.accent).toBe("#009f6e"); + expect(closed.accent).toBe("#8a70dd"); + expect(closed.symbol).toBe(open.symbol); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 753501acffe75..81111ae37d0ba 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([ "pull-request", "provider-auth", "linear", + "github-issue", ]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/mobile/src/state/githubIssues.ts b/apps/mobile/src/state/githubIssues.ts new file mode 100644 index 0000000000000..7d1ceb6122a91 --- /dev/null +++ b/apps/mobile/src/state/githubIssues.ts @@ -0,0 +1,5 @@ +import { createGitHubIssueEnvironmentAtoms } from "@t3tools/client-runtime/state/github-issues"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitHubIssueEnvironment = createGitHubIssueEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2f5170a35ae60..e1756267f50e6 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -125,6 +125,12 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.linearCompleteLogin]: AuthOrchestrationOperateScope, [WS_METHODS.linearCancelLogin]: AuthOrchestrationOperateScope, [WS_METHODS.linearDisconnect]: AuthOrchestrationOperateScope, + [WS_METHODS.githubIssuesList]: AuthOrchestrationReadScope, + [WS_METHODS.githubIssuesGet]: AuthOrchestrationReadScope, + [WS_METHODS.githubIssuesGetSummary]: AuthOrchestrationReadScope, + [WS_METHODS.githubIssuesSubscribeThreadLinks]: AuthOrchestrationReadScope, + [WS_METHODS.githubIssuesLinkThread]: AuthOrchestrationOperateScope, + [WS_METHODS.githubIssuesUnlinkThread]: AuthOrchestrationOperateScope, [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope, [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/githubIssues/GitHubIssueThreadLinks.test.ts b/apps/server/src/githubIssues/GitHubIssueThreadLinks.test.ts new file mode 100644 index 0000000000000..8b41cd8426b87 --- /dev/null +++ b/apps/server/src/githubIssues/GitHubIssueThreadLinks.test.ts @@ -0,0 +1,95 @@ +import { GitHubIssueError, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import { ensureThreadTabsSchema } from "../threadTabs/schema.ts"; +import { GitHubIssues } from "./GitHubIssues.ts"; +import * as GitHubIssueThreadLinks from "./GitHubIssueThreadLinks.ts"; +import { ensureGitHubIssueThreadLinksSchema } from "./threadLinksSchema.ts"; + +const issueUrl = (number: number) => `https://github.com/acme/app/issues/${number}`; +const issue = (number: number) => ({ + repository: "acme/app", + number, + title: `Issue ${number}`, + url: issueUrl(number), + state: "open" as const, + stateReason: null, + authorLogin: "ada", + assigneeLogins: [], + labels: [], + updatedAt: "2026-09-30T00:00:00.000Z", +}); + +const sqlLayer = NodeSqliteClient.layer({ filename: ":memory:" }); +const schemaLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* runMigrations(); + yield* ensureThreadTabsSchema(); + yield* ensureGitHubIssueThreadLinksSchema(); + }), +); +const issuesLayer = Layer.mock(GitHubIssues)({ + getIssueSummary: ({ url }) => + url === issueUrl(1) || url === issueUrl(2) + ? Effect.succeed(issue(Number(url.split("/").at(-1)))) + : Effect.fail(new GitHubIssueError({ reason: "not-found", detail: "Issue not found" })), +}); + +const testLayer = GitHubIssueThreadLinks.layer.pipe( + Layer.provideMerge(issuesLayer), + Layer.provideMerge(schemaLayer), + Layer.provideMerge(sqlLayer), +); + +const first = ThreadId.make("first"); +const second = ThreadId.make("second"); + +it.layer(testLayer)("GitHubIssueThreadLinks", (it) => { + it.effect("links a tab group once, whichever tab links it", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const links = yield* GitHubIssueThreadLinks.GitHubIssueThreadLinks; + yield* sql` + INSERT INTO fork_thread_tabs (thread_id, group_id, position, created_at) VALUES + ('first', 'first', 0, '2026-01-01T00:00:00.000Z'), + ('second', 'first', 1, '2026-01-01T00:00:01.000Z') + `; + yield* links.refresh; + + const linked = yield* links.link({ threadId: second, url: issueUrl(1) }); + assert.strictEqual(linked.groupId, first); + assert.deepEqual(linked.threadIds, [first, second]); + assert.strictEqual(linked.repository, "acme/app"); + assert.strictEqual(linked.number, 1); + + // Linking again from the other tab replaces the group's issue. + const relinked = yield* links.link({ threadId: first, url: issueUrl(2) }); + assert.strictEqual(relinked.number, 2); + const rows = yield* sql<{ readonly n: number }>` + SELECT COUNT(*) AS n FROM fork_github_issue_thread_links + `; + assert.strictEqual(rows[0]?.n, 1); + + yield* links.unlink({ threadId: second }); + const remaining = yield* sql<{ readonly n: number }>` + SELECT COUNT(*) AS n FROM fork_github_issue_thread_links + `; + assert.strictEqual(remaining[0]?.n, 0); + }), + ); + + it.effect("refuses an issue GitHub does not know", () => + Effect.gen(function* () { + const links = yield* GitHubIssueThreadLinks.GitHubIssueThreadLinks; + const error = yield* links + .link({ threadId: ThreadId.make("solo"), url: issueUrl(404) }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + }), + ); +}); diff --git a/apps/server/src/githubIssues/GitHubIssueThreadLinks.ts b/apps/server/src/githubIssues/GitHubIssueThreadLinks.ts new file mode 100644 index 0000000000000..79ae8319e1062 --- /dev/null +++ b/apps/server/src/githubIssues/GitHubIssueThreadLinks.ts @@ -0,0 +1,133 @@ +import { + GitHubIssueError, + type GitHubIssueThreadLink, + type GitHubIssueThreadLinks as GitHubIssueThreadLinksList, + type GitHubLinkThreadInput, + type GitHubUnlinkThreadInput, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { GitHubIssues } from "./GitHubIssues.ts"; + +interface LinkRow { + readonly groupId: string; + readonly repository: string; + readonly number: number; + readonly title: string; + readonly url: string; + readonly linkedAt: string; + readonly threadId: string | null; +} + +/** Folds the joined rows into one link per group; the group's own id is always a member. */ +export function groupLinkRows(rows: ReadonlyArray): GitHubIssueThreadLinksList { + const links = new Map(); + for (const row of rows) { + let link = links.get(row.groupId); + if (link === undefined) { + link = { + groupId: ThreadId.make(row.groupId), + threadIds: [ThreadId.make(row.groupId)], + repository: row.repository, + number: row.number, + title: row.title, + url: row.url, + linkedAt: row.linkedAt, + }; + links.set(row.groupId, link); + } + if (row.threadId !== null && !link.threadIds.includes(row.threadId as ThreadId)) { + link.threadIds.push(ThreadId.make(row.threadId)); + } + } + return [...links.values()]; +} + +const storageError = (cause: unknown) => + new GitHubIssueError({ reason: "api", detail: "Could not save the GitHub issue link.", cause }); + +export class GitHubIssueThreadLinks extends Context.Service< + GitHubIssueThreadLinks, + { + /** Every link in the environment, re-emitted whenever a link or tab group changes. */ + readonly links: Stream.Stream; + readonly link: ( + input: GitHubLinkThreadInput, + ) => Effect.Effect; + readonly unlink: (input: GitHubUnlinkThreadInput) => Effect.Effect; + /** Re-reads group membership after a tab joins a group. */ + readonly refresh: Effect.Effect; + } +>()("t3/githubIssues/GitHubIssueThreadLinks") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const issues = yield* GitHubIssues; + + const read = sql` + SELECT l.group_id AS "groupId", l.repository, l.number, l.title, l.url, + l.linked_at AS "linkedAt", t.thread_id AS "threadId" + FROM fork_github_issue_thread_links l + LEFT JOIN fork_thread_tabs t ON t.group_id = l.group_id + ORDER BY l.linked_at, t.position, t.created_at + `.pipe(Effect.map(groupLinkRows)); + + const state = yield* SubscriptionRef.make(yield* read.pipe(Effect.orDie)); + const refresh = read.pipe( + Effect.flatMap((links) => SubscriptionRef.set(state, links)), + Effect.catch((cause) => + Effect.logWarning("Could not re-read GitHub issue thread links", cause), + ), + ); + + const groupIdFor = (threadId: ThreadId) => + sql<{ readonly groupId: string }>` + SELECT group_id AS "groupId" FROM fork_thread_tabs WHERE thread_id = ${threadId} + `.pipe(Effect.map((rows) => rows[0]?.groupId ?? threadId)); + + const link = Effect.fn("github_issues.link_thread")(function* (input: GitHubLinkThreadInput) { + // Validates the issue and copies what identifies it; the state stays live. + const issue = yield* issues.getIssueSummary({ url: input.url }); + const groupId = yield* groupIdFor(input.threadId).pipe(Effect.mapError(storageError)); + const linkedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO fork_github_issue_thread_links (group_id, repository, number, title, url, linked_at) + VALUES (${groupId}, ${issue.repository}, ${issue.number}, ${issue.title}, ${issue.url}, ${linkedAt}) + ON CONFLICT (group_id) DO UPDATE SET repository = excluded.repository, + number = excluded.number, title = excluded.title, url = excluded.url, + linked_at = excluded.linked_at + `.pipe(Effect.mapError(storageError)); + yield* refresh; + const links = yield* SubscriptionRef.get(state); + const linked = links.find((entry) => entry.groupId === groupId); + if (linked === undefined) return yield* storageError("The link was not saved"); + return linked; + }); + + const unlink = Effect.fn("github_issues.unlink_thread")(function* ( + input: GitHubUnlinkThreadInput, + ) { + const groupId = yield* groupIdFor(input.threadId).pipe(Effect.mapError(storageError)); + yield* sql`DELETE FROM fork_github_issue_thread_links WHERE group_id = ${groupId}`.pipe( + Effect.mapError(storageError), + ); + yield* refresh; + }); + + return GitHubIssueThreadLinks.of({ + links: SubscriptionRef.changes(state), + link, + unlink, + refresh, + }); +}); + +export const layer = Layer.effect(GitHubIssueThreadLinks, make); diff --git a/apps/server/src/githubIssues/GitHubIssues.test.ts b/apps/server/src/githubIssues/GitHubIssues.test.ts new file mode 100644 index 0000000000000..bebb38433492f --- /dev/null +++ b/apps/server/src/githubIssues/GitHubIssues.test.ts @@ -0,0 +1,123 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubIssues from "./GitHubIssues.ts"; + +const calls: Array> = []; + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; +} + +const issueJson = (number: number, kind: "issues" | "pull" = "issues") => ({ + number, + title: `Issue ${number}`, + url: `https://github.com/acme/app/${kind}/${number}`, + state: "CLOSED", + stateReason: "NOT_PLANNED", + author: { login: "ada" }, + assignees: [{ login: "bo" }], + labels: [{ name: "bug", color: "d73a4a" }], + updatedAt: "2026-09-30T00:00:00Z", +}); + +const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { + calls.push(input.args); + const [, action, reference] = input.args; + if (action === "list") + return Effect.succeed(output(JSON.stringify([issueJson(1), issueJson(2)]))); + if (reference === "404") { + return Effect.fail( + new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", cwd: input.cwd, cause: null }), + ); + } + if (reference === "9") return Effect.succeed(output(JSON.stringify(issueJson(9, "pull")))); + return Effect.succeed( + output( + JSON.stringify({ + ...issueJson(Number(reference?.split("/").at(-1) ?? reference)), + body: "Broken.", + milestone: null, + comments: [ + { + author: { login: "cy" }, + authorAssociation: "MEMBER", + body: "On it.", + createdAt: "2026-09-30T00:00:00Z", + isMinimized: false, + }, + ], + }), + ), + ); +}; + +const layer = it.layer( + GitHubIssues.layer.pipe(Layer.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))), +); + +layer("GitHubIssues", (it) => { + it.effect("lists issues in the checkout's repository", () => + Effect.gen(function* () { + const issues = yield* GitHubIssues.GitHubIssues; + calls.length = 0; + const result = yield* issues.listIssues({ cwd: "/repo", query: "login", state: "all" }); + assert.deepEqual(calls[0], [ + "issue", + "list", + "--state", + "all", + "--limit", + "50", + "--search", + "login", + "--json", + "number,title,url,state,stateReason,author,assignees,labels,updatedAt", + ]); + assert.deepEqual(result.issues[0], { + repository: "acme/app", + number: 1, + title: "Issue 1", + url: "https://github.com/acme/app/issues/1", + state: "closed", + stateReason: "not planned", + authorLogin: "ada", + assigneeLogins: ["bo"], + labels: [{ name: "bug", color: "d73a4a" }], + updatedAt: "2026-09-30T00:00:00Z", + }); + }), + ); + + it.effect("looks a number up directly, and finds nothing for a missing issue or a PR", () => + Effect.gen(function* () { + const issues = yield* GitHubIssues.GitHubIssues; + calls.length = 0; + const found = yield* issues.listIssues({ cwd: "/repo", query: "#3" }); + assert.deepEqual(calls[0]?.slice(0, 3), ["issue", "view", "3"]); + assert.strictEqual(found.issues[0]?.number, 3); + assert.deepEqual((yield* issues.listIssues({ cwd: "/repo", query: "404" })).issues, []); + assert.deepEqual((yield* issues.listIssues({ cwd: "/repo", query: "9" })).issues, []); + }), + ); + + it.effect("renders an issue snapshot with its comments", () => + Effect.gen(function* () { + const issues = yield* GitHubIssues.GitHubIssues; + const issue = yield* issues.getIssue({ url: "https://github.com/acme/app/issues/5" }); + assert.strictEqual(issue.number, 5); + assert.include(issue.markdown, "# acme/app#5: Issue 5"); + assert.include(issue.markdown, "**cy (maintainer)** (2026-09-30):\nOn it."); + }), + ); +}); diff --git a/apps/server/src/githubIssues/GitHubIssues.ts b/apps/server/src/githubIssues/GitHubIssues.ts new file mode 100644 index 0000000000000..61aa5e6828f86 --- /dev/null +++ b/apps/server/src/githubIssues/GitHubIssues.ts @@ -0,0 +1,221 @@ +import { + GITHUB_ISSUE_MARKDOWN_MAX_CHARS, + GitHubIssueError, + type GitHubGetIssueInput, + type GitHubIssueContext, + type GitHubIssueSummary, + type GitHubListIssuesInput, + type GitHubListIssuesResult, + parseGitHubIssueUrl, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { GitHubCli, type GitHubCliError } from "../sourceControl/GitHubCli.ts"; +import { renderGitHubIssueMarkdown } from "./gitHubIssueMarkdown.ts"; + +const LIST_LIMIT = 50; +const SUMMARY_FIELDS = "number,title,url,state,stateReason,author,assignees,labels,updatedAt"; +const DETAIL_FIELDS = `${SUMMARY_FIELDS},body,milestone,comments`; +// `123` or `#123` names an issue in the checkout's repository. +const NUMBER_PATTERN = /^#?(\d+)$/; + +const Login = Schema.NullishOr(Schema.Struct({ login: Schema.String })); + +const IssueSummaryJson = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + state: Schema.String, + stateReason: Schema.optional(Schema.NullishOr(Schema.String)), + author: Login, + assignees: Schema.Array(Schema.Struct({ login: Schema.String })), + labels: Schema.Array(Schema.Struct({ name: Schema.String, color: Schema.String })), + updatedAt: Schema.String, +}); +type IssueSummaryJson = typeof IssueSummaryJson.Type; + +const IssueDetailJson = Schema.Struct({ + ...IssueSummaryJson.fields, + body: Schema.NullishOr(Schema.String), + milestone: Schema.NullishOr(Schema.Struct({ title: Schema.String })), + comments: Schema.Array( + Schema.Struct({ + author: Login, + authorAssociation: Schema.optional(Schema.String), + body: Schema.String, + createdAt: Schema.String, + isMinimized: Schema.optional(Schema.Boolean), + }), + ), +}); + +const decodeSummary = Schema.decodeUnknownEffect(Schema.fromJsonString(IssueSummaryJson)); +const decodeSummaries = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(IssueSummaryJson)), +); +const decodeDetail = Schema.decodeUnknownEffect(Schema.fromJsonString(IssueDetailJson)); + +/** `NOT_PLANNED` reads as `not planned`; a reopened issue has no reason to show. */ +function stateReason(raw: string | null | undefined): string | null { + if (!raw || raw === "REOPENED") return null; + return raw.toLowerCase().replaceAll("_", " "); +} + +/** The issue's summary, or null when the URL is not an issue (`gh issue view` also finds PRs). */ +function toSummary(json: IssueSummaryJson): GitHubIssueSummary | null { + const parsed = parseGitHubIssueUrl(json.url); + if (parsed === null) return null; + return { + repository: parsed.repository, + number: json.number, + title: json.title, + url: json.url, + state: json.state.toUpperCase() === "OPEN" ? "open" : "closed", + stateReason: json.state.toUpperCase() === "OPEN" ? null : stateReason(json.stateReason), + authorLogin: json.author?.login ?? null, + assigneeLogins: json.assignees.map((assignee) => assignee.login), + labels: json.labels.map((label) => ({ name: label.name, color: label.color })), + updatedAt: json.updatedAt, + }; +} + +const notFound = () => + new GitHubIssueError({ reason: "not-found", detail: "GitHub issue not found." }); + +function toIssueError(error: GitHubCliError): GitHubIssueError { + switch (error._tag) { + case "GitHubCliUnavailableError": + return new GitHubIssueError({ + reason: "unavailable", + detail: "GitHub CLI (`gh`) is required to read GitHub issues.", + cause: error, + }); + case "GitHubCliAuthenticationError": + return new GitHubIssueError({ + reason: "not-authenticated", + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + case "GitHubCliRateLimitError": + return new GitHubIssueError({ + reason: "rate-limited", + detail: "GitHub's API rate limit is exhausted. Try again shortly.", + cause: error, + }); + case "GitHubPullRequestNotFoundError": + return notFound(); + default: + return new GitHubIssueError({ + reason: "api", + detail: "Could not read GitHub issues. Check that this project's remote is on GitHub.", + cause: error, + }); + } +} + +const decodeError = (cause: unknown) => + new GitHubIssueError({ reason: "api", detail: "GitHub CLI returned unexpected JSON.", cause }); + +export class GitHubIssues extends Context.Service< + GitHubIssues, + { + readonly listIssues: ( + input: GitHubListIssuesInput, + ) => Effect.Effect; + readonly getIssue: ( + input: GitHubGetIssueInput, + ) => Effect.Effect; + /** The issue's summary fields alone, for linking and live state. */ + readonly getIssueSummary: ( + input: GitHubGetIssueInput, + ) => Effect.Effect; + } +>()("t3/githubIssues/GitHubIssues") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const gh = yield* GitHubCli; + + const run = (cwd: string, args: ReadonlyArray) => + gh.execute({ cwd, args }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(toIssueError), + ); + + // A URL names its repository, so the server's own directory is as good a cwd as any. + const viewSummary = (cwd: string, reference: string) => + run(cwd, ["issue", "view", reference, "--json", SUMMARY_FIELDS]).pipe( + Effect.flatMap((raw) => decodeSummary(raw).pipe(Effect.mapError(decodeError))), + Effect.flatMap((json) => { + const summary = toSummary(json); + return summary === null ? Effect.fail(notFound()) : Effect.succeed(summary); + }), + ); + + const listIssues = Effect.fn("github_issues.list")(function* (input: GitHubListIssuesInput) { + const text = input.query?.trim() ?? ""; + const number = NUMBER_PATTERN.exec(text)?.[1]; + const reference = number ?? (parseGitHubIssueUrl(text) !== null ? text : null); + if (reference !== null) { + const issues = yield* viewSummary(input.cwd, reference).pipe( + Effect.map((summary) => [summary]), + Effect.catchIf( + (error) => error.reason === "not-found", + () => Effect.succeed([]), + ), + ); + return { issues }; + } + const raw = yield* run(input.cwd, [ + "issue", + "list", + "--state", + input.state ?? "open", + "--limit", + String(LIST_LIMIT), + ...(text.length > 0 ? ["--search", text] : []), + "--json", + SUMMARY_FIELDS, + ]); + const json = + raw.length === 0 ? [] : yield* decodeSummaries(raw).pipe(Effect.mapError(decodeError)); + return { issues: json.flatMap((issue) => toSummary(issue) ?? []) }; + }); + + const getIssue = Effect.fn("github_issues.get")(function* (input: GitHubGetIssueInput) { + const raw = yield* run(process.cwd(), ["issue", "view", input.url, "--json", DETAIL_FIELDS]); + const json = yield* decodeDetail(raw).pipe(Effect.mapError(decodeError)); + const summary = toSummary(json); + if (summary === null) return yield* notFound(); + const markdown = renderGitHubIssueMarkdown( + { + ...summary, + body: json.body ?? "", + labels: summary.labels.map((label) => label.name), + milestone: json.milestone?.title ?? null, + comments: json.comments.map((comment) => ({ + authorLogin: comment.author?.login ?? null, + authorAssociation: comment.authorAssociation ?? "NONE", + body: comment.body, + createdAt: comment.createdAt, + isMinimized: comment.isMinimized ?? false, + })), + }, + GITHUB_ISSUE_MARKDOWN_MAX_CHARS, + ); + return { ...summary, markdown } satisfies GitHubIssueContext; + }); + + const getIssueSummary = Effect.fn("github_issues.get_summary")(function* ( + input: GitHubGetIssueInput, + ) { + return yield* viewSummary(process.cwd(), input.url); + }); + + return GitHubIssues.of({ listIssues, getIssue, getIssueSummary }); +}); + +export const layer = Layer.effect(GitHubIssues, make); diff --git a/apps/server/src/githubIssues/gitHubIssueMarkdown.test.ts b/apps/server/src/githubIssues/gitHubIssueMarkdown.test.ts new file mode 100644 index 0000000000000..a60237628ea7a --- /dev/null +++ b/apps/server/src/githubIssues/gitHubIssueMarkdown.test.ts @@ -0,0 +1,92 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + type GitHubIssueComment, + type GitHubIssueDetail, + renderGitHubIssueMarkdown, + selectGitHubIssueComments, +} from "./gitHubIssueMarkdown.ts"; + +const comment = (overrides: Partial): GitHubIssueComment => ({ + authorLogin: "bo", + authorAssociation: "NONE", + body: "A comment", + createdAt: "2026-01-02T00:00:00Z", + isMinimized: false, + ...overrides, +}); + +const issue: GitHubIssueDetail = { + repository: "acme/app", + number: 7, + title: "Login button does nothing", + url: "https://github.com/acme/app/issues/7", + body: "Clicking login has no effect.", + state: "closed", + stateReason: "not planned", + authorLogin: "ada", + assigneeLogins: ["cy"], + labels: ["bug"], + milestone: "v2", + comments: [ + comment({ authorLogin: "cy", authorAssociation: "MEMBER", body: "Repro'd on main." }), + comment({ authorLogin: "ada", body: "Still happens", createdAt: "2026-01-03T00:00:00Z" }), + ], +}; + +describe("selectGitHubIssueComments", () => { + it("drops hidden, empty, and +1 comments", () => { + const selected = selectGitHubIssueComments({ + authorLogin: "ada", + comments: [ + comment({ body: "+1" }), + comment({ body: "👍🏽" }), + comment({ body: "Same here!" }), + comment({ body: " " }), + comment({ body: "Spam", isMinimized: true }), + comment({ body: "Happens on Safari 18 too" }), + ], + }); + assert.deepEqual( + selected.map((entry) => entry.body), + ["Happens on Safari 18 too"], + ); + }); + + it("marks the issue's author and maintainers", () => { + const selected = selectGitHubIssueComments(issue); + assert.deepEqual( + selected.map((entry) => entry.author), + ["cy (maintainer)", "ada (author)"], + ); + }); +}); + +describe("renderGitHubIssueMarkdown", () => { + it("renders the header, description, and comments", () => { + const markdown = renderGitHubIssueMarkdown(issue, 32_000); + assert.include(markdown, "# acme/app#7: Login button does nothing"); + assert.include(markdown, "State: Closed (not planned)"); + assert.include(markdown, "Assignees: cy"); + assert.include(markdown, "Milestone: v2"); + assert.include(markdown, "## Description\nClicking login has no effect."); + assert.include(markdown, "**cy (maintainer)** (2026-01-02):\nRepro'd on main."); + assert.isBelow(markdown.indexOf("Repro'd"), markdown.indexOf("Still happens")); + }); + + it("keeps the newest comments when the budget runs out", () => { + const markdown = renderGitHubIssueMarkdown( + { + ...issue, + comments: [ + comment({ body: "old ".repeat(100), createdAt: "2026-01-01T00:00:00Z" }), + comment({ body: "newest", createdAt: "2026-01-05T00:00:00Z" }), + ], + }, + 400, + ); + assert.include(markdown, "newest"); + assert.include(markdown, "_1 earlier comment omitted._"); + assert.isAtMost(markdown.length, 400); + }); +}); diff --git a/apps/server/src/githubIssues/gitHubIssueMarkdown.ts b/apps/server/src/githubIssues/gitHubIssueMarkdown.ts new file mode 100644 index 0000000000000..9291beefa7d06 --- /dev/null +++ b/apps/server/src/githubIssues/gitHubIssueMarkdown.ts @@ -0,0 +1,93 @@ +import { type IssueComment, renderIssueMarkdown } from "../issueMarkdown.ts"; + +export interface GitHubIssueComment { + readonly authorLogin: string | null; + readonly authorAssociation: string; + readonly body: string; + readonly createdAt: string; + readonly isMinimized: boolean; +} + +export interface GitHubIssueDetail { + readonly repository: string; + readonly number: number; + readonly title: string; + readonly url: string; + readonly body: string; + readonly state: "open" | "closed"; + readonly stateReason: string | null; + readonly authorLogin: string | null; + readonly assigneeLogins: ReadonlyArray; + readonly labels: ReadonlyArray; + readonly milestone: string | null; + readonly comments: ReadonlyArray; +} + +const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +// "+1", "same here", and reaction-only comments say nothing an agent can act on. +const NOISE_PATTERN = + /^(?:\+1|-1|same(?: here| issue)?|me too|bump|any updates?\??|following|subscribed?)[.!]*$/i; +const EMOJI_PATTERN = + /\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Variation_Selector}|\p{Join_Control}/gu; + +function isNoise(body: string): boolean { + return NOISE_PATTERN.test(body) || body.replace(EMOJI_PATTERN, "").trim().length === 0; +} + +/** + * The comments worth an agent's context: hidden (minimized) comments and empty, "+1", or + * reaction-only comments are dropped. Maintainers and the issue's author are marked, since their + * comments usually carry the decisions. + */ +export function selectGitHubIssueComments( + issue: Pick, +): IssueComment[] { + return issue.comments + .filter((comment) => !comment.isMinimized && !isNoise(comment.body.trim())) + .map((comment) => { + const login = comment.authorLogin ?? "ghost"; + const role = + comment.authorLogin !== null && comment.authorLogin === issue.authorLogin + ? "author" + : MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation) + ? "maintainer" + : null; + return { + author: role === null ? login : `${login} (${role})`, + createdAt: comment.createdAt, + body: comment.body, + }; + }); +} + +function renderHeader(issue: GitHubIssueDetail): string { + const state = + issue.state === "open" + ? "Open" + : issue.stateReason + ? `Closed (${issue.stateReason})` + : "Closed"; + const facts = [ + `State: ${state}`, + issue.authorLogin ? `Author: ${issue.authorLogin}` : null, + issue.assigneeLogins.length > 0 ? `Assignees: ${issue.assigneeLogins.join(", ")}` : null, + issue.labels.length > 0 ? `Labels: ${issue.labels.join(", ")}` : null, + issue.milestone ? `Milestone: ${issue.milestone}` : null, + ].filter((fact) => fact !== null); + return [`# ${issue.repository}#${issue.number}: ${issue.title}`, issue.url, "", ...facts].join( + "\n", + ); +} + +/** Renders an issue and its selected comments as the markdown an agent receives. */ +export function renderGitHubIssueMarkdown(issue: GitHubIssueDetail, maxChars: number): string { + return renderIssueMarkdown( + { + header: renderHeader(issue), + description: issue.body, + sections: [], + comments: selectGitHubIssueComments(issue), + }, + maxChars, + ); +} diff --git a/apps/server/src/githubIssues/threadLinksSchema.ts b/apps/server/src/githubIssues/threadLinksSchema.ts new file mode 100644 index 0000000000000..1a17935f08799 --- /dev/null +++ b/apps/server/src/githubIssues/threadLinksSchema.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** One linked GitHub issue per chat-tab group. Runs after `ensureThreadTabsSchema`. */ +export const ensureGitHubIssueThreadLinksSchema = Effect.fn("GitHubIssueThreadLinks.ensureSchema")( + function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS fork_github_issue_thread_links ( + group_id TEXT PRIMARY KEY, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + linked_at TEXT NOT NULL + ) + `; + yield* sql`INSERT OR IGNORE INTO fork_schema_migrations (feature, version) VALUES ('github_issue_thread_links', 1)`; + }, +); diff --git a/apps/server/src/issueMarkdown.ts b/apps/server/src/issueMarkdown.ts new file mode 100644 index 0000000000000..f178bbe3956db --- /dev/null +++ b/apps/server/src/issueMarkdown.ts @@ -0,0 +1,75 @@ +export interface IssueComment { + readonly author: string; + readonly createdAt: string; + readonly body: string; +} + +const TRUNCATION_MARKER = "\n\n…[truncated]"; +const SEPARATOR = "\n\n"; + +export function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + if (maxChars <= TRUNCATION_MARKER.length) return ""; + return text.slice(0, maxChars - TRUNCATION_MARKER.length).trimEnd() + TRUNCATION_MARKER; +} + +function renderComment(comment: IssueComment): string { + return `**${comment.author}** (${comment.createdAt.slice(0, 10)}):\n${comment.body.trim()}`; +} + +/** + * Renders a Linear or GitHub issue as the markdown an agent receives. Within `maxChars`, the + * header and `sections` (sub-issues, links) are kept whole, the description comes next, and + * comments fill what is left, newest first, shown in chronological order. + */ +export function renderIssueMarkdown( + issue: { + readonly header: string; + readonly description: string | null; + readonly sections: ReadonlyArray; + readonly comments: ReadonlyArray; + }, + maxChars: number, +): string { + let budget = maxChars - [issue.header, ...issue.sections].join(SEPARATOR).length; + + const descriptionText = issue.description?.trim() ?? ""; + const descriptionHeading = "## Description\n"; + let description: string | null = null; + if (descriptionText.length > 0 && budget > descriptionHeading.length + SEPARATOR.length) { + description = + descriptionHeading + + truncate(descriptionText, budget - descriptionHeading.length - SEPARATOR.length); + budget -= description.length + SEPARATOR.length; + } + + const comments = issue.comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const commentsHeading = "## Comments"; + const kept: string[] = []; + let omitted = comments.length; + if (comments.length > 0) { + let remaining = budget - commentsHeading.length - SEPARATOR.length * 2; + for (let index = comments.length - 1; index >= 0; index--) { + const rendered = renderComment(comments[index]!); + if (rendered.length + SEPARATOR.length > remaining) break; + kept.unshift(rendered); + remaining -= rendered.length + SEPARATOR.length; + omitted--; + } + } + const commentsSection = + kept.length > 0 + ? [ + commentsHeading, + ...(omitted > 0 + ? [`_${omitted} earlier comment${omitted === 1 ? "" : "s"} omitted._`] + : []), + ...kept, + ].join(SEPARATOR) + : null; + + const parts = [issue.header, description, ...issue.sections, commentsSection].filter( + (part) => part !== null, + ); + return truncate(parts.join(SEPARATOR), maxChars); +} diff --git a/apps/server/src/linear/linearIssueMarkdown.ts b/apps/server/src/linear/linearIssueMarkdown.ts index d5295981c5272..d2bf500f0e391 100644 --- a/apps/server/src/linear/linearIssueMarkdown.ts +++ b/apps/server/src/linear/linearIssueMarkdown.ts @@ -1,3 +1,5 @@ +import { type IssueComment, renderIssueMarkdown } from "../issueMarkdown.ts"; + const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/; const ISSUE_URL_PATH_PATTERN = /^\/[^/]+\/issue\/([A-Za-z][A-Za-z0-9_]*-\d+)(?:\/|$)/; @@ -37,19 +39,7 @@ export interface LinearIssueDetail { readonly stateName: string; }>; readonly links: ReadonlyArray<{ readonly title: string; readonly url: string }>; - readonly comments: ReadonlyArray<{ - readonly author: string; - readonly createdAt: string; - readonly body: string; - }>; -} - -const TRUNCATION_MARKER = "\n\n…[truncated]"; - -function truncate(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - if (maxChars <= TRUNCATION_MARKER.length) return ""; - return text.slice(0, maxChars - TRUNCATION_MARKER.length).trimEnd() + TRUNCATION_MARKER; + readonly comments: ReadonlyArray; } function renderHeader(issue: LinearIssueDetail): string { @@ -65,17 +55,8 @@ function renderHeader(issue: LinearIssueDetail): string { return [`# ${issue.identifier}: ${issue.title}`, issue.url, "", ...facts].join("\n"); } -function renderComment(comment: LinearIssueDetail["comments"][number]): string { - return `**${comment.author}** (${comment.createdAt.slice(0, 10)}):\n${comment.body.trim()}`; -} - -/** - * Renders an issue as the markdown an agent receives. Within `maxChars`, the - * header, sub-issues, and links are kept whole, the description comes next, - * and comments fill what is left, newest first. - */ +/** Renders an issue as the markdown an agent receives; see `renderIssueMarkdown`. */ export function renderLinearIssueMarkdown(issue: LinearIssueDetail, maxChars: number): string { - const header = renderHeader(issue); const children = issue.children.length > 0 ? [ @@ -87,47 +68,13 @@ export function renderLinearIssueMarkdown(issue: LinearIssueDetail, maxChars: nu issue.links.length > 0 ? ["## Links", ...issue.links.map((link) => `- [${link.title}](${link.url})`)].join("\n") : null; - const fixed = [header, children, links].filter((part) => part !== null); - const separator = "\n\n"; - let budget = maxChars - fixed.join(separator).length; - - const descriptionText = issue.description?.trim() ?? ""; - const descriptionHeading = "## Description\n"; - let description: string | null = null; - if (descriptionText.length > 0 && budget > descriptionHeading.length + separator.length) { - description = - descriptionHeading + - truncate(descriptionText, budget - descriptionHeading.length - separator.length); - budget -= description.length + separator.length; - } - - const comments = issue.comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); - const commentsHeading = "## Comments"; - const kept: string[] = []; - let omitted = comments.length; - if (comments.length > 0) { - let remaining = budget - commentsHeading.length - separator.length * 2; - for (let index = comments.length - 1; index >= 0; index--) { - const rendered = renderComment(comments[index]!); - if (rendered.length + separator.length > remaining) break; - kept.unshift(rendered); - remaining -= rendered.length + separator.length; - omitted--; - } - } - const commentsSection = - kept.length > 0 - ? [ - commentsHeading, - ...(omitted > 0 - ? [`_${omitted} earlier comment${omitted === 1 ? "" : "s"} omitted._`] - : []), - ...kept, - ].join(separator) - : null; - - const sections = [header, description, children, links, commentsSection].filter( - (part) => part !== null, + return renderIssueMarkdown( + { + header: renderHeader(issue), + description: issue.description, + sections: [children, links].filter((part) => part !== null), + comments: issue.comments, + }, + maxChars, ); - return truncate(sections.join(separator), maxChars); } diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index e81b32357c194..ec1e147bc8eb6 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -8,6 +8,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ensureThreadTabsSchema } from "../../threadTabs/schema.ts"; import { ensureLinearThreadLinksSchema } from "../../linear/threadLinksSchema.ts"; +import { ensureGitHubIssueThreadLinksSchema } from "../../githubIssues/threadLinksSchema.ts"; import { ensurePullRequestWatchSchema } from "../../pullRequestWatch/store.ts"; import { ensureSubagentTranscriptSchema } from "../../provider/SubagentTranscriptStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -28,6 +29,7 @@ const setup = Layer.effectDiscard( yield* runMigrations(); yield* ensureThreadTabsSchema(); yield* ensureLinearThreadLinksSchema(); + yield* ensureGitHubIssueThreadLinksSchema(); yield* ensurePullRequestWatchSchema(); yield* ensureSubagentTranscriptSchema(); }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 970f847123a06..a51605c768616 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -140,6 +140,8 @@ import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts" import { LinearApi } from "./linear/LinearApi.ts"; import { LinearThreadLinks } from "./linear/LinearThreadLinks.ts"; import { LinearAuth } from "./linear/LinearAuth.ts"; +import { GitHubIssues } from "./githubIssues/GitHubIssues.ts"; +import { GitHubIssueThreadLinks } from "./githubIssues/GitHubIssueThreadLinks.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { AntigravityInstallation, @@ -837,6 +839,8 @@ const buildAppUnderTest = (options?: { Layer.mock(LinearAuth)({}), Layer.mock(LinearApi)({}), Layer.mock(LinearThreadLinks)({ refresh: Effect.void }), + Layer.mock(GitHubIssues)({}), + Layer.mock(GitHubIssueThreadLinks)({ refresh: Effect.void }), Layer.mock(ProviderInstanceRegistry)({ getInstance: () => Effect.undefined, listInstances: Effect.succeed([]), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 269095bf4885f..6dc4c6ec2cba9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -59,6 +59,8 @@ import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.t import * as LinearApi from "./linear/LinearApi.ts"; import * as LinearAuth from "./linear/LinearAuth.ts"; import * as LinearThreadLinks from "./linear/LinearThreadLinks.ts"; +import * as GitHubIssues from "./githubIssues/GitHubIssues.ts"; +import * as GitHubIssueThreadLinks from "./githubIssues/GitHubIssueThreadLinks.ts"; import { CodexInstallation } from "./provider/CodexInstallation.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -552,6 +554,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(LinearAuth.layer), ), ), + Layer.provideMerge(GitHubIssueThreadLinks.layer.pipe(Layer.provideMerge(GitHubIssues.layer))), // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index c525740efeae3..35f9b00314009 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -466,7 +466,7 @@ export const make = Effect.gen(function* () { const [command, action] = input.args; if ( !( - (command === "pr" && (action === "list" || action === "view")) || + ((command === "pr" || command === "issue") && (action === "list" || action === "view")) || (command === "repo" && action === "view") ) ) diff --git a/apps/server/src/threadTabs/http.ts b/apps/server/src/threadTabs/http.ts index 559f1f63cd9d9..c4b32c28f535d 100644 --- a/apps/server/src/threadTabs/http.ts +++ b/apps/server/src/threadTabs/http.ts @@ -20,6 +20,7 @@ import { requireEnvironmentScope, } from "../auth/http.ts"; import { LinearThreadLinks } from "../linear/LinearThreadLinks.ts"; +import { GitHubIssueThreadLinks } from "../githubIssues/GitHubIssueThreadLinks.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -42,6 +43,7 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group( const snapshots = yield* ProjectionSnapshotQuery; const engine = yield* OrchestrationEngineService; const linearThreadLinks = yield* LinearThreadLinks; + const githubIssueThreadLinks = yield* GitHubIssueThreadLinks; const groupFor = Effect.fn("ThreadTabs.groupFor")(function* (threadId: ThreadId) { const source = yield* snapshots.getThreadShellById(threadId); @@ -149,8 +151,9 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group( }), ) .pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))); - // The group's Linear link, if any, now covers the new tab too. + // The group's issue links, if any, now cover the new tab too. yield* linearThreadLinks.refresh; + yield* githubIssueThreadLinks.refresh; yield* engine .dispatch({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 25f3a23c368a4..5b6cfd9533e08 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -93,7 +93,8 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai (normalized.includes("could not resolve to a pullrequest") || normalized.includes("repository.pullrequest") || normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || + normalized.includes("pull request not found") || + normalized.includes("could not resolve to an issue"))) || (command === "glab" && (normalized.includes("merge request not found") || normalized.includes("not found") || diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d286746b4f96c..694056db705b7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,6 +124,8 @@ import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts" import { LinearApi } from "./linear/LinearApi.ts"; import { LinearAuth } from "./linear/LinearAuth.ts"; import { LinearThreadLinks } from "./linear/LinearThreadLinks.ts"; +import { GitHubIssues } from "./githubIssues/GitHubIssues.ts"; +import { GitHubIssueThreadLinks } from "./githubIssues/GitHubIssueThreadLinks.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; @@ -628,6 +630,8 @@ const makeWsRpcLayer = ( const linearAuth = yield* LinearAuth; const linearApi = yield* LinearApi; const linearThreadLinks = yield* LinearThreadLinks; + const githubIssues = yield* GitHubIssues; + const githubIssueThreadLinks = yield* GitHubIssueThreadLinks; const providerInstances = yield* ProviderInstanceRegistry; const providerInstallation = yield* makeProviderInstallation(); const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; @@ -3543,6 +3547,34 @@ const makeWsRpcLayer = ( linearThreadLinks.unlink(input).pipe(Effect.as({})), { "rpc.aggregate": "linear" }, ), + [WS_METHODS.githubIssuesList]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesList, githubIssues.listIssues(input), { + "rpc.aggregate": "github-issues", + }), + [WS_METHODS.githubIssuesGet]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesGet, githubIssues.getIssue(input), { + "rpc.aggregate": "github-issues", + }), + [WS_METHODS.githubIssuesGetSummary]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesGetSummary, githubIssues.getIssueSummary(input), { + "rpc.aggregate": "github-issues", + }), + [WS_METHODS.githubIssuesSubscribeThreadLinks]: (_input) => + observeRpcStream( + WS_METHODS.githubIssuesSubscribeThreadLinks, + githubIssueThreadLinks.links, + { "rpc.aggregate": "github-issues" }, + ), + [WS_METHODS.githubIssuesLinkThread]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesLinkThread, githubIssueThreadLinks.link(input), { + "rpc.aggregate": "github-issues", + }), + [WS_METHODS.githubIssuesUnlinkThread]: (input) => + observeRpcEffect( + WS_METHODS.githubIssuesUnlinkThread, + githubIssueThreadLinks.unlink(input).pipe(Effect.as({})), + { "rpc.aggregate": "github-issues" }, + ), [WS_METHODS.sourceControlPublishRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlPublishRepository, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f94822cda9e35..73567f079dfb2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,6 +226,7 @@ import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavaila import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { GitHubIssuePickerHost } from "./chat/GitHubIssuePicker"; import { LinearIssuePickerHost } from "./chat/LinearIssuePicker"; import { ThreadAttachPickerHost } from "./chat/ThreadAttachPicker"; import { RepositoryAttachPickerHost } from "./chat/RepositoryAttachPicker"; @@ -233,6 +234,7 @@ import { PullRequestAttachPickerHost } from "./chat/PullRequestAttachPicker"; import { openStartFromPicker, StartFromPickerHost } from "./chat/StartFromPicker"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; +import { isGitHubProject } from "~/state/githubIssues"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; import { WizardPopup } from "./ui/wizard"; @@ -338,10 +340,7 @@ import { stripInlineContextReferences, } from "../lib/composerContextReferences"; import { readThreadTabContextRecords, useThreadTabContextStore } from "../threadTabContextStore"; -import { - readLinearIssueContextRecords, - useLinearIssueContextStore, -} from "../linearIssueContextStore"; +import { readIssueContextRecords, useIssueContextStore } from "../issueContextStore"; import { readRepositoryContextRecords, useRepositoryContextStore } from "../repositoryContextStore"; import { useThreadTabRecencyStore } from "../threadTabRecencyStore"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; @@ -3811,6 +3810,9 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + // GitHub issues are listed with `gh` in the thread's checkout; any checkout of the repository + // answers the same, so a draft headed for a new worktree uses the project folder. + const githubIssueCwd = isGitHubProject(activeProject) ? (activeWorkspaceRoot ?? null) : null; useLayoutEffect(() => { if ( threadDetailLoading || @@ -7518,7 +7520,7 @@ export default function ChatView(props: ChatViewProps) { const store = useComposerDraftStore.getState(); const draft = store.getComposerDraft(composerDraftTarget); const tabContexts = readThreadTabContextRecords(activeThread.id); - const linearIssueContexts = readLinearIssueContextRecords(activeThread.id); + const issueContexts = readIssueContextRecords(activeThread.id); const repositoryContexts = readRepositoryContextRecords(activeThread.id); const tabRef = await forkThreadTab(connection, { environmentId, @@ -7532,8 +7534,8 @@ export default function ChatView(props: ChatViewProps) { for (const record of tabContexts) { useThreadTabContextStore.getState().upsert(tabRef.threadId, record); } - for (const record of linearIssueContexts) { - useLinearIssueContextStore.getState().upsert(tabRef.threadId, record); + for (const record of issueContexts) { + useIssueContextStore.getState().upsert(tabRef.threadId, record); } for (const record of repositoryContexts) { useRepositoryContextStore.getState().upsert(tabRef.threadId, record); @@ -8260,12 +8262,12 @@ export default function ChatView(props: ChatViewProps) { const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const referencedContextIds = new Set(collectInlineContextIds(promptForSend)); - // Chat summaries and Linear issues also attach to a draft thread's first message. + // Chat summaries and issues also attach to a draft thread's first message. const composerThreadTabsSnapshot = readThreadTabContextRecords(threadIdForSend).filter( (record) => referencedContextIds.has(record.contextId), ); - const composerLinearIssuesSnapshot = readLinearIssueContextRecords(threadIdForSend).filter( - (record) => referencedContextIds.has(record.contextId), + const composerIssuesSnapshot = readIssueContextRecords(threadIdForSend).filter((record) => + referencedContextIds.has(record.contextId), ); const composerRepositoriesSnapshot = readRepositoryContextRecords(threadIdForSend).filter( (record) => referencedContextIds.has(record.contextId), @@ -8288,7 +8290,7 @@ export default function ChatView(props: ChatViewProps) { reviewComments: composerReviewCommentsSnapshot, previewAnnotations: composerPreviewAnnotationsSnapshot, threadTabs: composerThreadTabsSnapshot, - linearIssues: composerLinearIssuesSnapshot, + issues: composerIssuesSnapshot, repositories: composerRepositoriesSnapshot, attachments: composerAttachmentsSnapshot.map((attachment, index) => ({ attachment, @@ -8969,7 +8971,7 @@ export default function ChatView(props: ChatViewProps) { } else { turnStartSucceeded = true; useThreadTabContextStore.getState().clear(threadIdForSend); - useLinearIssueContextStore.getState().clear(threadIdForSend); + useIssueContextStore.getState().clear(threadIdForSend); useRepositoryContextStore.getState().clear(threadIdForSend); // The turn is under way and will spend quota, so that thread's limits // snapshot is stale. Uploads may have outlasted a navigation, so only @@ -10725,6 +10727,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestRepository={ supportsPullRequests ? activeProjectRepository : null } + githubIssueCwd={githubIssueCwd} restingControlsHost={restingComposerControlsHost} restingControlsHaveLeadingContext={ isGitRepo || showComposerEnvironmentIndicator @@ -11063,6 +11066,9 @@ export default function ChatView(props: ChatViewProps) { + {githubIssueCwd !== null && activeProjectRef ? ( + + ) : null} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2b4cd996c4a9f..c99de725b9789 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -39,6 +39,7 @@ import { resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { openGitHubIssuePicker } from "./chat/GitHubIssuePicker"; import { openLinearIssuePicker } from "./chat/LinearIssuePicker"; import { useThreadLinearLink, useUnlinkLinearIssue } from "./chat/LinearThreadLink"; import { openThreadAttachPicker } from "./chat/ThreadAttachPicker"; @@ -113,6 +114,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; +import { isGitHubProject } from "../state/githubIssues"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -1968,6 +1970,22 @@ function OpenCommandPaletteDialog(props: { openLinearIssuePicker(composerThreadRef); }, }); + // The picker is mounted by a chat view whose project is on GitHub, so only offer it there. + const composerProject = projectByKey.get( + `${composerThreadRef.environmentId}:${currentProjectId ?? ""}`, + ); + if (isGitHubProject(composerProject)) { + actionItems.push({ + kind: "action", + value: "action:attach-github-issue", + searchTerms: ["github", "issue", "ticket", "attach", "context"], + title: "Attach GitHub issue", + icon: , + run: async () => { + openGitHubIssuePicker(composerThreadRef); + }, + }); + } actionItems.push({ kind: "action", value: "action:link-linear-issue", diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index 4057af3c7e2d9..9d6b842a0a0c2 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -46,6 +46,7 @@ const contextChipVariants = cva( citation: "[--context-chip-accent:oklch(0.62_0.16_259)]", "thread-tab": "[--context-chip-accent:oklch(0.62_0.134_120)]", "linear-issue": "[--context-chip-accent:oklch(0.62_0.16_270)]", + "github-issue": "[--context-chip-accent:oklch(0.62_0.02_259)]", repository: "[--context-chip-accent:oklch(0.62_0.12_195)]", }, // Colors live in compoundVariants below so they come after the kind colors. @@ -74,6 +75,7 @@ const contextChipVariants = cva( "citation", "thread-tab", "linear-issue", + "github-issue", "repository", ], className: diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6185608702416..2e0b51e774a1e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -203,7 +203,7 @@ import { } from "../composerContextPresentation"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useThreadTabContextRecords } from "~/threadTabContextStore"; -import { useLinearIssueContextRecords } from "~/linearIssueContextStore"; +import { useIssueContextRecords } from "~/issueContextStore"; import { useRepositoryContextRecords } from "~/repositoryContextStore"; import { useLinkClickHandler } from "~/browser/useOpenLink"; import { @@ -261,7 +261,9 @@ import { useCaptureThreadTabContext } from "./ThreadTabs"; import { composerThreadReferenceItems } from "./composerThreadReferences"; import { readProjects, readThreadShells } from "../../state/entities"; import { ComposerAttachMenu } from "./ComposerAttachMenu"; +import { useAttachGitHubIssue } from "./GitHubIssuePicker"; import { useAttachLinearIssue } from "./LinearIssuePicker"; +import { useComposerGitHubIssueItems } from "./useComposerGitHubIssueItems"; import { type ComposerReferenceTab, defaultComposerReferenceTab, @@ -342,10 +344,11 @@ import { } from "./composerScrollGesture"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; -const REFERENCE_MENU_TABS = [ +const REFERENCE_MENU_TABS: ReadonlyArray<{ id: ComposerReferenceTab; label: string }> = [ { id: "pull-requests", label: "Pull requests" }, + { id: "github-issues", label: "GitHub issues" }, { id: "linear-issues", label: "Linear issues" }, -] as const; +]; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -1454,6 +1457,8 @@ export interface ChatComposerProps { gitCwd: string | null; pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; + /** The checkout GitHub issues are listed from; null when the project is not on GitHub. */ + githubIssueCwd: string | null; restingControlsHost: HTMLDivElement | null; restingControlsHaveLeadingContext: boolean; onRestingControlsVisibilityChange: (visible: boolean) => void; @@ -1585,6 +1590,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) gitCwd, pullRequestProjectId, pullRequestRepository, + githubIssueCwd, restingControlsHost, restingControlsHaveLeadingContext, onRestingControlsVisibilityChange, @@ -1753,7 +1759,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ], ); const composerThreadTabContexts = useThreadTabContextRecords(routeThreadRef?.threadId); - const composerLinearIssueContexts = useLinearIssueContextRecords(routeThreadRef?.threadId); + const composerIssueContexts = useIssueContextRecords(routeThreadRef?.threadId); const composerRepositoryContexts = useRepositoryContextRecords(routeThreadRef?.threadId); const composerContextRecords = useMemo( () => @@ -1762,7 +1768,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadTabs: composerThreadTabContexts, - linearIssues: composerLinearIssueContexts, + issues: composerIssueContexts, repositories: composerRepositoryContexts, images: composerImages, files: composerFiles, @@ -1775,7 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerReviewComments, composerTerminalContexts, composerThreadTabContexts, - composerLinearIssueContexts, + composerIssueContexts, composerRepositoryContexts, uploadsByImageId, ], @@ -2433,12 +2439,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const showLinearIssues = linearIssueMenu.enabled && referenceTab === "linear-issues"; const attachLinearIssue = useAttachLinearIssue(); + const githubIssueMenu = useComposerGitHubIssueItems( + environmentId, + githubIssueCwd, + composerTrigger, + referenceTab === "github-issues", + ); + const showGitHubIssues = githubIssueMenu.enabled && referenceTab === "github-issues"; + const attachGitHubIssue = useAttachGitHubIssue(); + const referenceMenuTabs = REFERENCE_MENU_TABS.filter( + (tab) => + tab.id === "pull-requests" || + (tab.id === "linear-issues" ? linearIssueMenu.enabled : githubIssueMenu.enabled), + ); const captureThreadTabContext = useCaptureThreadTabContext(environmentId, activeThreadId); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "pull-request" && showLinearIssues) { return linearIssueMenu.items; } + if (composerTrigger.kind === "pull-request" && showGitHubIssues) { + return githubIssueMenu.items; + } if (composerTrigger.kind === "path") { const tabQuery = composerTrigger.query.trim().toLowerCase(); const tabItems = (threadTabGroup?.tabs ?? []) @@ -2610,6 +2632,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactSlashCommandAvailable, composerTrigger, exactPullRequestLookup.data, + githubIssueMenu.items, linearIssueMenu.items, planModeUiEnabled, pullRequestLookup.data, @@ -2621,6 +2644,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSlashCommands, selectedProviderStatus, settings.showSkillsInSlashMenu, + showGitHubIssues, showLinearIssues, threadTabGroup, activeThreadId, @@ -2700,13 +2724,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || (composerTriggerKind === "pull-request" && !showLinearIssues && + !showGitHubIssues && pullRequestProjectId !== null && pullRequestRepository !== null && (pullRequestLookup.isPending || pullRequestTextQuery !== debouncedPullRequestTextQuery || pullRequestTriggerNumber !== debouncedPullRequestNumber || exactPullRequestLookup.isPending)) || - (showLinearIssues && linearIssueMenu.isPending); + (showLinearIssues && linearIssueMenu.isPending) || + (showGitHubIssues && githubIssueMenu.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -2717,6 +2743,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? `No Linear issue matches ${composerTrigger.query}.` : "No open Linear issues are assigned to you."; } + if (showGitHubIssues) { + if (githubIssueMenu.error !== null) return githubIssueMenu.error; + return composerTrigger?.query + ? `No GitHub issue matches ${composerTrigger.query}.` + : "No open GitHub issues in this repository."; + } if (composerTriggerKind === "pull-request") { if (pullRequestProjectId === null || pullRequestRepository === null) { return "Pull requests are not available for this project."; @@ -2737,7 +2769,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ composerTrigger, composerTriggerKind, + githubIssueMenu.error, linearIssueMenu.error, + showGitHubIssues, showLinearIssues, pullRequestLookup.data?.errors, pullRequestLookup.error, @@ -3783,6 +3817,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void attachLinearIssue(routeThreadRef, item.issueId); return; } + if (item.type === "github-issue") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (!applied) return; + setComposerHighlightedItemId(null); + // The chip lands at the caret once the issue is fetched and snapshotted. + void attachGitHubIssue(routeThreadRef, item.url); + return; + } if (item.type === "slash-command") { if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -3886,6 +3930,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ addComposerDraftReviewComment, applyPromptReplacement, + attachGitHubIssue, attachLinearIssue, captureThreadTabContext, composerDraftTarget, @@ -6763,15 +6808,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) emptyStateText={composerMenuEmptyState} environmentId={environmentId} {...(showLinearIssues ? { loadingText: "Searching Linear issues..." } : {})} - {...(linearIssueMenu.enabled && composerTrigger?.kind === "pull-request" + {...(showGitHubIssues ? { loadingText: "Searching GitHub issues..." } : {})} + {...(referenceMenuTabs.length > 1 && composerTrigger?.kind === "pull-request" ? { tabs: { - options: REFERENCE_MENU_TABS, + options: referenceMenuTabs, activeId: referenceTab, onSelect: (tab: string) => setReferenceTabChoice({ rangeStart: composerTrigger.rangeStart, - tab: tab === "linear-issues" ? "linear-issues" : "pull-requests", + tab: + referenceMenuTabs.find((option) => option.id === tab)?.id ?? + "pull-requests", }), }, } @@ -7313,6 +7361,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) attachmentInputRef.current?.click()} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 91ebbcf85bf8d..f8696a172d678 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -36,6 +36,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { GitHubIssueThreadLinkChip } from "./GitHubIssueThreadLink"; import { LinearThreadLinkChip } from "./LinearThreadLink"; import { ThreadTabMenu } from "./ThreadTabs"; import { useSplitPaneFocus, useSplitViewActions } from "./splitPane"; @@ -535,6 +536,9 @@ export const ChatHeader = memo(function ChatHeader({ + {splitPaneFocus !== null ? ( void; }) { const composerMenuProps = useComposerMenuProps(); @@ -42,6 +45,12 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: { Linear issue + {props.githubIssuesAvailable ? ( + openGitHubIssuePicker(props.threadRef)}> + + GitHub issue + + ) : null} {props.pullRequestsAvailable ? ( openPullRequestAttachPicker(props.threadRef)}> diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f995ba725c762..8861ba646c1d3 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -21,7 +21,7 @@ import { UserRoundIcon, type LucideIcon, } from "lucide-react"; -import { LinearIcon } from "../Icons"; +import { GitHubIcon, LinearIcon } from "../Icons"; import { memo, useCallback, useLayoutEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; @@ -30,6 +30,10 @@ import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { ComposerBanner } from "./ComposerBanner"; +import { + GITHUB_ISSUE_STATE_PRESENTATION, + GitHubIssueHoverPreview, +} from "./GitHubIssueHoverPreview"; import { LinearIssueHoverPreview } from "./LinearIssueHoverPreview"; import { SourceTabs } from "./SourceTabs"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; @@ -88,6 +92,15 @@ export type ComposerCommandItem = description: string; assigneeName: string | null; stateName: string; + } + | { + id: string; + type: "github-issue"; + url: string; + label: string; + description: string; + authorLogin: string | null; + state: "open" | "closed"; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -105,7 +118,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { onSelect: (id: string) => void; }; activeItemId: string | null; - /** Lets Linear issue rows fetch their hover preview. */ + /** Lets Linear and GitHub issue rows fetch their hover preview. */ environmentId?: EnvironmentId; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; @@ -218,6 +231,37 @@ function LinearIssueRow(props: { ); } +type GitHubIssueCommandItem = Extract; + +/** Fixed-width columns so numbers, authors, and states line up down the list. */ +function GitHubIssueRow(props: { + item: GitHubIssueCommandItem; + environmentId: EnvironmentId | null; +}) { + const state = GITHUB_ISSUE_STATE_PRESENTATION[props.item.state]; + const row = ( + + + {props.item.label} + + {props.item.description} + + {props.item.authorLogin ?? ""} + + {state.label} + + ); + return props.environmentId === null ? ( + row + ) : ( + + ); +} + const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; environmentId: EnvironmentId | null; @@ -262,6 +306,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "linear-issue" ? (