lazyreview 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.github/workflows/publish-npm.yml +51 -0
  2. package/.prettierrc +7 -0
  3. package/CLAUDE.md +50 -0
  4. package/README.md +119 -0
  5. package/dist/cli.js +3830 -0
  6. package/dist/cli.js.map +1 -0
  7. package/package.json +64 -0
  8. package/pnpm-workspace.yaml +5 -0
  9. package/src/app.tsx +235 -0
  10. package/src/cli.tsx +78 -0
  11. package/src/components/common/BorderedBox.tsx +41 -0
  12. package/src/components/common/Divider.tsx +31 -0
  13. package/src/components/common/EmptyState.tsx +35 -0
  14. package/src/components/common/FilterModal.tsx +117 -0
  15. package/src/components/common/LoadingIndicator.tsx +31 -0
  16. package/src/components/common/Modal.tsx +24 -0
  17. package/src/components/common/PaginationBar.tsx +56 -0
  18. package/src/components/common/SortModal.tsx +91 -0
  19. package/src/components/common/Spinner.tsx +28 -0
  20. package/src/components/common/index.ts +9 -0
  21. package/src/components/layout/HelpModal.tsx +61 -0
  22. package/src/components/layout/MainPanel.tsx +26 -0
  23. package/src/components/layout/Sidebar.tsx +71 -0
  24. package/src/components/layout/StatusBar.tsx +42 -0
  25. package/src/components/layout/TokenInputModal.tsx +92 -0
  26. package/src/components/layout/TopBar.tsx +44 -0
  27. package/src/components/layout/index.ts +6 -0
  28. package/src/components/pr/CommentsTab.tsx +92 -0
  29. package/src/components/pr/CommitsTab.tsx +142 -0
  30. package/src/components/pr/ConversationsTab.tsx +273 -0
  31. package/src/components/pr/FilesTab.tsx +532 -0
  32. package/src/components/pr/PRHeader.tsx +69 -0
  33. package/src/components/pr/PRListItem.tsx +92 -0
  34. package/src/components/pr/PRTabs.tsx +50 -0
  35. package/src/components/pr/index.ts +8 -0
  36. package/src/hooks/index.ts +12 -0
  37. package/src/hooks/useActivePanel.ts +54 -0
  38. package/src/hooks/useAppKeymap.ts +22 -0
  39. package/src/hooks/useAuth.ts +131 -0
  40. package/src/hooks/useConfig.ts +52 -0
  41. package/src/hooks/useFilter.ts +192 -0
  42. package/src/hooks/useGitHub.ts +260 -0
  43. package/src/hooks/useInputFocus.tsx +35 -0
  44. package/src/hooks/useListNavigation.ts +121 -0
  45. package/src/hooks/useLoading.ts +25 -0
  46. package/src/hooks/usePagination.ts +87 -0
  47. package/src/models/comment.ts +15 -0
  48. package/src/models/commit.ts +20 -0
  49. package/src/models/diff.ts +93 -0
  50. package/src/models/errors.ts +24 -0
  51. package/src/models/file-change.ts +22 -0
  52. package/src/models/index.ts +12 -0
  53. package/src/models/pull-request.ts +40 -0
  54. package/src/models/review.ts +17 -0
  55. package/src/models/user.ts +9 -0
  56. package/src/screens/InvolvedScreen.tsx +161 -0
  57. package/src/screens/MyPRsScreen.tsx +161 -0
  58. package/src/screens/PRDetailScreen.tsx +88 -0
  59. package/src/screens/PRListScreen.tsx +96 -0
  60. package/src/screens/ReviewRequestsScreen.tsx +161 -0
  61. package/src/screens/SettingsScreen.tsx +284 -0
  62. package/src/screens/ThisRepoScreen.tsx +175 -0
  63. package/src/screens/index.ts +7 -0
  64. package/src/services/Auth.ts +314 -0
  65. package/src/services/Config.ts +81 -0
  66. package/src/services/GitHubApi.ts +262 -0
  67. package/src/services/Loading.ts +54 -0
  68. package/src/services/index.ts +27 -0
  69. package/src/theme/index.ts +28 -0
  70. package/src/theme/themes.ts +84 -0
  71. package/src/theme/types.ts +28 -0
  72. package/src/utils/date.ts +28 -0
  73. package/src/utils/git.ts +67 -0
  74. package/src/utils/index.ts +2 -0
  75. package/src/utils/terminal.ts +25 -0
  76. package/tsconfig.json +24 -0
  77. package/tsup.config.ts +13 -0
  78. package/vitest.config.ts +26 -0
