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.
- package/.github/workflows/publish-npm.yml +51 -0
- package/.prettierrc +7 -0
- package/CLAUDE.md +50 -0
- package/README.md +119 -0
- package/dist/cli.js +3830 -0
- package/dist/cli.js.map +1 -0
- package/package.json +64 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/app.tsx +235 -0
- package/src/cli.tsx +78 -0
- package/src/components/common/BorderedBox.tsx +41 -0
- package/src/components/common/Divider.tsx +31 -0
- package/src/components/common/EmptyState.tsx +35 -0
- package/src/components/common/FilterModal.tsx +117 -0
- package/src/components/common/LoadingIndicator.tsx +31 -0
- package/src/components/common/Modal.tsx +24 -0
- package/src/components/common/PaginationBar.tsx +56 -0
- package/src/components/common/SortModal.tsx +91 -0
- package/src/components/common/Spinner.tsx +28 -0
- package/src/components/common/index.ts +9 -0
- package/src/components/layout/HelpModal.tsx +61 -0
- package/src/components/layout/MainPanel.tsx +26 -0
- package/src/components/layout/Sidebar.tsx +71 -0
- package/src/components/layout/StatusBar.tsx +42 -0
- package/src/components/layout/TokenInputModal.tsx +92 -0
- package/src/components/layout/TopBar.tsx +44 -0
- package/src/components/layout/index.ts +6 -0
- package/src/components/pr/CommentsTab.tsx +92 -0
- package/src/components/pr/CommitsTab.tsx +142 -0
- package/src/components/pr/ConversationsTab.tsx +273 -0
- package/src/components/pr/FilesTab.tsx +532 -0
- package/src/components/pr/PRHeader.tsx +69 -0
- package/src/components/pr/PRListItem.tsx +92 -0
- package/src/components/pr/PRTabs.tsx +50 -0
- package/src/components/pr/index.ts +8 -0
- package/src/hooks/index.ts +12 -0
- package/src/hooks/useActivePanel.ts +54 -0
- package/src/hooks/useAppKeymap.ts +22 -0
- package/src/hooks/useAuth.ts +131 -0
- package/src/hooks/useConfig.ts +52 -0
- package/src/hooks/useFilter.ts +192 -0
- package/src/hooks/useGitHub.ts +260 -0
- package/src/hooks/useInputFocus.tsx +35 -0
- package/src/hooks/useListNavigation.ts +121 -0
- package/src/hooks/useLoading.ts +25 -0
- package/src/hooks/usePagination.ts +87 -0
- package/src/models/comment.ts +15 -0
- package/src/models/commit.ts +20 -0
- package/src/models/diff.ts +93 -0
- package/src/models/errors.ts +24 -0
- package/src/models/file-change.ts +22 -0
- package/src/models/index.ts +12 -0
- package/src/models/pull-request.ts +40 -0
- package/src/models/review.ts +17 -0
- package/src/models/user.ts +9 -0
- package/src/screens/InvolvedScreen.tsx +161 -0
- package/src/screens/MyPRsScreen.tsx +161 -0
- package/src/screens/PRDetailScreen.tsx +88 -0
- package/src/screens/PRListScreen.tsx +96 -0
- package/src/screens/ReviewRequestsScreen.tsx +161 -0
- package/src/screens/SettingsScreen.tsx +284 -0
- package/src/screens/ThisRepoScreen.tsx +175 -0
- package/src/screens/index.ts +7 -0
- package/src/services/Auth.ts +314 -0
- package/src/services/Config.ts +81 -0
- package/src/services/GitHubApi.ts +262 -0
- package/src/services/Loading.ts +54 -0
- package/src/services/index.ts +27 -0
- package/src/theme/index.ts +28 -0
- package/src/theme/themes.ts +84 -0
- package/src/theme/types.ts +28 -0
- package/src/utils/date.ts +28 -0
- package/src/utils/git.ts +67 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/terminal.ts +25 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +13 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { writeFile, readFile, mkdir, unlink } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { AuthError } from '../models/errors'
|
|
8
|
+
import { User } from '../models/user'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
|
|
12
|
+
export type TokenSource = 'manual' | 'env' | 'gh_cli' | 'none'
|
|
13
|
+
|
|
14
|
+
export interface TokenInfo {
|
|
15
|
+
readonly source: TokenSource
|
|
16
|
+
readonly token: string | null
|
|
17
|
+
readonly maskedToken: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Config directory for storing token
|
|
21
|
+
const CONFIG_DIR = join(homedir(), '.config', 'lazyreview')
|
|
22
|
+
const TOKEN_FILE = join(CONFIG_DIR, '.token')
|
|
23
|
+
|
|
24
|
+
// In-memory token storage for current session
|
|
25
|
+
let sessionToken: string | null = null
|
|
26
|
+
let preferredSource: TokenSource | null = null
|
|
27
|
+
|
|
28
|
+
// Load saved token from file on startup
|
|
29
|
+
async function loadSavedToken(): Promise<string | null> {
|
|
30
|
+
try {
|
|
31
|
+
const token = await readFile(TOKEN_FILE, 'utf-8')
|
|
32
|
+
return token.trim() || null
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Save token to file for persistence
|
|
39
|
+
async function saveTokenToFile(token: string): Promise<void> {
|
|
40
|
+
await mkdir(CONFIG_DIR, { recursive: true })
|
|
41
|
+
await writeFile(TOKEN_FILE, token, { mode: 0o600 }) // Secure permissions
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Delete saved token file
|
|
45
|
+
async function deleteSavedToken(): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
await unlink(TOKEN_FILE)
|
|
48
|
+
} catch {
|
|
49
|
+
// File might not exist
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Initialize: load saved token on module load
|
|
54
|
+
let savedToken: string | null = null
|
|
55
|
+
loadSavedToken().then((token) => {
|
|
56
|
+
savedToken = token
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
function maskToken(token: string): string {
|
|
60
|
+
if (token.length <= 8) return '****'
|
|
61
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function tryGetGhToken(): Promise<string | null> {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execFileAsync('gh', ['auth', 'token'])
|
|
67
|
+
return stdout.trim() || null
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AuthService {
|
|
74
|
+
readonly getToken: () => Effect.Effect<string, AuthError>
|
|
75
|
+
readonly getUser: () => Effect.Effect<User, AuthError>
|
|
76
|
+
readonly isAuthenticated: () => Effect.Effect<boolean, AuthError>
|
|
77
|
+
readonly setToken: (token: string) => Effect.Effect<void, never>
|
|
78
|
+
readonly getTokenInfo: () => Effect.Effect<TokenInfo, never>
|
|
79
|
+
readonly setPreferredSource: (source: TokenSource) => Effect.Effect<void, never>
|
|
80
|
+
readonly getAvailableSources: () => Effect.Effect<TokenSource[], never>
|
|
81
|
+
readonly clearManualToken: () => Effect.Effect<void, never>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class Auth extends Context.Tag('Auth')<Auth, AuthService>() {}
|
|
85
|
+
|
|
86
|
+
function resolveToken(): Effect.Effect<string, AuthError> {
|
|
87
|
+
return Effect.gen(function* () {
|
|
88
|
+
// If preferred source is set, use only that source
|
|
89
|
+
if (preferredSource === 'manual') {
|
|
90
|
+
const manualToken = sessionToken ?? savedToken
|
|
91
|
+
if (manualToken) return manualToken
|
|
92
|
+
return yield* Effect.fail(
|
|
93
|
+
new AuthError({ message: 'No manual token found', reason: 'no_token' })
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (preferredSource === 'env') {
|
|
98
|
+
const envToken = process.env['LAZYREVIEW_GITHUB_TOKEN']
|
|
99
|
+
if (envToken) return envToken
|
|
100
|
+
return yield* Effect.fail(
|
|
101
|
+
new AuthError({ message: 'LAZYREVIEW_GITHUB_TOKEN not set', reason: 'no_token' })
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (preferredSource === 'gh_cli') {
|
|
106
|
+
const ghToken = yield* Effect.tryPromise({
|
|
107
|
+
try: tryGetGhToken,
|
|
108
|
+
catch: () => new AuthError({ message: 'gh CLI failed', reason: 'no_token' }),
|
|
109
|
+
})
|
|
110
|
+
if (ghToken) return ghToken
|
|
111
|
+
return yield* Effect.fail(
|
|
112
|
+
new AuthError({ message: 'gh CLI token not available', reason: 'no_token' })
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Default priority order:
|
|
117
|
+
// 1. LAZYREVIEW_GITHUB_TOKEN env var (highest priority)
|
|
118
|
+
const envToken = process.env['LAZYREVIEW_GITHUB_TOKEN']
|
|
119
|
+
if (envToken) return envToken
|
|
120
|
+
|
|
121
|
+
// 2. Manual/saved token (from settings or file)
|
|
122
|
+
const manualToken = sessionToken ?? savedToken
|
|
123
|
+
if (manualToken) return manualToken
|
|
124
|
+
|
|
125
|
+
// 3. gh CLI fallback
|
|
126
|
+
const ghResult = yield* Effect.tryPromise({
|
|
127
|
+
try: tryGetGhToken,
|
|
128
|
+
catch: () =>
|
|
129
|
+
new AuthError({
|
|
130
|
+
message: 'No GitHub token found. Set LAZYREVIEW_GITHUB_TOKEN or configure in Settings.',
|
|
131
|
+
reason: 'no_token',
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
if (ghResult) return ghResult
|
|
136
|
+
|
|
137
|
+
// 4. No token found - will show modal
|
|
138
|
+
return yield* Effect.fail(
|
|
139
|
+
new AuthError({
|
|
140
|
+
message: 'No GitHub token found. Set LAZYREVIEW_GITHUB_TOKEN or configure in Settings.',
|
|
141
|
+
reason: 'no_token',
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveTokenInfo(): Effect.Effect<TokenInfo, never> {
|
|
148
|
+
return Effect.gen(function* () {
|
|
149
|
+
// Check preferred source first
|
|
150
|
+
if (preferredSource === 'manual') {
|
|
151
|
+
const manualToken = sessionToken ?? savedToken
|
|
152
|
+
if (manualToken) {
|
|
153
|
+
return {
|
|
154
|
+
source: 'manual' as TokenSource,
|
|
155
|
+
token: manualToken,
|
|
156
|
+
maskedToken: maskToken(manualToken),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (preferredSource === 'env') {
|
|
162
|
+
const envToken = process.env['LAZYREVIEW_GITHUB_TOKEN']
|
|
163
|
+
if (envToken) {
|
|
164
|
+
return {
|
|
165
|
+
source: 'env' as TokenSource,
|
|
166
|
+
token: envToken,
|
|
167
|
+
maskedToken: maskToken(envToken),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (preferredSource === 'gh_cli') {
|
|
173
|
+
const ghToken = yield* Effect.promise(tryGetGhToken)
|
|
174
|
+
if (ghToken) {
|
|
175
|
+
return {
|
|
176
|
+
source: 'gh_cli' as TokenSource,
|
|
177
|
+
token: ghToken,
|
|
178
|
+
maskedToken: maskToken(ghToken),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Default: show what's currently being used (following priority order)
|
|
184
|
+
// 1. LAZYREVIEW_GITHUB_TOKEN
|
|
185
|
+
const envToken = process.env['LAZYREVIEW_GITHUB_TOKEN']
|
|
186
|
+
if (envToken) {
|
|
187
|
+
return {
|
|
188
|
+
source: 'env' as TokenSource,
|
|
189
|
+
token: envToken,
|
|
190
|
+
maskedToken: maskToken(envToken),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Manual/saved token
|
|
195
|
+
const manualToken = sessionToken ?? savedToken
|
|
196
|
+
if (manualToken) {
|
|
197
|
+
return {
|
|
198
|
+
source: 'manual' as TokenSource,
|
|
199
|
+
token: manualToken,
|
|
200
|
+
maskedToken: maskToken(manualToken),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. gh CLI
|
|
205
|
+
const ghToken = yield* Effect.promise(tryGetGhToken)
|
|
206
|
+
if (ghToken) {
|
|
207
|
+
return {
|
|
208
|
+
source: 'gh_cli' as TokenSource,
|
|
209
|
+
token: ghToken,
|
|
210
|
+
maskedToken: maskToken(ghToken),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
source: 'none' as TokenSource,
|
|
216
|
+
token: null,
|
|
217
|
+
maskedToken: null,
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const AuthLive = Layer.succeed(
|
|
223
|
+
Auth,
|
|
224
|
+
Auth.of({
|
|
225
|
+
getToken: resolveToken,
|
|
226
|
+
|
|
227
|
+
getUser: () =>
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
const token = yield* resolveToken()
|
|
230
|
+
|
|
231
|
+
const user = yield* Effect.tryPromise({
|
|
232
|
+
try: async () => {
|
|
233
|
+
const response = await fetch('https://api.github.com/user', {
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${token}`,
|
|
236
|
+
Accept: 'application/vnd.github+json',
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(`GitHub API returned ${response.status}`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = await response.json()
|
|
245
|
+
return S.decodeUnknownSync(User)(data)
|
|
246
|
+
},
|
|
247
|
+
catch: (error) =>
|
|
248
|
+
new AuthError({
|
|
249
|
+
message: `Failed to get user: ${String(error)}`,
|
|
250
|
+
reason: 'invalid_token',
|
|
251
|
+
}),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
return user
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
isAuthenticated: () =>
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const result = yield* Effect.either(resolveToken())
|
|
260
|
+
return result._tag === 'Right'
|
|
261
|
+
}),
|
|
262
|
+
|
|
263
|
+
setToken: (token: string) =>
|
|
264
|
+
Effect.gen(function* () {
|
|
265
|
+
sessionToken = token
|
|
266
|
+
savedToken = token
|
|
267
|
+
preferredSource = 'manual'
|
|
268
|
+
// Save to file for persistence across launches
|
|
269
|
+
yield* Effect.promise(() => saveTokenToFile(token))
|
|
270
|
+
}),
|
|
271
|
+
|
|
272
|
+
getTokenInfo: resolveTokenInfo,
|
|
273
|
+
|
|
274
|
+
setPreferredSource: (source: TokenSource) =>
|
|
275
|
+
Effect.sync(() => {
|
|
276
|
+
preferredSource = source === 'none' ? null : source
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
getAvailableSources: () =>
|
|
280
|
+
Effect.gen(function* () {
|
|
281
|
+
const sources: TokenSource[] = []
|
|
282
|
+
|
|
283
|
+
// Check LAZYREVIEW_GITHUB_TOKEN only
|
|
284
|
+
const envToken = process.env['LAZYREVIEW_GITHUB_TOKEN']
|
|
285
|
+
if (envToken) {
|
|
286
|
+
sources.push('env')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check for manual token (session or saved)
|
|
290
|
+
if (sessionToken || savedToken) {
|
|
291
|
+
sources.push('manual')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check gh CLI
|
|
295
|
+
const ghToken = yield* Effect.promise(tryGetGhToken)
|
|
296
|
+
if (ghToken) {
|
|
297
|
+
sources.push('gh_cli')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return sources
|
|
301
|
+
}),
|
|
302
|
+
|
|
303
|
+
clearManualToken: () =>
|
|
304
|
+
Effect.gen(function* () {
|
|
305
|
+
sessionToken = null
|
|
306
|
+
savedToken = null
|
|
307
|
+
if (preferredSource === 'manual') {
|
|
308
|
+
preferredSource = null
|
|
309
|
+
}
|
|
310
|
+
// Delete the saved token file
|
|
311
|
+
yield* Effect.promise(deleteSavedToken)
|
|
312
|
+
}),
|
|
313
|
+
}),
|
|
314
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import { parse, stringify } from 'yaml'
|
|
6
|
+
import { ConfigError } from '../models/errors'
|
|
7
|
+
|
|
8
|
+
const KeybindingsSchema = S.Struct({
|
|
9
|
+
toggleSidebar: S.optionalWith(S.String, { default: () => 'b' }),
|
|
10
|
+
help: S.optionalWith(S.String, { default: () => '?' }),
|
|
11
|
+
quit: S.optionalWith(S.String, { default: () => 'q' }),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export class AppConfig extends S.Class<AppConfig>('AppConfig')({
|
|
15
|
+
provider: S.optionalWith(S.Literal('github'), {
|
|
16
|
+
default: () => 'github' as const,
|
|
17
|
+
}),
|
|
18
|
+
theme: S.optionalWith(S.String, { default: () => 'tokyo-night' }),
|
|
19
|
+
defaultOwner: S.optional(S.String),
|
|
20
|
+
defaultRepo: S.optional(S.String),
|
|
21
|
+
pageSize: S.optionalWith(S.Number.pipe(S.int(), S.between(1, 100)), {
|
|
22
|
+
default: () => 30,
|
|
23
|
+
}),
|
|
24
|
+
keybindings: S.optionalWith(KeybindingsSchema, {
|
|
25
|
+
default: () => ({ toggleSidebar: 'b', help: '?', quit: 'q' }),
|
|
26
|
+
}),
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
const defaultConfig = S.decodeUnknownSync(AppConfig)({})
|
|
30
|
+
|
|
31
|
+
export interface ConfigService {
|
|
32
|
+
readonly load: () => Effect.Effect<AppConfig, ConfigError>
|
|
33
|
+
readonly save: (config: AppConfig) => Effect.Effect<void, ConfigError>
|
|
34
|
+
readonly getPath: () => string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class Config extends Context.Tag('Config')<Config, ConfigService>() {}
|
|
38
|
+
|
|
39
|
+
function getConfigPath(): string {
|
|
40
|
+
return join(homedir(), '.config', 'lazyreview', 'config.yaml')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ConfigLive = Layer.succeed(
|
|
44
|
+
Config,
|
|
45
|
+
Config.of({
|
|
46
|
+
getPath: getConfigPath,
|
|
47
|
+
|
|
48
|
+
load: () =>
|
|
49
|
+
Effect.tryPromise({
|
|
50
|
+
try: async () => {
|
|
51
|
+
const configPath = getConfigPath()
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(configPath, 'utf-8')
|
|
54
|
+
const parsed = parse(content)
|
|
55
|
+
return S.decodeUnknownSync(AppConfig)(parsed)
|
|
56
|
+
} catch {
|
|
57
|
+
return defaultConfig
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
catch: (error) =>
|
|
61
|
+
new ConfigError({
|
|
62
|
+
message: `Failed to load config: ${String(error)}`,
|
|
63
|
+
path: getConfigPath(),
|
|
64
|
+
}),
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
save: (config: AppConfig) =>
|
|
68
|
+
Effect.tryPromise({
|
|
69
|
+
try: async () => {
|
|
70
|
+
const configPath = getConfigPath()
|
|
71
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
72
|
+
await writeFile(configPath, stringify(config), 'utf-8')
|
|
73
|
+
},
|
|
74
|
+
catch: (error) =>
|
|
75
|
+
new ConfigError({
|
|
76
|
+
message: `Failed to save config: ${String(error)}`,
|
|
77
|
+
path: getConfigPath(),
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import { AuthError, GitHubError, NetworkError } from '../models/errors'
|
|
3
|
+
import { PullRequest } from '../models/pull-request'
|
|
4
|
+
import { Comment } from '../models/comment'
|
|
5
|
+
import { Review } from '../models/review'
|
|
6
|
+
import { FileChange } from '../models/file-change'
|
|
7
|
+
import { Commit } from '../models/commit'
|
|
8
|
+
import { Auth } from './Auth'
|
|
9
|
+
|
|
10
|
+
const BASE_URL = 'https://api.github.com'
|
|
11
|
+
|
|
12
|
+
export interface ListPRsOptions {
|
|
13
|
+
readonly state?: 'open' | 'closed' | 'all'
|
|
14
|
+
readonly sort?: 'created' | 'updated' | 'popularity' | 'long-running'
|
|
15
|
+
readonly direction?: 'asc' | 'desc'
|
|
16
|
+
readonly perPage?: number
|
|
17
|
+
readonly page?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ApiError = GitHubError | NetworkError | AuthError
|
|
21
|
+
|
|
22
|
+
export interface GitHubApiService {
|
|
23
|
+
readonly listPullRequests: (
|
|
24
|
+
owner: string,
|
|
25
|
+
repo: string,
|
|
26
|
+
options?: ListPRsOptions,
|
|
27
|
+
) => Effect.Effect<readonly PullRequest[], ApiError>
|
|
28
|
+
|
|
29
|
+
readonly getPullRequest: (
|
|
30
|
+
owner: string,
|
|
31
|
+
repo: string,
|
|
32
|
+
number: number,
|
|
33
|
+
) => Effect.Effect<PullRequest, ApiError>
|
|
34
|
+
|
|
35
|
+
readonly getPullRequestFiles: (
|
|
36
|
+
owner: string,
|
|
37
|
+
repo: string,
|
|
38
|
+
number: number,
|
|
39
|
+
) => Effect.Effect<readonly FileChange[], ApiError>
|
|
40
|
+
|
|
41
|
+
readonly getPullRequestComments: (
|
|
42
|
+
owner: string,
|
|
43
|
+
repo: string,
|
|
44
|
+
number: number,
|
|
45
|
+
) => Effect.Effect<readonly Comment[], ApiError>
|
|
46
|
+
|
|
47
|
+
readonly getPullRequestReviews: (
|
|
48
|
+
owner: string,
|
|
49
|
+
repo: string,
|
|
50
|
+
number: number,
|
|
51
|
+
) => Effect.Effect<readonly Review[], ApiError>
|
|
52
|
+
|
|
53
|
+
readonly getPullRequestCommits: (
|
|
54
|
+
owner: string,
|
|
55
|
+
repo: string,
|
|
56
|
+
number: number,
|
|
57
|
+
) => Effect.Effect<readonly Commit[], ApiError>
|
|
58
|
+
|
|
59
|
+
readonly getMyPRs: () => Effect.Effect<readonly PullRequest[], ApiError>
|
|
60
|
+
|
|
61
|
+
readonly getReviewRequests: () => Effect.Effect<
|
|
62
|
+
readonly PullRequest[],
|
|
63
|
+
ApiError
|
|
64
|
+
>
|
|
65
|
+
|
|
66
|
+
readonly getInvolvedPRs: () => Effect.Effect<readonly PullRequest[], ApiError>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class GitHubApi extends Context.Tag('GitHubApi')<
|
|
70
|
+
GitHubApi,
|
|
71
|
+
GitHubApiService
|
|
72
|
+
>() {}
|
|
73
|
+
|
|
74
|
+
function fetchGitHub<A, I>(
|
|
75
|
+
path: string,
|
|
76
|
+
token: string,
|
|
77
|
+
schema: S.Schema<A, I>,
|
|
78
|
+
): Effect.Effect<A, GitHubError | NetworkError> {
|
|
79
|
+
const url = `${BASE_URL}${path}`
|
|
80
|
+
const decode = S.decodeUnknownSync(schema)
|
|
81
|
+
|
|
82
|
+
return Effect.tryPromise({
|
|
83
|
+
try: async () => {
|
|
84
|
+
const response = await fetch(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${token}`,
|
|
87
|
+
Accept: 'application/vnd.github+json',
|
|
88
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const body = await response.text().catch(() => '')
|
|
94
|
+
throw new GitHubError({
|
|
95
|
+
message: `GitHub API error: ${response.status} ${response.statusText} - ${body}`,
|
|
96
|
+
status: response.status,
|
|
97
|
+
url,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = await response.json()
|
|
102
|
+
return decode(data)
|
|
103
|
+
},
|
|
104
|
+
catch: (error) => {
|
|
105
|
+
if (error instanceof GitHubError) return error
|
|
106
|
+
return new NetworkError({
|
|
107
|
+
message: `Network request failed: ${String(error)}`,
|
|
108
|
+
cause: error,
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Schema for GitHub Search API response
|
|
115
|
+
const SearchResultSchema = S.Struct({
|
|
116
|
+
total_count: S.Number,
|
|
117
|
+
incomplete_results: S.Boolean,
|
|
118
|
+
items: S.Array(PullRequest),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function fetchGitHubSearch(
|
|
122
|
+
query: string,
|
|
123
|
+
token: string,
|
|
124
|
+
): Effect.Effect<readonly PullRequest[], GitHubError | NetworkError> {
|
|
125
|
+
const url = `${BASE_URL}/search/issues?q=${encodeURIComponent(query)}&per_page=100`
|
|
126
|
+
const decode = S.decodeUnknownSync(SearchResultSchema)
|
|
127
|
+
|
|
128
|
+
return Effect.tryPromise({
|
|
129
|
+
try: async () => {
|
|
130
|
+
const response = await fetch(url, {
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: `Bearer ${token}`,
|
|
133
|
+
Accept: 'application/vnd.github+json',
|
|
134
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
const body = await response.text().catch(() => '')
|
|
140
|
+
throw new GitHubError({
|
|
141
|
+
message: `GitHub API error: ${response.status} ${response.statusText} - ${body}`,
|
|
142
|
+
status: response.status,
|
|
143
|
+
url,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const data = await response.json()
|
|
148
|
+
const result = decode(data)
|
|
149
|
+
return result.items
|
|
150
|
+
},
|
|
151
|
+
catch: (error) => {
|
|
152
|
+
if (error instanceof GitHubError) return error
|
|
153
|
+
return new NetworkError({
|
|
154
|
+
message: `Network request failed: ${String(error)}`,
|
|
155
|
+
cause: error,
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildQueryString(options: ListPRsOptions): string {
|
|
162
|
+
const params = new URLSearchParams()
|
|
163
|
+
if (options.state) params.set('state', options.state)
|
|
164
|
+
if (options.sort) params.set('sort', options.sort)
|
|
165
|
+
if (options.direction) params.set('direction', options.direction)
|
|
166
|
+
if (options.perPage) params.set('per_page', String(options.perPage))
|
|
167
|
+
if (options.page) params.set('page', String(options.page))
|
|
168
|
+
const qs = params.toString()
|
|
169
|
+
return qs ? `?${qs}` : ''
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const GitHubApiLive = Layer.effect(
|
|
173
|
+
GitHubApi,
|
|
174
|
+
Effect.gen(function* () {
|
|
175
|
+
const auth = yield* Auth
|
|
176
|
+
|
|
177
|
+
return GitHubApi.of({
|
|
178
|
+
listPullRequests: (owner, repo, options = {}) =>
|
|
179
|
+
Effect.gen(function* () {
|
|
180
|
+
const token = yield* auth.getToken()
|
|
181
|
+
const mergedOptions = { ...options, perPage: options.perPage ?? 100 }
|
|
182
|
+
const qs = buildQueryString(mergedOptions)
|
|
183
|
+
return yield* fetchGitHub(
|
|
184
|
+
`/repos/${owner}/${repo}/pulls${qs}`,
|
|
185
|
+
token,
|
|
186
|
+
S.Array(PullRequest),
|
|
187
|
+
)
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
getPullRequest: (owner, repo, number) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const token = yield* auth.getToken()
|
|
193
|
+
return yield* fetchGitHub(
|
|
194
|
+
`/repos/${owner}/${repo}/pulls/${number}`,
|
|
195
|
+
token,
|
|
196
|
+
PullRequest,
|
|
197
|
+
)
|
|
198
|
+
}),
|
|
199
|
+
|
|
200
|
+
getPullRequestFiles: (owner, repo, number) =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const token = yield* auth.getToken()
|
|
203
|
+
return yield* fetchGitHub(
|
|
204
|
+
`/repos/${owner}/${repo}/pulls/${number}/files`,
|
|
205
|
+
token,
|
|
206
|
+
S.Array(FileChange),
|
|
207
|
+
)
|
|
208
|
+
}),
|
|
209
|
+
|
|
210
|
+
getPullRequestComments: (owner, repo, number) =>
|
|
211
|
+
Effect.gen(function* () {
|
|
212
|
+
const token = yield* auth.getToken()
|
|
213
|
+
return yield* fetchGitHub(
|
|
214
|
+
`/repos/${owner}/${repo}/pulls/${number}/comments`,
|
|
215
|
+
token,
|
|
216
|
+
S.Array(Comment),
|
|
217
|
+
)
|
|
218
|
+
}),
|
|
219
|
+
|
|
220
|
+
getPullRequestReviews: (owner, repo, number) =>
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
const token = yield* auth.getToken()
|
|
223
|
+
return yield* fetchGitHub(
|
|
224
|
+
`/repos/${owner}/${repo}/pulls/${number}/reviews`,
|
|
225
|
+
token,
|
|
226
|
+
S.Array(Review),
|
|
227
|
+
)
|
|
228
|
+
}),
|
|
229
|
+
|
|
230
|
+
getPullRequestCommits: (owner, repo, number) =>
|
|
231
|
+
Effect.gen(function* () {
|
|
232
|
+
const token = yield* auth.getToken()
|
|
233
|
+
return yield* fetchGitHub(
|
|
234
|
+
`/repos/${owner}/${repo}/pulls/${number}/commits`,
|
|
235
|
+
token,
|
|
236
|
+
S.Array(Commit),
|
|
237
|
+
)
|
|
238
|
+
}),
|
|
239
|
+
|
|
240
|
+
getMyPRs: () =>
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const token = yield* auth.getToken()
|
|
243
|
+
return yield* fetchGitHubSearch('is:pr is:open author:@me', token)
|
|
244
|
+
}),
|
|
245
|
+
|
|
246
|
+
getReviewRequests: () =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const token = yield* auth.getToken()
|
|
249
|
+
return yield* fetchGitHubSearch(
|
|
250
|
+
'is:pr is:open review-requested:@me',
|
|
251
|
+
token,
|
|
252
|
+
)
|
|
253
|
+
}),
|
|
254
|
+
|
|
255
|
+
getInvolvedPRs: () =>
|
|
256
|
+
Effect.gen(function* () {
|
|
257
|
+
const token = yield* auth.getToken()
|
|
258
|
+
return yield* fetchGitHubSearch('is:pr is:open involves:@me', token)
|
|
259
|
+
}),
|
|
260
|
+
})
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Context, Layer } from 'effect'
|
|
2
|
+
|
|
3
|
+
export interface LoadingState {
|
|
4
|
+
readonly isLoading: boolean
|
|
5
|
+
readonly message: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type Listener = () => void
|
|
9
|
+
|
|
10
|
+
export interface LoadingService {
|
|
11
|
+
readonly start: (message: string) => void
|
|
12
|
+
readonly stop: () => void
|
|
13
|
+
readonly getState: () => LoadingState
|
|
14
|
+
readonly subscribe: (listener: Listener) => () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Loading extends Context.Tag('Loading')<
|
|
18
|
+
Loading,
|
|
19
|
+
LoadingService
|
|
20
|
+
>() {}
|
|
21
|
+
|
|
22
|
+
function createLoadingService(): LoadingService {
|
|
23
|
+
let state: LoadingState = { isLoading: false, message: null }
|
|
24
|
+
const listeners = new Set<Listener>()
|
|
25
|
+
|
|
26
|
+
function notify(): void {
|
|
27
|
+
for (const listener of listeners) {
|
|
28
|
+
listener()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
start: (message: string) => {
|
|
34
|
+
state = { isLoading: true, message }
|
|
35
|
+
notify()
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
stop: () => {
|
|
39
|
+
state = { isLoading: false, message: null }
|
|
40
|
+
notify()
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
getState: () => state,
|
|
44
|
+
|
|
45
|
+
subscribe: (listener: Listener) => {
|
|
46
|
+
listeners.add(listener)
|
|
47
|
+
return () => {
|
|
48
|
+
listeners.delete(listener)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const LoadingLive = Layer.sync(Loading, createLoadingService)
|