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,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)