@@ -0,0 +1,50 @@
1
+ import React from 'react'
2
+ import { Box } from 'ink'
3
+ import { Tab, Tabs } from 'ink-tab'
4
+ import { useTheme } from '../../theme/index'
5
+
6
+ export const PR_TAB_NAMES = ['Conversations', 'Commits', 'Files'] as const
7
+ export type PRTabName = (typeof PR_TAB_NAMES)[number]
8
+
9
+ interface PRTabsProps {
10
+ readonly activeIndex: number
11
+ readonly onChange: (index: number) => void
12
+ }
13
+
14
+ export function PRTabs({ activeIndex, onChange }: PRTabsProps): React.ReactElement {
15
+ const theme = useTheme()
16
+
17
+ return (
18
+ <Box
19
+ flexDirection="row"
20
+ paddingX={1}
21
+ paddingY={1}
22
+ borderStyle="single"
23
+ borderColor={theme.colors.border}
24
+ >
25
+ <Tabs
26
+ key={activeIndex}
27
+ defaultValue={PR_TAB_NAMES[activeIndex]}
28
+ onChange={(name) => {
29
+ const index = PR_TAB_NAMES.indexOf(name as PRTabName)
30
+ if (index >= 0) onChange(index)
31
+ }}
32
+ showIndex={true}
33
+ isFocused={true}
34
+ colors={{
35
+ activeTab: {
36
+ color: theme.colors.accent,
37
+ backgroundColor: theme.colors.bg,
38
+ },
39
+ }}
40
+ keyMap={{ useNumbers: true, useTab: true }}
41
+ >
42
+ {PR_TAB_NAMES.map((name) => (
43
+ <Tab key={name} name={name}>
44
+ {name}
45
+ </Tab>
46
+ ))}
47
+ </Tabs>
48
+ </Box>
49
+ )
50
+ }
@@ -0,0 +1,8 @@
1
+ export { PRListItem } from './PRListItem'
2
+ export { PRHeader } from './PRHeader'
3
+ export { PRTabs, PR_TAB_NAMES } from './PRTabs'
4
+ export type { PRTabName } from './PRTabs'
5
+ export { FilesTab } from './FilesTab'
6
+ export { CommentsTab } from './CommentsTab'
7
+ export { ConversationsTab } from './ConversationsTab'
8
+ export { CommitsTab } from './CommitsTab'
@@ -0,0 +1,12 @@
1
+ export { useAuth } from './useAuth'
2
+ export { useConfig } from './useConfig'
3
+ export { useGitHub } from './useGitHub'
4
+ export { useLoading, setLoadingService, getLoadingService } from './useLoading'
5
+ export { useAppKeymap } from './useAppKeymap'
6
+ export { useTheme } from '../theme/index'
7
+ export { useListNavigation } from './useListNavigation'
8
+ export { useActivePanel } from './useActivePanel'
9
+ export { usePagination } from './usePagination'
10
+ export { useFilter } from './useFilter'
11
+ export type { Panel } from './useActivePanel'
12
+ export type { SortField, SortDirection, FilterState } from './useFilter'
@@ -0,0 +1,54 @@
1
+ import { useInput } from 'ink'
2
+ import { useCallback, useEffect, useState } from 'react'
3
+
4
+ export type Panel = 'sidebar' | 'list' | 'detail'
5
+
6
+ interface UseActivePanelOptions {
7
+ readonly hasSelection: boolean
8
+ }
9
+
10
+ interface UseActivePanelResult {
11
+ readonly activePanel: Panel
12
+ readonly setActivePanel: (panel: Panel) => void
13
+ }
14
+
15
+ export function useActivePanel({
16
+ hasSelection,
17
+ }: UseActivePanelOptions): UseActivePanelResult {
18
+ const [activePanel, setActivePanel] = useState<Panel>('sidebar')
19
+
20
+ // Reset to list when selection is lost
21
+ useEffect(() => {
22
+ if (!hasSelection && activePanel === 'detail') {
23
+ setActivePanel('list')
24
+ }
25
+ }, [hasSelection, activePanel])
26
+
27
+ const handleEscape = useCallback(() => {
28
+ if (activePanel === 'detail') {
29
+ setActivePanel('list')
30
+ } else if (activePanel === 'list') {
31
+ setActivePanel('sidebar')
32
+ }
33
+ }, [activePanel])
34
+
35
+ const handleTab = useCallback(() => {
36
+ if (activePanel === 'sidebar') {
37
+ setActivePanel('list')
38
+ } else if (activePanel === 'list' && hasSelection) {
39
+ setActivePanel('detail')
40
+ } else if (activePanel === 'detail') {
41
+ setActivePanel('sidebar')
42
+ }
43
+ }, [activePanel, hasSelection])
44
+
45
+ useInput((_input, key) => {
46
+ if (key.escape) {
47
+ handleEscape()
48
+ } else if (key.tab) {
49
+ handleTab()
50
+ }
51
+ })
52
+
53
+ return { activePanel, setActivePanel }
54
+ }
@@ -0,0 +1,22 @@
1
+ import { useInput } from 'ink'
2
+
3
+ interface AppKeymapActions {
4
+ readonly toggleSidebar: () => void
5
+ readonly toggleHelp: () => void
6
+ readonly quit: () => void
7
+ readonly switchFocus: () => void
8
+ }
9
+
10
+ export function useAppKeymap(actions: AppKeymapActions): void {
11
+ useInput((input, key) => {
12
+ if (input === 'b') {
13
+ actions.toggleSidebar()
14
+ } else if (input === '?') {
15
+ actions.toggleHelp()
16
+ } else if (input === 'q') {
17
+ actions.quit()
18
+ } else if (key.tab) {
19
+ actions.switchFocus()
20
+ }
21
+ })
22
+ }
@@ -0,0 +1,131 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import { Effect } from 'effect'
3
+ import { Auth, AuthLive } from '../services/Auth'
4
+ import type { TokenSource, TokenInfo } from '../services/Auth'
5
+ import type { User } from '../models/user'
6
+
7
+ interface UseAuthReturn {
8
+ readonly user: User | null
9
+ readonly isAuthenticated: boolean
10
+ readonly error: string | null
11
+ readonly loading: boolean
12
+ readonly saveToken: (token: string) => Promise<void>
13
+ readonly isSavingToken: boolean
14
+ readonly tokenInfo: TokenInfo | null
15
+ readonly availableSources: TokenSource[]
16
+ readonly setPreferredSource: (source: TokenSource) => Promise<void>
17
+ readonly clearManualToken: () => Promise<void>
18
+ readonly refetch: () => void
19
+ }
20
+
21
+ export function useAuth(): UseAuthReturn {
22
+ const queryClient = useQueryClient()
23
+
24
+ const { data, error, isLoading, refetch } = useQuery({
25
+ queryKey: ['auth'],
26
+ queryFn: () =>
27
+ Effect.runPromise(
28
+ Effect.gen(function* () {
29
+ const auth = yield* Auth
30
+ const authenticated = yield* auth.isAuthenticated()
31
+
32
+ if (!authenticated) {
33
+ return { user: null, isAuthenticated: false }
34
+ }
35
+
36
+ const currentUser = yield* auth.getUser()
37
+ return { user: currentUser, isAuthenticated: true }
38
+ }).pipe(Effect.provide(AuthLive)),
39
+ ),
40
+ staleTime: Infinity,
41
+ })
42
+
43
+ const { data: tokenInfoData } = useQuery({
44
+ queryKey: ['tokenInfo'],
45
+ queryFn: () =>
46
+ Effect.runPromise(
47
+ Effect.gen(function* () {
48
+ const auth = yield* Auth
49
+ return yield* auth.getTokenInfo()
50
+ }).pipe(Effect.provide(AuthLive)),
51
+ ),
52
+ staleTime: 0, // Always refetch
53
+ })
54
+
55
+ const { data: availableSourcesData } = useQuery({
56
+ queryKey: ['availableSources'],
57
+ queryFn: () =>
58
+ Effect.runPromise(
59
+ Effect.gen(function* () {
60
+ const auth = yield* Auth
61
+ return yield* auth.getAvailableSources()
62
+ }).pipe(Effect.provide(AuthLive)),
63
+ ),
64
+ staleTime: 0,
65
+ })
66
+
67
+ const saveTokenMutation = useMutation({
68
+ mutationFn: async (token: string) => {
69
+ await Effect.runPromise(
70
+ Effect.gen(function* () {
71
+ const auth = yield* Auth
72
+ yield* auth.setToken(token)
73
+ }).pipe(Effect.provide(AuthLive)),
74
+ )
75
+ },
76
+ onSuccess: () => {
77
+ queryClient.invalidateQueries({ queryKey: ['auth'] })
78
+ queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
79
+ queryClient.invalidateQueries({ queryKey: ['availableSources'] })
80
+ },
81
+ })
82
+
83
+ const setPreferredSourceMutation = useMutation({
84
+ mutationFn: async (source: TokenSource) => {
85
+ await Effect.runPromise(
86
+ Effect.gen(function* () {
87
+ const auth = yield* Auth
88
+ yield* auth.setPreferredSource(source)
89
+ }).pipe(Effect.provide(AuthLive)),
90
+ )
91
+ },
92
+ onSuccess: () => {
93
+ queryClient.invalidateQueries({ queryKey: ['auth'] })
94
+ queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
95
+ },
96
+ })
97
+
98
+ const clearManualTokenMutation = useMutation({
99
+ mutationFn: async () => {
100
+ await Effect.runPromise(
101
+ Effect.gen(function* () {
102
+ const auth = yield* Auth
103
+ yield* auth.clearManualToken()
104
+ }).pipe(Effect.provide(AuthLive)),
105
+ )
106
+ },
107
+ onSuccess: () => {
108
+ queryClient.invalidateQueries({ queryKey: ['auth'] })
109
+ queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
110
+ queryClient.invalidateQueries({ queryKey: ['availableSources'] })
111
+ },
112
+ })
113
+
114
+ return {
115
+ user: data?.user ?? null,
116
+ isAuthenticated: data?.isAuthenticated ?? false,
117
+ error: error ? String(error) : null,
118
+ loading: isLoading,
119
+ saveToken: saveTokenMutation.mutateAsync,
120
+ isSavingToken: saveTokenMutation.isPending,
121
+ tokenInfo: tokenInfoData ?? null,
122
+ availableSources: availableSourcesData ?? [],
123
+ setPreferredSource: setPreferredSourceMutation.mutateAsync,
124
+ clearManualToken: clearManualTokenMutation.mutateAsync,
125
+ refetch: () => {
126
+ refetch()
127
+ queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
128
+ queryClient.invalidateQueries({ queryKey: ['availableSources'] })
129
+ },
130
+ }
131
+ }
@@ -0,0 +1,52 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import { Effect } from 'effect'
3
+ import { Config, ConfigLive, type AppConfig } from '../services/Config'
4
+
5
+ interface UseConfigReturn {
6
+ readonly config: AppConfig | null
7
+ readonly error: string | null
8
+ readonly loading: boolean
9
+ readonly updateConfig: (updates: Partial<AppConfig>) => void
10
+ }
11
+
12
+ export function useConfig(): UseConfigReturn {
13
+ const queryClient = useQueryClient()
14
+
15
+ const { data, error, isLoading } = useQuery({
16
+ queryKey: ['config'],
17
+ queryFn: () =>
18
+ Effect.runPromise(
19
+ Effect.gen(function* () {
20
+ const configService = yield* Config
21
+ return yield* configService.load()
22
+ }).pipe(Effect.provide(ConfigLive)),
23
+ ),
24
+ })
25
+
26
+ const mutation = useMutation({
27
+ mutationFn: (newConfig: AppConfig) =>
28
+ Effect.runPromise(
29
+ Effect.gen(function* () {
30
+ const configService = yield* Config
31
+ yield* configService.save(newConfig)
32
+ }).pipe(Effect.provide(ConfigLive)),
33
+ ),
34
+ onSuccess: () => {
35
+ queryClient.invalidateQueries({ queryKey: ['config'] })
36
+ },
37
+ })
38
+
39
+ const updateConfig = (updates: Partial<AppConfig>) => {
40
+ if (!data) return
41
+ const newConfig = { ...data, ...updates } as AppConfig
42
+ queryClient.setQueryData(['config'], newConfig)
43
+ mutation.mutate(newConfig)
44
+ }
45
+
46
+ return {
47
+ config: data ?? null,
48
+ error: error ? String(error) : null,
49
+ loading: isLoading,
50
+ updateConfig,
51
+ }
52
+ }
@@ -0,0 +1,192 @@
1
+ import { useState, useMemo, useCallback } from 'react'
2
+ import type { PullRequest } from '../models/pull-request'
3
+
4
+ export type SortField = 'updated' | 'created' | 'repo' | 'author' | 'title'
5
+ export type SortDirection = 'asc' | 'desc'
6
+
7
+ export interface FilterState {
8
+ readonly search: string
9
+ readonly repo: string | null
10
+ readonly author: string | null
11
+ readonly label: string | null
12
+ readonly sortBy: SortField
13
+ readonly sortDirection: SortDirection
14
+ }
15
+
16
+ const defaultFilter: FilterState = {
17
+ search: '',
18
+ repo: null,
19
+ author: null,
20
+ label: null,
21
+ sortBy: 'updated',
22
+ sortDirection: 'desc',
23
+ }
24
+
25
+ function extractRepoFromUrl(url: string): string | null {
26
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)\/pull/)
27
+ return match?.[1] ?? null
28
+ }
29
+
30
+ function matchesSearch(pr: PullRequest, search: string): boolean {
31
+ if (!search) return true
32
+ const lowerSearch = search.toLowerCase()
33
+ return (
34
+ pr.title.toLowerCase().includes(lowerSearch) ||
35
+ pr.user.login.toLowerCase().includes(lowerSearch) ||
36
+ String(pr.number).includes(lowerSearch)
37
+ )
38
+ }
39
+
40
+ function matchesRepo(pr: PullRequest, repo: string | null): boolean {
41
+ if (!repo) return true
42
+ const prRepo = extractRepoFromUrl(pr.html_url)
43
+ return prRepo?.toLowerCase().includes(repo.toLowerCase()) ?? false
44
+ }
45
+
46
+ function matchesAuthor(pr: PullRequest, author: string | null): boolean {
47
+ if (!author) return true
48
+ return pr.user.login.toLowerCase().includes(author.toLowerCase())
49
+ }
50
+
51
+ function matchesLabel(pr: PullRequest, label: string | null): boolean {
52
+ if (!label) return true
53
+ const lowerLabel = label.toLowerCase()
54
+ return pr.labels.some((l) => l.name.toLowerCase().includes(lowerLabel))
55
+ }
56
+
57
+ function comparePRs(
58
+ a: PullRequest,
59
+ b: PullRequest,
60
+ sortBy: SortField,
61
+ sortDirection: SortDirection,
62
+ ): number {
63
+ let comparison = 0
64
+
65
+ switch (sortBy) {
66
+ case 'updated':
67
+ comparison =
68
+ new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
69
+ break
70
+ case 'created':
71
+ comparison =
72
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
73
+ break
74
+ case 'repo': {
75
+ const repoA = extractRepoFromUrl(a.html_url) ?? ''
76
+ const repoB = extractRepoFromUrl(b.html_url) ?? ''
77
+ comparison = repoA.localeCompare(repoB)
78
+ break
79
+ }
80
+ case 'author':
81
+ comparison = a.user.login.localeCompare(b.user.login)
82
+ break
83
+ case 'title':
84
+ comparison = a.title.localeCompare(b.title)
85
+ break
86
+ }
87
+
88
+ return sortDirection === 'asc' ? -comparison : comparison
89
+ }
90
+
91
+ interface UseFilterResult {
92
+ readonly filter: FilterState
93
+ readonly filteredItems: readonly PullRequest[]
94
+ readonly setSearch: (search: string) => void
95
+ readonly setRepo: (repo: string | null) => void
96
+ readonly setAuthor: (author: string | null) => void
97
+ readonly setLabel: (label: string | null) => void
98
+ readonly setSortBy: (sortBy: SortField) => void
99
+ readonly toggleSortDirection: () => void
100
+ readonly clearFilters: () => void
101
+ readonly hasActiveFilters: boolean
102
+ readonly availableRepos: readonly string[]
103
+ readonly availableAuthors: readonly string[]
104
+ readonly availableLabels: readonly string[]
105
+ }
106
+
107
+ export function useFilter(items: readonly PullRequest[]): UseFilterResult {
108
+ const [filter, setFilter] = useState<FilterState>(defaultFilter)
109
+
110
+ const availableRepos = useMemo(() => {
111
+ const repos = new Set<string>()
112
+ items.forEach((pr) => {
113
+ const repo = extractRepoFromUrl(pr.html_url)
114
+ if (repo) repos.add(repo)
115
+ })
116
+ return Array.from(repos).sort()
117
+ }, [items])
118
+
119
+ const availableAuthors = useMemo(() => {
120
+ const authors = new Set<string>()
121
+ items.forEach((pr) => authors.add(pr.user.login))
122
+ return Array.from(authors).sort()
123
+ }, [items])
124
+
125
+ const availableLabels = useMemo(() => {
126
+ const labels = new Set<string>()
127
+ items.forEach((pr) => pr.labels.forEach((l) => labels.add(l.name)))
128
+ return Array.from(labels).sort()
129
+ }, [items])
130
+
131
+ const filteredItems = useMemo(() => {
132
+ return items
133
+ .filter((pr) => matchesSearch(pr, filter.search))
134
+ .filter((pr) => matchesRepo(pr, filter.repo))
135
+ .filter((pr) => matchesAuthor(pr, filter.author))
136
+ .filter((pr) => matchesLabel(pr, filter.label))
137
+ .sort((a, b) => comparePRs(a, b, filter.sortBy, filter.sortDirection))
138
+ }, [items, filter])
139
+
140
+ const setSearch = useCallback((search: string) => {
141
+ setFilter((prev) => ({ ...prev, search }))
142
+ }, [])
143
+
144
+ const setRepo = useCallback((repo: string | null) => {
145
+ setFilter((prev) => ({ ...prev, repo }))
146
+ }, [])
147
+
148
+ const setAuthor = useCallback((author: string | null) => {
149
+ setFilter((prev) => ({ ...prev, author }))
150
+ }, [])
151
+
152
+ const setLabel = useCallback((label: string | null) => {
153
+ setFilter((prev) => ({ ...prev, label }))
154
+ }, [])
155
+
156
+ const setSortBy = useCallback((sortBy: SortField) => {
157
+ setFilter((prev) => ({ ...prev, sortBy }))
158
+ }, [])
159
+
160
+ const toggleSortDirection = useCallback(() => {
161
+ setFilter((prev) => ({
162
+ ...prev,
163
+ sortDirection: prev.sortDirection === 'asc' ? 'desc' : 'asc',
164
+ }))
165
+ }, [])
166
+
167
+ const clearFilters = useCallback(() => {
168
+ setFilter(defaultFilter)
169
+ }, [])
170
+
171
+ const hasActiveFilters =
172
+ filter.search !== '' ||
173
+ filter.repo !== null ||
174
+ filter.author !== null ||
175
+ filter.label !== null
176
+
177
+ return {
178
+ filter,
179
+ filteredItems,
180
+ setSearch,
181
+ setRepo,
182
+ setAuthor,
183
+ setLabel,
184
+ setSortBy,
185
+ toggleSortDirection,
186
+ clearFilters,
187
+ hasActiveFilters,
188
+ availableRepos,
189
+ availableAuthors,
190
+ availableLabels,
191
+ }
192
+ }