feat: enhance subscription column functionality
- Introduced new components for managing subscription entries, including EntrySubscriptionItem and EntrySubscriptionList, to improve the display and interaction within the subscription column. - Added hooks for handling entry visibility and data fetching, optimizing performance and user experience. - Implemented a toolbar for toggling the visibility of the entry list, enhancing user control over the interface. - Refactored sidebar atoms to support new subscription-related states and actions. These changes aim to provide a more robust and user-friendly experience in managing subscriptions within the application. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
6e028b3e68
commit
863bc43f3d
|
|
@ -1,4 +1,5 @@
|
|||
import { atom } from "jotai"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { createAtomHooks } from "~/lib/jotai"
|
||||
|
||||
|
|
@ -11,13 +12,9 @@ const [
|
|||
setTimelineColumnShow,
|
||||
] = createAtomHooks(atom(true))
|
||||
|
||||
export const useSubscriptionColumnShow = () => {
|
||||
return internal_useSubscriptionColumnShow()
|
||||
}
|
||||
export const useSubscriptionColumnShow = internal_useSubscriptionColumnShow
|
||||
|
||||
export const getSubscriptionColumnShow = () => {
|
||||
return internal_getSubscriptionShow()
|
||||
}
|
||||
export const getSubscriptionColumnShow = internal_getSubscriptionShow
|
||||
|
||||
export { setTimelineColumnShow }
|
||||
|
||||
|
|
@ -29,3 +26,21 @@ export const [
|
|||
getSubscriptionColumnTempShow,
|
||||
setSubscriptionColumnTempShow,
|
||||
] = createAtomHooks(atom(false))
|
||||
|
||||
export const [
|
||||
,
|
||||
,
|
||||
useSubscriptionColumnApronNode,
|
||||
,
|
||||
getSubscriptionColumnApronNode,
|
||||
setSubscriptionColumnApronNode,
|
||||
] = createAtomHooks(atom<ReactNode | null>(null))
|
||||
|
||||
export const [
|
||||
,
|
||||
,
|
||||
useSubscriptionEntryPlaneVisible,
|
||||
,
|
||||
getSubscriptionEntryPlaneVisible,
|
||||
setSubscriptionEntryPlaneVisible,
|
||||
] = createAtomHooks(atom(true))
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { PanelSplitter } from "@follow/components/ui/divider/index.js"
|
|||
import { defaultUISettings } from "@follow/shared/settings/defaults"
|
||||
import { cn } from "@follow/utils"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { memo, useMemo, useRef } from "react"
|
||||
import { memo, startTransition, useEffect, useMemo, useRef } from "react"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { useParams } from "react-router"
|
||||
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
|
||||
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
||||
import { setSubscriptionColumnApronNode, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
|
||||
import { EntryContent } from "~/modules/entry-content/components/entry-content"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
|
@ -18,6 +20,8 @@ import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-cont
|
|||
import { AIChatRoot } from "../ai-chat/components/layouts/AIChatRoot"
|
||||
import { AIIndicator } from "../app-layout/ai/AISplineButton"
|
||||
import { AIEntryHeader } from "../entry-content/components/entry-header"
|
||||
import { EntryPlaneToolbar } from "./components/EntryPlaneToolbar"
|
||||
import { EntrySubscriptionList } from "./EntrySubscriptionList"
|
||||
import { EntryColumn } from "./index"
|
||||
|
||||
const AIEntryLayoutImpl = () => {
|
||||
|
|
@ -112,6 +116,7 @@ const AIEntryLayoutImpl = () => {
|
|||
|
||||
{/* Floating panel - renders outside layout flow */}
|
||||
{aiPanelStyle === AIChatPanelStyle.Floating && <AIChatLayout key="ai-chat-layout" />}
|
||||
<SubscriptionColumnToggler />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -125,3 +130,57 @@ export const AIEntryLayout = memo(function AIEntryLayout() {
|
|||
)
|
||||
})
|
||||
AIEntryLayout.displayName = "AIEntryLayout"
|
||||
|
||||
const SubscriptionColumnToggler = () => {
|
||||
const isInEntry = useRouteParamsSelector((s) => s.entryId !== ROUTE_ENTRY_PENDING)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInEntry) {
|
||||
startTransition(() => {
|
||||
setSubscriptionColumnApronNode(<SubscriptionEntryListPlaneNode />)
|
||||
})
|
||||
return () => {
|
||||
startTransition(() => {
|
||||
setSubscriptionColumnApronNode(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isInEntry])
|
||||
return null
|
||||
}
|
||||
|
||||
const SubscriptionEntryListPlaneNode = () => {
|
||||
const entryId = useRouteParamsSelector((s) => s.entryId)
|
||||
const isVisible = useSubscriptionEntryPlaneVisible()
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className={cn(
|
||||
"bg-sidebar backdrop-blur-background absolute left-0 top-12 z-[2] rounded-r-lg",
|
||||
isVisible ? "w-feed-col bottom-0 flex flex-col" : "w-[40px]",
|
||||
)}
|
||||
id="subscription-entry-list-plane-node"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isVisible ? "var(--fo-feed-col-w, 256px)" : "40px",
|
||||
}}
|
||||
transition={Spring.presets.smooth}
|
||||
>
|
||||
<EntryPlaneToolbar />
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isVisible && (
|
||||
<m.div
|
||||
key="entry-list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="w-feed-col flex flex-1 flex-col whitespace-pre"
|
||||
>
|
||||
<EntrySubscriptionList scrollToEntryId={entryId} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { memo, useCallback } from "react"
|
||||
|
||||
import { RelativeTime } from "~/components/ui/datetime"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { feedColumnStyles } from "~/modules/subscription-column/styles"
|
||||
|
||||
interface EntrySubscriptionItemProps {
|
||||
entryId: string
|
||||
view: FeedViewType
|
||||
className?: string
|
||||
isPreview?: boolean
|
||||
}
|
||||
|
||||
const EntrySubscriptionItemImpl = ({ entryId, view, className }: EntrySubscriptionItemProps) => {
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
// Use current route view for navigation to stay in current view
|
||||
const currentRouteView = useRouteParamsSelector((s) => s.view)
|
||||
const navigationView = currentRouteView ?? view
|
||||
|
||||
const entry = useEntry(entryId, (entry) => {
|
||||
if (!entry) return null
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
publishedAt: entry.publishedAt,
|
||||
feedId: entry.feedId,
|
||||
read: entry.read,
|
||||
}
|
||||
})
|
||||
|
||||
const feed = useFeedById(entry?.feedId, (feed) => {
|
||||
if (!feed) return null
|
||||
return {
|
||||
id: feed.id,
|
||||
type: feed.type || "feed",
|
||||
title: feed.title,
|
||||
image: feed.image,
|
||||
siteUrl: feed.siteUrl,
|
||||
url: feed.url,
|
||||
}
|
||||
})
|
||||
|
||||
const isActive = useRouteParamsSelector((routerParams) => routerParams.entryId === entryId)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (navigationView === undefined || !entry) return
|
||||
|
||||
navigate({
|
||||
feedId: entry.feedId,
|
||||
entryId: entry.id,
|
||||
view: navigationView,
|
||||
})
|
||||
},
|
||||
[entry, navigate, navigationView],
|
||||
)
|
||||
|
||||
if (!entry || !feed) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-entry-id={entryId}
|
||||
data-active={isActive}
|
||||
className={cn(feedColumnStyles.item, "py-1 pl-2.5", !entry.read && "font-medium", className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<FeedIcon fallback feed={feed} size={14} className="mt-0.5 shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<EllipsisHorizontalTextWithTooltip
|
||||
className={cn("text-text truncate text-sm leading-tight", entry.read && "opacity-90")}
|
||||
>
|
||||
{entry.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
|
||||
<div className="text-text-secondary flex items-center gap-1 text-xs opacity-80">
|
||||
<EllipsisHorizontalTextWithTooltip className="max-w-24 truncate">
|
||||
{feed.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
{entry.publishedAt && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<RelativeTime date={entry.publishedAt} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EntrySubscriptionItem = memo(EntrySubscriptionItemImpl)
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import type { FC, RefObject } from "react"
|
||||
import { memo, startTransition, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { EntryItemSkeleton } from "~/modules/entry-column/EntryItemSkeleton"
|
||||
|
||||
import { EntrySubscriptionItem } from "./EntrySubscriptionItem"
|
||||
import { useEntrySubscriptionData } from "./hooks/subscription/useEntrySubscriptionData"
|
||||
import { useEntryVirtualizer } from "./hooks/subscription/useEntryVirtualizer"
|
||||
|
||||
export interface EntrySubscriptionListProps {
|
||||
scrollToEntryId?: string
|
||||
}
|
||||
|
||||
// Prevent scroll list move when press up/down key
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
export const EntrySubscriptionList: FC<EntrySubscriptionListProps> = memo(({ scrollToEntryId }) => {
|
||||
// Get all data internally
|
||||
const { entriesIds, hasNextPage, isFetchingNextPage, fetchNextPage, view } =
|
||||
useEntrySubscriptionData()
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle virtualization internally
|
||||
const { rowVirtualizer, renderData, totalSize } = useEntryVirtualizer({
|
||||
entriesIds,
|
||||
scrollToEntryId,
|
||||
scrollElement: scrollAreaRef as RefObject<HTMLElement>,
|
||||
})
|
||||
|
||||
// Handle infinite loading
|
||||
useEffect(() => {
|
||||
const lastRenderItem = renderData.at(-1)
|
||||
if (!lastRenderItem) return
|
||||
|
||||
if (lastRenderItem.isLoaderRow && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [renderData, hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setReady(true)
|
||||
})
|
||||
}, [])
|
||||
return (
|
||||
<ScrollArea rootClassName="h-0 grow" ref={scrollAreaRef}>
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
className="relative w-full select-none"
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
}}
|
||||
>
|
||||
{renderData.map((item) => {
|
||||
if (!ready) return null
|
||||
|
||||
if (item.isLoaderRow) {
|
||||
const Content = hasNextPage ? <EntryItemSkeleton view={view} count={3} /> : null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full will-change-transform"
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
style={{
|
||||
transform: item.transform,
|
||||
}}
|
||||
>
|
||||
{Content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="absolute left-0 top-0 w-full will-change-transform"
|
||||
style={{ transform: item.transform }}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
>
|
||||
<EntrySubscriptionItem entryId={item.entryId!} view={view} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
|
||||
EntrySubscriptionList.displayName = "EntrySubscriptionList"
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { cn, stopPropagation } from "@follow/utils"
|
||||
import type { FC, SVGProps } from "react"
|
||||
|
||||
import { setSubscriptionEntryPlaneVisible, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar"
|
||||
|
||||
interface EntryPlaneToolbarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const EntryPlaneToolbar: FC<EntryPlaneToolbarProps> = ({ className }) => {
|
||||
const isVisible = useSubscriptionEntryPlaneVisible()
|
||||
|
||||
const handleToggle = () => {
|
||||
setSubscriptionEntryPlaneVisible(!isVisible)
|
||||
}
|
||||
|
||||
// When hidden, show only a compact toggle button
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<div className={cn("translate-y-2 p-2", className)} onClick={stopPropagation}>
|
||||
<ActionButton tooltip="Show Entry List" size="sm" onClick={handleToggle}>
|
||||
<MaterialSymbolsExpandContent />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// When visible, show full toolbar
|
||||
return (
|
||||
<div
|
||||
onClick={stopPropagation}
|
||||
className={cn(
|
||||
"text-text-secondary flex h-11 items-center justify-between px-2 text-xl",
|
||||
"backdrop-blur-background shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 whitespace-pre">
|
||||
<div className="text-sm font-medium">Entry List</div>
|
||||
</div>
|
||||
|
||||
<ActionButton tooltip="Hide Entry List" size="sm" onClick={handleToggle}>
|
||||
<MaterialSymbolsCollapseContentRounded />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MaterialSymbolsCollapseContentRounded(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 15H6q-.425 0-.712-.288T5 14t.288-.712T6 13h4q.425 0 .713.288T11 14v4q0 .425-.288.713T10 19t-.712-.288T9 18zm6-6h3q.425 0 .713.288T19 10t-.288.713T18 11h-4q-.425 0-.712-.288T13 10V6q0-.425.288-.712T14 5t.713.288T15 6z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MaterialSymbolsExpandContent(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
|
||||
<path fill="currentColor" d="M5 19v-6h2v4h4v2zm12-8V7h-4V5h6v6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export interface EntryColumnWrapperProps extends ComponentType {
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
onPullToRefresh?: () => Promise<any>
|
||||
|
||||
ref?: React.Ref<HTMLDivElement | null>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import { useEntriesByView } from "../useEntriesByView"
|
||||
|
||||
/**
|
||||
* Hook for managing entry data in subscription column context
|
||||
* Uses local entries only for better performance in sidebar
|
||||
*/
|
||||
export const useEntrySubscriptionData = () => {
|
||||
const { view = FeedViewType.Articles } = useRouteParams()
|
||||
|
||||
// Reuse the same data source logic as EntryColumn's ListComponent
|
||||
// to ensure entries are identical in order, filtering, and paging.
|
||||
const entriesData = useEntriesByView({})
|
||||
|
||||
return {
|
||||
entriesIds: entriesData.entriesIds,
|
||||
hasNextPage: entriesData.hasNextPage,
|
||||
isFetchingNextPage: entriesData.isFetchingNextPage,
|
||||
fetchNextPage: entriesData.fetchNextPage,
|
||||
refetch: entriesData.refetch,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { Range } from "@tanstack/react-virtual"
|
||||
import type { RefObject } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
import { useEntryVirtualization } from "../useEntryVirtualization"
|
||||
|
||||
interface UseEntryVirtualizerOptions {
|
||||
entriesIds: string[]
|
||||
onRangeChange?: (range: Range) => void
|
||||
scrollToEntryId?: string
|
||||
scrollElement?: RefObject<HTMLElement> | (() => HTMLElement | null)
|
||||
}
|
||||
|
||||
export const useEntryVirtualizer = ({
|
||||
entriesIds,
|
||||
onRangeChange,
|
||||
scrollToEntryId,
|
||||
scrollElement,
|
||||
}: UseEntryVirtualizerOptions) => {
|
||||
// Find scroll to index
|
||||
const scrollToIndex = useMemo(() => {
|
||||
if (scrollToEntryId) {
|
||||
const index = entriesIds.indexOf(scrollToEntryId)
|
||||
return index !== -1 ? { index, align: "start" as const } : undefined
|
||||
}
|
||||
return
|
||||
}, [entriesIds, scrollToEntryId])
|
||||
|
||||
const virtualization = useEntryVirtualization({
|
||||
count: entriesIds.length + 1, // +1 for loading placeholder
|
||||
estimateSize: () => 48, // Smaller height for subscription column
|
||||
overscan: 5,
|
||||
cacheKey: "entry-subscription-list",
|
||||
onRangeChange,
|
||||
scrollToIndex,
|
||||
scrollElement,
|
||||
})
|
||||
|
||||
// Memoized render data with subscription-specific logic
|
||||
const renderData = useMemo(() => {
|
||||
return virtualization.renderData.map((item) => {
|
||||
const isLoaderRow = item.index === entriesIds.length
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
index: item.index,
|
||||
isLoaderRow,
|
||||
transform: item.transform,
|
||||
entryId: isLoaderRow ? null : entriesIds[item.index],
|
||||
}
|
||||
})
|
||||
}, [virtualization.renderData, entriesIds])
|
||||
|
||||
// Scroll to specific entry programmatically
|
||||
const scrollToEntry = useCallback(
|
||||
(entryId: string) => {
|
||||
const index = entriesIds.indexOf(entryId)
|
||||
if (index !== -1) {
|
||||
virtualization.scrollTo(index, "start")
|
||||
}
|
||||
},
|
||||
[entriesIds, virtualization],
|
||||
)
|
||||
|
||||
return {
|
||||
rowVirtualizer: virtualization.virtualizer,
|
||||
renderData,
|
||||
totalSize: virtualization.totalSize,
|
||||
scrollToEntry,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { LRUCache } from "@follow/utils/lru-cache"
|
||||
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { RefObject } from "react"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
interface UseEntryVirtualizationOptions {
|
||||
count: number
|
||||
estimateSize?: () => number
|
||||
overscan?: number
|
||||
gap?: number
|
||||
cacheKey?: string
|
||||
onRangeChange?: (range: Range) => void
|
||||
scrollToIndex?: number | { index: number; align?: "start" | "center" | "end" | "auto" }
|
||||
scrollElement?: RefObject<HTMLElement> | (() => HTMLElement | null)
|
||||
}
|
||||
|
||||
const capacity = 3
|
||||
const offsetCache = new LRUCache<string, number>(capacity)
|
||||
const measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)
|
||||
|
||||
export const useEntryVirtualization = ({
|
||||
count,
|
||||
estimateSize = () => 112,
|
||||
overscan = 5,
|
||||
gap,
|
||||
cacheKey = "entry-list",
|
||||
onRangeChange,
|
||||
scrollToIndex,
|
||||
scrollElement,
|
||||
}: UseEntryVirtualizationOptions) => {
|
||||
const defaultScrollRef = useScrollViewElement()
|
||||
|
||||
const getScrollElement = useCallback(() => {
|
||||
if (scrollElement) {
|
||||
return typeof scrollElement === "function" ? scrollElement() : scrollElement.current
|
||||
}
|
||||
return defaultScrollRef
|
||||
}, [scrollElement, defaultScrollRef])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count,
|
||||
estimateSize,
|
||||
overscan,
|
||||
gap,
|
||||
getScrollElement,
|
||||
initialOffset: offsetCache.get(cacheKey) ?? 0,
|
||||
initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [],
|
||||
onChange: useTypeScriptHappyCallback(
|
||||
(virtualizer: Virtualizer<HTMLElement, Element>) => {
|
||||
if (!virtualizer.isScrolling) {
|
||||
measurementsCache.put(cacheKey, virtualizer.measurementsCache)
|
||||
offsetCache.put(cacheKey, virtualizer.scrollOffset ?? 0)
|
||||
}
|
||||
|
||||
onRangeChange?.(virtualizer.range as Range)
|
||||
},
|
||||
[cacheKey],
|
||||
),
|
||||
})
|
||||
|
||||
// Handle scroll to index with viewport check
|
||||
useEffect(() => {
|
||||
if (scrollToIndex !== undefined) {
|
||||
const targetIndex = typeof scrollToIndex === "number" ? scrollToIndex : scrollToIndex.index
|
||||
|
||||
// Check if target index is already in viewport
|
||||
const { range } = rowVirtualizer
|
||||
if (range && targetIndex >= range.startIndex && targetIndex <= range.endIndex) {
|
||||
// Target is already visible, no need to scroll
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof scrollToIndex === "number") {
|
||||
rowVirtualizer.scrollToIndex(scrollToIndex)
|
||||
} else {
|
||||
rowVirtualizer.scrollToIndex(scrollToIndex.index, { align: scrollToIndex.align })
|
||||
}
|
||||
}
|
||||
}, [scrollToIndex, rowVirtualizer])
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
// Create render data with common transformations
|
||||
const renderData = useMemo(() => {
|
||||
return virtualItems.map((virtualRow) => ({
|
||||
key: virtualRow.key,
|
||||
index: virtualRow.index,
|
||||
start: virtualRow.start,
|
||||
size: virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}))
|
||||
}, [virtualItems])
|
||||
|
||||
// Scroll to specific index programmatically with viewport check
|
||||
const scrollTo = useCallback(
|
||||
(index: number, align?: "start" | "center" | "end" | "auto") => {
|
||||
// Check if target index is already in viewport
|
||||
const { range } = rowVirtualizer
|
||||
if (range && index >= range.startIndex && index <= range.endIndex) {
|
||||
// Target is already visible, no need to scroll
|
||||
return
|
||||
}
|
||||
|
||||
rowVirtualizer.scrollToIndex(index, { align })
|
||||
},
|
||||
[rowVirtualizer],
|
||||
)
|
||||
|
||||
return {
|
||||
virtualizer: rowVirtualizer,
|
||||
virtualItems,
|
||||
renderData,
|
||||
totalSize: rowVirtualizer.getTotalSize(),
|
||||
scrollTo,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { useCollectionEntryList } from "@follow/store/collection/hooks"
|
||||
import {
|
||||
useEntryIdsByFeedId,
|
||||
useEntryIdsByFeedIds,
|
||||
useEntryIdsByInboxId,
|
||||
useEntryIdsByListId,
|
||||
useEntryIdsByView,
|
||||
} from "@follow/store/entry/hooks"
|
||||
import { useEntryStore } from "@follow/store/entry/store"
|
||||
import type { UseEntriesReturn } from "@follow/store/entry/types"
|
||||
import { useFolderFeedsByFeedId } from "@follow/store/subscription/hooks"
|
||||
import { debounce } from "es-toolkit/compat"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { ROUTE_FEED_PENDING } from "~/constants/app"
|
||||
|
||||
interface UseLocalEntriesOptions {
|
||||
feedId?: string
|
||||
view?: FeedViewType
|
||||
inboxId?: string
|
||||
listId?: string
|
||||
isCollection?: boolean
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
function getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined | null>) {
|
||||
return entryIds.find((ids) => ids?.length) ?? []
|
||||
}
|
||||
|
||||
export const useLocalEntries = ({
|
||||
feedId,
|
||||
view,
|
||||
inboxId,
|
||||
listId,
|
||||
isCollection,
|
||||
pageSize = 30,
|
||||
}: UseLocalEntriesOptions = {}): UseEntriesReturn => {
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
|
||||
"hidePrivateSubscriptionsInTimeline",
|
||||
)
|
||||
|
||||
const folderIds = useFolderFeedsByFeedId({
|
||||
feedId,
|
||||
view,
|
||||
})
|
||||
const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)
|
||||
const entryIdsByCollections = useCollectionEntryList(view)
|
||||
const entryIdsByFeedId = useEntryIdsByFeedId(feedId)
|
||||
const entryIdsByCategory = useEntryIdsByFeedIds(folderIds)
|
||||
const entryIdsByListId = useEntryIdsByListId(listId)
|
||||
const entryIdsByInboxId = useEntryIdsByInboxId(inboxId)
|
||||
|
||||
const showEntriesByView =
|
||||
(!feedId || feedId === ROUTE_FEED_PENDING) &&
|
||||
folderIds.length === 0 &&
|
||||
!isCollection &&
|
||||
!inboxId &&
|
||||
!listId
|
||||
|
||||
const allEntries = useEntryStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const ids = isCollection
|
||||
? entryIdsByCollections
|
||||
: showEntriesByView
|
||||
? (entryIdsByView ?? [])
|
||||
: (getEntryIdsFromMultiplePlace(
|
||||
entryIdsByFeedId,
|
||||
entryIdsByCategory,
|
||||
entryIdsByListId,
|
||||
entryIdsByInboxId,
|
||||
) ?? [])
|
||||
|
||||
return ids
|
||||
.map((id) => {
|
||||
const entry = state.data[id]
|
||||
if (!entry) return null
|
||||
if (unreadOnly && entry.read) {
|
||||
return null
|
||||
}
|
||||
return entry.id
|
||||
})
|
||||
.filter((id) => typeof id === "string")
|
||||
},
|
||||
[
|
||||
entryIdsByCategory,
|
||||
entryIdsByCollections,
|
||||
entryIdsByFeedId,
|
||||
entryIdsByInboxId,
|
||||
entryIdsByListId,
|
||||
entryIdsByView,
|
||||
isCollection,
|
||||
showEntriesByView,
|
||||
unreadOnly,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const totalPage = useMemo(
|
||||
() => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0),
|
||||
[allEntries, pageSize],
|
||||
)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
return allEntries?.slice(0, (page + 1) * pageSize) || []
|
||||
}, [allEntries, page, pageSize])
|
||||
|
||||
const hasNext = useMemo(() => {
|
||||
return entries.length < (allEntries?.length || 0)
|
||||
}, [entries.length, allEntries])
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setPage(0)
|
||||
}, [])
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
const debouncedFetch = debounce(() => {
|
||||
setPage((prev) => prev + 1)
|
||||
}, 300)
|
||||
return debouncedFetch()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [view, feedId])
|
||||
|
||||
return {
|
||||
entriesIds: entries,
|
||||
hasNext,
|
||||
hasUpdate: false,
|
||||
refetch,
|
||||
fetchNextPage: fetchNextPage as () => Promise<void>,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isReady: true,
|
||||
isFetchingNextPage: false,
|
||||
isFetching: false,
|
||||
hasNextPage: page < totalPage,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
|
|
@ -117,21 +116,19 @@ function EntryColumnImpl() {
|
|||
entries.fetchNextPage()
|
||||
}
|
||||
}, [entries])
|
||||
const isMobile = useMobile()
|
||||
|
||||
const ListComponent = views.find((v) => v.view === view)?.gridMode ? EntryColumnGrid : EntryList
|
||||
|
||||
return (
|
||||
<Focusable
|
||||
scope={HotkeyScope.Timeline}
|
||||
data-hide-in-print
|
||||
className="@container relative flex h-full flex-1 flex-col"
|
||||
onClick={
|
||||
isMobile
|
||||
? undefined
|
||||
: () =>
|
||||
navigate({
|
||||
entryId: null,
|
||||
})
|
||||
onClick={() =>
|
||||
navigate({
|
||||
view,
|
||||
entryId: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{entriesIds.length === 0 &&
|
||||
|
|
@ -145,11 +142,7 @@ function EntryColumnImpl() {
|
|||
hasUpdate={entries.hasUpdate}
|
||||
/>
|
||||
|
||||
<EntryColumnWrapper
|
||||
onScroll={handleScroll}
|
||||
onPullToRefresh={entries.refetch}
|
||||
key={`${routeFeedId}-${view}`}
|
||||
>
|
||||
<EntryColumnWrapper onScroll={handleScroll} key={`${routeFeedId}-${view}`}>
|
||||
{entriesIds.length === 0 ? (
|
||||
entries.isLoading ? (
|
||||
<EntryItemSkeleton view={view} />
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
|||
|
||||
import { useRootContainerElement } from "~/atoms/dom"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar"
|
||||
import {
|
||||
setTimelineColumnShow,
|
||||
useSubscriptionColumnApronNode,
|
||||
useSubscriptionColumnShow,
|
||||
} from "~/atoms/sidebar"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope, ROUTE_TIMELINE_OF_VIEW } from "~/constants"
|
||||
import { useBackHome } from "~/hooks/biz/useNavigateEntry"
|
||||
|
|
@ -174,11 +178,17 @@ export function SubscriptionColumn({
|
|||
</SwipeWrapper>
|
||||
</div>
|
||||
|
||||
<ApronNodeContainer />
|
||||
|
||||
{children}
|
||||
</WindowUnderBlur>
|
||||
)
|
||||
}
|
||||
|
||||
const ApronNodeContainer: FC = () => {
|
||||
return useSubscriptionColumnApronNode()
|
||||
}
|
||||
|
||||
const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo(
|
||||
({ children, active }) => {
|
||||
const reduceMotion = useReduceMotion()
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
# @follow/changelog-cli
|
||||
|
||||
AI-driven changelog generator for the Follow project. This package automatically analyzes git commits and generates user-friendly changelog entries using configurable AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **AI-Powered Analysis**: Uses OpenAI GPT models to analyze commit messages
|
||||
- 📊 **Smart Categorization**: Automatically categorizes changes into sections
|
||||
- 👥 **Contributor Recognition**: Identifies and thanks external contributors
|
||||
- 🔧 **Configurable**: Supports custom AI endpoints and models
|
||||
- 🔄 **Fallback Support**: Falls back to keyword-based analysis if AI is unavailable
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Follow monorepo and is not published to npm. It's designed to be used within the monorepo environment.
|
||||
|
||||
## Usage
|
||||
|
||||
### As a Workspace Script
|
||||
|
||||
From the mobile app directory:
|
||||
|
||||
```bash
|
||||
npm run changelog:generate
|
||||
```
|
||||
|
||||
### Direct Execution
|
||||
|
||||
From the package directory:
|
||||
|
||||
```bash
|
||||
pnpm run generate
|
||||
```
|
||||
|
||||
### CLI Command (after building)
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
./bin/cli.js
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create or modify `config.json` in the package root:
|
||||
|
||||
```json
|
||||
{
|
||||
"internalTeamMembers": [
|
||||
"innei",
|
||||
"DIYgod",
|
||||
"hyoban",
|
||||
"renovate[bot]",
|
||||
"github-actions[bot]",
|
||||
"dependabot[bot]",
|
||||
"vercel[bot]"
|
||||
],
|
||||
"aiModel": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.3,
|
||||
"maxTokens": 1000,
|
||||
"apiKey": "",
|
||||
"baseURL": "https://api.openai.com/v1",
|
||||
"customEndpoint": ""
|
||||
},
|
||||
"commitAnalysis": {
|
||||
"categories": {
|
||||
"features": {
|
||||
"keywords": ["feat", "feature", "add", "implement", "introduce"],
|
||||
"section": "Shiny new things"
|
||||
},
|
||||
"improvements": {
|
||||
"keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"],
|
||||
"section": "Improvements"
|
||||
},
|
||||
"fixes": {
|
||||
"keywords": ["fix", "bug", "patch", "resolve", "correct"],
|
||||
"section": "No longer broken"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"^chore:",
|
||||
"^docs:",
|
||||
"^test:",
|
||||
"^ci:",
|
||||
"^build:",
|
||||
"Merge pull request",
|
||||
"Merge branch",
|
||||
"version bump",
|
||||
"changelog",
|
||||
"Update dependencies"
|
||||
]
|
||||
},
|
||||
"changelog": {
|
||||
"maxCommitsPerSection": 5,
|
||||
"includeCommitHash": true,
|
||||
"includePullRequestLinks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom OpenAI Endpoints
|
||||
|
||||
The tool supports custom OpenAI-compatible endpoints:
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://your-custom-endpoint.com/v1"
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"apiKey": "your-api-key",
|
||||
"customEndpoint": "https://your-custom-endpoint.com/v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- OpenAI API
|
||||
- Azure OpenAI
|
||||
- Any OpenAI-compatible API (e.g., Claude via proxy, local models)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- Git repository with proper tag structure (`mobile@x.y.z`)
|
||||
- Must be run on a release branch (`release/mobile/*`)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Branch Validation**: Ensures execution on a release branch
|
||||
2. **Git Analysis**: Finds commits between latest mobile tag and current HEAD
|
||||
3. **Commit Filtering**: Removes noise commits (merges, version bumps, etc.)
|
||||
4. **AI Analysis**: Sends filtered commits to AI for categorization
|
||||
5. **Contributor Detection**: Identifies external contributors from commit authors
|
||||
6. **Changelog Generation**: Formats and writes changelog to target file
|
||||
|
||||
## Files Structure
|
||||
|
||||
- `index.ts` - Main entry point and orchestration logic
|
||||
- `git-tools.ts` - Git command execution and analysis
|
||||
- `ai-agent.ts` - AI-powered commit analysis and categorization
|
||||
- `openai-client.ts` - OpenAI API client with custom endpoint support
|
||||
- `config.json` - Configuration file
|
||||
- `bin/cli.js` - CLI executable wrapper
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run in development mode
|
||||
pnpm run dev
|
||||
|
||||
# Type checking
|
||||
pnpm run typecheck
|
||||
|
||||
# Build for production
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
This package is designed to integrate with the existing release workflow in the Follow monorepo. It can be called from other apps' package.json scripts or integrated into the bump configuration.
|
||||
|
||||
Example integration in mobile app's `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"changelog:generate": "tsx ../packages/changelog-cli/index.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This package is part of the Follow project and follows the same license terms.
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
# Usage Examples
|
||||
|
||||
This document provides practical examples of how to use the `@follow/changelog-cli` package in different scenarios.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Generate Changelog for Current Release
|
||||
|
||||
Make sure you're on a release branch (e.g., `release/mobile/0.2.5`):
|
||||
|
||||
```bash
|
||||
# From mobile app directory
|
||||
cd apps/mobile
|
||||
npm run changelog:generate
|
||||
|
||||
# Or from the package directory
|
||||
cd packages/changelog-cli
|
||||
pnpm run generate
|
||||
```
|
||||
|
||||
### 2. Using with Custom OpenAI Endpoint
|
||||
|
||||
If you're using a custom OpenAI-compatible endpoint (like Azure OpenAI or a local model):
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://your-endpoint.openai.azure.com/v1"
|
||||
|
||||
# Run the generator
|
||||
npm run changelog:generate
|
||||
```
|
||||
|
||||
### 3. Configuration for Different AI Providers
|
||||
|
||||
#### Azure OpenAI
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"apiKey": "your-azure-key",
|
||||
"customEndpoint": "https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Local Model (Ollama)
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"provider": "openai",
|
||||
"model": "llama2",
|
||||
"apiKey": "not-needed",
|
||||
"customEndpoint": "http://localhost:11434/v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Claude via Proxy
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"provider": "openai",
|
||||
"model": "claude-3-sonnet-20240229",
|
||||
"apiKey": "your-anthropic-key",
|
||||
"customEndpoint": "https://api.anthropic.com/v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### 1. Auto-generate during Release Process
|
||||
|
||||
Add to `apps/mobile/bump.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
leading: [
|
||||
"git pull --rebase",
|
||||
"npm run changelog:generate", // Add this line
|
||||
"tsx scripts/apply-changelog.ts ${NEW_VERSION}",
|
||||
"git add changelog",
|
||||
// ... rest of config
|
||||
],
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### 2. CI/CD Integration
|
||||
|
||||
Example GitHub Actions workflow:
|
||||
|
||||
```yaml
|
||||
name: Generate Changelog
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "release/mobile/*"
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Important: fetch full history
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Generate changelog
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
cd apps/mobile
|
||||
npm run changelog:generate
|
||||
|
||||
- name: Commit changelog
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add apps/mobile/changelog/next.md
|
||||
git commit -m "chore: auto-generate changelog" || exit 0
|
||||
git push
|
||||
```
|
||||
|
||||
### 3. Custom Script Integration
|
||||
|
||||
Create a custom script that uses the changelog CLI:
|
||||
|
||||
```typescript
|
||||
// scripts/release-workflow.ts
|
||||
import { execSync } from "node:child_process"
|
||||
|
||||
async function releaseWorkflow() {
|
||||
console.log("🚀 Starting release workflow...")
|
||||
|
||||
// 1. Generate changelog
|
||||
console.log("📝 Generating changelog...")
|
||||
execSync("npm run changelog:generate", {
|
||||
cwd: "apps/mobile",
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
// 2. Review and edit if needed
|
||||
console.log("📋 Changelog generated. Please review before continuing.")
|
||||
|
||||
// 3. Continue with release process...
|
||||
}
|
||||
|
||||
releaseWorkflow()
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### 1. Team-specific Configuration
|
||||
|
||||
For different teams with different internal members:
|
||||
|
||||
```json
|
||||
{
|
||||
"internalTeamMembers": [
|
||||
"innei",
|
||||
"DIYgod",
|
||||
"hyoban",
|
||||
"team-lead-1",
|
||||
"team-lead-2",
|
||||
"renovate[bot]",
|
||||
"dependabot[bot]"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Project-specific Keywords
|
||||
|
||||
Customize keywords for different types of projects:
|
||||
|
||||
```json
|
||||
{
|
||||
"commitAnalysis": {
|
||||
"categories": {
|
||||
"features": {
|
||||
"keywords": ["feat", "feature", "add", "implement", "new"],
|
||||
"section": "🎉 New Features"
|
||||
},
|
||||
"improvements": {
|
||||
"keywords": ["improve", "enhance", "optimize", "perf", "refactor"],
|
||||
"section": "⚡ Improvements"
|
||||
},
|
||||
"fixes": {
|
||||
"keywords": ["fix", "bug", "patch", "resolve", "hotfix"],
|
||||
"section": "🐛 Bug Fixes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Strict Filtering
|
||||
|
||||
For projects with very specific changelog requirements:
|
||||
|
||||
```json
|
||||
{
|
||||
"commitAnalysis": {
|
||||
"ignorePatterns": [
|
||||
"^chore:",
|
||||
"^docs:",
|
||||
"^test:",
|
||||
"^ci:",
|
||||
"^build:",
|
||||
"^style:",
|
||||
"^refactor:",
|
||||
"Merge pull request",
|
||||
"Merge branch",
|
||||
"version bump",
|
||||
"changelog",
|
||||
"Update dependencies",
|
||||
"^deps:",
|
||||
"^devDeps:"
|
||||
]
|
||||
},
|
||||
"changelog": {
|
||||
"maxCommitsPerSection": 3,
|
||||
"includeCommitHash": false,
|
||||
"includePullRequestLinks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting Examples
|
||||
|
||||
### 1. API Rate Limiting
|
||||
|
||||
If you hit OpenAI API rate limits:
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"temperature": 0.1,
|
||||
"maxTokens": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or process commits in smaller batches by modifying the batch size in `ai-agent.ts`.
|
||||
|
||||
### 2. Custom Git Tag Format
|
||||
|
||||
If your project uses a different tag format, modify `git-tools.ts`:
|
||||
|
||||
```typescript
|
||||
// In git-tools.ts, modify getLatestMobileTag()
|
||||
getLatestMobileTag(): GitTag | null {
|
||||
try {
|
||||
// Change this line to match your tag format
|
||||
const tags = this.exec('git tag --sort=-version:refname | grep "^v" | head -1')
|
||||
// ... rest of the method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Working with Monorepos
|
||||
|
||||
For complex monorepo setups, you might need to adjust the working directory:
|
||||
|
||||
```typescript
|
||||
// Create a custom git tools instance
|
||||
const git = new GitTools("/path/to/your/specific/repo")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Review Before Committing
|
||||
|
||||
Always review the generated changelog before committing:
|
||||
|
||||
```bash
|
||||
# Generate changelog
|
||||
npm run changelog:generate
|
||||
|
||||
# Review the changes
|
||||
cat apps/mobile/changelog/next.md
|
||||
|
||||
# Edit if necessary
|
||||
vim apps/mobile/changelog/next.md
|
||||
|
||||
# Commit when satisfied
|
||||
git add apps/mobile/changelog/next.md
|
||||
git commit -m "chore: update changelog"
|
||||
```
|
||||
|
||||
### 2. Backup Configuration
|
||||
|
||||
Keep your configuration in version control but sensitive data in environment variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"aiModel": {
|
||||
"apiKey": "", // Leave empty in version control
|
||||
"customEndpoint": "" // Can be committed if not sensitive
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Testing Configuration
|
||||
|
||||
Test your configuration with a small batch first:
|
||||
|
||||
```json
|
||||
{
|
||||
"changelog": {
|
||||
"maxCommitsPerSection": 1, // Start small
|
||||
"includeCommitHash": true,
|
||||
"includePullRequestLinks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This way you can verify the output format before processing many commits.
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
import type { GitCommit } from "./git-tools"
|
||||
import OpenAIClient from "./openai-client"
|
||||
|
||||
export interface ChangelogEntry {
|
||||
section: "Shiny new things" | "Improvements" | "No longer broken"
|
||||
description: string
|
||||
commitHash?: string
|
||||
pullRequest?: string
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
provider: string
|
||||
model: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface CommitCategory {
|
||||
keywords: string[]
|
||||
section: "Shiny new things" | "Improvements" | "No longer broken"
|
||||
}
|
||||
|
||||
export class AIAgent {
|
||||
private config: AIConfig
|
||||
private categories: Record<string, CommitCategory>
|
||||
private openaiClient: OpenAIClient
|
||||
|
||||
constructor(config: AIConfig, categories: Record<string, CommitCategory>) {
|
||||
this.config = config
|
||||
this.categories = categories
|
||||
this.openaiClient = new OpenAIClient(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze commits and generate changelog entries using AI
|
||||
*/
|
||||
async analyzeCommits(commits: GitCommit[]): Promise<ChangelogEntry[]> {
|
||||
console.info(`Analyzing ${commits.length} commits with AI...`)
|
||||
|
||||
const filteredCommits = this.filterImportantCommits(commits)
|
||||
console.info(`Selected ${filteredCommits.length} important commits`)
|
||||
|
||||
const changelogEntries: ChangelogEntry[] = []
|
||||
|
||||
// Process commits in batches to avoid API limits
|
||||
const batchSize = 10
|
||||
for (let i = 0; i < filteredCommits.length; i += batchSize) {
|
||||
const batch = filteredCommits.slice(i, i + batchSize)
|
||||
const batchEntries = await this.processBatch(batch)
|
||||
changelogEntries.push(...batchEntries)
|
||||
}
|
||||
|
||||
return changelogEntries
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commits to identify important ones
|
||||
*/
|
||||
private filterImportantCommits(commits: GitCommit[]): GitCommit[] {
|
||||
return commits.filter((commit) => {
|
||||
const message = `${commit.subject} ${commit.body}`.toLowerCase()
|
||||
|
||||
// Skip ignored patterns
|
||||
const shouldIgnore = [
|
||||
/^chore:/,
|
||||
/^docs:/,
|
||||
/^test:/,
|
||||
/^ci:/,
|
||||
/^build:/,
|
||||
/merge pull request/,
|
||||
/merge branch/,
|
||||
/version bump/,
|
||||
/changelog/,
|
||||
/update dependencies/,
|
||||
/^\d+\.\d+\.\d+/, // version numbers
|
||||
/^bump /,
|
||||
/^release\(/,
|
||||
].some((pattern) => pattern.test(message))
|
||||
|
||||
if (shouldIgnore) return false
|
||||
|
||||
// Include commits with significant changes
|
||||
const hasSignificantChange = [
|
||||
/\bfeat\b/,
|
||||
/\bfeature\b/,
|
||||
/\badd\b/,
|
||||
/\bimplement\b/,
|
||||
/\bintroduce\b/,
|
||||
/\bfix\b/,
|
||||
/\bbug\b/,
|
||||
/\bpatch\b/,
|
||||
/\bresolve\b/,
|
||||
/\bcorrect\b/,
|
||||
/\bimprove\b/,
|
||||
/\benhance\b/,
|
||||
/\boptimize\b/,
|
||||
/\brefactor\b/,
|
||||
/\bupdate\b/,
|
||||
/\bperf\b/,
|
||||
/\bbreaking\b/,
|
||||
/\bmajor\b/,
|
||||
/\bminor\b/,
|
||||
/\bui\b/,
|
||||
/\bux\b/,
|
||||
/\bdesign\b/,
|
||||
].some((pattern) => pattern.test(message))
|
||||
|
||||
return hasSignificantChange || commit.pullRequest
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of commits using AI
|
||||
*/
|
||||
private async processBatch(commits: GitCommit[]): Promise<ChangelogEntry[]> {
|
||||
const prompt = this.buildAnalysisPrompt(commits)
|
||||
|
||||
try {
|
||||
const aiResponse = await this.callAI(prompt)
|
||||
return this.parseAIResponse(aiResponse, commits)
|
||||
} catch (error) {
|
||||
console.warn("AI analysis failed, falling back to keyword-based categorization:", error)
|
||||
return this.fallbackCategorization(commits)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the analysis prompt for AI
|
||||
*/
|
||||
private buildAnalysisPrompt(commits: GitCommit[]): string {
|
||||
const commitsText = commits
|
||||
.map(
|
||||
(commit, index) =>
|
||||
`${index + 1}. [${commit.shortHash}] ${commit.subject}\n ${commit.body || "No description"}\n ${commit.pullRequest ? `PR #${commit.pullRequest}` : ""}`,
|
||||
)
|
||||
.join("\n\n")
|
||||
|
||||
return `You are a technical writer creating a changelog for a React Native RSS reader app called "Follow".
|
||||
|
||||
Analyze these git commits and categorize the most important user-facing changes into these sections:
|
||||
|
||||
**Shiny new things**: New features, major additions that users can see/use
|
||||
**Improvements**: Enhancements, optimizations, better UX/UI, performance improvements
|
||||
**No longer broken**: Bug fixes, crash fixes, issues resolved
|
||||
|
||||
For each relevant commit, provide:
|
||||
1. A user-friendly description (not technical jargon)
|
||||
2. Which section it belongs to
|
||||
3. Reference the commit hash or PR number if significant
|
||||
|
||||
Focus on changes that impact the user experience. Skip internal refactoring, dependency updates, or development-only changes unless they significantly affect users.
|
||||
|
||||
Commits to analyze:
|
||||
${commitsText}
|
||||
|
||||
Respond in this JSON format:
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"section": "Shiny new things",
|
||||
"description": "User-friendly description of what changed",
|
||||
"commitHash": "abc123",
|
||||
"pullRequest": "1234"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Only include meaningful user-facing changes. Be selective and focus on quality over quantity.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI API (mock implementation - you need to implement the actual API call)
|
||||
*/
|
||||
private async callAI(prompt: string): Promise<string> {
|
||||
// This is a mock implementation. You need to implement the actual AI API call
|
||||
// based on your chosen provider (OpenAI, Anthropic, etc.)
|
||||
|
||||
if (this.config.provider === "openai") {
|
||||
return this.callOpenAI(prompt)
|
||||
}
|
||||
|
||||
throw new Error(`AI provider "${this.config.provider}" not implemented`)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API call implementation
|
||||
*/
|
||||
private async callOpenAI(prompt: string): Promise<string> {
|
||||
if (!this.openaiClient.isAvailable()) {
|
||||
throw new Error("OpenAI API not available")
|
||||
}
|
||||
|
||||
console.info("🤖 Calling OpenAI API...")
|
||||
return await this.openaiClient.chat(prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response into changelog entries
|
||||
*/
|
||||
private parseAIResponse(response: string, commits: GitCommit[]): ChangelogEntry[] {
|
||||
try {
|
||||
const parsed = JSON.parse(response)
|
||||
return parsed.entries || []
|
||||
} catch {
|
||||
console.warn("Failed to parse AI response, using fallback")
|
||||
return this.fallbackCategorization(commits)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback categorization using keywords when AI fails
|
||||
*/
|
||||
private fallbackCategorization(commits: GitCommit[]): ChangelogEntry[] {
|
||||
return commits.map((commit) => {
|
||||
const message = `${commit.subject} ${commit.body}`.toLowerCase()
|
||||
|
||||
// Determine category based on keywords
|
||||
let section: ChangelogEntry["section"] = "Improvements"
|
||||
|
||||
if (this.categories.features?.keywords.some((keyword) => message.includes(keyword))) {
|
||||
section = "Shiny new things"
|
||||
} else if (this.categories.fixes?.keywords.some((keyword) => message.includes(keyword))) {
|
||||
section = "No longer broken"
|
||||
}
|
||||
|
||||
// Clean up the description
|
||||
let description = commit.subject
|
||||
if (
|
||||
description.startsWith("feat:") ||
|
||||
description.startsWith("fix:") ||
|
||||
description.startsWith("chore:")
|
||||
) {
|
||||
description = description.slice(Math.max(0, description.indexOf(":") + 1)).trim()
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
description = description.charAt(0).toUpperCase() + description.slice(1)
|
||||
|
||||
return {
|
||||
section,
|
||||
description,
|
||||
commitHash: commit.shortHash,
|
||||
pullRequest: commit.pullRequest,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default AIAgent
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { dirname, join } from "pathe"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const indexPath = join(__dirname, "..", "index.ts")
|
||||
|
||||
try {
|
||||
execSync(`tsx "${indexPath}"`, {
|
||||
stdio: "inherit",
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
} catch (error) {
|
||||
process.exit(error.status || 1)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
{
|
||||
"internalTeamMembers": [
|
||||
"Innei",
|
||||
"DIYgod",
|
||||
"hyoban",
|
||||
"renovate[bot]",
|
||||
"github-actions[bot]",
|
||||
"dependabot[bot]",
|
||||
"vercel[bot]",
|
||||
"whitewater"
|
||||
],
|
||||
"aiModel": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.3,
|
||||
"maxTokens": 1000,
|
||||
"apiKey": "",
|
||||
"baseURL": "https://api.openai.com/v1",
|
||||
"customEndpoint": ""
|
||||
},
|
||||
"commitAnalysis": {
|
||||
"categories": {
|
||||
"features": {
|
||||
"keywords": ["feat", "feature", "add", "implement", "introduce"],
|
||||
"section": "Shiny new things"
|
||||
},
|
||||
"improvements": {
|
||||
"keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"],
|
||||
"section": "Improvements"
|
||||
},
|
||||
"fixes": {
|
||||
"keywords": ["fix", "bug", "patch", "resolve", "correct"],
|
||||
"section": "No longer broken"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"^chore:",
|
||||
"^docs:",
|
||||
"^test:",
|
||||
"^ci:",
|
||||
"^build:",
|
||||
"Merge pull request",
|
||||
"Merge branch",
|
||||
"version bump",
|
||||
"changelog",
|
||||
"Update dependencies"
|
||||
]
|
||||
},
|
||||
"changelog": {
|
||||
"maxCommitsPerSection": 5,
|
||||
"includeCommitHash": true,
|
||||
"includePullRequestLinks": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { execSync } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { dirname, join } from "pathe"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = join(__dirname, "..", "..")
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
subject: string
|
||||
body: string
|
||||
author: string
|
||||
email: string
|
||||
date: string
|
||||
pullRequest?: string
|
||||
}
|
||||
|
||||
export interface GitTag {
|
||||
name: string
|
||||
hash: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export class GitTools {
|
||||
private workingDir: string
|
||||
|
||||
constructor(workingDir = projectRoot) {
|
||||
this.workingDir = workingDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute git command and return output
|
||||
*/
|
||||
private exec(command: string): string {
|
||||
try {
|
||||
return execSync(command, {
|
||||
cwd: this.workingDir,
|
||||
encoding: "utf-8",
|
||||
}).trim()
|
||||
} catch (error) {
|
||||
console.error(`Git command failed: ${command}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest tag for mobile releases
|
||||
*/
|
||||
getLatestMobileTag(): GitTag | null {
|
||||
try {
|
||||
const tags = this.exec('git tag --sort=-version:refname | grep "^mobile@" | head -1')
|
||||
if (!tags) return null
|
||||
|
||||
const tagName = tags.trim()
|
||||
const hash = this.exec(`git rev-list -n 1 ${tagName}`)
|
||||
const date = this.exec(`git log -1 --format=%ai ${hash}`)
|
||||
|
||||
return {
|
||||
name: tagName,
|
||||
hash,
|
||||
date,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
getCurrentBranch(): string {
|
||||
return this.exec("git rev-parse --abbrev-ref HEAD")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest commit hash on current branch
|
||||
*/
|
||||
getLatestCommitHash(): string {
|
||||
return this.exec("git rev-parse HEAD")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commits between two references
|
||||
*/
|
||||
getCommitsBetween(fromRef: string, toRef: string): GitCommit[] {
|
||||
const format = [
|
||||
"%H", // full hash
|
||||
"%h", // short hash
|
||||
"%s", // subject
|
||||
"%b", // body
|
||||
"%an", // author name
|
||||
"%ae", // author email
|
||||
"%ai", // author date
|
||||
].join("%x1f") // use ASCII unit separator
|
||||
|
||||
const command = `git log --format="${format}%x1e" ${fromRef}..${toRef}`
|
||||
const output = this.exec(command)
|
||||
|
||||
if (!output) return []
|
||||
|
||||
return output
|
||||
.split("\x1e")
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const parts = entry.split("\x1f")
|
||||
const [hash, shortHash, subject, body, author, email, date] = parts
|
||||
|
||||
// Ensure all required fields exist
|
||||
if (!hash || !shortHash || !subject || !author || !email || !date) {
|
||||
throw new Error("Invalid git log entry format")
|
||||
}
|
||||
|
||||
// Check if commit is related to a pull request
|
||||
const pullRequest = this.extractPullRequestNumber(subject, body || "")
|
||||
|
||||
return {
|
||||
hash: hash.trim(),
|
||||
shortHash: shortHash.trim(),
|
||||
subject: subject.trim(),
|
||||
body: (body || "").trim(),
|
||||
author: author.trim(),
|
||||
email: email.trim(),
|
||||
date: date.trim(),
|
||||
pullRequest,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pull request number from commit message
|
||||
*/
|
||||
private extractPullRequestNumber(subject: string, body: string): string | undefined {
|
||||
const text = `${subject} ${body}`
|
||||
const prMatch = text.match(/#(\d+)/)
|
||||
return prMatch ? prMatch[1] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique authors from commits, excluding bots and internal members
|
||||
*/
|
||||
getUniqueAuthors(commits: GitCommit[], internalMembers: string[]): string[] {
|
||||
const authors = new Set<string>()
|
||||
|
||||
commits.forEach((commit) => {
|
||||
const author = commit.author.toLowerCase()
|
||||
const isBotOrInternal =
|
||||
author.includes("[bot]") ||
|
||||
author.includes("bot") ||
|
||||
internalMembers.some((member) => author.includes(member.toLowerCase()))
|
||||
|
||||
if (!isBotOrInternal) {
|
||||
authors.add(commit.author)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(authors).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're on a release branch
|
||||
*/
|
||||
isOnReleaseBranch(): boolean {
|
||||
const currentBranch = this.getCurrentBranch()
|
||||
return currentBranch.startsWith("release/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version from release branch name
|
||||
*/
|
||||
getVersionFromBranch(): string | null {
|
||||
const currentBranch = this.getCurrentBranch()
|
||||
const match = currentBranch.match(/release\/mobile\/(.+)/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export default GitTools
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
import "dotenv/config"
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { dirname, join } from "pathe"
|
||||
|
||||
import type { ChangelogEntry } from "./ai-agent"
|
||||
import AIAgent from "./ai-agent"
|
||||
import GitTools from "./git-tools"
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const changelogDir = join(__dirname, "..", "..", "apps", "mobile", "changelog")
|
||||
const configPath = join(__dirname, "config.json")
|
||||
|
||||
interface Config {
|
||||
internalTeamMembers: string[]
|
||||
aiModel: {
|
||||
provider: string
|
||||
model: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
baseURL?: string
|
||||
customEndpoint?: string
|
||||
}
|
||||
commitAnalysis: {
|
||||
categories: Record<
|
||||
string,
|
||||
{
|
||||
keywords: string[]
|
||||
section: string
|
||||
}
|
||||
>
|
||||
ignorePatterns: string[]
|
||||
}
|
||||
changelog: {
|
||||
maxCommitsPerSection: number
|
||||
includeCommitHash: boolean
|
||||
includePullRequestLinks: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from file
|
||||
*/
|
||||
function loadConfig(): Config {
|
||||
try {
|
||||
const configContent = readFileSync(configPath, "utf-8")
|
||||
return JSON.parse(configContent)
|
||||
} catch (error) {
|
||||
console.error("Failed to load config file:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format changelog entry for output
|
||||
*/
|
||||
function formatChangelogEntry(entry: ChangelogEntry, config: Config): string {
|
||||
let line = `- ${entry.description}`
|
||||
|
||||
if (config.changelog.includePullRequestLinks && entry.pullRequest) {
|
||||
line += `(#${entry.pullRequest})`
|
||||
} else if (config.changelog.includeCommitHash && entry.commitHash) {
|
||||
line += `(${entry.commitHash})`
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* Group changelog entries by section
|
||||
*/
|
||||
function groupEntriesBySection(entries: ChangelogEntry[]): Record<string, ChangelogEntry[]> {
|
||||
const grouped: Record<string, ChangelogEntry[]> = {
|
||||
"Shiny new things": [],
|
||||
Improvements: [],
|
||||
"No longer broken": [],
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (grouped[entry.section]) {
|
||||
grouped[entry.section]!.push(entry)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the changelog content
|
||||
*/
|
||||
function generateChangelogContent(
|
||||
version: string,
|
||||
entries: ChangelogEntry[],
|
||||
contributors: string[],
|
||||
config: Config,
|
||||
): string {
|
||||
const groupedEntries = groupEntriesBySection(entries)
|
||||
|
||||
let content = `# What's New in v${version}\n\n`
|
||||
|
||||
// Add sections with entries
|
||||
Object.entries(groupedEntries).forEach(([section, sectionEntries]) => {
|
||||
content += `## ${section}\n\n`
|
||||
|
||||
if (sectionEntries.length > 0) {
|
||||
// Limit entries per section
|
||||
const limitedEntries = sectionEntries.slice(0, config.changelog.maxCommitsPerSection)
|
||||
limitedEntries.forEach((entry) => {
|
||||
content += `${formatChangelogEntry(entry, config)}\n`
|
||||
})
|
||||
}
|
||||
|
||||
content += "\n"
|
||||
})
|
||||
|
||||
// Add contributors section
|
||||
content += "## Thanks\n\n"
|
||||
if (contributors.length > 0) {
|
||||
const contributorList = contributors.map((name) => `@${name}`).join(" ")
|
||||
content += `Special thanks to volunteer contributors ${contributorList} for their valuable contributions\n`
|
||||
} else {
|
||||
content += "Special thanks to volunteer contributors @ for their valuable contributions\n"
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to generate changelog
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
console.info("🤖 Starting AI-driven changelog generation...")
|
||||
|
||||
// Load configuration
|
||||
const config = loadConfig()
|
||||
console.info("✅ Configuration loaded")
|
||||
|
||||
// Initialize Git tools
|
||||
const git = new GitTools()
|
||||
|
||||
// Check if we're on a release branch
|
||||
if (!git.isOnReleaseBranch()) {
|
||||
console.error("❌ This script should only be run on a release branch (release/mobile/*)")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get version from branch name
|
||||
const version = git.getVersionFromBranch()
|
||||
if (!version) {
|
||||
console.error("❌ Could not extract version from branch name")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.info(`📝 Generating changelog for version: ${version}`)
|
||||
|
||||
// Get the latest mobile tag
|
||||
const latestTag = git.getLatestMobileTag()
|
||||
if (!latestTag) {
|
||||
console.error("❌ No previous mobile tags found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.info(`📌 Latest tag: ${latestTag.name}`)
|
||||
|
||||
// Get commits between latest tag and current HEAD
|
||||
const currentCommit = git.getLatestCommitHash()
|
||||
const commits = git.getCommitsBetween(latestTag.hash, currentCommit)
|
||||
|
||||
console.info(`🔍 Found ${commits.length} commits since last release`)
|
||||
|
||||
if (commits.length === 0) {
|
||||
console.warn("⚠️ No new commits found since last release")
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize AI agent
|
||||
const aiAgent = new AIAgent(config.aiModel, config.commitAnalysis.categories as any)
|
||||
|
||||
// Analyze commits with AI
|
||||
const changelogEntries = await aiAgent.analyzeCommits(commits)
|
||||
console.info(`✨ Generated ${changelogEntries.length} changelog entries`)
|
||||
|
||||
// Get contributors
|
||||
const contributors = git.getUniqueAuthors(commits, config.internalTeamMembers)
|
||||
console.info(`👥 Found ${contributors.length} external contributors`)
|
||||
|
||||
// Generate changelog content
|
||||
const changelogContent = generateChangelogContent(
|
||||
version,
|
||||
changelogEntries,
|
||||
contributors,
|
||||
config,
|
||||
)
|
||||
|
||||
// Write to next.md
|
||||
const nextFilePath = join(changelogDir, "next.md")
|
||||
writeFileSync(nextFilePath, changelogContent, "utf-8")
|
||||
|
||||
console.info("✅ Changelog generated successfully!")
|
||||
console.info(`📄 Updated: ${nextFilePath}`)
|
||||
|
||||
// Show preview
|
||||
console.info("\n📋 Preview:")
|
||||
console.info("─".repeat(50))
|
||||
console.info(changelogContent)
|
||||
console.info("─".repeat(50))
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to generate changelog:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script if called directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main()
|
||||
}
|
||||
|
||||
export default main
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
export interface OpenAIConfig {
|
||||
apiKey?: string
|
||||
model: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
baseURL?: string
|
||||
customEndpoint?: string
|
||||
}
|
||||
|
||||
export class OpenAIClient {
|
||||
private config: OpenAIConfig
|
||||
private apiKey: string
|
||||
private baseURL: string
|
||||
|
||||
constructor(config: OpenAIConfig) {
|
||||
this.config = config
|
||||
this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || ""
|
||||
|
||||
// Support custom endpoints
|
||||
if (config.customEndpoint) {
|
||||
this.baseURL = config.customEndpoint
|
||||
} else if (config.baseURL) {
|
||||
this.baseURL = config.baseURL
|
||||
} else {
|
||||
this.baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
console.warn("⚠️ No OpenAI API key provided. Using fallback analysis.")
|
||||
}
|
||||
|
||||
if (config.customEndpoint) {
|
||||
console.info(`🔗 Using custom OpenAI endpoint: ${this.baseURL}`)
|
||||
}
|
||||
}
|
||||
|
||||
async chat(prompt: string): Promise<string> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OpenAI API key not provided")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: this.config.temperature,
|
||||
max_tokens: this.config.maxTokens,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
throw new Error("No response from OpenAI API")
|
||||
}
|
||||
|
||||
return data.choices[0].message.content
|
||||
} catch (error) {
|
||||
console.error("OpenAI API call failed:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return Boolean(this.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenAIClient
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"name": "@follow/changelog-cli",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "AI-driven changelog generator for Follow project",
|
||||
"keywords": [
|
||||
"changelog",
|
||||
"git",
|
||||
"ai",
|
||||
"cli",
|
||||
"automation"
|
||||
],
|
||||
"main": "./index.ts",
|
||||
"bin": {
|
||||
"changelog-cli": "./bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx index.ts",
|
||||
"generate": "tsx index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "17.2.0",
|
||||
"pathe": "2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"extends": "../configs/tsconfig.extend.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type * as React from "react"
|
||||
import type { CSSProperties } from "react"
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from "react"
|
||||
import { useLayoutEffect, useMemo, useRef } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
type Target = HTMLElement | null | string | (() => HTMLElement | null | undefined) | undefined
|
||||
|
|
@ -40,13 +40,16 @@ export function ReparentPortal({
|
|||
const hostEl = useMemo(() => {
|
||||
const el = document.createElement(hostTag)
|
||||
if (debugName) el.dataset.reparentPortal = debugName
|
||||
if (hostClassName != null) el.className = hostClassName
|
||||
if (hostStyle != null) Object.assign(el.style, hostStyle)
|
||||
|
||||
return el
|
||||
}, [hostTag, debugName])
|
||||
|
||||
const lastParentRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Sync styles/classes to hostEl
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (hostClassName != null) hostEl.className = hostClassName
|
||||
if (hostStyle != null) Object.assign(hostEl.style, hostStyle)
|
||||
}, [hostEl, hostClassName, hostStyle])
|
||||
|
|
|
|||
Loading…
Reference in New Issue