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,260 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Effect } from 'effect'
3
+ import { GitHubApi, type ListPRsOptions } from '../services/GitHubApi'
4
+ import { AppLayer } from '../services/index'
5
+ import type { PullRequest } from '../models/pull-request'
6
+ import type { FileChange } from '../models/file-change'
7
+ import type { Comment } from '../models/comment'
8
+ import type { Review } from '../models/review'
9
+ import type { Commit } from '../models/commit'
10
+
11
+ function runEffect<A>(
12
+ effect: Effect.Effect<A, unknown, unknown>,
13
+ ): Promise<A> {
14
+ return Effect.runPromise(
15
+ effect.pipe(Effect.provide(AppLayer)) as Effect.Effect<A, never, never>,
16
+ )
17
+ }
18
+
19
+ export function usePullRequests(
20
+ owner: string,
21
+ repo: string,
22
+ options?: ListPRsOptions,
23
+ ) {
24
+ return useQuery({
25
+ queryKey: ['prs', owner, repo, options],
26
+ queryFn: () =>
27
+ runEffect(
28
+ Effect.gen(function* () {
29
+ const api = yield* GitHubApi
30
+ return yield* api.listPullRequests(owner, repo, options)
31
+ }),
32
+ ),
33
+ enabled: !!owner && !!repo,
34
+ })
35
+ }
36
+
37
+ export function usePullRequest(owner: string, repo: string, number: number) {
38
+ return useQuery({
39
+ queryKey: ['pr', owner, repo, number],
40
+ queryFn: () =>
41
+ runEffect(
42
+ Effect.gen(function* () {
43
+ const api = yield* GitHubApi
44
+ return yield* api.getPullRequest(owner, repo, number)
45
+ }),
46
+ ),
47
+ enabled: !!owner && !!repo && !!number,
48
+ })
49
+ }
50
+
51
+ export function usePRFiles(owner: string, repo: string, number: number) {
52
+ return useQuery({
53
+ queryKey: ['pr-files', owner, repo, number],
54
+ queryFn: () =>
55
+ runEffect(
56
+ Effect.gen(function* () {
57
+ const api = yield* GitHubApi
58
+ return yield* api.getPullRequestFiles(owner, repo, number)
59
+ }),
60
+ ),
61
+ enabled: !!owner && !!repo && !!number,
62
+ })
63
+ }
64
+
65
+ export function usePRComments(owner: string, repo: string, number: number) {
66
+ return useQuery({
67
+ queryKey: ['pr-comments', owner, repo, number],
68
+ queryFn: () =>
69
+ runEffect(
70
+ Effect.gen(function* () {
71
+ const api = yield* GitHubApi
72
+ return yield* api.getPullRequestComments(owner, repo, number)
73
+ }),
74
+ ),
75
+ enabled: !!owner && !!repo && !!number,
76
+ })
77
+ }
78
+
79
+ export function usePRReviews(owner: string, repo: string, number: number) {
80
+ return useQuery({
81
+ queryKey: ['pr-reviews', owner, repo, number],
82
+ queryFn: () =>
83
+ runEffect(
84
+ Effect.gen(function* () {
85
+ const api = yield* GitHubApi
86
+ return yield* api.getPullRequestReviews(owner, repo, number)
87
+ }),
88
+ ),
89
+ enabled: !!owner && !!repo && !!number,
90
+ })
91
+ }
92
+
93
+ export function usePRCommits(owner: string, repo: string, number: number) {
94
+ return useQuery({
95
+ queryKey: ['pr-commits', owner, repo, number],
96
+ queryFn: () =>
97
+ runEffect(
98
+ Effect.gen(function* () {
99
+ const api = yield* GitHubApi
100
+ return yield* api.getPullRequestCommits(owner, repo, number)
101
+ }),
102
+ ),
103
+ enabled: !!owner && !!repo && !!number,
104
+ })
105
+ }
106
+
107
+ export function useMyPRs() {
108
+ return useQuery({
109
+ queryKey: ['my-prs'],
110
+ queryFn: () =>
111
+ runEffect(
112
+ Effect.gen(function* () {
113
+ const api = yield* GitHubApi
114
+ return yield* api.getMyPRs()
115
+ }),
116
+ ),
117
+ })
118
+ }
119
+
120
+ export function useReviewRequests() {
121
+ return useQuery({
122
+ queryKey: ['review-requests'],
123
+ queryFn: () =>
124
+ runEffect(
125
+ Effect.gen(function* () {
126
+ const api = yield* GitHubApi
127
+ return yield* api.getReviewRequests()
128
+ }),
129
+ ),
130
+ })
131
+ }
132
+
133
+ export function useInvolvedPRs() {
134
+ return useQuery({
135
+ queryKey: ['involved-prs'],
136
+ queryFn: () =>
137
+ runEffect(
138
+ Effect.gen(function* () {
139
+ const api = yield* GitHubApi
140
+ return yield* api.getInvolvedPRs()
141
+ }),
142
+ ),
143
+ })
144
+ }
145
+
146
+ // Legacy hook for backwards compatibility during migration
147
+ interface UseGitHubReturn {
148
+ readonly prs: readonly PullRequest[]
149
+ readonly loading: boolean
150
+ readonly error: string | null
151
+ readonly fetchPRs: (
152
+ owner: string,
153
+ repo: string,
154
+ options?: ListPRsOptions,
155
+ ) => void
156
+ readonly fetchPR: (
157
+ owner: string,
158
+ repo: string,
159
+ number: number,
160
+ ) => Promise<PullRequest | null>
161
+ readonly fetchFiles: (
162
+ owner: string,
163
+ repo: string,
164
+ number: number,
165
+ ) => Promise<readonly FileChange[]>
166
+ readonly fetchComments: (
167
+ owner: string,
168
+ repo: string,
169
+ number: number,
170
+ ) => Promise<readonly Comment[]>
171
+ readonly fetchReviews: (
172
+ owner: string,
173
+ repo: string,
174
+ number: number,
175
+ ) => Promise<readonly Review[]>
176
+ readonly fetchMyPRs: () => void
177
+ readonly fetchReviewRequests: () => void
178
+ }
179
+
180
+ export function useGitHub(): UseGitHubReturn {
181
+ const { data: prs = [], isLoading, error } = usePullRequests('', '')
182
+
183
+ return {
184
+ prs,
185
+ loading: isLoading,
186
+ error: error ? String(error) : null,
187
+ fetchPRs: (owner, repo, options) => {
188
+ runEffect(
189
+ Effect.gen(function* () {
190
+ const api = yield* GitHubApi
191
+ return yield* api.listPullRequests(owner, repo, options)
192
+ }),
193
+ )
194
+ },
195
+ fetchPR: async (owner, repo, number) => {
196
+ try {
197
+ return await runEffect(
198
+ Effect.gen(function* () {
199
+ const api = yield* GitHubApi
200
+ return yield* api.getPullRequest(owner, repo, number)
201
+ }),
202
+ )
203
+ } catch {
204
+ return null
205
+ }
206
+ },
207
+ fetchFiles: async (owner, repo, number) => {
208
+ try {
209
+ return await runEffect(
210
+ Effect.gen(function* () {
211
+ const api = yield* GitHubApi
212
+ return yield* api.getPullRequestFiles(owner, repo, number)
213
+ }),
214
+ )
215
+ } catch {
216
+ return []
217
+ }
218
+ },
219
+ fetchComments: async (owner, repo, number) => {
220
+ try {
221
+ return await runEffect(
222
+ Effect.gen(function* () {
223
+ const api = yield* GitHubApi
224
+ return yield* api.getPullRequestComments(owner, repo, number)
225
+ }),
226
+ )
227
+ } catch {
228
+ return []
229
+ }
230
+ },
231
+ fetchReviews: async (owner, repo, number) => {
232
+ try {
233
+ return await runEffect(
234
+ Effect.gen(function* () {
235
+ const api = yield* GitHubApi
236
+ return yield* api.getPullRequestReviews(owner, repo, number)
237
+ }),
238
+ )
239
+ } catch {
240
+ return []
241
+ }
242
+ },
243
+ fetchMyPRs: () => {
244
+ runEffect(
245
+ Effect.gen(function* () {
246
+ const api = yield* GitHubApi
247
+ return yield* api.getMyPRs()
248
+ }),
249
+ )
250
+ },
251
+ fetchReviewRequests: () => {
252
+ runEffect(
253
+ Effect.gen(function* () {
254
+ const api = yield* GitHubApi
255
+ return yield* api.getReviewRequests()
256
+ }),
257
+ )
258
+ },
259
+ }
260
+ }
@@ -0,0 +1,35 @@
1
+ import React, { createContext, useContext, useState, useCallback } from 'react'
2
+
3
+ interface InputFocusContextType {
4
+ readonly isInputActive: boolean
5
+ readonly setInputActive: (active: boolean) => void
6
+ }
7
+
8
+ const InputFocusContext = createContext<InputFocusContextType>({
9
+ isInputActive: false,
10
+ setInputActive: () => {},
11
+ })
12
+
13
+ interface InputFocusProviderProps {
14
+ readonly children: React.ReactNode
15
+ }
16
+
17
+ export function InputFocusProvider({
18
+ children,
19
+ }: InputFocusProviderProps): React.ReactElement {
20
+ const [isInputActive, setIsInputActive] = useState(false)
21
+
22
+ const setInputActive = useCallback((active: boolean) => {
23
+ setIsInputActive(active)
24
+ }, [])
25
+
26
+ return React.createElement(
27
+ InputFocusContext.Provider,
28
+ { value: { isInputActive, setInputActive } },
29
+ children,
30
+ )
31
+ }
32
+
33
+ export function useInputFocus(): InputFocusContextType {
34
+ return useContext(InputFocusContext)
35
+ }
@@ -0,0 +1,121 @@
1
+ import { useInput } from 'ink'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+
4
+ interface UseListNavigationOptions {
5
+ readonly itemCount: number
6
+ readonly viewportHeight: number
7
+ readonly isActive?: boolean
8
+ }
9
+
10
+ interface UseListNavigationResult {
11
+ readonly selectedIndex: number
12
+ readonly scrollOffset: number
13
+ readonly setSelectedIndex: (index: number) => void
14
+ }
15
+
16
+ export function useListNavigation({
17
+ itemCount,
18
+ viewportHeight,
19
+ isActive = true,
20
+ }: UseListNavigationOptions): UseListNavigationResult {
21
+ const [selectedIndex, setSelectedIndex] = useState(0)
22
+ const gPressedAt = useRef<number | null>(null)
23
+ const prevItemCount = useRef(itemCount)
24
+
25
+ // Auto-follow: if user was at the last item and a new one arrives, stay at bottom
26
+ useEffect(() => {
27
+ const wasAtEnd = selectedIndex === prevItemCount.current - 1
28
+ prevItemCount.current = itemCount
29
+ if (wasAtEnd && itemCount > 0) {
30
+ setSelectedIndex(itemCount - 1)
31
+ }
32
+ }, [itemCount, selectedIndex])
33
+
34
+ // Clamp selectedIndex if itemCount shrinks
35
+ useEffect(() => {
36
+ if (itemCount === 0) {
37
+ setSelectedIndex(0)
38
+ } else if (selectedIndex >= itemCount) {
39
+ setSelectedIndex(itemCount - 1)
40
+ }
41
+ }, [itemCount, selectedIndex])
42
+
43
+ const clamp = useCallback(
44
+ (index: number) => Math.max(0, Math.min(index, itemCount - 1)),
45
+ [itemCount],
46
+ )
47
+
48
+ useInput(
49
+ (input, key) => {
50
+ if (!isActive || itemCount === 0) return
51
+
52
+ // j / ↓ — move down
53
+ if (input === 'j' || key.downArrow) {
54
+ setSelectedIndex((i) => clamp(i + 1))
55
+ return
56
+ }
57
+
58
+ // k / ↑ — move up
59
+ if (input === 'k' || key.upArrow) {
60
+ setSelectedIndex((i) => clamp(i - 1))
61
+ return
62
+ }
63
+
64
+ // G — jump to bottom
65
+ if (input === 'G') {
66
+ setSelectedIndex(itemCount - 1)
67
+ return
68
+ }
69
+
70
+ // g — first press starts gg detection
71
+ if (input === 'g') {
72
+ const now = Date.now()
73
+ if (gPressedAt.current !== null && now - gPressedAt.current < 500) {
74
+ setSelectedIndex(0)
75
+ gPressedAt.current = null
76
+ } else {
77
+ gPressedAt.current = now
78
+ }
79
+ return
80
+ }
81
+
82
+ // Ctrl+d — page down
83
+ if (key.ctrl && input === 'd') {
84
+ setSelectedIndex((i) => clamp(i + Math.floor(viewportHeight / 2)))
85
+ return
86
+ }
87
+
88
+ // Ctrl+u — page up
89
+ if (key.ctrl && input === 'u') {
90
+ setSelectedIndex((i) => clamp(i - Math.floor(viewportHeight / 2)))
91
+ return
92
+ }
93
+
94
+ // Any other key resets gg state
95
+ gPressedAt.current = null
96
+ },
97
+ { isActive },
98
+ )
99
+
100
+ // Derive scroll offset to keep selectedIndex visible
101
+ const scrollOffset = deriveScrollOffset(
102
+ selectedIndex,
103
+ viewportHeight,
104
+ itemCount,
105
+ )
106
+
107
+ return { selectedIndex, scrollOffset, setSelectedIndex }
108
+ }
109
+
110
+ function deriveScrollOffset(
111
+ selectedIndex: number,
112
+ viewportHeight: number,
113
+ itemCount: number,
114
+ ): number {
115
+ if (itemCount <= viewportHeight) return 0
116
+
117
+ let offset = selectedIndex - Math.floor(viewportHeight / 2)
118
+ offset = Math.max(0, offset)
119
+ offset = Math.min(offset, itemCount - viewportHeight)
120
+ return offset
121
+ }
@@ -0,0 +1,25 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import type { LoadingState, LoadingService } from '../services/Loading'
3
+
4
+ let loadingService: LoadingService | null = null
5
+
6
+ export function setLoadingService(service: LoadingService): void {
7
+ loadingService = service
8
+ }
9
+
10
+ export function getLoadingService(): LoadingService | null {
11
+ return loadingService
12
+ }
13
+
14
+ const emptyState: LoadingState = { isLoading: false, message: null }
15
+
16
+ export function useLoading(): LoadingState {
17
+ return useSyncExternalStore(
18
+ (callback) => {
19
+ if (!loadingService) return () => {}
20
+ return loadingService.subscribe(callback)
21
+ },
22
+ () => loadingService?.getState() ?? emptyState,
23
+ () => emptyState,
24
+ )
25
+ }
@@ -0,0 +1,87 @@
1
+ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
2
+
3
+ interface UsePaginationOptions {
4
+ readonly pageSize?: number
5
+ }
6
+
7
+ interface UsePaginationResult<T> {
8
+ readonly currentPage: number
9
+ readonly totalPages: number
10
+ readonly pageItems: readonly T[]
11
+ readonly hasNextPage: boolean
12
+ readonly hasPrevPage: boolean
13
+ readonly nextPage: () => void
14
+ readonly prevPage: () => void
15
+ readonly goToPage: (page: number) => void
16
+ readonly startIndex: number
17
+ readonly endIndex: number
18
+ }
19
+
20
+ export function usePagination<T>(
21
+ items: readonly T[],
22
+ options: UsePaginationOptions = {},
23
+ ): UsePaginationResult<T> {
24
+ const pageSize = options.pageSize ?? 18
25
+ const [currentPage, setCurrentPage] = useState(1)
26
+ const prevItemsLengthRef = useRef(items.length)
27
+
28
+ const totalPages = useMemo(
29
+ () => Math.max(1, Math.ceil(items.length / pageSize)),
30
+ [items.length, pageSize],
31
+ )
32
+
33
+ // Reset to page 1 when items change (e.g., filter applied)
34
+ useEffect(() => {
35
+ if (items.length !== prevItemsLengthRef.current) {
36
+ setCurrentPage(1)
37
+ prevItemsLengthRef.current = items.length
38
+ }
39
+ }, [items.length])
40
+
41
+ // Ensure current page is within bounds
42
+ const safePage = Math.min(currentPage, totalPages)
43
+
44
+ const startIndex = (safePage - 1) * pageSize
45
+ const endIndex = Math.min(startIndex + pageSize, items.length)
46
+
47
+ const pageItems = useMemo(
48
+ () => items.slice(startIndex, endIndex),
49
+ [items, startIndex, endIndex],
50
+ )
51
+
52
+ const hasNextPage = safePage < totalPages
53
+ const hasPrevPage = safePage > 1
54
+
55
+ const nextPage = useCallback(() => {
56
+ if (hasNextPage) {
57
+ setCurrentPage((p) => p + 1)
58
+ }
59
+ }, [hasNextPage])
60
+
61
+ const prevPage = useCallback(() => {
62
+ if (hasPrevPage) {
63
+ setCurrentPage((p) => p - 1)
64
+ }
65
+ }, [hasPrevPage])
66
+
67
+ const goToPage = useCallback(
68
+ (page: number) => {
69
+ const clampedPage = Math.max(1, Math.min(page, totalPages))
70
+ setCurrentPage(clampedPage)
71
+ },
72
+ [totalPages],
73
+ )
74
+
75
+ return {
76
+ currentPage: safePage,
77
+ totalPages,
78
+ pageItems,
79
+ hasNextPage,
80
+ hasPrevPage,
81
+ nextPage,
82
+ prevPage,
83
+ goToPage,
84
+ startIndex,
85
+ endIndex,
86
+ }
87
+ }
@@ -0,0 +1,15 @@
1
+ import { Schema as S } from 'effect'
2
+ import { User } from './user'
3
+
4
+ export class Comment extends S.Class<Comment>('Comment')({
5
+ id: S.Number,
6
+ body: S.String,
7
+ user: User,
8
+ created_at: S.String,
9
+ updated_at: S.String,
10
+ html_url: S.String,
11
+ path: S.optional(S.String),
12
+ line: S.optional(S.NullOr(S.Number)),
13
+ side: S.optional(S.Literal('LEFT', 'RIGHT')),
14
+ in_reply_to_id: S.optional(S.Number),
15
+ }) {}
@@ -0,0 +1,20 @@
1
+ import { Schema as S } from 'effect'
2
+ import { User } from './user'
3
+
4
+ export class CommitAuthor extends S.Class<CommitAuthor>('CommitAuthor')({
5
+ name: S.String,
6
+ email: S.String,
7
+ date: S.String,
8
+ }) {}
9
+
10
+ export class CommitDetails extends S.Class<CommitDetails>('CommitDetails')({
11
+ message: S.String,
12
+ author: CommitAuthor,
13
+ }) {}
14
+
15
+ export class Commit extends S.Class<Commit>('Commit')({
16
+ sha: S.String,
17
+ commit: CommitDetails,
18
+ author: S.optionalWith(S.NullOr(User), { default: () => null }),
19
+ html_url: S.String,
20
+ }) {}
@@ -0,0 +1,93 @@
1
+ export interface DiffLine {
2
+ readonly type: 'add' | 'del' | 'context' | 'header'
3
+ readonly content: string
4
+ readonly oldLineNumber?: number
5
+ readonly newLineNumber?: number
6
+ }
7
+
8
+ export interface Hunk {
9
+ readonly header: string
10
+ readonly oldStart: number
11
+ readonly oldCount: number
12
+ readonly newStart: number
13
+ readonly newCount: number
14
+ readonly lines: readonly DiffLine[]
15
+ }
16
+
17
+ export interface FileDiff {
18
+ readonly filename: string
19
+ readonly status: 'added' | 'removed' | 'modified' | 'renamed'
20
+ readonly additions: number
21
+ readonly deletions: number
22
+ readonly hunks: readonly Hunk[]
23
+ readonly previousFilename?: string
24
+ }
25
+
26
+ export interface Diff {
27
+ readonly files: readonly FileDiff[]
28
+ readonly totalAdditions: number
29
+ readonly totalDeletions: number
30
+ }
31
+
32
+ export function parseDiffPatch(patch: string): readonly Hunk[] {
33
+ const hunks: Hunk[] = []
34
+ const lines = patch.split('\n')
35
+ let currentHunk: Hunk | null = null
36
+ let currentLines: DiffLine[] = []
37
+ let oldLine = 0
38
+ let newLine = 0
39
+
40
+ for (const line of lines) {
41
+ const hunkMatch = line.match(
42
+ /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/,
43
+ )
44
+
45
+ if (hunkMatch) {
46
+ if (currentHunk) {
47
+ hunks.push({ ...currentHunk, lines: currentLines })
48
+ }
49
+
50
+ oldLine = parseInt(hunkMatch[1]!, 10)
51
+ newLine = parseInt(hunkMatch[3]!, 10)
52
+ currentLines = [{ type: 'header', content: line }]
53
+ currentHunk = {
54
+ header: line,
55
+ oldStart: oldLine,
56
+ oldCount: parseInt(hunkMatch[2] ?? '1', 10),
57
+ newStart: newLine,
58
+ newCount: parseInt(hunkMatch[4] ?? '1', 10),
59
+ lines: [],
60
+ }
61
+ continue
62
+ }
63
+
64
+ if (!currentHunk) continue
65
+
66
+ if (line.startsWith('+')) {
67
+ currentLines.push({
68
+ type: 'add',
69
+ content: line.slice(1),
70
+ newLineNumber: newLine++,
71
+ })
72
+ } else if (line.startsWith('-')) {
73
+ currentLines.push({
74
+ type: 'del',
75
+ content: line.slice(1),
76
+ oldLineNumber: oldLine++,
77
+ })
78
+ } else if (line.startsWith(' ') || line === '') {
79
+ currentLines.push({
80
+ type: 'context',
81
+ content: line.slice(1),
82
+ oldLineNumber: oldLine++,
83
+ newLineNumber: newLine++,
84
+ })
85
+ }
86
+ }
87
+
88
+ if (currentHunk) {
89
+ hunks.push({ ...currentHunk, lines: currentLines })
90
+ }
91
+
92
+ return hunks
93
+ }
@@ -0,0 +1,24 @@
1
+ import { Data } from 'effect'
2
+
3
+ export class GitHubError extends Data.TaggedError('GitHubError')<{
4
+ readonly message: string
5
+ readonly status?: number
6
+ readonly url?: string
7
+ }> {}
8
+
9
+ export class AuthError extends Data.TaggedError('AuthError')<{
10
+ readonly message: string
11
+ readonly reason: 'no_token' | 'invalid_token' | 'expired_token' | 'save_failed'
12
+ }> {}
13
+
14
+ export class ConfigError extends Data.TaggedError('ConfigError')<{
15
+ readonly message: string
16
+ readonly path?: string
17
+ }> {}
18
+
19
+ export class NetworkError extends Data.TaggedError('NetworkError')<{
20
+ readonly message: string
21
+ readonly cause?: unknown
22
+ }> {}
23
+
24
+ export type AppError = GitHubError | AuthError | ConfigError | NetworkError
@@ -0,0 +1,22 @@
1
+ import { Schema as S } from 'effect'
2
+
3
+ export class FileChange extends S.Class<FileChange>('FileChange')({
4
+ sha: S.String,
5
+ filename: S.String,
6
+ status: S.Literal(
7
+ 'added',
8
+ 'removed',
9
+ 'modified',
10
+ 'renamed',
11
+ 'copied',
12
+ 'changed',
13
+ 'unchanged',
14
+ ),
15
+ additions: S.Number,
16
+ deletions: S.Number,
17
+ changes: S.Number,
18
+ patch: S.optional(S.String),
19
+ previous_filename: S.optional(S.String),
20
+ blob_url: S.optional(S.String),
21
+ raw_url: S.optional(S.String),
22
+ }) {}